diff --git a/src/neuroglancer/datasource/graphene/frontend.ts b/src/neuroglancer/datasource/graphene/frontend.ts index 16ee5a8309..691fd90e6e 100644 --- a/src/neuroglancer/datasource/graphene/frontend.ts +++ b/src/neuroglancer/datasource/graphene/frontend.ts @@ -64,6 +64,8 @@ import { makeIcon } from 'neuroglancer/widget/icon'; import { EventActionMap } from 'neuroglancer/util/event_action_map'; import { packColor } from 'neuroglancer/util/color'; import { Uint64Set } from 'neuroglancer/uint64_set'; +import { addLayerControlToOptionsTab, LayerControlFactory, registerLayerControl } from 'neuroglancer/widget/layer_control'; +import { DateTimeInputWidget } from 'neuroglancer/widget/datetime_input'; function vec4FromVec3(vec: vec3, alpha = 0) { const res = vec4.clone([...vec]); @@ -506,24 +508,32 @@ const SEGMENT_ID_JSON_KEY = "segmentId"; const ROOT_ID_JSON_KEY = "rootId"; const POSITION_JSON_KEY = "position"; +const TIMESTAMP_JSON_KEY = "timestamp"; + class GrapheneState implements Trackable { changed = new NullarySignal(); public multicutState = new MulticutState(); + public timestamp: TrackableValue = new TrackableValue(0, x => x); constructor() { this.multicutState.changed.add(() => { this.changed.dispatch(); }); + this.timestamp.changed.add(() => { + this.changed.dispatch(); + }); } reset() { this.multicutState.reset(); + this.timestamp.reset(); } toJSON() { return { [MULTICUT_JSON_KEY]: this.multicutState.toJSON(), + [TIMESTAMP_JSON_KEY]: this.timestamp.toJSON(), } } @@ -531,6 +541,9 @@ class GrapheneState implements Trackable { verifyOptionalObjectProperty(x, MULTICUT_JSON_KEY, value => { this.multicutState.restoreState(value); }); + verifyOptionalObjectProperty(x, TIMESTAMP_JSON_KEY, value => { + this.timestamp.restoreState(value); + }); } } @@ -647,6 +660,13 @@ class GraphConnection extends SegmentationGraphSourceConnection { public state: GrapheneState) { 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); + }); + segmentsState.visibleSegments.changed.add((segmentIds: Uint64[]|Uint64|null, add: boolean) => { if (segmentIds !== null) { segmentIds = Array().concat(segmentIds); @@ -681,44 +701,35 @@ class GraphConnection extends SegmentationGraphSourceConnection { private visibleSegmentsChanged(segments: Uint64[]|null, added: boolean) { const {segmentsState} = this; - + const {focusSegment: {value: focusSegment}} = this.graph.state.multicutState; + if (focusSegment && !segmentsState.visibleSegments.has(focusSegment)) { + if (segmentsState.selectedSegments.has(focusSegment)) { + StatusMessage.showTemporaryMessage(`Can't hide active multicut segment.`, 3000); + } else { + StatusMessage.showTemporaryMessage(`Can't deselect active multicut segment.`, 3000); + } + segmentsState.selectedSegments.add(focusSegment); + segmentsState.visibleSegments.add(focusSegment); + if (segments) { + segments = segments.filter(segment => !Uint64.equal(segment, focusSegment)); + } + } if (segments === null) { - const leafSegmentCount = this.segmentsState.visibleSegments.size; + const leafSegmentCount = this.segmentsState.selectedSegments.size; this.segmentsState.segmentEquivalences.clear(); - StatusMessage.showTemporaryMessage(`Deselected all ${leafSegmentCount} segments.`, 3000); + StatusMessage.showTemporaryMessage(`Hid all ${leafSegmentCount} segments.`, 3000); return; } - for (const segmentId of segments) { - const isBaseSegment = isBaseSegmentId(segmentId, this.graph.info.graph.nBitsForLayerId); - - const segmentConst = segmentId.clone(); - - if (added) { - if (isBaseSegment) { - this.graph.getRoot(segmentConst).then(rootId => { - segmentsState.visibleSegments.delete(segmentConst); - segmentsState.visibleSegments.add(rootId); - }); - } - } else if (!isBaseSegment) { - const {focusSegment: {value: focusSegment}} = this.graph.state.multicutState; - if (focusSegment && Uint64.equal(segmentId, focusSegment)) { - segmentsState.visibleSegments.add(segmentId); - StatusMessage.showTemporaryMessage(`Can't deselect active multicut segment.`, 3000); - return; - } - + if (!added) { const segmentCount = [...segmentsState.segmentEquivalences.setElements(segmentId)].length; // Approximation - segmentsState.segmentEquivalences.deleteSet(segmentId); - if (this.lastDeselectionMessage && this.lastDeselectionMessageExists) { this.lastDeselectionMessage.dispose(); this.lastDeselectionMessageExists = false; } this.lastDeselectionMessage = - StatusMessage.showMessage(`Deselected ${segmentCount} segments.`); + StatusMessage.showMessage(`Hid ${segmentCount} segments.`); this.lastDeselectionMessageExists = true; setTimeout(() => { if (this.lastDeselectionMessageExists) { @@ -729,6 +740,30 @@ class GraphConnection extends SegmentationGraphSourceConnection { } } } + + private selectedSegmentsChanged(segments: Uint64[]|null, added: boolean) { + const {segmentsState} = this; + if (segments === null) { + const leafSegmentCount = this.segmentsState.selectedSegments.size; + StatusMessage.showTemporaryMessage(`Deselected all ${leafSegmentCount} segments.`, 3000); + return; + } + for (const segmentId of segments) { + const isBaseSegment = isBaseSegmentId(segmentId, this.graph.info.graph.nBitsForLayerId); + const segmentConst = segmentId.clone(); + if (added) { + if (isBaseSegment) { + this.graph.getRoot(segmentConst).then(rootId => { + if (segmentsState.visibleSegments.has(segmentConst)) { + segmentsState.visibleSegments.add(rootId); + } + segmentsState.selectedSegments.delete(segmentConst); + segmentsState.selectedSegments.add(rootId); + }); + } + } + } + } computeSplit(include: Uint64, exclude: Uint64): ComputedSplit|undefined { include; @@ -751,7 +786,11 @@ class GraphConnection extends SegmentationGraphSourceConnection { const focusSegment = multicutState.focusSegment.value!; multicutState.reset(); // need to clear the focus segment before deleting the multicut segment const {segmentsState} = this; - segmentsState.visibleSegments.delete(focusSegment); + segmentsState.selectedSegments.delete(focusSegment); + for (const segment of [...sinks, ...sources]) { + segmentsState.selectedSegments.delete(segment.rootId); + } + segmentsState.selectedSegments.add(splitRoots); segmentsState.visibleSegments.add(splitRoots); return true; } @@ -793,11 +832,18 @@ export const GRAPH_SERVER_NOT_SPECIFIED = Symbol('Graph Server Not Specified.'); class GrapheneGraphServerInterface { constructor(private url: string, private credentialsProvider: SpecialProtocolCredentialsProvider) {} - async getRoot(segment: Uint64, timestamp = '') { - const timestampEpoch = (new Date(timestamp)).valueOf() / 1000; + async getTimestampLimit() { + const response = await cancellableFetchSpecialOk( + this.credentialsProvider, `${this.url}/oldest_timestamp`, {}, responseJson); + const isoString = verifyObjectProperty(response, 'iso', verifyString); + return new Date(isoString).valueOf(); + } + + async getRoot(segment: Uint64, timestamp = 0) { + const timestampEpoch = timestamp / 1000; const url = `${this.url}/node/${String(segment)}/root?int64_as_str=1${ - Number.isNaN(timestampEpoch) ? '' : `×tamp=${timestampEpoch}`}` + timestamp > 0 ? `×tamp=${timestampEpoch}` : ''}` const promise = cancellableFetchSpecialOk( this.credentialsProvider, @@ -864,6 +910,7 @@ class GrapheneGraphServerInterface { class GrapheneGraphSource extends SegmentationGraphSource { private connections = new Set(); public graphServer: GrapheneGraphServerInterface; + public timestampLimit: TrackableValue = new TrackableValue(0, x => x); constructor(public info: GrapheneMultiscaleVolumeInfo, credentialsProvider: SpecialProtocolCredentialsProvider, @@ -871,6 +918,9 @@ class GrapheneGraphSource extends SegmentationGraphSource { public state: GrapheneState) { super(); this.graphServer = new GrapheneGraphServerInterface(info.app!.segmentationUrl, credentialsProvider); + this.graphServer.getTimestampLimit().then(limit => { + this.timestampLimit.value = limit; + }); } connect(layer: SegmentationUserLayer): Owned { @@ -890,12 +940,13 @@ class GrapheneGraphSource extends SegmentationGraphSource { } getRoot(segment: Uint64) { - return this.graphServer.getRoot(segment); + return this.graphServer.getRoot(segment, this.state.timestamp.value); } tabContents(layer: SegmentationUserLayer, context: DependentViewContext, tab: SegmentationGraphSourceTab) { const parent = document.createElement('div'); parent.style.display = 'contents'; + parent.appendChild(addLayerControlToOptionsTab(tab, layer, tab.visibility, timeControl)); const toolbox = document.createElement('div'); toolbox.className = 'neuroglancer-segmentation-toolbox'; toolbox.appendChild(makeToolButton(context, layer, { @@ -1261,7 +1312,7 @@ class MulticutSegmentsTool extends Tool { activation.bindAction('set-anchor', event => { event.stopPropagation(); - const currentSegmentSelection = maybeGetSelection(this, segmentationGroupState.visibleSegments); + const currentSegmentSelection = maybeGetSelection(this, segmentationGroupState.visibleSegments, graphConnection.graph); if (!currentSegmentSelection) return; const {rootId, segmentId} = currentSegmentSelection; const {focusSegment, segments} = multicutState; @@ -1295,7 +1346,11 @@ class MulticutSegmentsTool extends Tool { } } -const maybeGetSelection = (tool: Tool, visibleSegments: Uint64Set): SegmentSelection|undefined => { +const maybeGetSelection = (tool: Tool, visibleSegments: Uint64Set, graph: GrapheneGraphSource): SegmentSelection|undefined => { + if (graph.state.timestamp.value !== 0) { + StatusMessage.showTemporaryMessage('Editing can not be performed with a segmentation at an older state.'); + return; + } const {layer, mouseState} = tool; const {segmentSelectionState: {value, baseValue}} = layer.displayState; if (!baseValue || !value) return; @@ -1384,22 +1439,23 @@ class MergeSegmentsTool extends Tool { (async () => { const lastSegmentSelection = this.lastAnchorSelection.value; if (!lastSegmentSelection) { // first selection - const selection = maybeGetSelection(this, segmentationGroupState.visibleSegments); + const selection = maybeGetSelection(this, segmentationGroupState.visibleSegments, graph); if (selection) { this.lastAnchorSelection.value = selection; setSink(selection.rootId); } } else if (!activeSubmission) { - const selection = maybeGetSelection(this, segmentationGroupState.visibleSegments); + const selection = maybeGetSelection(this, segmentationGroupState.visibleSegments, graph); if (selection) { activeSubmission = true; setSource(selection.rootId); const loadedSubsource = getGraphLoadedSubsource(this.layer)!; const annotationToNanometers = loadedSubsource.loadedDataSource.transform.inputSpace.value.scales.map(x => x / 1e-9); const mergedRoot = await graph.graphServer.mergeSegments(lastSegmentSelection, selection, annotationToNanometers); - const {visibleSegments} = segmentationGroupState; - visibleSegments.delete(lastSegmentSelection.rootId); - visibleSegments.delete(selection.rootId); + const {selectedSegments, visibleSegments} = segmentationGroupState; + selectedSegments.delete(lastSegmentSelection.rootId); + selectedSegments.delete(selection.rootId); + selectedSegments.add(mergedRoot); visibleSegments.add(mergedRoot); this.lastAnchorSelection.value = undefined; activation.cancel(); @@ -1425,3 +1481,63 @@ registerLayerTool(SegmentationUserLayer, GRAPHENE_MULTICUT_SEGMENTS_TOOL_ID, lay registerLayerTool(SegmentationUserLayer, GRAPHENE_MERGE_SEGMENTS_TOOL_ID, layer => { return new MergeSegmentsTool(layer, true); }); + +const GRAPHENE_TIME_JSON_KEY = 'grapheneTime'; + +const timeControl = { + label: 'Time', + title: 'View segmentation at earlier point of time', + toolJson: GRAPHENE_TIME_JSON_KEY, + hideStatus: true, + foo: true, + ...timeLayerControl(), +}; + +registerLayerControl(SegmentationUserLayer, timeControl); + +function timeLayerControl(): LayerControlFactory { + return { + makeControl: (layer, context) => { + const segmentationGroupState = layer.displayState.segmentationGroupState.value; + const {graph: {value: graph}} = segmentationGroupState; + const timestamp = graph instanceof GrapheneGraphSource ? graph.state.timestamp : new TrackableValue(0, x => x); + const timestampLimit = graph instanceof GrapheneGraphSource ? graph.timestampLimit : new TrackableValue(0, x => x); + const controlElement = document.createElement('div'); + controlElement.classList.add('neuroglancer-time-control'); + const intermediateTimestamp = new TrackableValue(timestamp.value, x => x); + intermediateTimestamp.changed.add(() => { + if (intermediateTimestamp.value === timestamp.value) { + return; + } + if (confirm('Changing timestamp will clear all selected segments')) { + if (graph instanceof GrapheneGraphSource) { + graph.state.multicutState.reset(); + } + timestamp.value = intermediateTimestamp.value; + } else { + intermediateTimestamp.value = timestamp.value; + } + }); + const widget = + context.registerDisposer(new DateTimeInputWidget(intermediateTimestamp, new Date(timestampLimit.value), new Date())); + timestampLimit.changed.add(() => { + widget.setMin(new Date(timestampLimit.value)); + }); + timestamp.changed.add(() => { + segmentationGroupState.selectedSegments.clear(); + segmentationGroupState.temporaryVisibleSegments.clear(); // TODO, do we care about this? maybe we need to force close/clear multicut + if (timestamp.value !== intermediateTimestamp.value) { + intermediateTimestamp.value = timestamp.value; + } + }); + controlElement.appendChild(widget.element); + return {controlElement, control: widget}; + }, + activateTool: (_activation, control: DateTimeInputWidget) => { + if ('showPicker' in HTMLInputElement.prototype) { + // esbuild complaining, maybe update + (control.element as any).showPicker(); + } + }, + }; +} diff --git a/src/neuroglancer/datasource/middleauth/register_credentials_provider.ts b/src/neuroglancer/datasource/middleauth/register_credentials_provider.ts index 788609f05e..054e286ed7 100644 --- a/src/neuroglancer/datasource/middleauth/register_credentials_provider.ts +++ b/src/neuroglancer/datasource/middleauth/register_credentials_provider.ts @@ -16,7 +16,7 @@ import {defaultCredentialsManager} from 'neuroglancer/credentials_provider/default_manager'; import {MiddleAuthCredentialsProvider, MiddleAuthAppCredentialsProvider} from 'neuroglancer/datasource/middleauth/credentials_provider'; -import { CredentialsManager } from 'src/neuroglancer/credentials_provider'; +import { CredentialsManager } from 'neuroglancer/credentials_provider'; defaultCredentialsManager.register('middleauth', serverUrl => new MiddleAuthCredentialsProvider(serverUrl)); diff --git a/src/neuroglancer/datasource/nggraph/frontend.ts b/src/neuroglancer/datasource/nggraph/frontend.ts index e62eb2df08..4e120e3a77 100644 --- a/src/neuroglancer/datasource/nggraph/frontend.ts +++ b/src/neuroglancer/datasource/nggraph/frontend.ts @@ -218,9 +218,12 @@ class GraphConnection extends SegmentationGraphSourceConnection { try { this.ignoreVisibleSegmentsChanged = true; if (this.segmentsState.visibleSegments.has(oldId)) { - this.segmentsState.visibleSegments.delete(oldId); this.segmentsState.visibleSegments.add(newId); } + if (this.segmentsState.selectedSegments.has(oldId)) { + this.segmentsState.selectedSegments.delete(oldId); + this.segmentsState.selectedSegments.add(newId); + } if (this.segmentsState.temporaryVisibleSegments.has(oldId)) { this.segmentsState.temporaryVisibleSegments.delete(oldId); this.segmentsState.temporaryVisibleSegments.add(newId); diff --git a/src/neuroglancer/segmentation_display_state/backend.ts b/src/neuroglancer/segmentation_display_state/backend.ts index 08277090a2..223635ca8a 100644 --- a/src/neuroglancer/segmentation_display_state/backend.ts +++ b/src/neuroglancer/segmentation_display_state/backend.ts @@ -43,6 +43,7 @@ export const withSegmentationLayerBackendState = >(Base: TBase) => class SegmentationLayerState extends Base implements VisibleSegmentsState { visibleSegments: Uint64Set; + selectedSegments: Uint64Set; segmentEquivalences: SharedDisjointUint64Sets; temporaryVisibleSegments: Uint64Set; temporarySegmentEquivalences: SharedDisjointUint64Sets; diff --git a/src/neuroglancer/segmentation_display_state/base.ts b/src/neuroglancer/segmentation_display_state/base.ts index 282358276d..033ffb4d11 100644 --- a/src/neuroglancer/segmentation_display_state/base.ts +++ b/src/neuroglancer/segmentation_display_state/base.ts @@ -23,6 +23,7 @@ import {VisibleSegmentEquivalencePolicy} from 'neuroglancer/segmentation_graph/s export interface VisibleSegmentsState { visibleSegments: Uint64Set; + selectedSegments: Uint64Set; segmentEquivalences: SharedDisjointUint64Sets; // Specifies a temporary/alternative set of segments/equivalences to use for display purposes, diff --git a/src/neuroglancer/segmentation_display_state/frontend.ts b/src/neuroglancer/segmentation_display_state/frontend.ts index a3b36b43d0..024fa8611b 100644 --- a/src/neuroglancer/segmentation_display_state/frontend.ts +++ b/src/neuroglancer/segmentation_display_state/frontend.ts @@ -40,6 +40,7 @@ import {Uint64} from 'neuroglancer/util/uint64'; import {withSharedVisibility} from 'neuroglancer/visibility_priority/frontend'; import {makeCopyButton} from 'neuroglancer/widget/copy_button'; import {makeFilterButton} from 'neuroglancer/widget/filter_button'; +import {makeStarButton} from 'neuroglancer/widget/star_button'; export class Uint64MapEntry { constructor(public key: Uint64, public value?: Uint64, public label?: string|undefined) {} @@ -270,6 +271,13 @@ const segmentWidgetTemplate = (() => { idElement.classList.add('neuroglancer-segment-list-entry-id'); const idIndex = idContainer.childElementCount; idContainer.appendChild(idElement); + const starButton = makeStarButton({ + title: `Star segment`, + }); + starButton.classList.add('neuroglancer-segment-list-entry-star'); + const starIndex = stickyContainer.childElementCount; + stickyContainer.appendChild(starButton); + const nameElement = document.createElement('span'); nameElement.classList.add('neuroglancer-segment-list-entry-name'); const labelIndex = template.childElementCount; @@ -289,6 +297,7 @@ const segmentWidgetTemplate = (() => { idIndex, labelIndex, filterIndex, + starIndex, unmappedIdIndex: -1, unmappedCopyIndex: -1 }; @@ -381,8 +390,12 @@ function makeRegisterSegmentWidgetEventHandlers(displayState: SegmentationDispla const idString = entryElement.dataset.id!; const id = tempStatedColor; id.tryParseString(idString); - const {visibleSegments} = displayState.segmentationGroupState.value; - visibleSegments.set(id, !visibleSegments.has(id)); + const {selectedSegments, visibleSegments} = displayState.segmentationGroupState.value; + const shouldBeVisible = !visibleSegments.has(id); + if (shouldBeVisible) { + selectedSegments.add(id); + } + visibleSegments.set(id, shouldBeVisible); event.stopPropagation(); }; @@ -421,6 +434,16 @@ function makeRegisterSegmentWidgetEventHandlers(displayState: SegmentationDispla stickyChildren[template.visibleIndex].addEventListener('click', visibleCheckboxHandler); children[template.filterIndex].addEventListener('click', filterHandler); element.addEventListener('action:select-position', selectHandler); + + const starButton = stickyChildren[template.starIndex] as HTMLElement; + starButton.addEventListener('click', (event: MouseEvent) => { + const entryElement = getEntryElement(event); + const idString = entryElement.dataset.id!; + const id = tempStatedColor + id.tryParseString(idString); + const {selectedSegments} = displayState.segmentationGroupState.value; + selectedSegments.set(id, !selectedSegments.has(id)); + }); }; } @@ -511,6 +534,8 @@ export class SegmentWidgetFactory