diff --git a/python/examples/example_accordion.py b/python/examples/example_accordion.py new file mode 100644 index 0000000000..fbc152e957 --- /dev/null +++ b/python/examples/example_accordion.py @@ -0,0 +1,37 @@ +import argparse + +import neuroglancer +import neuroglancer.cli +import numpy as np + + +def add_example_layers(state): + state.dimensions = neuroglancer.CoordinateSpace( + names=["x", "y", "z"], units="nm", scales=[10, 10, 10] + ) + state.layers.append( + name="example_layer", + layer=neuroglancer.LocalVolume( + data=np.ones((10, 10, 10)).astype(np.float32), + dimensions=state.dimensions, + ), + ) + return state.layers[0] + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + neuroglancer.cli.add_server_arguments(ap) + args = ap.parse_args() + neuroglancer.cli.handle_server_arguments(args) + viewer = neuroglancer.Viewer() + with viewer.txn() as s: + add_example_layers(s) + s.layers[0].annotations_accordion.annotations_expanded = False + s.layers[0].annotations_accordion.related_segments_expanded = True + s.layers[0].rendering_accordion.slice_expanded = True + s.layers[0].rendering_accordion.shader_expanded = False + s.selected_layer.layer = "example_layer" + s.selected_layer.visible = True + + print(viewer) diff --git a/python/neuroglancer/viewer_state.py b/python/neuroglancer/viewer_state.py index 27f3beedde..d3834ca525 100644 --- a/python/neuroglancer/viewer_state.py +++ b/python/neuroglancer/viewer_state.py @@ -416,6 +416,61 @@ class DimensionPlaybackVelocity(JsonObjectWrapper): paused = wrapped_property("paused", optional(bool, True)) +@export +class AnnotationsAccordion(JsonObjectWrapper): + """Accordion state for layer annotation controls.""" + + __slots__ = () + + spacing_expanded = spacingExpanded = wrapped_property( + "spacingExpanded", optional(bool) + ) + related_segments_expanded = relatedSegmentsExpanded = wrapped_property( + "relatedSegmentsExpanded", optional(bool) + ) + annotations_expanded = annotationsExpanded = wrapped_property( + "annotationsExpanded", optional(bool) + ) + + +@export +class ImageRenderingAccordion(JsonObjectWrapper): + """Accordion state for image layer rendering controls.""" + + __slots__ = () + + slice_expanded = sliceExpanded = wrapped_property("sliceExpanded", optional(bool)) + volume_rendering_expanded = volumeRenderingExpanded = wrapped_property( + "volumeRenderingExpanded", optional(bool) + ) + shader_expanded = shaderExpanded = wrapped_property( + "shaderExpanded", optional(bool) + ) + + +@export +class SegmentationRenderingAccordion(JsonObjectWrapper): + """Accordion state for segmentation layer rendering controls.""" + + __slots__ = () + + visibility_expanded = visibilityExpanded = wrapped_property( + "visibilityExpanded", optional(bool) + ) + appearance_expanded = appearanceExpanded = wrapped_property( + "appearanceExpanded", optional(bool) + ) + slice_rendering_expanded = sliceRenderingExpanded = wrapped_property( + "sliceRenderingExpanded", optional(bool) + ) + mesh_rendering_expanded = meshRenderingExpanded = wrapped_property( + "meshRenderingExpanded", optional(bool) + ) + skeletons_expanded = skeletonsExpanded = wrapped_property( + "skeletonsExpanded", optional(bool) + ) + + @export class Layer(JsonObjectWrapper): __slots__ = () @@ -438,6 +493,10 @@ class Layer(JsonObjectWrapper): ) tool = wrapped_property("tool", optional(Tool)) + annotations_accordion = annotationsAccordion = wrapped_property( + "annotationsAccordion", AnnotationsAccordion + ) + @staticmethod def interpolate(a, b, t): c = copy.deepcopy(a) @@ -619,6 +678,9 @@ def __init__(self, *args, **kwargs): cross_section_render_scale = crossSectionRenderScale = wrapped_property( "crossSectionRenderScale", optional(float, 1) ) + rendering_accordion = renderingAccordion = wrapped_property( + "renderingAccordion", ImageRenderingAccordion + ) @staticmethod def interpolate(a, b, t): @@ -954,6 +1016,9 @@ def visible_segments(self, segments): skeleton_rendering = skeletonRendering = wrapped_property( "skeletonRendering", SkeletonRenderingOptions ) + rendering_accordion = renderingAccordion = wrapped_property( + "renderingAccordion", SegmentationRenderingAccordion + ) @property def skeleton_shader(self): diff --git a/rspack.config.ts b/rspack.config.ts index e7c0c70e71..118c1e4545 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -110,7 +110,8 @@ export default defineConfig((env, args) => { NEUROGLANCER_BRAINMAPS_CLIENT_ID: JSON.stringify( "639403125587-4k5hgdfumtrvur8v48e3pr7oo91d765k.apps.googleusercontent.com", ), - + // NEUROGLANCER_USE_ACCORDIONS: false, + // NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED: true, // NEUROGLANCER_CREDIT_LINK: JSON.stringify({url: '...', text: '...'}), // NEUROGLANCER_DEFAULT_STATE_FRAGMENT: JSON.stringify('gs://bucket/state.json'), // NEUROGLANCER_SHOW_LAYER_BAR_EXTRA_BUTTONS: true, diff --git a/src/datasource/graphene/frontend.ts b/src/datasource/graphene/frontend.ts index d3fc71fa18..73d73cb912 100644 --- a/src/datasource/graphene/frontend.ts +++ b/src/datasource/graphene/frontend.ts @@ -1954,7 +1954,7 @@ class MulticutAnnotationLayerView extends AnnotationLayerView { public layer: SegmentationUserLayer, public displayState: AnnotationDisplayState, ) { - super(layer, displayState); + super(layer, displayState, layer.annotationAccordionState); const { graphConnection: { value: graphConnection }, } = layer; diff --git a/src/layer/annotation/index.ts b/src/layer/annotation/index.ts index 600b61aacf..51f67169d5 100644 --- a/src/layer/annotation/index.ts +++ b/src/layer/annotation/index.ts @@ -57,7 +57,11 @@ import type { AnnotationLayerView, MergedAnnotationStates, } from "#src/ui/annotations.js"; -import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; +import { + RELATED_SEGMENT_SECTION_JSON_KEY, + SPACING_SECTION_JSON_KEY, + UserLayerWithAnnotationsMixin, +} from "#src/ui/annotations.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { Borrowed, Owned } from "#src/util/disposable.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -676,12 +680,14 @@ export class AnnotationUserLayer extends Base { renderScaleWidget.label.textContent = "Spacing (projection)"; parent.appendChild(renderScaleWidget.element); } + tab.showSection(SPACING_SECTION_JSON_KEY); }, ), ); - tab.element.insertBefore( + tab.appendChild( renderScaleControls.element, - tab.element.firstChild, + SPACING_SECTION_JSON_KEY, + true /* hidden */, ); { const checkbox = tab.registerDisposer( @@ -696,12 +702,13 @@ export class AnnotationUserLayer extends Base { label.title = "Display all annotations if filtering by related segments is enabled but no segments are selected"; label.appendChild(checkbox.element); - tab.element.appendChild(label); + tab.appendChild(label, RELATED_SEGMENT_SECTION_JSON_KEY); } - tab.element.appendChild( + tab.appendChild( tab.registerDisposer( new LinkedSegmentationLayersWidget(this.linkedSegmentationLayers), ).element, + RELATED_SEGMENT_SECTION_JSON_KEY, ); } diff --git a/src/layer/image/index.ts b/src/layer/image/index.ts index 241b69853e..0ce6c95259 100644 --- a/src/layer/image/index.ts +++ b/src/layer/image/index.ts @@ -79,6 +79,7 @@ import { setControlsInShader, ShaderControlState, } from "#src/webgl/shader_ui_controls.js"; +import { AccordionState, AccordionTab } from "#src/widget/accordion.js"; import { ChannelDimensionsWidget } from "#src/widget/channel_dimensions_widget.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; @@ -102,7 +103,6 @@ import { registerLayerShaderControlsTool, ShaderControls, } from "#src/widget/shader_controls.js"; -import { Tab } from "#src/widget/tab_view.js"; const OPACITY_JSON_KEY = "opacity"; const BLEND_JSON_KEY = "blend"; @@ -114,6 +114,10 @@ const CHANNEL_DIMENSIONS_JSON_KEY = "channelDimensions"; const VOLUME_RENDERING_JSON_KEY = "volumeRendering"; const VOLUME_RENDERING_GAIN_JSON_KEY = "volumeRenderingGain"; const VOLUME_RENDERING_DEPTH_SAMPLES_JSON_KEY = "volumeRenderingDepthSamples"; +const RENDERING_ACCORDION_JSON_KEY = "renderingAccordion"; +const SLICE_SECTION_JSON_KEY = "sliceExpanded"; +const VOLUME_RENDERING_SECTION_JSON_KEY = "volumeRenderingExpanded"; +const SHADER_SECTION_JSON_KEY = "shaderExpanded"; export interface ImageLayerSelectionState extends UserLayerSelectionState { value: any; @@ -157,6 +161,28 @@ export class ImageUserLayer extends Base { ); volumeRenderingMode = trackableShaderModeValue(); + renderingAccordionState = this.registerDisposer( + new AccordionState({ + accordionJsonKey: RENDERING_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: SLICE_SECTION_JSON_KEY, + displayName: "Slice 2D", + }, + { + jsonKey: VOLUME_RENDERING_SECTION_JSON_KEY, + displayName: "Volume rendering", + }, + { + jsonKey: SHADER_SECTION_JSON_KEY, + displayName: "Shader controls", + defaultExpanded: true, + isDefaultKey: true, + }, + ], + }), + ); + shaderControlState = this.registerDisposer( new ShaderControlState( this.fragmentMain, @@ -219,10 +245,13 @@ export class ImageUserLayer extends Base { this.volumeRenderingDepthSamplesTarget.changed.add( this.specificationChanged.dispatch, ); + this.renderingAccordionState.specificationChanged.add( + this.specificationChanged.dispatch, + ); this.tabs.add("rendering", { label: "Rendering", order: -100, - getter: () => new RenderingOptionsTab(this), + getter: () => new RenderingOptionsTab(this, this.renderingAccordionState), }); this.tabs.default = "rendering"; } @@ -339,6 +368,13 @@ export class ImageUserLayer extends Base { volumeRenderingDepthSamplesTarget, ), ); + verifyOptionalObjectProperty( + specification, + RENDERING_ACCORDION_JSON_KEY, + (accordionState) => { + this.renderingAccordionState.restoreState(accordionState); + }, + ); } toJSON() { const x = super.toJSON(); @@ -354,6 +390,7 @@ export class ImageUserLayer extends Base { x[VOLUME_RENDERING_GAIN_JSON_KEY] = this.volumeRenderingGain.toJSON(); x[VOLUME_RENDERING_DEPTH_SAMPLES_JSON_KEY] = this.volumeRenderingDepthSamplesTarget.toJSON(); + x[RENDERING_ACCORDION_JSON_KEY] = this.renderingAccordionState.toJSON(); return x; } @@ -470,6 +507,7 @@ const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Resolution (slice)", toolJson: CROSS_SECTION_RENDER_SCALE_JSON_KEY, + sectionKey: SLICE_SECTION_JSON_KEY, ...renderScaleLayerControl((layer) => ({ histogram: layer.sliceViewRenderScaleHistogram, target: layer.sliceViewRenderScaleTarget, @@ -478,21 +516,25 @@ const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Blending (slice)", toolJson: BLEND_JSON_KEY, + sectionKey: SLICE_SECTION_JSON_KEY, ...enumLayerControl((layer) => layer.blendMode), }, { label: "Opacity (slice)", toolJson: OPACITY_JSON_KEY, + sectionKey: SLICE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.opacity })), }, { label: "Volume rendering (experimental)", toolJson: VOLUME_RENDERING_JSON_KEY, + sectionKey: VOLUME_RENDERING_SECTION_JSON_KEY, ...enumLayerControl((layer) => layer.volumeRenderingMode), }, { label: "Gain (3D)", toolJson: VOLUME_RENDERING_GAIN_JSON_KEY, + sectionKey: VOLUME_RENDERING_SECTION_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( (volumeRenderingMode) => @@ -507,6 +549,7 @@ const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Resolution (3D)", toolJson: VOLUME_RENDERING_DEPTH_SAMPLES_JSON_KEY, + sectionKey: VOLUME_RENDERING_SECTION_JSON_KEY, isValid: (layer) => makeCachedDerivedWatchableValue( (volumeRenderingMode) => @@ -527,21 +570,25 @@ for (const control of LAYER_CONTROLS) { registerLayerControl(ImageUserLayer, control); } -class RenderingOptionsTab extends Tab { +class RenderingOptionsTab extends AccordionTab { codeWidget: ShaderCodeWidget; - constructor(public layer: ImageUserLayer) { - super(); + constructor( + public layer: ImageUserLayer, + protected accordionState: AccordionState, + ) { + super(accordionState); const { element } = this; this.codeWidget = this.registerDisposer(makeShaderCodeWidget(this.layer)); element.classList.add("neuroglancer-image-dropdown"); for (const control of LAYER_CONTROLS) { - element.appendChild( + this.appendChild( addLayerControlToOptionsTab(this, layer, this.visibility, control), + control.sectionKey, ); } - element.appendChild( + this.appendChild( makeShaderCodeWidgetTopRow( this.layer, this.codeWidget, @@ -553,14 +600,14 @@ class RenderingOptionsTab extends Tab { "neuroglancer-image-dropdown-top-row", ), ); - element.appendChild( + this.appendChild( this.registerDisposer( new ChannelDimensionsWidget(layer.channelCoordinateSpaceCombiner), ).element, ); - element.appendChild(this.codeWidget.element); - element.appendChild( + this.appendChild(this.codeWidget.element); + this.appendChild( this.registerDisposer( new ShaderControls( layer.shaderControlState, diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 8fc24b8fd4..e6ea6a6f9b 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -34,7 +34,14 @@ import { import type { LoadedDataSubsource } from "#src/layer/layer_data_source.js"; import { layerDataSourceSpecificationFromJson } from "#src/layer/layer_data_source.js"; import * as json_keys from "#src/layer/segmentation/json_keys.js"; -import { registerLayerControls } from "#src/layer/segmentation/layer_controls.js"; +import { + APPEARANCE_SECTION_JSON_KEY, + MESH_SECTION_JSON_KEY, + registerLayerControls, + SKELETON_SECTION_JSON_KEY, + SLICE_SECTION_JSON_KEY, + VISIBILITY_SECTION_JSON_KEY, +} from "#src/layer/segmentation/layer_controls.js"; import { MeshLayer, MeshSource, @@ -130,9 +137,12 @@ import { } from "#src/util/json.js"; import { Signal } from "#src/util/signal.js"; import { makeWatchableShaderError } from "#src/webgl/dynamic_shader.js"; +import { AccordionState } from "#src/widget/accordion.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import { registerLayerShaderControlsTool } from "#src/widget/shader_controls.js"; +export const SEGMENTATION_RENDERING_ACCORDION_JSON_KEY = "renderingAccordion"; + const MAX_LAYER_BAR_UI_INDICATOR_COLORS = 6; export class SegmentationUserLayerGroupState @@ -626,6 +636,36 @@ export class SegmentationUserLayer extends Base { x === undefined ? undefined : parseUint64(x), ); + renderingAccordionState = this.registerDisposer( + new AccordionState({ + accordionJsonKey: SEGMENTATION_RENDERING_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: VISIBILITY_SECTION_JSON_KEY, + displayName: "Visibility", + }, + { + jsonKey: APPEARANCE_SECTION_JSON_KEY, + displayName: "Appearance", + defaultExpanded: true, + isDefaultKey: true, + }, + { + jsonKey: SLICE_SECTION_JSON_KEY, + displayName: "Slice 2D", + }, + { + jsonKey: MESH_SECTION_JSON_KEY, + displayName: "Mesh 3D", + }, + { + jsonKey: SKELETON_SECTION_JSON_KEY, + displayName: "Skeletons", + }, + ], + }), + ); + constructor(managedLayer: Borrowed) { super(managedLayer); this.codeVisible.changed.add(this.specificationChanged.dispatch); @@ -689,6 +729,9 @@ export class SegmentationUserLayer extends Base { this.displayState.linkedSegmentationGroup.changed.add(() => this.updateDataSubsourceActivations(), ); + this.renderingAccordionState.specificationChanged.add( + this.specificationChanged.dispatch, + ); this.tabs.add("rendering", { label: "Render", order: -100, @@ -1040,6 +1083,9 @@ export class SegmentationUserLayer extends Base { this.displayState.segmentationColorGroupState.value.restoreState( specification, ); + this.renderingAccordionState.restoreState( + specification[SEGMENTATION_RENDERING_ACCORDION_JSON_KEY], + ); } toJSON() { @@ -1091,6 +1137,8 @@ export class SegmentationUserLayer extends Base { this.displayState.segmentationColorGroupState.value.toJSON(), ); } + x[SEGMENTATION_RENDERING_ACCORDION_JSON_KEY] = + this.renderingAccordionState.toJSON(); return x; } diff --git a/src/layer/segmentation/layer_controls.ts b/src/layer/segmentation/layer_controls.ts index 8bcc268fa2..4e669bb22f 100644 --- a/src/layer/segmentation/layer_controls.ts +++ b/src/layer/segmentation/layer_controls.ts @@ -11,11 +11,18 @@ import { fixedColorLayerControl, } from "#src/widget/segmentation_color_mode.js"; +export const VISIBILITY_SECTION_JSON_KEY = "visibilityExpanded"; +export const APPEARANCE_SECTION_JSON_KEY = "appearanceExpanded"; +export const SLICE_SECTION_JSON_KEY = "sliceRenderingExpanded"; +export const MESH_SECTION_JSON_KEY = "meshRenderingExpanded"; +export const SKELETON_SECTION_JSON_KEY = "skeletonsExpanded"; + export const LAYER_CONTROLS: LayerControlDefinition[] = [ { label: "Color seed", title: "Color segments based on a hash of their id", toolJson: json_keys.COLOR_SEED_JSON_KEY, + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...colorSeedLayerControl(), }, { @@ -23,12 +30,14 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ title: "Use a fixed color for all segments without an explicitly-specified color", toolJson: json_keys.SEGMENT_DEFAULT_COLOR_JSON_KEY, + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...fixedColorLayerControl(), }, { label: "Saturation", toolJson: json_keys.SATURATION_JSON_KEY, title: "Saturation of segment colors", + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.saturation })), }, { @@ -36,6 +45,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ toolJson: json_keys.SELECTED_ALPHA_JSON_KEY, isValid: (layer) => layer.has2dLayer, title: "Opacity in cross-section views of segments that are selected", + sectionKey: SLICE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.selectedAlpha, })), @@ -45,6 +55,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ toolJson: json_keys.NOT_SELECTED_ALPHA_JSON_KEY, isValid: (layer) => layer.has2dLayer, title: "Opacity in cross-section views of segments that are not selected", + sectionKey: SLICE_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.notSelectedAlpha, })), @@ -53,6 +64,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ label: "Resolution (slice)", toolJson: json_keys.CROSS_SECTION_RENDER_SCALE_JSON_KEY, isValid: (layer) => layer.has2dLayer, + sectionKey: SLICE_SECTION_JSON_KEY, ...renderScaleLayerControl((layer) => ({ histogram: layer.sliceViewRenderScaleHistogram, target: layer.sliceViewRenderScaleTarget, @@ -62,6 +74,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ label: "Resolution (mesh)", toolJson: json_keys.MESH_RENDER_SCALE_JSON_KEY, isValid: (layer) => layer.has3dLayer, + sectionKey: MESH_SECTION_JSON_KEY, ...renderScaleLayerControl((layer) => ({ histogram: layer.displayState.renderScaleHistogram, target: layer.displayState.renderScaleTarget, @@ -72,6 +85,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ toolJson: json_keys.OBJECT_ALPHA_JSON_KEY, isValid: (layer) => layer.has3dLayer, title: "Opacity of meshes and skeletons", + sectionKey: MESH_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.objectAlpha, })), @@ -82,6 +96,7 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ isValid: (layer) => layer.has3dLayer, title: "Set to a non-zero value to increase transparency of object faces perpendicular to view direction", + sectionKey: MESH_SECTION_JSON_KEY, ...rangeLayerControl((layer) => ({ value: layer.displayState.silhouetteRendering, options: { min: 0, max: maxSilhouettePower, step: 0.1 }, @@ -91,24 +106,28 @@ export const LAYER_CONTROLS: LayerControlDefinition[] = [ label: "Hide segment ID 0", toolJson: json_keys.HIDE_SEGMENT_ZERO_JSON_KEY, title: "Disallow selection and display of segment id 0", + sectionKey: VISIBILITY_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.hideSegmentZero), }, { label: "Base segment coloring", toolJson: json_keys.BASE_SEGMENT_COLORING_JSON_KEY, title: "Color base segments individually", + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.baseSegmentColoring), }, { label: "Show all by default", title: "Show all segments if none are selected", toolJson: json_keys.IGNORE_NULL_VISIBLE_SET_JSON_KEY, + sectionKey: VISIBILITY_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.ignoreNullVisibleSet), }, { label: "Highlight on hover", toolJson: json_keys.HOVER_HIGHLIGHT_JSON_KEY, title: "Highlight the segment under the mouse pointer", + sectionKey: APPEARANCE_SECTION_JSON_KEY, ...checkboxLayerControl((layer) => layer.displayState.hoverHighlight), }, ...getViewSpecificSkeletonRenderingControl("2d"), @@ -124,6 +143,7 @@ function getViewSpecificSkeletonRenderingControl( { label: `Skeleton mode (${viewName})`, toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.mode${viewName}`, + sectionKey: SKELETON_SECTION_JSON_KEY, isValid: (layer) => layer.hasSkeletonsLayer, ...enumLayerControl( (layer) => @@ -135,6 +155,7 @@ function getViewSpecificSkeletonRenderingControl( { label: `Line width (${viewName})`, toolJson: `${json_keys.SKELETON_RENDERING_JSON_KEY}.lineWidth${viewName}`, + sectionKey: SKELETON_SECTION_JSON_KEY, isValid: (layer) => layer.hasSkeletonsLayer, toolDescription: `Skeleton line width (${viewName})`, title: `Skeleton line width (${viewName})`, diff --git a/src/ui/annotations.css b/src/ui/annotations.css index 0998ac9a70..6a18c2f152 100644 --- a/src/ui/annotations.css +++ b/src/ui/annotations.css @@ -29,7 +29,6 @@ overflow-y: auto; height: 0px; flex: 1; - flex-basis: 0px; min-height: 0px; } diff --git a/src/ui/annotations.ts b/src/ui/annotations.ts index 5dc30d30bd..fc9fb2bc43 100644 --- a/src/ui/annotations.ts +++ b/src/ui/annotations.ts @@ -101,6 +101,7 @@ import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; import { NullarySignal, Signal } from "#src/util/signal.js"; import * as vector from "#src/util/vector.js"; +import { AccordionState, AccordionTab } from "#src/widget/accordion.js"; import { makeAddButton } from "#src/widget/add_button.js"; import { ColorWidget } from "#src/widget/color.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; @@ -236,7 +237,7 @@ interface AnnotationLayerViewAttachedState { listOffset: number; } -export class AnnotationLayerView extends Tab { +export class AnnotationLayerView extends AccordionTab { private previousSelectedState: | { annotationId: string; @@ -380,8 +381,9 @@ export class AnnotationLayerView extends Tab { constructor( public layer: Borrowed, public displayState: AnnotationDisplayState, + public annotationAccordionState: AccordionState, ) { - super(); + super(annotationAccordionState); this.element.classList.add("neuroglancer-annotation-layer-view"); this.selectedAnnotationState = makeCachedLazyDerivedWatchableValue( (selectionState, pin) => { @@ -488,7 +490,7 @@ export class AnnotationLayerView extends Tab { const helpIcon = makeIcon({ title: - "The left icons allow you to select the type of the anotation. Color and other display settings are available in the 'Rendering' tab.", + "The left icons allow you to select the type of the annotation. Color and other display settings are available in the 'Rendering' tab.", svg: svg_help, clickable: false, }); @@ -496,12 +498,12 @@ export class AnnotationLayerView extends Tab { mutableControls.appendChild(helpIcon); toolbox.appendChild(mutableControls); - this.element.appendChild(toolbox); + this.appendChild(toolbox); - this.element.appendChild(this.headerRow); + this.appendChild(this.headerRow); const { virtualList } = this; virtualList.element.classList.add("neuroglancer-annotation-list"); - this.element.appendChild(virtualList.element); + this.appendChild(virtualList.element); this.virtualList.element.addEventListener("mouseleave", () => { this.displayState.hoverState.value = undefined; }); @@ -1005,7 +1007,11 @@ export class AnnotationTab extends Tab { constructor(public layer: Borrowed) { super(); this.layerView = this.registerDisposer( - new AnnotationLayerView(layer, layer.annotationDisplayState), + new AnnotationLayerView( + layer, + layer.annotationDisplayState, + layer.annotationAccordionState, + ), ); const { element } = this; @@ -1856,12 +1862,35 @@ function makeRelatedSegmentList( } const ANNOTATION_COLOR_JSON_KEY = "annotationColor"; +const ANNOTATION_ACCORDION_JSON_KEY = "annotationsAccordion"; +export const ANNOTATION_SECTION_JSON_KEY = "annotationsExpanded"; +export const RELATED_SEGMENT_SECTION_JSON_KEY = "relatedSegmentsExpanded"; +export const SPACING_SECTION_JSON_KEY = "spacingExpanded"; export function UserLayerWithAnnotationsMixin< TBase extends { new (...args: any[]): UserLayer }, >(Base: TBase) { abstract class C extends Base implements UserLayerWithAnnotations { annotationStates = this.registerDisposer(new MergedAnnotationStates()); annotationDisplayState = new AnnotationDisplayState(); + annotationAccordionState = new AccordionState({ + accordionJsonKey: ANNOTATION_ACCORDION_JSON_KEY, + sections: [ + { + jsonKey: SPACING_SECTION_JSON_KEY, + displayName: "Spacing", + }, + { + jsonKey: RELATED_SEGMENT_SECTION_JSON_KEY, + displayName: "Related segments", + }, + { + jsonKey: ANNOTATION_SECTION_JSON_KEY, + displayName: "Annotations", + defaultExpanded: true, + isDefaultKey: true, + }, + ], + }); annotationCrossSectionRenderScaleHistogram = new RenderScaleHistogram(); annotationCrossSectionRenderScaleTarget = trackableRenderScaleTarget(8); annotationProjectionRenderScaleHistogram = new RenderScaleHistogram(); @@ -1879,6 +1908,9 @@ export function UserLayerWithAnnotationsMixin< this.annotationDisplayState.shaderControls.changed.add( this.specificationChanged.dispatch, ); + this.annotationAccordionState.specificationChanged.add( + this.specificationChanged.dispatch, + ); this.tabs.add("annotations", { label: "Annotations", order: 10, @@ -1939,6 +1971,9 @@ export function UserLayerWithAnnotationsMixin< this.annotationDisplayState.color.restoreState( specification[ANNOTATION_COLOR_JSON_KEY], ); + this.annotationAccordionState.restoreState( + specification[ANNOTATION_ACCORDION_JSON_KEY], + ); } captureSelectionState( @@ -2443,6 +2478,7 @@ export function UserLayerWithAnnotationsMixin< toJSON() { const x = super.toJSON(); x[ANNOTATION_COLOR_JSON_KEY] = this.annotationDisplayState.color.toJSON(); + x[ANNOTATION_ACCORDION_JSON_KEY] = this.annotationAccordionState.toJSON(); return x; } } diff --git a/src/ui/layer_data_sources_tab.css b/src/ui/layer_data_sources_tab.css index a18860239f..98bf65737c 100644 --- a/src/ui/layer_data_sources_tab.css +++ b/src/ui/layer_data_sources_tab.css @@ -24,7 +24,7 @@ display: flex; flex-direction: column; flex: 1; - height: 0px; + height: 0; z-index: 2; } diff --git a/src/ui/segmentation_display_options_tab.ts b/src/ui/segmentation_display_options_tab.ts index d4072c42e0..dce0d46278 100644 --- a/src/ui/segmentation_display_options_tab.ts +++ b/src/ui/segmentation_display_options_tab.ts @@ -17,8 +17,14 @@ import { buildShaderPropertyList } from "#src/layer/annotation/shader_ui_property_list.js"; import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { SKELETON_RENDERING_SHADER_CONTROL_TOOL_ID } from "#src/layer/segmentation/json_keys.js"; -import { LAYER_CONTROLS } from "#src/layer/segmentation/layer_controls.js"; +import { + APPEARANCE_SECTION_JSON_KEY, + LAYER_CONTROLS, + VISIBILITY_SECTION_JSON_KEY, + SKELETON_SECTION_JSON_KEY, +} from "#src/layer/segmentation/layer_controls.js"; import { Overlay } from "#src/overlay.js"; +import { AccordionTab } from "#src/widget/accordion.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; import { addLayerControlToOptionsTab } from "#src/widget/layer_control.js"; import { LinkedLayerGroupWidget } from "#src/widget/linked_layer.js"; @@ -27,7 +33,6 @@ import { ShaderCodeWidget, } from "#src/widget/shader_code_widget.js"; import { ShaderControls } from "#src/widget/shader_controls.js"; -import { Tab } from "#src/widget/tab_view.js"; function makeSkeletonShaderCodeWidget(layer: SegmentationUserLayer) { return new ShaderCodeWidget({ @@ -38,9 +43,9 @@ function makeSkeletonShaderCodeWidget(layer: SegmentationUserLayer) { }); } -export class DisplayOptionsTab extends Tab { +export class DisplayOptionsTab extends AccordionTab { constructor(public layer: SegmentationUserLayer) { - super(); + super(layer.renderingAccordionState); const { element } = this; element.classList.add("neuroglancer-segmentation-rendering-tab"); @@ -50,7 +55,7 @@ export class DisplayOptionsTab extends Tab { new LinkedLayerGroupWidget(layer.displayState.linkedSegmentationGroup), ); widget.label.textContent = "Linked to: "; - element.appendChild(widget.element); + this.appendChild(widget.element, VISIBILITY_SECTION_JSON_KEY); } // Linked segmentation control @@ -61,12 +66,13 @@ export class DisplayOptionsTab extends Tab { ), ); widget.label.textContent = "Colors linked to: "; - element.appendChild(widget.element); + this.appendChild(widget.element, APPEARANCE_SECTION_JSON_KEY); } for (const control of LAYER_CONTROLS) { - element.appendChild( + this.appendChild( addLayerControlToOptionsTab(this, layer, this.visibility, control), + control.sectionKey, ); } @@ -121,7 +127,23 @@ export class DisplayOptionsTab extends Tab { this.visibility, ), ); - element.appendChild(skeletonControls.element); + this.appendChild( + skeletonControls.element, + SKELETON_SECTION_JSON_KEY, + !this.layer.hasSkeletonsLayer.value, + ); + this.setSectionHidden( + SKELETON_SECTION_JSON_KEY, + !this.layer.hasSkeletonsLayer.value, + ); + this.registerDisposer( + this.layer.hasSkeletonsLayer.changed.add(() => { + this.setSectionHidden( + SKELETON_SECTION_JSON_KEY, + !this.layer.hasSkeletonsLayer.value, + ); + }), + ); } } diff --git a/src/ui/side_panel.css b/src/ui/side_panel.css index 0225c4063c..54d0f070cf 100644 --- a/src/ui/side_panel.css +++ b/src/ui/side_panel.css @@ -57,7 +57,5 @@ width: 1px; background-color: #333; background-clip: content-box; - padding-right: 2px; - padding-left: 2px; cursor: col-resize; } diff --git a/src/widget/accordion.browser_test.ts b/src/widget/accordion.browser_test.ts new file mode 100644 index 0000000000..c40219e9fc --- /dev/null +++ b/src/widget/accordion.browser_test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; +import { + AccordionState, + AccordionTab, + type AccordionOptions, +} from "#src/widget/accordion.js"; + +function makeAccordionOptions(): AccordionOptions { + return { + accordionJsonKey: "test", + sections: [ + { + jsonKey: "first", + displayName: "First", + defaultExpanded: false, + isDefaultKey: true, + }, + { + jsonKey: "second", + displayName: "Second", + defaultExpanded: true, + }, + ], + }; +} + +describe("accordion", () => { + it("restores and serializes state", () => { + const state = new AccordionState(makeAccordionOptions()); + + expect(state.toJSON()).toBeUndefined(); + + state.restoreState({ first: true, second: false }); + + expect(state.getSectionState("first")?.isExpanded.value).toBe(true); + expect(state.getSectionState("second")?.isExpanded.value).toBe(false); + expect(state.toJSON()).toEqual({ first: true, second: false }); + }); + + it("reflects initial expanded state in the DOM", () => { + const tab = new AccordionTab(new AccordionState(makeAccordionOptions())); + + expect(tab.sections[0].container.dataset.expanded).toBe("false"); + expect(tab.sections[0].header.getAttribute("aria-expanded")).toBe("false"); + expect(tab.sections[1].container.dataset.expanded).toBe("true"); + expect(tab.sections[1].header.getAttribute("aria-expanded")).toBe("true"); + }); + + it("toggles expanded state when header is clicked", () => { + const state = new AccordionState(makeAccordionOptions()); + const tab = new AccordionTab(state); + + const section = tab.sections[0]; + expect(section.container.dataset.expanded).toBe("false"); + + section.header.click(); + + expect(state.getSectionState("first")?.isExpanded.value).toBe(true); + expect(section.container.dataset.expanded).toBe("true"); + expect(section.header.getAttribute("aria-expanded")).toBe("true"); + expect(section.chevron.title).toBe("Collapse accordion section"); + }); + + it("appends content to the requested section and shows it", () => { + const tab = new AccordionTab(new AccordionState(makeAccordionOptions())); + const child = document.createElement("div"); + child.textContent = "hello"; + + tab.appendChild(child, "second"); + + expect(tab.sections[1].body.contains(child)).toBe(true); + expect(tab.sections[1].container.style.display).toBe(""); + + // With no section specified, appends to default section + tab.appendChild(child); + expect(tab.sections[0].body.contains(child)).toBe(true); + }); +}); diff --git a/src/widget/accordion.css b/src/widget/accordion.css new file mode 100644 index 0000000000..8ff0c4062c --- /dev/null +++ b/src/widget/accordion.css @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.neuroglancer-accordion-item { + border-bottom: 1px solid #ddd; +} + +.neuroglancer-accordion-item[data-expanded="true"] + .neuroglancer-accordion-body { + display: flex; /* Show when expanded */ +} + +.neuroglancer-accordion-item[data-expanded="false"] + .neuroglancer-accordion-body { + display: none; /* Hide when collapsed */ +} + +.neuroglancer-accordion-item[data-hidden="true"] { + display: none; +} + +.neuroglancer-accordion-item[data-expanded="true"] + .neuroglancer-accordion-header + .neuroglancer-accordion-chevron + svg { + transform: rotate(180deg); /* Rotate chevron when expanded */ +} + +.neuroglancer-accordion-chevron { + display: inline-flex; + align-items: center; +} + +.neuroglancer-accordion-chevron svg { + width: 20px; + height: 20px; + fill: rgba(255, 255, 255, 0.8); /* Chevron color */ +} + +.neuroglancer-accordion-header { + padding: 8px 2px; + cursor: pointer; + justify-content: space-between; + align-items: center; + display: flex; +} + +.neuroglancer-accordion-header-text { + color: rgba(255, 255, 255, 0.8); +} + +.neuroglancer-accordion-body { + padding: 8px 2px; + overflow-x: auto; + overflow-y: auto; + padding-top: 0; + flex-direction: column; +} + +.neuroglancer-accordion-item.neuroglancer-accordion-no-border { + border-bottom: none; +} + +.neuroglancer-accordion-item.neuroglancer-accordion-no-border + .neuroglancer-accordion-body { + padding: 0; +} diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts new file mode 100644 index 0000000000..9d6e670181 --- /dev/null +++ b/src/widget/accordion.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import svg_chevron_down from "ikonate/icons/chevron-down.svg?raw"; +import { TrackableBoolean } from "#src/trackable_boolean.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { NullarySignal } from "#src/util/signal.js"; +import "#src/widget/accordion.css"; +import { Tab } from "#src/widget/tab_view.js"; + +declare let NEUROGLANCER_USE_ACCORDIONS: boolean | undefined; +declare let NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED: boolean | undefined; + +export interface AccordionOptions { + accordionJsonKey: string; + sections: AccordionSectionOptions[]; +} + +interface AccordionSectionOptions { + jsonKey: string; + displayName: string; + defaultExpanded?: boolean; + isDefaultKey?: boolean; +} + +interface AccordionSection { + name: string; + jsonKey: string; + container: HTMLElement; + header: HTMLElement; + body: HTMLElement; + chevron: HTMLElement; +} + +function getGlobalAccordionDefaultExpanded(): boolean { + return typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== "undefined" + ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED + : false; +} + +function getGlobalUseAccordions(): boolean { + return typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; +} + +export class AccordionSectionState extends RefCounted { + isExpanded: WatchableValueInterface; + + constructor( + public jsonKey: string, + private defaultExpanded: boolean, + onChangeCallback: () => void, + ) { + super(); + this.isExpanded = new TrackableBoolean(defaultExpanded, defaultExpanded); + this.registerDisposer(this.isExpanded.changed.add(onChangeCallback)); + } + + toJSON() { + if (this.isExpanded.value === this.defaultExpanded) return undefined; + return { [this.jsonKey]: this.isExpanded.value }; + } +} + +export class AccordionState extends RefCounted { + sectionStates: AccordionSectionState[] = []; + specificationChanged = new NullarySignal(); + + constructor(public accordionOptions: AccordionOptions) { + super(); + for (const sectionOptions of accordionOptions.sections) { + this.getOrCreateSectionState(sectionOptions); + } + } + + getOrCreateSectionState(sectionOptions: AccordionSectionOptions) { + const { jsonKey, defaultExpanded } = sectionOptions; + let sectionState = this.getSectionState(jsonKey); + if (sectionState === undefined) { + sectionState = this.registerDisposer( + new AccordionSectionState( + jsonKey, + defaultExpanded ?? getGlobalAccordionDefaultExpanded(), + this.specificationChanged.dispatch, + ), + ); + this.sectionStates.push(sectionState); + } + return sectionState; + } + + getSectionState(jsonKey: string): AccordionSectionState | undefined { + return this.sectionStates.find((s) => s.jsonKey === jsonKey); + } + + setSectionExpanded(jsonKey: string, expand?: boolean): void { + const section = this.getSectionState(jsonKey); + if (section !== undefined) { + section.isExpanded.value = expand ?? !section.isExpanded.value; + } + } + + restoreState(obj: unknown) { + if (obj === undefined || obj === null || typeof obj !== "object") { + return; + } + for (const [jsonKey, isExpanded] of Object.entries(obj)) { + if (typeof isExpanded !== "boolean") continue; + this.setSectionExpanded(jsonKey, isExpanded); + } + } + + toJSON() { + const sectionsData = this.sectionStates + .map((section) => section.toJSON()) + .filter((data) => data !== undefined); + + return sectionsData.length === 0 + ? undefined + : Object.assign({}, ...sectionsData); + } +} + +export class AccordionTab extends Tab { + sections: AccordionSection[] = []; + defaultKey: string | undefined; + + constructor(protected accordionState: AccordionState) { + super(); + const options = accordionState.accordionOptions; + this.element.classList.add("neuroglancer-accordion"); + this.registerDisposer( + this.accordionState.specificationChanged.add(() => + this.updateSectionsExpanded(), + ), + ); + options.sections.forEach((option) => { + this.createAccordionSection(option); + }); + if (this.defaultKey === undefined && options.sections.length > 0) { + this.defaultKey = options.sections[0].jsonKey; + } + this.updateSectionsExpanded(); + if (!getGlobalUseAccordions()) { + this.setAccordionHeadersHidden(true); + } + } + + private setSectionExpanded(jsonKey: string, expand?: boolean): void { + this.accordionState.setSectionExpanded(jsonKey, expand); + } + + private updateSectionsExpanded() { + const accordionsDisabled = !getGlobalUseAccordions(); + this.accordionState.sectionStates.forEach((state) => { + const section = this.getSectionByKey(state.jsonKey); + if (section === undefined) return; + const { container, header, chevron } = section; + const expand = accordionsDisabled || state.isExpanded.value; + container.dataset.expanded = String(expand); + header.setAttribute("aria-expanded", String(expand)); + chevron.title = expand + ? "Collapse accordion section" + : "Expand accordion section"; + }); + } + + private createAccordionSection( + option: AccordionSectionOptions, + ): AccordionSection | undefined { + const newSection: AccordionSection = { + name: option.displayName, + jsonKey: option.jsonKey, + container: document.createElement("div"), + header: document.createElement("div"), + body: document.createElement("div"), + chevron: document.createElement("span"), + }; + this.sections.push(newSection); + const { container, header, body, chevron } = newSection; + container.classList.add("neuroglancer-accordion-item"); + body.classList.add("neuroglancer-accordion-body"); + header.classList.add("neuroglancer-accordion-header"); + container.appendChild(newSection.header); + container.appendChild(newSection.body); + this.element.appendChild(container); + + chevron.classList.add("neuroglancer-accordion-chevron"); + chevron.classList.add("neuroglancer-icon"); + chevron.innerHTML = svg_chevron_down; + const headerText = document.createElement("span"); + headerText.classList.add("neuroglancer-accordion-header-text"); + headerText.textContent = option.displayName; + header.appendChild(headerText); + header.appendChild(chevron); + + container.dataset.expanded = String(option.defaultExpanded ?? false); + + if (option.isDefaultKey) { + this.defaultKey = option.jsonKey; + } + + this.registerEventListener(newSection.header, "click", () => + this.setSectionExpanded(option.jsonKey), + ); + + const useAccordions = + typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; + if (!useAccordions) { + container.classList.add("neuroglancer-accordion-no-border"); + } + + // Usually, the state is pre-propulated with all the relevant sections. + // However, because appendChild is public and can be called with + // a jsonKey that is not in the initial accordionOptions, we need to + // add the section into the state if that happens + // This state wouldn't get properly restored if that occurs, + // but in case there is some unforeseen section added, at least + // the controls to expand/collapse it will still work because of this + this.accordionState.getOrCreateSectionState(option); + return newSection; + } + + private getSectionByKey( + jsonKey: string | undefined, + ): AccordionSection | undefined { + return this.sections.find((e) => e.jsonKey === jsonKey); + } + + private getSectionWithFallback(jsonKey?: string): AccordionSection { + const section = + this.getSectionByKey(jsonKey ?? this.defaultKey) ?? + this.getSectionByKey(this.defaultKey); + if (section === undefined) { + throw new Error( + `Accordion section with key "${jsonKey ?? this.defaultKey}" not found.`, + ); + } + return section; + } + + appendChild(content: HTMLElement, jsonKey?: string, hidden?: boolean): void { + const section = this.getSectionWithFallback(jsonKey); + section.body.appendChild(content); + if (!hidden) section.container.style.display = ""; + } + + /** + * Set the visibility of the section with the given jsonKey. + * This is different to expanding/collapsing the section. + */ + setSectionHidden(jsonKey: string, hidden: boolean): void { + const section = this.getSectionByKey(jsonKey); + if (section !== undefined) { + section.container.dataset.hidden = hidden ? "true" : "false"; + } + } + + /** + * Show the section with the given jsonKey. + * This is different to expanding the section, it is only about visibility. + */ + showSection(jsonKey: string): void { + this.setSectionHidden(jsonKey, false); + } + + /** + * Hide the section with the given jsonKey. + * This is different to collapsing the section, it is only about visibility. + */ + hideSection(jsonKey: string): void { + this.setSectionHidden(jsonKey, true); + } + + setAccordionHeadersHidden(hidden: boolean): void { + this.sections.forEach((section) => { + section.header.style.display = hidden ? "none" : ""; + }); + } +} diff --git a/src/widget/layer_control.ts b/src/widget/layer_control.ts index 7497ee3006..67172e53a8 100644 --- a/src/widget/layer_control.ts +++ b/src/widget/layer_control.ts @@ -37,6 +37,7 @@ export interface LayerControlLabelOptions< title?: string; toolDescription?: string; toolJson: any; + sectionKey?: string; isValid?: (layer: LayerType) => WatchableValueInterface; }