diff --git a/src/data_panel_layout.ts b/src/data_panel_layout.ts index 7154a5785..4fec6dd41 100644 --- a/src/data_panel_layout.ts +++ b/src/data_panel_layout.ts @@ -100,6 +100,7 @@ export interface ViewerUIState inputEventBindings: InputEventBindings; crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; + enableLayerColorWidget: TrackableBoolean; } export interface DataDisplayLayout extends RefCounted { @@ -180,6 +181,7 @@ export function getCommonViewerState(viewer: ViewerUIState) { selectedLayer: viewer.selectedLayer, visibility: viewer.visibility, scaleBarOptions: viewer.scaleBarOptions, + enableLayerColorWidget: viewer.enableLayerColorWidget, }; } diff --git a/src/layer/annotation/index.ts b/src/layer/annotation/index.ts index 1d20308cf..933015f0c 100644 --- a/src/layer/annotation/index.ts +++ b/src/layer/annotation/index.ts @@ -45,7 +45,10 @@ 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 { + makeCachedLazyDerivedWatchableValue, + observeWatchable, +} from "#src/trackable_value.js"; import type { AnnotationLayerView, MergedAnnotationStates, @@ -713,8 +716,47 @@ export class AnnotationUserLayer extends Base { return x; } + observeLayerColor(callback: () => void) { + const disposer = super.observeLayerColor(callback); + const subDisposer = observeWatchable( + callback, + this.annotationDisplayState.color, + ); + const shaderDisposer = observeWatchable( + callback, + this.annotationDisplayState.shader, + ); + return () => { + disposer(); + subDisposer(); + shaderDisposer(); + }; + } + + get automaticLayerBarColor() { + const shaderHasDefaultColor = + this.annotationDisplayState.shader.value.includes("defaultColor"); + if (shaderHasDefaultColor && this.annotationDisplayState.color.value) { + const [r, g, b] = this.annotationDisplayState.color.value; + return `rgb(${r * 255}, ${g * 255}, ${b * 255})`; + } + + return undefined; + } + + colorWidgetTooltip(): string | undefined { + const shaderHasDefaultColor = + this.annotationDisplayState.shader.value.includes("defaultColor"); + if (shaderHasDefaultColor && this.annotationDisplayState.color.value) { + return `The color comes from the selected shader default color`; + } + + return "Your shader code doesn't use the default color, we cannot determine which color you are using"; + } + static type = "annotation"; static typeAbbreviation = "ann"; + static supportsLayerBarColorSyncOption = true; } function makeShaderCodeWidget(layer: AnnotationUserLayer) { diff --git a/src/layer/index.ts b/src/layer/index.ts index 95fbf6502..bbe440e8f 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -185,6 +185,7 @@ export class UserLayer extends RefCounted { } static supportsPickOption = false; + static supportsLayerBarColorSyncOption = false; pick = new TrackableBoolean(true, true); @@ -192,6 +193,22 @@ export class UserLayer extends RefCounted { messages = new MessageList(); + observeLayerColor(_: () => void): () => void { + return () => {}; + } + + get automaticLayerBarColor(): string | undefined { + return ""; + } + + get layerBarColor(): string | undefined { + return this.automaticLayerBarColor; + } + + colorWidgetTooltip(): string | undefined { + return undefined; + } + initializeSelectionState(state: this["selectionState"]) { state.generation = -1; state.localPositionValid = false; @@ -740,6 +757,33 @@ export class ManagedUserLayer extends RefCounted { } } + get layerBarColor(): string | undefined { + const userLayer = this.layer; + return userLayer?.layerBarColor; + } + + colorWidgetTooltip(): string | undefined { + const userLayer = this.layer; + return userLayer?.colorWidgetTooltip(); + } + + observeLayerColor(callback: () => void): () => void { + const userLayer = this.layer; + if (userLayer !== null) { + return userLayer.observeLayerColor(callback); + } + return () => {}; + } + + get supportsLayerBarColorSyncOption() { + const userLayer = this.layer; + return ( + userLayer !== null && + (userLayer.constructor as typeof UserLayer) + .supportsLayerBarColorSyncOption + ); + } + /** * If layer is not null, tranfers ownership of a reference. */ diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 5efdc0ca8..8bca4ad07 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -95,6 +95,7 @@ import { IndirectWatchableValue, makeCachedDerivedWatchableValue, makeCachedLazyDerivedWatchableValue, + observeWatchable, registerNestedSync, TrackableValue, WatchableValue, @@ -1282,9 +1283,66 @@ export class SegmentationUserLayer extends Base { ); } + observeLayerColor(callback: () => void) { + const disposer = super.observeLayerColor(callback); + const defaultColorDisposer = observeWatchable( + callback, + this.displayState.segmentDefaultColor, + ); + const visibleSegmentDisposer = + this.displayState.segmentationGroupState.value.visibleSegments.changed.add( + callback, + ); + const colorHashChangeDisposer = + this.displayState.segmentationColorGroupState.value.segmentColorHash.changed.add( + callback, + ); + return () => { + disposer(); + defaultColorDisposer(); + visibleSegmentDisposer(); + colorHashChangeDisposer(); + }; + } + + get automaticLayerBarColor() { + if (this.displayState.segmentDefaultColor.value) { + const [r, g, b] = this.displayState.segmentDefaultColor.value; + return `rgb(${r * 255}, ${g * 255}, ${b * 255})`; + } + + const visibleSegments = + this.displayState.segmentationGroupState.value.visibleSegments; + if (visibleSegments.size === 1) { + const id = [...visibleSegments][0]; + const color = + this.displayState.segmentationColorGroupState.value.segmentColorHash.computeCssColor( + id, + ); + return color; + } + + return undefined; + } + + colorWidgetTooltip(): string { + if (this.displayState.segmentDefaultColor.value) { + return `The color comes from the manually selected color`; + } + + const visibleSegments = + this.displayState.segmentationGroupState.value.visibleSegments; + if (visibleSegments.size === 1) { + const id = [...visibleSegments][0]; + return `The color of the visible segment with id "${id}"`; + } + return "The segmentation layer has multiple segments visible"; + } + static type = "segmentation"; static typeAbbreviation = "seg"; static supportsPickOption = true; + static supportsLayerBarColorSyncOption = true; } registerLayerControls(SegmentationUserLayer); diff --git a/src/layer_group_viewer.ts b/src/layer_group_viewer.ts index c7b195e90..3e7517ed9 100644 --- a/src/layer_group_viewer.ts +++ b/src/layer_group_viewer.ts @@ -102,6 +102,7 @@ export interface LayerGroupViewerState { visibleLayerRoles: WatchableSet; crossSectionBackgroundColor: TrackableRGB; perspectiveViewBackgroundColor: TrackableRGB; + enableLayerColorWidget: TrackableBoolean; } export interface LayerGroupViewerOptions { @@ -381,6 +382,9 @@ export class LayerGroupViewer extends RefCounted { get scaleBarOptions() { return this.viewerState.scaleBarOptions; } + get enableLayerColorWidget() { + return this.viewerState.enableLayerColorWidget; + } layerPanel: LayerBar | undefined; layout: DataPanelLayoutContainer; toolBinder: LocalToolBinder; diff --git a/src/layer_groups_layout.ts b/src/layer_groups_layout.ts index bfcd190e8..a2a610639 100644 --- a/src/layer_groups_layout.ts +++ b/src/layer_groups_layout.ts @@ -420,6 +420,7 @@ function getCommonViewerState(viewer: Viewer) { velocity: viewer.velocity.addRef(), crossSectionBackgroundColor: viewer.crossSectionBackgroundColor, perspectiveViewBackgroundColor: viewer.perspectiveViewBackgroundColor, + enableLayerColorWidget: viewer.enableLayerColorWidget, }; } diff --git a/src/ui/layer_bar.css b/src/ui/layer_bar.css index 7d24208df..5b487649f 100644 --- a/src/ui/layer_bar.css +++ b/src/ui/layer_bar.css @@ -134,6 +134,101 @@ align-items: center; } +.neuroglancer-layer-color-value { + border-radius: 50%; + height: 10px; + width: 10px; +} + +.neuroglancer-layer-item[data-color="fixed"] .neuroglancer-layer-color-value { + border-radius: 50%; + height: 10px; + width: 10px; +} + +.neuroglancer-layer-item[data-color="rainbow"] .neuroglancer-layer-color-value { + position: relative; + border-radius: 50%; + height: 10px; + width: 10px; + overflow: hidden; +} + +.neuroglancer-layer-item[data-color="rainbow"] + .neuroglancer-layer-color-value::before { + content: ""; + position: absolute; + top: -17.5%; + left: -17.5%; + width: 135%; + height: 135%; + background: conic-gradient( + from 0deg, + hsl(0, 100%, 50%), + hsl(60, 100%, 50%), + hsl(120, 100%, 50%), + hsl(180, 100%, 50%), + hsl(240, 100%, 50%), + hsl(300, 100%, 50%), + hsl(360, 100%, 50%) + ); + filter: blur(1px); + transform: scale(1.35); +} + +.neuroglancer-layer-item[data-color="unsupported"] + .neuroglancer-layer-color-value { + background-image: linear-gradient( + 45deg, + rgba(128, 128, 128, 1.5) 25%, + transparent 25% + ), + linear-gradient(-45deg, rgba(128, 128, 128, 1.5) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(128, 128, 128, 1.5) 75%), + linear-gradient(-45deg, transparent 75%, rgba(128, 128, 128, 1.5) 75%); + background-size: 4px 4px; + background-position: + 0 0, + 0 2px, + 2px -2px, + -2px 0px; +} + +.neuroglancer-layer-color-value-wrapper { + position: relative; + width: 10px; + height: 10px; + padding: 5px; + margin: 0 4px; +} + +.neuroglancer-layer-item[data-color="unsupported"] + .neuroglancer-layer-color-value-wrapper { + position: relative; +} + +.neuroglancer-layer-item[data-visible="false"] + .neuroglancer-layer-color-value-wrapper + .neuroglancer-layer-color-value, +.neuroglancer-layer-item[data-visible="false"] + .neuroglancer-layer-color-value-wrapper + .neuroglancer-layer-color-value::before { + opacity: 0.4; +} + +.neuroglancer-layer-item[data-visible="false"] + .neuroglancer-layer-color-value-wrapper::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 1px; + background-color: rgb(255, 255, 255); + transform: translate(-50%, -50%) rotate(135deg); + z-index: 1; +} + .neuroglancer-layer-item-value { grid-row: 1; grid-column: 1; diff --git a/src/ui/layer_bar.ts b/src/ui/layer_bar.ts index 711a77e40..3b52fb6dc 100644 --- a/src/ui/layer_bar.ts +++ b/src/ui/layer_bar.ts @@ -21,7 +21,10 @@ import type { ManagedUserLayer } from "#src/layer/index.js"; import { addNewLayer, deleteLayer, makeLayer } from "#src/layer/index.js"; import type { LayerGroupViewer } from "#src/layer_group_viewer.js"; import { NavigationLinkType } from "#src/navigation_state.js"; -import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { + observeWatchable, + type WatchableValueInterface, +} from "#src/trackable_value.js"; import type { DropLayers } from "#src/ui/layer_drag_and_drop.js"; import { registerLayerBarDragLeaveHandler, @@ -45,6 +48,7 @@ class LayerWidget extends RefCounted { prefetchProgress = document.createElement("div"); labelElementText = document.createTextNode(""); valueElement = document.createElement("div"); + layerColorElement = document.createElement("div"); maxLength = 0; prevValueText = ""; @@ -61,6 +65,7 @@ class LayerWidget extends RefCounted { visibleProgress, prefetchProgress, labelElementText, + layerColorElement, } = this; element.className = "neuroglancer-layer-item neuroglancer-noselect"; element.appendChild(visibleProgress); @@ -103,7 +108,24 @@ class LayerWidget extends RefCounted { deleteLayer(this.layer); event.stopPropagation(); }); + + const layerColorElementWrapper = document.createElement("div"); + layerColorElementWrapper.className = + "neuroglancer-layer-color-value-wrapper"; + layerColorElement.className = "neuroglancer-layer-color-value"; + layerColorElementWrapper.appendChild(layerColorElement); + + this.registerDisposer( + observeWatchable((layerColorEnabled) => { + layerColorElementWrapper.style.display = layerColorEnabled + ? "block" + : "none"; + }, this.panel.layerGroupViewer.viewerState.enableLayerColorWidget), + ); + + // Compose the layer's title bar element.appendChild(layerNumberElement); + element.appendChild(layerColorElementWrapper); valueContainer.appendChild(valueElement); valueContainer.appendChild(buttonContainer); buttonContainer.appendChild(closeElement); @@ -151,7 +173,7 @@ class LayerWidget extends RefCounted { } update() { - const { layer, element } = this; + const { layer, element, panel, layerColorElement } = this; this.labelElementText.textContent = layer.name; element.dataset.visible = layer.visible.toString(); element.dataset.selected = ( @@ -168,6 +190,24 @@ class LayerWidget extends RefCounted { } title += ", drag to move, shift+drag to copy"; element.title = title; + // Color widget updates + if (panel.layerGroupViewer.viewerState.enableLayerColorWidget.value) { + if (layer.supportsLayerBarColorSyncOption) { + const color = this.layer.layerBarColor; + if (color) { + element.dataset.color = "fixed"; + layerColorElement.style.backgroundColor = color; + } else { + element.dataset.color = "rainbow"; + } + } else { + layerColorElement.style.backgroundColor = ""; + element.dataset.color = "unsupported"; + } + } + layerColorElement.title = + layer.colorWidgetTooltip() || + "The color of this layer cannot be determined"; } disposed() { @@ -212,6 +252,10 @@ export class LayerBar extends RefCounted { return this.layerGroupViewer.viewerNavigationState; } + get viewerState() { + return this.layerGroupViewer.viewerState; + } + constructor( public layerGroupViewer: LayerGroupViewer, public getLayoutSpecForDrag: () => any, @@ -229,7 +273,7 @@ export class LayerBar extends RefCounted { ), ); - const { element, manager, selectedLayer } = this; + const { element, manager, selectedLayer, viewerState } = this; element.className = "neuroglancer-layer-panel"; this.registerDisposer( manager.layerSelectedValues.changed.add(() => { @@ -251,6 +295,11 @@ export class LayerBar extends RefCounted { this.handleLayerItemValueChanged(); }), ); + this.registerDisposer( + viewerState.enableLayerColorWidget.changed.add(() => { + this.handleLayersChanged(); + }), + ); this.element.dataset.showHoverValues = this.showLayerHoverValues.value.toString(); this.layerWidgetInsertionPoint.style.display = "none"; diff --git a/src/ui/layer_list_panel.css b/src/ui/layer_list_panel.css index ecdb04f79..5ffce5c41 100644 --- a/src/ui/layer_list_panel.css +++ b/src/ui/layer_list_panel.css @@ -14,6 +14,7 @@ padding: 2px; border: 1px solid #aaa; margin: 2px; + gap: 4px; } .neuroglancer-layer-list-panel-item[data-selected="true"] { @@ -45,6 +46,94 @@ display: inline-block; } +.neuroglancer-layer-list-panel-item input[type="checkbox"] { + width: "1rem"; + height: "1rem"; + margin: "0.25rem"; +} + +.neuroglancer-layer-list-panel-color-value-wrapper { + position: relative; + width: 10px; + height: 10px; + padding: 5px; +} + +.neuroglancer-layer-list-panel-color-value { + border-radius: 50%; + height: 10px; + width: 10px; +} + +.neuroglancer-layer-list-panel-color-value.rainbow { + position: relative; + border-radius: 50%; + height: 10px; + width: 10px; + overflow: hidden; +} + +.neuroglancer-layer-list-panel-color-value.rainbow::before { + content: ""; + position: absolute; + top: -17.5%; + left: -17.5%; + width: 135%; + height: 135%; + background: conic-gradient( + from 0deg, + hsl(0, 100%, 50%), + hsl(60, 100%, 50%), + hsl(120, 100%, 50%), + hsl(180, 100%, 50%), + hsl(240, 100%, 50%), + hsl(300, 100%, 50%), + hsl(360, 100%, 50%) + ); + filter: blur(1px); + transform: scale(1.35); +} + +.neuroglancer-layer-list-panel-color-value.unsupported { + background-image: linear-gradient( + 45deg, + rgba(128, 128, 128, 1.5) 25%, + transparent 25% + ), + linear-gradient(-45deg, rgba(128, 128, 128, 1.5) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(128, 128, 128, 1.5) 75%), + linear-gradient(-45deg, transparent 75%, rgba(128, 128, 128, 1.5) 75%); + background-size: 4px 4px; + background-position: + 0 0, + 0 2px, + 2px -2px, + -2px 0px; +} + +.neuroglancer-layer-list-panel-color-value-wrapper.unsupported::before { + position: relative; +} + +.neuroglancer-layer-list-panel-color-value-wrapper.cross + .neuroglancer-layer-list-panel-color-value, +.neuroglancer-layer-list-panel-color-value-wrapper.cross + .neuroglancer-layer-list-panel-color-value.rainbow::before { + opacity: 0.4; +} + +.neuroglancer-layer-list-panel-color-value-wrapper.cross::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 1px; + background-color: rgb(255, 255, 255); + transform: translate(-50%, -50%) rotate(135deg); + z-index: 1; +} + .neuroglancer-layer-list-panel-item:not(:hover) > .neuroglancer-layer-list-panel-item-delete { display: none; diff --git a/src/ui/layer_list_panel.ts b/src/ui/layer_list_panel.ts index 5fe9597b4..c1b3bd1d4 100644 --- a/src/ui/layer_list_panel.ts +++ b/src/ui/layer_list_panel.ts @@ -25,6 +25,7 @@ import type { } from "#src/layer/index.js"; import { deleteLayer } from "#src/layer/index.js"; import { TrackableBooleanCheckbox } from "#src/trackable_boolean.js"; +import { observeWatchable } from "#src/trackable_value.js"; import type { DropLayers } from "#src/ui/layer_drag_and_drop.js"; import { registerLayerBarDragLeaveHandler, @@ -91,6 +92,8 @@ export class LayerVisibilityWidget extends RefCounted { this.layer.setVisible(true); }, }); + element.style.display = "flex"; + element.style.alignItems = "center"; element.appendChild(showIcon); element.appendChild(hideIcon); const updateView = () => { @@ -103,6 +106,58 @@ export class LayerVisibilityWidget extends RefCounted { } } +class LayerColorWidget extends RefCounted { + element = document.createElement("div"); + elementWrapper = document.createElement("div"); + + constructor( + public panel: LayerListPanel, + public layer: ManagedUserLayer, + ) { + super(); + const { element, elementWrapper } = this; + element.className = "neuroglancer-layer-list-panel-color-value"; + elementWrapper.className = + "neuroglancer-layer-list-panel-color-value-wrapper"; + elementWrapper.appendChild(element); + const updateLayerColorWidget = () => { + element.classList.remove("rainbow"); + element.classList.remove("unsupported"); + const color = this.layer.layerBarColor; + if (color) { + element.style.backgroundColor = color; + element.classList.remove("rainbow"); + } else { + const style = this.layer.supportsLayerBarColorSyncOption + ? "rainbow" + : "unsupported"; + element.classList.add(style); + element.style.backgroundColor = ""; + } + }; + this.registerDisposer( + observeWatchable((layerColorEnabled) => { + elementWrapper.style.display = layerColorEnabled ? "block" : "none"; + }, panel.sidePanelManager.viewerState.enableLayerColorWidget), + ); + this.registerDisposer( + layer.observeLayerColor(() => { + updateLayerColorWidget(); + }), + ); + this.registerDisposer( + layer.layerChanged.add(() => { + if (!this.layer.visible) { + elementWrapper.classList.add("cross"); + } else { + elementWrapper.classList.remove("cross"); + } + updateLayerColorWidget(); + }), + ); + } +} + function makeSelectedLayerSidePanelCheckboxIcon(layer: ManagedUserLayer) { const { selectedLayer } = layer.manager.root; const icon = new CheckboxIcon( @@ -167,6 +222,9 @@ class LayerListItem extends RefCounted { element.appendChild( this.registerDisposer(new LayerVisibilityWidget(layer)).element, ); + element.appendChild( + this.registerDisposer(new LayerColorWidget(panel, layer)).elementWrapper, + ); element.appendChild( this.registerDisposer(new LayerNameWidget(layer)).element, ); diff --git a/src/ui/side_panel.ts b/src/ui/side_panel.ts index 14d97c437..b54cc9fa1 100644 --- a/src/ui/side_panel.ts +++ b/src/ui/side_panel.ts @@ -29,6 +29,7 @@ import { } from "#src/util/drag_and_drop.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; import { Signal } from "#src/util/signal.js"; +import type { ViewerState } from "#src/viewer_state.js"; import { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import { makeCloseButton } from "#src/widget/close_button.js"; @@ -273,6 +274,7 @@ export class SidePanelManager extends RefCounted { public visibility = new WatchableVisibilityPriority( WatchableVisibilityPriority.VISIBLE, ), + public viewerState: ViewerState, ) { super(); const { element, centerColumn } = this; diff --git a/src/ui/viewer_settings.ts b/src/ui/viewer_settings.ts index a0c936f94..155148352 100644 --- a/src/ui/viewer_settings.ts +++ b/src/ui/viewer_settings.ts @@ -129,6 +129,7 @@ export class ViewerSettingsPanel extends SidePanel { "Enable adaptive downsampling", viewer.enableAdaptiveDownsampling, ); + addCheckbox("Enable layer color legend", viewer.enableLayerColorWidget); const addColor = (label: string, value: WatchableValueInterface) => { const labelElement = document.createElement("label"); diff --git a/src/viewer.ts b/src/viewer.ts index 3a6ebf1f8..a7a808130 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -335,6 +335,7 @@ class TrackableViewerState extends CompoundTrackable { this.add("selectedStateServer", viewer.selectedStateServer); this.add("toolBindings", viewer.toolBinder); this.add("toolPalettes", viewer.toolPalettes); + this.add("enableLayerColorWidget", viewer.enableLayerColorWidget); } restoreState(obj: any) { @@ -475,6 +476,8 @@ export class Viewer extends RefCounted implements ViewerState { vec3.fromValues(0.5, 0.5, 0.5), ); perspectiveViewBackgroundColor = new TrackableRGB(vec3.fromValues(0, 0, 0)); + enableLayerColorWidget = new TrackableBoolean(false, false); + scaleBarOptions = new TrackableScaleBarOptions(); partialViewport = new TrackableWindowedViewport(); statisticsDisplayState = new StatisticsDisplayState(); @@ -932,7 +935,12 @@ export class Viewer extends RefCounted implements ViewerState { new RootLayoutContainer(this, "4panel"), ); this.sidePanelManager = this.registerDisposer( - new SidePanelManager(this.display, this.layout.element, this.visibility), + new SidePanelManager( + this.display, + this.layout.element, + this.visibility, + this, + ), ); this.registerDisposer( this.sidePanelManager.registerPanel({ diff --git a/src/viewer_state.ts b/src/viewer_state.ts index abb11b03b..1d3c30e90 100644 --- a/src/viewer_state.ts +++ b/src/viewer_state.ts @@ -36,4 +36,5 @@ export interface ViewerState extends VisibilityPrioritySpecification { layerManager: LayerManager; selectedLayer: SelectedLayerState; selectionDetailsState: TrackableDataSelectionState; + enableLayerColorWidget: TrackableBoolean; }