diff --git a/src/datasource/graphene/frontend.ts b/src/datasource/graphene/frontend.ts index 211007803..438ecfd32 100644 --- a/src/datasource/graphene/frontend.ts +++ b/src/datasource/graphene/frontend.ts @@ -19,8 +19,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"; @@ -143,7 +146,9 @@ import { AnnotationLayerView, MergedAnnotationStates, PlaceLineTool, + makeAnnotationListElement, } 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, @@ -152,10 +157,13 @@ import { registerLegacyTool, registerTool, } from "#src/ui/tool.js"; -import type { Uint64Set } from "#src/uint64_set.js"; +import { Uint64Set } from "#src/uint64_set.js"; +import type { CancellationToken } from "#src/util/cancellation.js"; +import { CancellationTokenSource } from "#src/util/cancellation.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"; @@ -173,7 +181,9 @@ import { verifyEnumString, verifyFiniteFloat, verifyFinitePositiveFloat, + verifyFloatArray, verifyInt, + verifyIntegerArray, verifyNonnegativeInt, verifyObject, verifyObjectProperty, @@ -181,7 +191,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 { @@ -215,6 +227,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), @@ -229,6 +242,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 @@ -239,8 +254,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); @@ -814,47 +832,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"; + +const FIND_PATH_JSON_KEY = "findPath"; +const TARGET_JSON_KEY = "target"; +const CENTROIDS_JSON_KEY = "centroids"; +const PRECISION_MODE_JSON_KEY = "precision"; -class GrapheneState implements Trackable { +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(), }; } @@ -865,6 +917,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); + }); } } @@ -886,6 +941,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(); @@ -893,15 +973,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, @@ -909,17 +980,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(), @@ -965,6 +1033,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(); @@ -995,8 +1204,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(); @@ -1004,15 +1235,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), @@ -1079,6 +1301,7 @@ class MulticutState extends RefCounted implements Trackable { class GraphConnection extends SegmentationGraphSourceConnection { public annotationLayerStates: AnnotationLayerState[] = []; public mergeAnnotationState: AnnotationLayerState; + public findPathAnnotationState: AnnotationLayerState; constructor( public graph: GrapheneGraphSource, @@ -1088,27 +1311,31 @@ 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.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, findPathState }, } = this; const loadedSubsource = getGraphLoadedSubsource(layer)!; const redGroup = makeColoredAnnotationState( @@ -1150,80 +1377,163 @@ 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; - }); - if (changed) { - merges.value = filtered; - } - }); + }), + ); } + + 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; + } + }); + let findPathCancellation: CancellationTokenSource | undefined = undefined; + const findPathChanged = () => { + if (findPathCancellation) { + findPathCancellation.cancel(); + } + 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(() => { + if (findPathCancellation) { + findPathCancellation.cancel(); + } + const loadedSubsource = getGraphLoadedSubsource(this.layer)!; + const annotationToNanometers = + loadedSubsource.loadedDataSource.transform.inputSpace.value.scales.map( + (x) => x / 1e-9, + ); + findPathCancellation = new CancellationTokenSource(); + this.submitFindPath( + findPathState.precisionMode.value, + annotationToNanometers, + findPathCancellation, + ).then((success) => { + success; + findPathCancellation = undefined; + }); + }), + ); + findPathChanged(); // initial state } createRenderLayers( @@ -1346,7 +1656,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; } @@ -1385,27 +1695,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) => { @@ -1420,7 +1738,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( @@ -1429,11 +1746,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"; @@ -1456,20 +1779,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[]) => { @@ -1490,10 +1799,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(); @@ -1530,24 +1842,55 @@ class GraphConnection extends SegmentationGraphSourceConnection { } else if (submission.mergedRoot) { segmentsToAdd.push(submission.mergedRoot); } + const segmentsState = + this.layer.displayState.segmentationGroupState.value; + const { visibleSegments, selectedSegments } = segmentsState; + selectedSegments.delete(segmentsToRemove); + const latestRoots = + await this.graph.graphServer.filterLatestRoots(segmentsToAdd); + this.meshAddNewSegments(latestRoots); + selectedSegments.add(latestRoots); + visibleSegments.add(latestRoots); + merges.changed.dispatch(); } const segmentsState = this.layer.displayState.segmentationGroupState.value; const { visibleSegments, selectedSegments } = segmentsState; selectedSegments.delete(segmentsToRemove); const latestRoots = await this.graph.graphServer.filterLatestRoots(segmentsToAdd); - this.meshAddNewSegments(latestRoots); selectedSegments.add(latestRoots); visibleSegments.add(latestRoots); merges.changed.dispatch(); } + + async submitFindPath( + precisionMode: boolean, + annotationToNanometers: Float64Array, + cancellationToken?: CancellationToken, + ): 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, + cancellationToken, + ); + 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(); } @@ -1577,20 +1920,34 @@ 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}`); + } + if (e instanceof DOMException && e.name === "AbortError") { + dispose(); + StatusMessage.showTemporaryMessage("Request was aborted."); } 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 { @@ -1615,16 +1972,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 === "") { @@ -1637,14 +1993,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, @@ -1653,7 +2003,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); @@ -1666,7 +2016,6 @@ class GrapheneGraphServerInterface { async splitSegments( first: SegmentSelection[], second: SegmentSelection[], - annotationToNanometers: Float64Array, ): Promise { const { url } = this; if (url === "") { @@ -1679,14 +2028,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, @@ -1697,9 +2040,9 @@ 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; } @@ -1712,35 +2055,87 @@ class GrapheneGraphServerInterface { 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()) { + for (const [i, isLatest] of jsonResp["is_latest"].entries()) { if (isLatest) { res.push(segments[i]); } } return res; } + + async findPath( + first: SegmentSelection, + second: SegmentSelection, + precisionMode: boolean, + cancellationToken?: CancellationToken, + ) { + 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, + cancellationToken, + ); + + 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, + }; + } } class GrapheneGraphSource extends SegmentationGraphSource { private connections = new Set(); public graphServer: GrapheneGraphServerInterface; + private l2CacheAvailable: boolean | undefined = undefined; constructor( public info: GrapheneMultiscaleVolumeInfo, - credentialsProvider: SpecialProtocolCredentialsProvider, + private credentialsProvider: SpecialProtocolCredentialsProvider, private chunkSource: GrapheneMultiscaleVolumeChunkSource, public state: GrapheneState, ) { @@ -1776,10 +2171,84 @@ class GrapheneGraphSource extends SegmentationGraphSource { ); } + 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) { return this.graphServer.getRoot(segment); } + async findPath( + first: SegmentSelection, + second: SegmentSelection, + precisionMode: boolean, + annotationToNanometers: Float64Array, + cancellationToken?: CancellationToken, + ): 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, + cancellationToken, + ); + 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, + cancellationToken, + ); + + // 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( layer: SegmentationUserLayer, context: DependentViewContext, @@ -1803,6 +2272,13 @@ 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", + }), + ); parent.appendChild(toolbox); parent.appendChild( context.registerDisposer( @@ -1843,6 +2319,15 @@ class ChunkedGraphChunkSource { spec: ChunkedGraphChunkSpecification; OPTIONS: { spec: ChunkedGraphChunkSpecification }; + + constructor( + chunkManager: ChunkManager, + options: { + spec: ChunkedGraphChunkSpecification; + }, + ) { + super(chunkManager, options); + } } class GrapheneChunkedGraphChunkSource extends WithParameters( @@ -1852,12 +2337,13 @@ class GrapheneChunkedGraphChunkSource extends WithParameters( ChunkedGraphSourceParameters, ) {} -type ChunkedGraphLayerDisplayState = SegmentationDisplayState3D; +interface ChunkedGraphLayerDisplayState extends SegmentationDisplayState3D {} -type TransformedChunkedGraphSource = FrontendTransformedSource< - SliceViewRenderLayer, - ChunkedGraphChunkSource ->; +interface TransformedChunkedGraphSource + extends FrontendTransformedSource< + SliceViewRenderLayer, + ChunkedGraphChunkSource + > {} interface AttachmentState { chunkTransform: ValueOrError; @@ -1992,6 +2478,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; @@ -2022,51 +2509,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); } }; @@ -2137,7 +2623,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", @@ -2316,7 +2802,7 @@ class MulticutSegmentsTool extends LayerTool { } get description() { - return "multicut"; + return `multicut`; } } @@ -2382,7 +2868,7 @@ export class MergeSegmentsPlaceLineTool extends PlaceLineTool { return this.annotationState; } get description() { - return "merge line"; + return `merge line`; } toJSON() { return ANNOTATE_MERGE_LINE_TOOL_ID; @@ -2432,8 +2918,6 @@ function mergeToLine(submission: MergeSubmission): Line { const MAX_MERGE_COUNT = 10; -// on error, copy (also clean up error message) - const MERGE_SEGMENTS_INPUT_EVENT_MAP = EventActionMap.fromObject({ "at:shift?+enter": { action: "submit" }, }); @@ -2463,7 +2947,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; @@ -2569,7 +3053,150 @@ class MergeSegmentsTool extends LayerTool { } get description() { - return "merge segments"; + return `merge segments`; + } +} + +const FIND_PATH_INPUT_EVENT_MAP = EventActionMap.fromObject({ + "at:shift?+enter": { action: "submit" }, + escape: { action: "clearPath" }, + "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(); + }; + const clearPath = () => { + findPathState.source.reset(); + findPathState.target.reset(); + findPathState.centroids.reset(); + }; + body.appendChild( + makeIcon({ + text: "Submit", + title: "Submit Find Path", + onClick: () => { + submitAction(); + }, + }), + ); + body.appendChild( + makeIcon({ + text: "Clear", + title: "Clear Find Path", + onClick: clearPath, + }), + ); + 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 && target.value) { + clearPath(); + } + 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; + } + } + })(); + }); + activation.bindAction("clearPath", clearPath); + } + + toJSON() { + return GRAPHENE_FIND_PATH_TOOL_ID; + } + + get description() { + return `find path`; } } @@ -2589,6 +3216,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..63eeaba75 100644 --- a/src/datasource/graphene/graphene.css +++ b/src/datasource/graphene/graphene.css @@ -14,12 +14,16 @@ color: #4444ff; } -.graphene-multicut-status { +.graphene-tool-status { display: flex; - flex-direction: row; + gap: 10px; +} + +.graphene-tool-status .neuroglancer-icon { + height: 100%; } -.graphene-multicut-status > .activeGroupIndicator { +.graphene-multicut > .activeGroupIndicator { padding: 2px; margin: auto 0; background-color: red; @@ -27,15 +31,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,16 +53,6 @@ gap: 10px; } -.graphene-merge-segments-status { - display: flex; - gap: 10px; -} - -.graphene-merge-segments-status .neuroglancer-icon, -.graphene-multicut-status .neuroglancer-icon { - height: 100%; -} - .graphene-merge-segments-status label { display: grid; grid-template-columns: min-content min-content; @@ -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/status.ts b/src/status.ts index 8aaf9058a..a652a8866 100644 --- a/src/status.ts +++ b/src/status.ts @@ -29,6 +29,7 @@ export class StatusMessage { if (statusContainer === null) { statusContainer = document.createElement("ul"); statusContainer.id = "statusContainer"; + // statusContainer.tabIndex = -1; // TODO why was this added? const el: HTMLElement | null = document.getElementById( "neuroglancer-container", ); diff --git a/src/ui/annotations.ts b/src/ui/annotations.ts index c799c90be..f84ccf261 100644 --- a/src/ui/annotations.ts +++ b/src/ui/annotations.ts @@ -609,7 +609,39 @@ export class AnnotationLayerView extends Tab { private render(index: number) { const { annotation, state } = this.listElements[index]; - return this.makeAnnotationListElement(annotation, state); + const { + layer, + gridTemplate, + globalDimensionIndices, + localDimensionIndices, + } = this; + const [element, elementColumnWidths] = makeAnnotationListElement( + layer, + annotation, + state, + gridTemplate, + globalDimensionIndices, + localDimensionIndices, + ); + for (const [column, width] of elementColumnWidths.entries()) { + this.setColumnWidth(column, width); + } + element.addEventListener("mouseenter", () => { + this.displayState.hoverState.value = { + id: annotation.id, + partIndex: 0, + annotationLayerState: state, + }; + }); + const selectionState = this.selectedAnnotationState.value; + if ( + selectionState !== undefined && + selectionState.annotationLayerState === state && + selectionState.annotationId === annotation.id + ) { + element.classList.add("neuroglancer-annotation-selected"); + } + return element; } private setColumnWidth(column: number, width: number) { @@ -829,141 +861,6 @@ export class AnnotationLayerView extends Tab { this.updateHoverView(); this.updateSelectionView(); } - - private makeAnnotationListElement( - annotation: Annotation, - state: AnnotationLayerState, - ) { - const chunkTransform = state.chunkTransform - .value as ChunkTransformParameters; - const element = document.createElement("div"); - element.classList.add("neuroglancer-annotation-list-entry"); - element.dataset.color = state.displayState.color.toString(); - element.style.gridTemplateColumns = this.gridTemplate; - const icon = document.createElement("div"); - icon.className = "neuroglancer-annotation-icon"; - icon.textContent = annotationTypeHandlers[annotation.type].icon; - element.appendChild(icon); - - let deleteButton: HTMLElement | undefined; - - const maybeAddDeleteButton = () => { - if (state.source.readonly) return; - if (deleteButton !== undefined) return; - deleteButton = makeDeleteButton({ - title: "Delete annotation", - onClick: (event) => { - event.stopPropagation(); - event.preventDefault(); - const ref = state.source.getReference(annotation.id); - try { - state.source.delete(ref); - } finally { - ref.dispose(); - } - }, - }); - deleteButton.classList.add("neuroglancer-annotation-list-entry-delete"); - element.appendChild(deleteButton); - }; - - let numRows = 0; - visitTransformedAnnotationGeometry( - annotation, - chunkTransform, - (layerPosition, isVector) => { - isVector; - ++numRows; - const position = document.createElement("div"); - position.className = "neuroglancer-annotation-position"; - element.appendChild(position); - let i = 0; - const addDims = ( - viewDimensionIndices: readonly number[], - layerDimensionIndices: readonly number[], - ) => { - for (const viewDim of viewDimensionIndices) { - const layerDim = layerDimensionIndices[viewDim]; - if (layerDim !== -1) { - const coord = Math.floor(layerPosition[layerDim]); - const coordElement = document.createElement("div"); - const text = coord.toString(); - coordElement.textContent = text; - coordElement.classList.add("neuroglancer-annotation-coordinate"); - coordElement.style.gridColumn = `dim ${i + 1}`; - this.setColumnWidth(i, text.length); - position.appendChild(coordElement); - } - ++i; - } - }; - addDims( - this.globalDimensionIndices, - chunkTransform.modelTransform.globalToRenderLayerDimensions, - ); - addDims( - this.localDimensionIndices, - chunkTransform.modelTransform.localToRenderLayerDimensions, - ); - maybeAddDeleteButton(); - }, - ); - if (annotation.description) { - ++numRows; - const description = document.createElement("div"); - description.classList.add("neuroglancer-annotation-description"); - description.textContent = annotation.description; - element.appendChild(description); - } - icon.style.gridRow = `span ${numRows}`; - if (deleteButton !== undefined) { - deleteButton.style.gridRow = `span ${numRows}`; - } - element.addEventListener("mouseenter", () => { - this.displayState.hoverState.value = { - id: annotation.id, - partIndex: 0, - annotationLayerState: state, - }; - this.layer.selectAnnotation(state, annotation.id, false); - }); - element.addEventListener("action:select-position", (event) => { - event.stopPropagation(); - this.layer.selectAnnotation(state, annotation.id, "toggle"); - }); - - element.addEventListener("action:pin-annotation", (event) => { - event.stopPropagation(); - this.layer.selectAnnotation(state, annotation.id, true); - }); - - element.addEventListener("action:move-to-annotation", (event) => { - event.stopPropagation(); - event.preventDefault(); - 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(this.layer, chunkTransform, layerPosition); - }); - - const selectionState = this.selectedAnnotationState.value; - if ( - selectionState !== undefined && - selectionState.annotationLayerState === state && - selectionState.annotationId === annotation.id - ) { - element.classList.add("neuroglancer-annotation-selected"); - } - return element; - } } export class AnnotationTab extends Tab { @@ -2153,3 +2050,128 @@ export function UserLayerWithAnnotationsMixin< export type UserLayerWithAnnotations = InstanceType< ReturnType >; + +export function makeAnnotationListElement( + layer: UserLayerWithAnnotations, + annotation: Annotation, + state: AnnotationLayerState, + gridTemplate: string, + globalDimensionIndices: number[], + localDimensionIndices: number[], +): [HTMLDivElement, number[]] { + const chunkTransform = state.chunkTransform.value as ChunkTransformParameters; + const element = document.createElement("div"); + element.classList.add("neuroglancer-annotation-list-entry"); + element.dataset.color = state.displayState.color.toString(); + element.style.gridTemplateColumns = gridTemplate; + const icon = document.createElement("div"); + icon.className = "neuroglancer-annotation-icon"; + icon.textContent = annotationTypeHandlers[annotation.type].icon; + element.appendChild(icon); + + let deleteButton: HTMLElement | undefined; + + const maybeAddDeleteButton = () => { + if (state.source.readonly) return; + if (deleteButton !== undefined) return; + deleteButton = makeDeleteButton({ + title: "Delete annotation", + onClick: (event) => { + event.stopPropagation(); + event.preventDefault(); + const ref = state.source.getReference(annotation.id); + try { + state.source.delete(ref); + } finally { + ref.dispose(); + } + }, + }); + deleteButton.classList.add("neuroglancer-annotation-list-entry-delete"); + element.appendChild(deleteButton); + }; + + const columnWidths: number[] = []; + + let numRows = 0; + visitTransformedAnnotationGeometry( + annotation, + chunkTransform, + (layerPosition, isVector) => { + isVector; + ++numRows; + const position = document.createElement("div"); + position.className = "neuroglancer-annotation-position"; + element.appendChild(position); + let i = 0; + + const addDims = ( + viewDimensionIndices: readonly number[], + layerDimensionIndices: readonly number[], + ) => { + for (const viewDim of viewDimensionIndices) { + const layerDim = layerDimensionIndices[viewDim]; + if (layerDim !== -1) { + const coord = Math.floor(layerPosition[layerDim]); + const coordElement = document.createElement("div"); + const text = coord.toString(); + coordElement.textContent = text; + coordElement.classList.add("neuroglancer-annotation-coordinate"); + coordElement.style.gridColumn = `dim ${i + 1}`; + columnWidths[i] = Math.max(columnWidths[i] || 0, text.length); + position.appendChild(coordElement); + } + ++i; + } + }; + addDims( + globalDimensionIndices, + chunkTransform.modelTransform.globalToRenderLayerDimensions, + ); + addDims( + localDimensionIndices, + chunkTransform.modelTransform.localToRenderLayerDimensions, + ); + maybeAddDeleteButton(); + }, + ); + if (annotation.description) { + ++numRows; + const description = document.createElement("div"); + description.classList.add("neuroglancer-annotation-description"); + description.textContent = annotation.description; + element.appendChild(description); + } + icon.style.gridRow = `span ${numRows}`; + if (deleteButton !== undefined) { + deleteButton.style.gridRow = `span ${numRows}`; + } + element.addEventListener("mouseenter", () => { + layer.selectAnnotation(state, annotation.id, false); + }); + element.addEventListener("action:select-position", (event) => { + event.stopPropagation(); + layer.selectAnnotation(state, annotation.id, "toggle"); + }); + element.addEventListener("action:pin-annotation", (event) => { + event.stopPropagation(); + layer.selectAnnotation(state, annotation.id, true); + }); + element.addEventListener("action:move-to-annotation", (event) => { + event.stopPropagation(); + event.preventDefault(); + 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); + }); + return [element, columnWidths]; +} diff --git a/src/util/json.ts b/src/util/json.ts index 793ad1604..ad3f0ec58 100644 --- a/src/util/json.ts +++ b/src/util/json.ts @@ -702,6 +702,16 @@ export function verifyIntegerArray(a: unknown) { return a; } +export function verifyFloatArray(a: unknown) { + if (!Array.isArray(a)) { + throw new Error(`Expected array, received: ${JSON.stringify(a)}.`); + } + for (const x of a) { + verifyFloat(x); + } + return a; +} + export function verifyBoolean(x: any) { if (typeof x !== "boolean") { throw new Error(`Expected boolean, received: ${JSON.stringify(x)}`);