diff --git a/python/neuroglancer/tool/screenshot.py b/python/neuroglancer/tool/screenshot.py index 2b3997522..01288f92c 100755 --- a/python/neuroglancer/tool/screenshot.py +++ b/python/neuroglancer/tool/screenshot.py @@ -596,6 +596,11 @@ def define_state_modification_args(ap: argparse.ArgumentParser): type=float, help="Multiply projection view scale by specified factor.", ) + ap.add_argument( + "--resolution-scale-factor", + type=float, + help="Divide cross section view scale by specified factor. E.g. a 2000x2000 output with a resolution scale factor of 2 will have the same FOV as a 1000x1000 output.", + ) ap.add_argument( "--system-memory-limit", type=int, @@ -635,6 +640,8 @@ def apply_state_modifications( state.show_default_annotations = args.show_default_annotations if args.projection_scale_multiplier is not None: state.projection_scale *= args.projection_scale_multiplier + if args.resolution_scale_factor is not None: + state.cross_section_scale /= args.resolution_scale_factor if args.cross_section_background_color is not None: state.cross_section_background_color = args.cross_section_background_color diff --git a/python/neuroglancer/viewer_config_state.py b/python/neuroglancer/viewer_config_state.py index ee069dbda..192b8dfde 100644 --- a/python/neuroglancer/viewer_config_state.py +++ b/python/neuroglancer/viewer_config_state.py @@ -97,6 +97,34 @@ class LayerSelectedValues(_LayerSelectedValuesBase): """Specifies the data values associated with the current mouse position.""" +@export +class PanelResolutionData(JsonObjectWrapper): + __slots__ = () + type = wrapped_property("type", str) + width = wrapped_property("width", int) + height = wrapped_property("height", int) + resolution = wrapped_property("resolution", str) + + +@export +class LayerResolutionData(JsonObjectWrapper): + __slots__ = () + name = wrapped_property("name", str) + type = wrapped_property("type", str) + resolution = wrapped_property("resolution", str) + + +@export +class ScreenshotResolutionMetadata(JsonObjectWrapper): + __slots__ = () + panel_resolution_data = panelResolutionData = wrapped_property( + "panelResolutionData", typed_list(PanelResolutionData) + ) + layer_resolution_data = layerResolutionData = wrapped_property( + "layerResolutionData", typed_list(LayerResolutionData) + ) + + @export class ScreenshotReply(JsonObjectWrapper): __slots__ = () @@ -106,6 +134,9 @@ class ScreenshotReply(JsonObjectWrapper): height = wrapped_property("height", int) image_type = imageType = wrapped_property("imageType", str) depth_data = depthData = wrapped_property("depthData", optional(base64.b64decode)) + resolution_metadata = resolutionMetadata = wrapped_property( + "resolutionMetadata", ScreenshotResolutionMetadata + ) @property def image_pixels(self): diff --git a/src/display_context.ts b/src/display_context.ts index f3c90ecdd..7887f714b 100644 --- a/src/display_context.ts +++ b/src/display_context.ts @@ -25,6 +25,10 @@ import { FramerateMonitor } from "#src/util/framerate.js"; import type { mat4 } from "#src/util/geom.js"; import { parseFixedLengthArray, verifyFloat01 } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; +import { + TrackableScreenshotMode, + ScreenshotMode, +} from "#src/util/trackable_screenshot_mode.js"; import type { WatchableVisibilityPriority } from "#src/visibility_priority/frontend.js"; import type { GL } from "#src/webgl/context.js"; import { initializeWebGL } from "#src/webgl/context.js"; @@ -135,7 +139,7 @@ export abstract class RenderedPanel extends RefCounted { abstract isReady(): boolean; - ensureBoundsUpdated() { + ensureBoundsUpdated(canScaleForScreenshot: boolean = false) { const { context } = this; context.ensureBoundsUpdated(); const { boundsGeneration } = context; @@ -221,8 +225,18 @@ export abstract class RenderedPanel extends RefCounted { 0, clippedBottom - clippedTop, )); - viewport.logicalWidth = logicalWidth; - viewport.logicalHeight = logicalHeight; + if ( + this.context.screenshotMode.value !== ScreenshotMode.OFF && + canScaleForScreenshot + ) { + viewport.width = logicalWidth * screenToCanvasPixelScaleX; + viewport.height = logicalHeight * screenToCanvasPixelScaleY; + viewport.logicalWidth = logicalWidth * screenToCanvasPixelScaleX; + viewport.logicalHeight = logicalHeight * screenToCanvasPixelScaleY; + } else { + viewport.logicalWidth = logicalWidth; + viewport.logicalHeight = logicalHeight; + } viewport.visibleLeftFraction = (clippedLeft - logicalLeft) / logicalWidth; viewport.visibleTopFraction = (clippedTop - logicalTop) / logicalHeight; viewport.visibleWidthFraction = clippedWidth / logicalWidth; @@ -410,6 +424,9 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { rootRect: DOMRect | undefined; resizeGeneration = 0; boundsGeneration = -1; + screenshotMode: TrackableScreenshotMode = new TrackableScreenshotMode( + ScreenshotMode.OFF, + ); force3DHistogramForAutoRange = false; private framerateMonitor = new FramerateMonitor(); @@ -599,8 +616,10 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { const { resizeGeneration } = this; if (this.boundsGeneration === resizeGeneration) return; const { canvas } = this; - canvas.width = canvas.offsetWidth; - canvas.height = canvas.offsetHeight; + if (this.screenshotMode.value === ScreenshotMode.OFF) { + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + } this.canvasRect = canvas.getBoundingClientRect(); this.rootRect = this.container.getBoundingClientRect(); this.boundsGeneration = resizeGeneration; diff --git a/src/overlay.ts b/src/overlay.ts index 3703e05e2..8ead82ac0 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -47,11 +47,15 @@ export class Overlay extends RefCounted { document.body.appendChild(container); this.registerDisposer(new KeyboardEventBinder(this.container, this.keyMap)); this.registerEventListener(container, "action:close", () => { - this.dispose(); + this.close(); }); content.focus(); } + close() { + this.dispose(); + } + disposed() { --overlaysOpen; document.body.removeChild(this.container); diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index 646519d90..20b1d82e7 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -605,7 +605,7 @@ export class PerspectivePanel extends RenderedDataPanel { } ensureBoundsUpdated() { - super.ensureBoundsUpdated(); + super.ensureBoundsUpdated(true /* canScaleForScreenshot */); this.projectionParameters.setViewport(this.renderViewport); } diff --git a/src/python_integration/screenshots.ts b/src/python_integration/screenshots.ts index ca5b39497..ce8233d94 100644 --- a/src/python_integration/screenshots.ts +++ b/src/python_integration/screenshots.ts @@ -28,11 +28,54 @@ import { convertEndian32, Endianness } from "#src/util/endian.js"; import { verifyOptionalString } from "#src/util/json.js"; import { Signal } from "#src/util/signal.js"; import { getCachedJson } from "#src/util/trackable.js"; +import { ScreenshotMode } from "#src/util/trackable_screenshot_mode.js"; +import type { ResolutionMetadata } from "#src/util/viewer_resolution_stats.js"; +import { getViewerResolutionMetadata } from "#src/util/viewer_resolution_stats.js"; import type { Viewer } from "#src/viewer.js"; +export interface ScreenshotResult { + id: string; + image: string; + imageType: string; + depthData: string | undefined; + width: number; + height: number; + resolutionMetadata: ResolutionMetadata; +} + +export interface ScreenshotActionState { + viewerState: any; + selectedValues: any; + screenshot: ScreenshotResult; +} + +export interface ScreenshotChunkStatistics { + downloadLatency: number; + visibleChunksDownloading: number; + visibleChunksFailed: number; + visibleChunksGpuMemory: number; + visibleChunksSystemMemory: number; + visibleChunksTotal: number; + visibleGpuMemory: number; +} + +export interface StatisticsActionState { + viewerState: any; + selectedValues: any; + screenshotStatistics: { + id: string; + chunkSources: any[]; + total: ScreenshotChunkStatistics; + }; +} + export class ScreenshotHandler extends RefCounted { - sendScreenshotRequested = new Signal<(state: any) => void>(); - sendStatisticsRequested = new Signal<(state: any) => void>(); + sendScreenshotRequested = new Signal< + (state: ScreenshotActionState) => void + >(); + sendStatisticsRequested = new Signal< + (state: StatisticsActionState) => void + >(); requestState = new TrackableValue( undefined, verifyOptionalString, @@ -124,12 +167,14 @@ export class ScreenshotHandler extends RefCounted { return; } const { viewer } = this; - if (!viewer.isReady()) { + const shouldForceScreenshot = + this.viewer.display.screenshotMode.value === ScreenshotMode.FORCE; + if (!viewer.isReady() && !shouldForceScreenshot) { this.wasAlreadyVisible = false; this.throttledSendStatistics(requestState); return; } - if (!this.wasAlreadyVisible) { + if (!this.wasAlreadyVisible && !shouldForceScreenshot) { this.throttledSendStatistics(requestState); this.wasAlreadyVisible = true; this.debouncedMaybeSendScreenshot(); @@ -140,6 +185,7 @@ export class ScreenshotHandler extends RefCounted { this.throttledSendStatistics.cancel(); viewer.display.draw(); const screenshotData = viewer.display.canvas.toDataURL(); + const resolutionMetadata = getViewerResolutionMetadata(viewer); const { width, height } = viewer.display.canvas; const prefix = "data:image/png;base64,"; let imageType: string; @@ -169,6 +215,7 @@ export class ScreenshotHandler extends RefCounted { depthData, width, height, + resolutionMetadata, }, }; diff --git a/src/single_mesh/frontend.ts b/src/single_mesh/frontend.ts index babbb0098..61202c735 100644 --- a/src/single_mesh/frontend.ts +++ b/src/single_mesh/frontend.ts @@ -20,8 +20,18 @@ import { ChunkSource, WithParameters, } from "#src/chunk_manager/frontend.js"; +import { + makeCoordinateSpace, + makeIdentityTransform, +} from "#src/coordinate_transform.js"; +import type { + DataSource, + GetKvStoreBasedDataSourceOptions, + KvStoreBasedDataSourceProvider, +} from "#src/datasource/index.js"; import { WithSharedKvStoreContext } from "#src/kvstore/chunk_source_frontend.js"; import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; +import { ensureEmptyUrlSuffix } from "#src/kvstore/url.js"; import type { PickState, VisibleLayerInfo } from "#src/layer/index.js"; import type { PerspectivePanel } from "#src/perspective_view/panel.js"; import type { PerspectiveViewRenderContext } from "#src/perspective_view/render_layer.js"; @@ -85,16 +95,6 @@ import { TextureFormat, } from "#src/webgl/texture_access.js"; import { SharedObject } from "#src/worker_rpc.js"; -import type { - DataSource, - GetKvStoreBasedDataSourceOptions, - KvStoreBasedDataSourceProvider, -} from "#src/datasource/index.js"; -import { ensureEmptyUrlSuffix } from "#src/kvstore/url.js"; -import { - makeCoordinateSpace, - makeIdentityTransform, -} from "#src/coordinate_transform.js"; const DEFAULT_FRAGMENT_MAIN = `void main() { emitGray(); diff --git a/src/sliceview/panel.ts b/src/sliceview/panel.ts index d92c94df9..bea3bf506 100644 --- a/src/sliceview/panel.ts +++ b/src/sliceview/panel.ts @@ -435,7 +435,7 @@ export class SliceViewPanel extends RenderedDataPanel { } ensureBoundsUpdated() { - super.ensureBoundsUpdated(); + super.ensureBoundsUpdated(true /* canScaleForScreenshot */); this.sliceView.projectionParameters.setViewport(this.renderViewport); } diff --git a/src/sliceview/volume/renderlayer.ts b/src/sliceview/volume/renderlayer.ts index 178719f26..e18948393 100644 --- a/src/sliceview/volume/renderlayer.ts +++ b/src/sliceview/volume/renderlayer.ts @@ -341,6 +341,7 @@ export abstract class SliceViewVolumeRenderLayer< >; private tempChunkPosition: Float32Array; shaderParameters: WatchableValueInterface; + highestResolutionLoadedVoxelSize: Float32Array | undefined; private vertexIdHelper: VertexIdHelper; constructor( @@ -570,6 +571,7 @@ void main() { this.chunkManager.chunkQueueManager.frameNumberCounter.frameNumber, ); } + this.highestResolutionLoadedVoxelSize = undefined; let shaderResult: ParameterizedShaderGetterResult< ShaderParameters, @@ -692,6 +694,18 @@ void main() { effectiveVoxelSize[1], effectiveVoxelSize[2], ); + if (presentCount > 0) { + const medianStoredVoxelSize = this.highestResolutionLoadedVoxelSize + ? medianOf3( + this.highestResolutionLoadedVoxelSize[0], + this.highestResolutionLoadedVoxelSize[1], + this.highestResolutionLoadedVoxelSize[2], + ) + : Infinity; + if (medianVoxelSize <= medianStoredVoxelSize) { + this.highestResolutionLoadedVoxelSize = effectiveVoxelSize; + } + } renderScaleHistogram.add( medianVoxelSize, medianVoxelSize / projectionParameters.pixelSize, diff --git a/src/ui/screenshot_menu.css b/src/ui/screenshot_menu.css new file mode 100644 index 000000000..7ec2c1450 --- /dev/null +++ b/src/ui/screenshot_menu.css @@ -0,0 +1,373 @@ +/** + * @license + * Copyright 2024 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. + */ + +/* Color variables for the screenshot */ +.neuroglancer-screenshot-overlay { + --gray300: #d0d5dd; + --gray600: #141415; + --gray500: #f7f7f7; + --gray50: #e6e6e6; + --gray800: #344054; + --gray700: rgba(20, 20, 21, 0.6); + --gray400: rgba(20, 20, 21, 0.4); + --gray200: rgba(20, 20, 21, 0.8); + --primary500: #0069eb; + --primary700: #0474ff; +} + +/* General headings, labels, and top-level containers */ +.neuroglancer-screenshot-overlay :is(div, table, tr, td, th, ) { + box-sizing: border-box; + outline: 0; +} + +.neuroglancer-screenshot-dialog { + width: 48.75rem; + padding: 0; + margin: 1.25rem auto; + position: relative; + transform: none; + left: auto; + top: auto; + border-radius: 0.5rem; + font-family: sans-serif; +} + +.neuroglancer-screenshot-main-body-container { + height: auto; + max-height: calc(100vh - 200px); + overflow-y: auto; + overflow-x: hidden; +} + +.neuroglancer-screenshot-title { + font-size: 0.938rem; + font-weight: 590; + color: var(--gray600); + margin: 0; +} + +.neuroglancer-screenshot-title-subheading { + display: flex; + align-items: center; + justify-content: space-between; +} + +.neuroglancer-screenshot-label { + font-size: 0.813rem; + color: var(--gray200); + font-weight: 590; +} + +/* Div at the top which contains the close */ +.neuroglancer-screenshot-close { + border-bottom: 1px solid var(--gray50); + display: flex; + align-items: center; + padding: 0.75rem 1rem; + gap: 10px; +} + +/* Filename input menu */ +.neuroglancer-screenshot-filename-container { + padding: 1rem 1rem 0.75rem 1rem; +} + +.neuroglancer-screenshot-name-label { + color: var(--gray800); + font-style: normal; + display: block; + margin: 0.75rem 0 0.375rem; +} + +.neuroglancer-screenshot-name-input { + width: 100%; + margin-right: 10px; + border-radius: 8px; + border: 1px solid var(--gray300); + background: white; + font-size: 0.813rem; + font-style: normal; + padding: 8px 12px; + align-items: center; + gap: 8px; + font-weight: 400; + color: var(--gray700); + box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); + line-height: 20px; + box-sizing: border-box; + outline: 0; +} + +.neuroglancer-screenshot-name-input:disabled { + background: var(--gray500); + color: var(--gray400); +} + +.neuroglancer-screenshot-name-input::placeholder { + color: var(--gray700); +} + +/* Scale selection menu */ +.neuroglancer-screenshot-scale-factor-label { + width: 100%; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.15rem; +} + +.neuroglancer-screenshot-scale-menu { + padding: 0 1rem; + display: flex; + flex-wrap: wrap; +} + +.neuroglancer-screenshot-scale-radio-container { + display: flex; + flex-direction: row; +} + +.neuroglancer-screenshot-scale-radio-item { + margin-right: 2.125rem; +} + +.neuroglancer-screenshot-scale-radio-label { + color: var(--gray700); + font-size: 0.75rem; +} + +.neuroglancer-screenshot-scale-radio-input { + margin: 0 0.188rem 0 0; + display: inline-block; + cursor: pointer; +} + +.neuroglancer-screenshot-warning { + color: red; + width: auto; + font-size: 0.75rem; + margin: 0.12rem 0 0 -1.25rem; +} + +/* Slice FOV fixed selection menu */ +.neuroglancer-screenshot-keep-slice-fov-checkbox { + margin: 0; +} + +.neuroglancer-screenshot-keep-slice-label { + display: flex; + flex-direction: row-reverse; + margin: 1rem 0; + width: 100%; + justify-content: flex-end; + align-items: center; + gap: 0.5rem; +} + +/* Take screenshot and close buttons */ +.neuroglancer-screenshot-button { + cursor: pointer; + margin: 2px 0px; + padding: 0; +} + +.neuroglancer-screenshot-close-button { + margin-left: auto; + background-color: transparent; + border: 0; +} + +/* Screenshot resolution table - voxel resolution, panel resolution */ +.neuroglancer-screenshot-size-text { + margin: 0.75rem 0 0.75rem 0; + display: flex; + align-items: center; +} + +.neuroglancer-screenshot-resolution-size-label { + text-align: left; + color: var(--gray200); + font-size: 0.813rem; + font-style: normal; + font-weight: 590; + width: 33.33%; + padding: 0; + margin: 0 0; +} + +.neuroglancer-screenshot-resolution-size-value { + font-size: 0.813rem; + color: var(--gray700); + width: 33.33%; + padding: 0.25rem; + font-weight: 400; +} + +.neuroglancer-screenshot-resolution-preview-container { + border-top: 1px solid var(--gray50); + border-bottom: 1px solid var(--gray50); + background: var(--gray500); + width: 100%; + padding: 1rem 1rem 0.5rem 1rem; +} + +.neuroglancer-screenshot-resolution-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + padding: 4px 2px 2px 2px; + background-color: var(--gray50); + margin-bottom: 0.75rem; + border-radius: 0.35rem; + border: 0; +} + +.neuroglancer-screenshot-resolution-table th { + font-size: 0.813rem; + width: 33.33%; + text-align: left; + color: var(--gray200); + font-style: normal; + font-weight: 590; + background: var(--gray50); + padding: 0.25rem 0.375rem; +} + +.neuroglancer-screenshot-resolution-table td { + font-size: 0.813rem; + width: 33.33%; + color: var(--gray700); + background: white; + padding: 0.375rem; + line-height: 1.25rem; +} + +.neuroglancer-screenshot-resolution-table-tooltip { + vertical-align: top; + margin-left: 0.25rem; +} + +.neuroglancer-screenshot-copy-icon { + outline: 0; + border: 0; + cursor: pointer; + height: 1rem; + margin-left: auto; + position: relative; + width: 33.33%; + justify-content: flex-end; +} + +.neuroglancer-screenshot-dimension { + color: var(--gray600); +} + +/* Screenshot statistics table - shows GPU memory etc. */ +.neuroglancer-screenshot-statistics-table { + width: 100%; + border-collapse: collapse; +} + +.neuroglancer-screenshot-statistics-table th { + text-align: left; + width: 33.33%; + padding: 0 0 0.5rem; +} + +.neuroglancer-screenshot-statistics-table td { + text-align: left; + width: 33.33%; + font-size: 0.813rem; + padding: 0.5rem 0; +} + +.neuroglancer-screenshot-statistics-table-data-key { + color: var(--gray200); + font-weight: 590; +} + +.neuroglancer-screenshot-statistics-table-data-value { + color: var(--gray700); + font-weight: 400; +} + +.neuroglancer-statistics-table-description-header { + font-size: 0.813rem; + color: var(--gray700); + line-height: 1.25rem; + font-weight: 400; +} + +.neuroglancer-statistics-table-description-link { + font-weight: 590; + cursor: pointer; + color: var(--primary500); + margin-left: 0.1rem; +} + +/* Icons in the dialog */ +.neuroglancer-screenshot-dialog .neuroglancer-icon svg { + stroke: rgba(20, 20, 21, 0.4); + width: 1rem; + height: 1rem; +} + +.neuroglancer-screenshot-dialog .neuroglancer-icon { + width: 1rem; + height: 1rem; + min-width: inherit; + min-height: inherit; + padding: 0; +} + +.neuroglancer-screenshot-dialog .neuroglancer-icon:hover { + background: none; +} + +/* Footer with progress and buttons */ +.neuroglancer-screenshot-footer-container { + margin: 0; + display: flex; + padding: 0.75rem 1rem; + border-top: 1px solid var(--gray50); +} + +.neuroglancer-screenshot-progress-text { + margin: 0; + flex: 1; + font-weight: 590; + cursor: pointer; + padding: 0; + font-size: 0.813rem; + align-self: center; + color: var(--primary700); +} + +.neuroglancer-screenshot-footer-button { + border-radius: 0.5rem; + border: 1px solid var(--gray300); + background: white; + padding: 0.5rem 0.75rem; + font-size: 0.813rem; + font-weight: 590; + color: var(--gray800); + margin: 0 0 0 0.25rem; +} + +.neuroglancer-screenshot-footer-button:disabled { + display: none; +} diff --git a/src/ui/screenshot_menu.ts b/src/ui/screenshot_menu.ts new file mode 100644 index 000000000..447f76186 --- /dev/null +++ b/src/ui/screenshot_menu.ts @@ -0,0 +1,857 @@ +/** + * @license + * Copyright 2024 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. + * + * @file UI menu for taking screenshots from the viewer. + */ + +import "#src/ui/screenshot_menu.css"; +import svg_close from "ikonate/icons/close.svg?raw"; +import svg_help from "ikonate/icons/help.svg?raw"; +import { throttle } from "lodash-es"; +import { Overlay } from "#src/overlay.js"; +import { StatusMessage } from "#src/status.js"; +import { setClipboard } from "#src/util/clipboard.js"; +import type { + ScreenshotLoadStatistics, + ScreenshotManager, +} from "#src/util/screenshot_manager.js"; +import { MAX_RENDER_AREA_PIXELS } from "#src/util/screenshot_manager.js"; +import { ScreenshotMode } from "#src/util/trackable_screenshot_mode.js"; +import type { + DimensionResolutionStats, + PanelViewport, +} from "#src/util/viewer_resolution_stats.js"; +import { + getViewerResolutionMetadata, + getViewerLayerResolutions, + getViewerPanelResolutions, +} from "#src/util/viewer_resolution_stats.js"; +import { makeCopyButton } from "#src/widget/copy_button.js"; +import { makeIcon } from "#src/widget/icon.js"; + +// If DEBUG_ALLOW_MENU_CLOSE is true, the menu can be closed by clicking the close button +// Usually the user is locked into the screenshot menu until the screenshot is taken or cancelled +// Setting this to true, and setting the SCREENSHOT_MENU_CLOSE_TIMEOUT in screenshot_manager.ts +// to a high value can be useful for debugging canvas handling of the resize +// Also helpful for viewing the canvas at higher resolutions +const DEBUG_ALLOW_MENU_CLOSE = false; + +// For easy access to UI elements +const PANEL_TABLE_HEADER_STRINGS = { + type: "Panel type", + pixelResolution: "Pixel resolution", + physicalResolution: "Physical scale", +}; +const LAYER_TABLE_HEADER_STRINGS = { + name: "Layer name", + type: "Data type", + resolution: "Physical voxel resolution", +}; +const TOOLTIPS = { + generalSettingsTooltip: + "In the main viewer, see the settings (cog icon, top right) for options to turn off the axis line indicators, the scale bar, and the default annotation yellow bounding box.", + orthographicSettingsTooltip: + "In the main viewer, press 'o' to toggle between perspective and orthographic views.", + layerDataTooltip: + "The highest loaded resolution of 2D image slices, 3D volume renderings, and 2D segmentation slices are shown here. Other layers are not shown.", + scaleFactorHelpTooltip: + "Adjusting the scale will zoom out 2D cross-section panels by that factor unless the box is ticked to keep the slice FOV fixed with scale changes. 3D panels always have fixed FOV regardless of the scale factor.", +}; + +interface UIScreenshotStatistics { + chunkUsageDescription: string; + gpuMemoryUsageDescription: string; + downloadSpeedDescription: string; +} + +const statisticsNamesForUI = { + chunkUsageDescription: "Number of loaded chunks", + gpuMemoryUsageDescription: "Visible chunk GPU memory usage", + downloadSpeedDescription: "Number of downloading chunks", +}; + +const layerNamesForUI = { + ImageRenderLayer: "Image slice (2D)", + VolumeRenderingRenderLayer: "Volume rendering (3D)", + SegmentationRenderLayer: "Segmentation slice (2D)", +}; + +function splitIntoLines(text: string, maxLineLength: number = 60): string { + const words = text.split(" "); + const lines = []; + let currentLine = ""; + + for (const word of words) { + if ((currentLine + word).length > maxLineLength) { + lines.push(currentLine.trim()); + currentLine = word + " "; + } else { + currentLine += word + " "; + } + } + lines.push(currentLine.trim()); + + return lines.join("\n"); +} + +/** + * Combine the resolution of all dimensions into a single string for UI display + */ +function formatPhysicalResolution(resolution: DimensionResolutionStats[]) { + if (resolution.length === 0) { + return { + type: "Loading...", + resolution: "Data not loaded", + }; + } + + const firstResolution = resolution[0]; + const type = firstResolution.panelType; + + if (firstResolution.dimensionName === "All_") { + return { + type, + resolution: firstResolution.resolutionWithUnit, + }; + } + + const resolutionHtml = resolution + .map( + (res) => + `${res.dimensionName} ${res.resolutionWithUnit}`, + ) + .join(" "); + + return { + type, + resolution: resolutionHtml, + }; +} + +function formatPixelResolution(panelArea: PanelViewport) { + const width = Math.round(panelArea.right - panelArea.left); + const height = Math.round(panelArea.bottom - panelArea.top); + const type = panelArea.panelType; + return { width, height, type }; +} + +/** + * This menu allows the user to take a screenshot of the current view, with options to + * set the filename, scale, and force the screenshot to be taken immediately. + * Once a screenshot is initiated, the user is locked into the menu until the + * screenshot is taken or cancelled, to prevent + * the user from interacting with the viewer while the screenshot is being taken. + * + * The menu displays statistics about the current view, such as the number of loaded + * chunks, GPU memory usage, and download speed. These are to inform the user about the + * progress of the screenshot, as it may take some time to load all the data. + * + * The menu also displays the resolution of each panel in the viewer, as well as the resolution + * of the voxels loaded for each Image, Volume, and Segmentation layer. + * This is to inform the user about the the physical units of the data and panels, + * and to help them decide on the scale of the screenshot. + * + * The screenshot menu supports keeping the slice view FOV fixed when changing the scale of the screenshot. + * This will cause the viewer to zoom in or out to keep the same FOV in the slice view. + * For example, an x2 scale will cause the viewer in slice views to zoom in by a factor of 2 + * such that when the number of pixels in the slice view is doubled, the FOV remains the same. + */ +export class ScreenshotDialog extends Overlay { + private nameInput: HTMLInputElement; + private takeScreenshotButton: HTMLButtonElement; + private closeMenuButton: HTMLButtonElement; + private cancelScreenshotButton: HTMLButtonElement; + private forceScreenshotButton: HTMLButtonElement; + private statisticsTable: HTMLTableElement; + private panelResolutionTable: HTMLTableElement; + private layerResolutionTable: HTMLTableElement; + private statisticsContainer: HTMLDivElement; + private filenameInputContainer: HTMLDivElement; + private screenshotSizeText: HTMLDivElement; + private warningElement: HTMLDivElement; + private footerScreenshotActionBtnsContainer: HTMLDivElement; + private progressText: HTMLParagraphElement; + private scaleRadioButtonsContainer: HTMLDivElement; + private keepSliceFOVFixedCheckbox: HTMLInputElement; + private helpTooltips: { + generalSettingsTooltip: HTMLElement; + orthographicSettingsTooltip: HTMLElement; + layerDataTooltip: HTMLElement; + scaleFactorHelpTooltip: HTMLElement; + }; + private statisticsKeyToCellMap: Map = new Map(); + private layerResolutionKeyToCellMap: Map = + new Map(); + + private throttledUpdateTableStatistics = this.registerCancellable( + throttle(() => { + this.populateLayerResolutionTable(); + this.handleScreenshotResize(); + this.populatePanelResolutionTable(); + }, 500), + ); + private screenshotWidth: number = 0; + private screenshotHeight: number = 0; + private screenshotPixelSize: HTMLElement; + constructor(private screenshotManager: ScreenshotManager) { + super(); + + this.initializeUI(); + this.setupEventListeners(); + this.screenshotManager.throttledSendStatistics(); + } + + dispose(): void { + super.dispose(); + if (!DEBUG_ALLOW_MENU_CLOSE) { + this.screenshotManager.shouldKeepSliceViewFOVFixed = true; + this.screenshotManager.screenshotScale = 1; + this.screenshotManager.cancelScreenshot(); + } + } + + close(): void { + if ( + this.screenshotMode !== ScreenshotMode.PREVIEW && + !DEBUG_ALLOW_MENU_CLOSE + ) { + StatusMessage.showTemporaryMessage( + "Cannot close screenshot menu while a screenshot is in progress. Hit 'Cancel screenshot' to stop the screenshot, or 'Force screenshot' to screenshot the currently available data.", + 4000, + ); + } else { + super.close(); + } + } + + private setupHelpTooltips() { + const generalSettingsTooltip = makeIcon({ + svg: svg_help, + title: splitIntoLines(TOOLTIPS.generalSettingsTooltip), + }); + + const orthographicSettingsTooltip = makeIcon({ + svg: svg_help, + title: TOOLTIPS.orthographicSettingsTooltip, + }); + orthographicSettingsTooltip.classList.add( + "neuroglancer-screenshot-resolution-table-tooltip", + ); + + const layerDataTooltip = makeIcon({ + svg: svg_help, + title: splitIntoLines(TOOLTIPS.layerDataTooltip), + }); + layerDataTooltip.classList.add( + "neuroglancer-screenshot-resolution-table-tooltip", + ); + + const scaleFactorHelpTooltip = makeIcon({ + svg: svg_help, + title: splitIntoLines(TOOLTIPS.scaleFactorHelpTooltip), + }); + + return (this.helpTooltips = { + generalSettingsTooltip, + orthographicSettingsTooltip, + layerDataTooltip, + scaleFactorHelpTooltip, + }); + } + + private initializeUI() { + const tooltips = this.setupHelpTooltips(); + this.content.classList.add("neuroglancer-screenshot-dialog"); + const parentElement = this.content.parentElement; + if (parentElement) { + parentElement.classList.add("neuroglancer-screenshot-overlay"); + } + + const titleText = document.createElement("h2"); + titleText.classList.add("neuroglancer-screenshot-title"); + titleText.textContent = "Screenshot"; + + this.closeMenuButton = this.createButton( + null, + () => this.close(), + "neuroglancer-screenshot-close-button", + svg_close, + ); + + this.cancelScreenshotButton = this.createButton( + "Cancel screenshot", + () => this.cancelScreenshot(), + "neuroglancer-screenshot-footer-button", + ); + this.takeScreenshotButton = this.createButton( + "Take screenshot", + () => this.screenshot(), + "neuroglancer-screenshot-footer-button", + ); + this.forceScreenshotButton = this.createButton( + "Force screenshot", + () => this.forceScreenshot(), + "neuroglancer-screenshot-footer-button", + ); + this.filenameInputContainer = document.createElement("div"); + this.filenameInputContainer.classList.add( + "neuroglancer-screenshot-filename-container", + ); + const menuText = document.createElement("h3"); + menuText.classList.add("neuroglancer-screenshot-title-subheading"); + menuText.classList.add("neuroglancer-screenshot-title"); + menuText.textContent = "Settings"; + menuText.appendChild(tooltips.generalSettingsTooltip); + this.filenameInputContainer.appendChild(menuText); + + const nameInputLabel = document.createElement("label"); + nameInputLabel.textContent = "Screenshot name"; + nameInputLabel.classList.add("neuroglancer-screenshot-label"); + nameInputLabel.classList.add("neuroglancer-screenshot-name-label"); + this.filenameInputContainer.appendChild(nameInputLabel); + this.filenameInputContainer.appendChild(this.createNameInput()); + + const closeAndHelpContainer = document.createElement("div"); + closeAndHelpContainer.classList.add("neuroglancer-screenshot-close"); + + closeAndHelpContainer.appendChild(titleText); + closeAndHelpContainer.appendChild(this.closeMenuButton); + + // This is the header + this.content.appendChild(closeAndHelpContainer); + + const mainBody = document.createElement("div"); + mainBody.classList.add("neuroglancer-screenshot-main-body-container"); + this.content.appendChild(mainBody); + + mainBody.appendChild(this.filenameInputContainer); + mainBody.appendChild(this.createScaleRadioButtons()); + + const previewContainer = document.createElement("div"); + previewContainer.classList.add( + "neuroglancer-screenshot-resolution-preview-container", + ); + const settingsPreview = document.createElement("div"); + settingsPreview.classList.add( + "neuroglancer-screenshot-resolution-table-container", + ); + const previewTopContainer = document.createElement("div"); + previewTopContainer.classList.add( + "neuroglancer-screenshot-resolution-preview-top-container", + ); + previewTopContainer.style.display = "flex"; + const previewLabel = document.createElement("h2"); + previewLabel.classList.add("neuroglancer-screenshot-title"); + previewLabel.textContent = "Preview"; + + this.screenshotSizeText = document.createElement("div"); + this.screenshotSizeText.classList.add("neuroglancer-screenshot-label"); + this.screenshotSizeText.classList.add("neuroglancer-screenshot-size-text"); + const screenshotLabel = document.createElement("h3"); + screenshotLabel.textContent = "Screenshot size"; + screenshotLabel.classList.add( + "neuroglancer-screenshot-resolution-size-label", + ); + this.screenshotPixelSize = document.createElement("span"); + this.screenshotPixelSize.classList.add( + "neuroglancer-screenshot-resolution-size-value", + ); + + const screenshotCopyButton = makeCopyButton({ + title: "Copy table to clipboard", + onClick: () => { + const result = setClipboard(this.getResolutionText()); + StatusMessage.showTemporaryMessage( + result + ? "Resolution table copied to clipboard" + : "Failed to copy resolution table to clipboard", + ); + }, + }); + screenshotCopyButton.classList.add("neuroglancer-screenshot-copy-icon"); + + this.screenshotSizeText.appendChild(screenshotLabel); + this.screenshotSizeText.appendChild(this.screenshotPixelSize); + + previewContainer.appendChild(previewTopContainer); + previewTopContainer.appendChild(previewLabel); + previewTopContainer.appendChild(screenshotCopyButton); + previewContainer.appendChild(this.screenshotSizeText); + previewContainer.appendChild(settingsPreview); + settingsPreview.appendChild(this.createPanelResolutionTable()); + settingsPreview.appendChild(this.createLayerResolutionTable()); + + mainBody.appendChild(previewContainer); + mainBody.appendChild(this.createStatisticsTable()); + + this.footerScreenshotActionBtnsContainer = document.createElement("div"); + this.footerScreenshotActionBtnsContainer.classList.add( + "neuroglancer-screenshot-footer-container", + ); + this.progressText = document.createElement("p"); + this.progressText.classList.add("neuroglancer-screenshot-progress-text"); + this.footerScreenshotActionBtnsContainer.appendChild(this.progressText); + this.footerScreenshotActionBtnsContainer.appendChild( + this.cancelScreenshotButton, + ); + this.footerScreenshotActionBtnsContainer.appendChild( + this.takeScreenshotButton, + ); + this.footerScreenshotActionBtnsContainer.appendChild( + this.forceScreenshotButton, + ); + this.content.appendChild(this.footerScreenshotActionBtnsContainer); + + this.screenshotManager.previewScreenshot(); + this.updateUIBasedOnMode(); + this.populatePanelResolutionTable(); + this.throttledUpdateTableStatistics(); + } + + private setupEventListeners() { + this.registerDisposer( + this.screenshotManager.screenshotFinished.add(() => { + this.dispose(); + }), + ); + this.registerDisposer( + this.screenshotManager.statisticsUpdated.add((screenshotLoadStats) => { + this.populateStatistics(screenshotLoadStats); + }), + ); + this.registerDisposer( + this.screenshotManager.viewer.display.updateFinished.add(() => { + this.throttledUpdateTableStatistics(); + }), + ); + this.registerDisposer( + this.screenshotManager.zoomMaybeChanged.add(() => { + this.populatePanelResolutionTable(); + }), + ); + } + + private createNameInput(): HTMLInputElement { + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.placeholder = "Enter optional screenshot name"; + nameInput.classList.add("neuroglancer-screenshot-name-input"); + return (this.nameInput = nameInput); + } + + private createButton( + text: string | null, + onClick: () => void, + cssClass: string = "", + svgUrl: string | null = null, + ): HTMLButtonElement { + const button = document.createElement("button"); + if (svgUrl) { + const icon = makeIcon({ svg: svgUrl }); + button.appendChild(icon); + } else if (text) { + button.textContent = text; + } + button.classList.add("neuroglancer-screenshot-button"); + if (cssClass) button.classList.add(cssClass); + button.addEventListener("click", onClick); + return button; + } + + private createScaleRadioButtons() { + const scaleMenu = document.createElement("div"); + scaleMenu.classList.add("neuroglancer-screenshot-scale-menu"); + + const scaleLabel = document.createElement("label"); + scaleLabel.classList.add("neuroglancer-screenshot-scale-factor-label"); + scaleLabel.classList.add("neuroglancer-screenshot-label"); + scaleLabel.textContent = "Screenshot scale factor"; + + scaleLabel.appendChild(this.helpTooltips.scaleFactorHelpTooltip); + + scaleMenu.appendChild(scaleLabel); + + this.scaleRadioButtonsContainer = document.createElement("div"); + this.scaleRadioButtonsContainer.classList.add( + "neuroglancer-screenshot-scale-radio-container", + ); + scaleMenu.appendChild(this.scaleRadioButtonsContainer); + + this.warningElement = document.createElement("div"); + this.warningElement.classList.add("neuroglancer-screenshot-warning"); + this.warningElement.textContent = ""; + + const scales = [1, 2, 4]; + scales.forEach((scale) => { + const container = document.createElement("div"); + const label = document.createElement("label"); + const input = document.createElement("input"); + + input.type = "radio"; + input.name = "screenshot-scale"; + input.value = scale.toString(); + input.checked = scale === this.screenshotManager.screenshotScale; + input.classList.add("neuroglancer-screenshot-scale-radio-input"); + + label.appendChild(document.createTextNode(`${scale}x`)); + label.classList.add("neuroglancer-screenshot-scale-radio-label"); + + container.classList.add("neuroglancer-screenshot-scale-radio-item"); + container.appendChild(input); + container.appendChild(label); + this.scaleRadioButtonsContainer.appendChild(container); + + input.addEventListener("change", () => { + this.screenshotManager.screenshotScale = scale; + this.handleScreenshotResize(); + }); + }); + scaleMenu.appendChild(this.warningElement); + + const keepSliceFOVFixedDiv = document.createElement("div"); + keepSliceFOVFixedDiv.classList.add( + "neuroglancer-screenshot-keep-slice-label", + ); + keepSliceFOVFixedDiv.classList.add("neuroglancer-screenshot-label"); + keepSliceFOVFixedDiv.textContent = "Keep slice FOV fixed with scale change"; + + const keepSliceFOVFixedCheckbox = document.createElement("input"); + keepSliceFOVFixedCheckbox.classList.add( + "neuroglancer-screenshot-keep-slice-fov-checkbox", + ); + keepSliceFOVFixedCheckbox.type = "checkbox"; + keepSliceFOVFixedCheckbox.checked = + this.screenshotManager.shouldKeepSliceViewFOVFixed; + keepSliceFOVFixedCheckbox.addEventListener("change", () => { + this.screenshotManager.shouldKeepSliceViewFOVFixed = + keepSliceFOVFixedCheckbox.checked; + }); + this.keepSliceFOVFixedCheckbox = keepSliceFOVFixedCheckbox; + keepSliceFOVFixedDiv.appendChild(keepSliceFOVFixedCheckbox); + scaleMenu.appendChild(keepSliceFOVFixedDiv); + + this.handleScreenshotResize(); + return scaleMenu; + } + + private createStatisticsTable() { + this.statisticsContainer = document.createElement("div"); + this.statisticsContainer.classList.add( + "neuroglancer-screenshot-statistics-title", + ); + this.statisticsContainer.style.padding = "1rem"; + + this.statisticsTable = document.createElement("table"); + this.statisticsTable.classList.add( + "neuroglancer-screenshot-statistics-table", + ); + + const headerRow = this.statisticsTable.createTHead().insertRow(); + const keyHeader = document.createElement("th"); + keyHeader.textContent = "Screenshot progress"; + keyHeader.classList.add("neuroglancer-screenshot-title"); + headerRow.appendChild(keyHeader); + const valueHeader = document.createElement("th"); + valueHeader.textContent = ""; + headerRow.appendChild(valueHeader); + + const descriptionRow = this.statisticsTable.createTHead().insertRow(); + const descriptionkeyHeader = document.createElement("th"); + descriptionkeyHeader.classList.add( + "neuroglancer-statistics-table-description-header", + ); + descriptionkeyHeader.colSpan = 2; + + descriptionkeyHeader.textContent = + "The screenshot will take when all the chunks are loaded. If GPU memory is full, the screenshot will only capture the successfully loaded chunks. A screenshot scale larger than 1 may cause new chunks to be downloaded once the screenshot is in progress."; + + // It can be used to point to a docs page when complete + // const descriptionLearnMoreLink = document.createElement("a"); + // descriptionLearnMoreLink.text = "Learn more"; + // descriptionLearnMoreLink.classList.add("neuroglancer-statistics-table-description-link") + + // descriptionkeyHeader.appendChild(descriptionLearnMoreLink); + descriptionRow.appendChild(descriptionkeyHeader); + + // Populate inital table elements with placeholder text + const orderedStatsRow: UIScreenshotStatistics = { + chunkUsageDescription: "", + gpuMemoryUsageDescription: "", + downloadSpeedDescription: "", + }; + for (const key in orderedStatsRow) { + const row = this.statisticsTable.insertRow(); + const keyCell = row.insertCell(); + keyCell.classList.add( + "neuroglancer-screenshot-statistics-table-data-key", + ); + const valueCell = row.insertCell(); + valueCell.classList.add( + "neuroglancer-screenshot-statistics-table-data-value", + ); + keyCell.textContent = + statisticsNamesForUI[key as keyof typeof statisticsNamesForUI]; + valueCell.textContent = + orderedStatsRow[key as keyof typeof orderedStatsRow]; + this.statisticsKeyToCellMap.set(key, valueCell); + } + + this.populateStatistics(this.screenshotManager.screenshotLoadStats); + this.statisticsContainer.appendChild(this.statisticsTable); + return this.statisticsContainer; + } + + private createPanelResolutionTable() { + const resolutionTable = (this.panelResolutionTable = + document.createElement("table")); + resolutionTable.classList.add("neuroglancer-screenshot-resolution-table"); + + const headerRow = resolutionTable.createTHead().insertRow(); + const keyHeader = document.createElement("th"); + keyHeader.textContent = PANEL_TABLE_HEADER_STRINGS.type; + keyHeader.appendChild(this.helpTooltips.orthographicSettingsTooltip); + + headerRow.appendChild(keyHeader); + const pixelValueHeader = document.createElement("th"); + pixelValueHeader.textContent = PANEL_TABLE_HEADER_STRINGS.pixelResolution; + headerRow.appendChild(pixelValueHeader); + const physicalValueHeader = document.createElement("th"); + physicalValueHeader.textContent = + PANEL_TABLE_HEADER_STRINGS.physicalResolution; + headerRow.appendChild(physicalValueHeader); + return resolutionTable; + } + + private populatePanelResolutionTable() { + // Clear the table before populating it + while (this.panelResolutionTable.rows.length > 1) { + this.panelResolutionTable.deleteRow(1); + } + const resolutionTable = this.panelResolutionTable; + const resolutions = getViewerPanelResolutions( + this.screenshotManager.viewer.display.panels, + ); + for (const resolution of resolutions) { + const physicalResolution = formatPhysicalResolution( + resolution.physicalResolution, + ); + const pixelResolution = formatPixelResolution(resolution.pixelResolution); + const row = resolutionTable.insertRow(); + const keyCell = row.insertCell(); + const pixelValueCell = row.insertCell(); + pixelValueCell.textContent = `${pixelResolution.width} x ${pixelResolution.height} px`; + const physicalValueCell = row.insertCell(); + keyCell.textContent = physicalResolution.type; + physicalValueCell.innerHTML = physicalResolution.resolution; + } + return resolutionTable; + } + + private createLayerResolutionTable() { + const resolutionTable = (this.layerResolutionTable = + document.createElement("table")); + resolutionTable.classList.add("neuroglancer-screenshot-resolution-table"); + + const headerRow = resolutionTable.createTHead().insertRow(); + const keyHeader = document.createElement("th"); + keyHeader.textContent = LAYER_TABLE_HEADER_STRINGS.name; + keyHeader.appendChild(this.helpTooltips.layerDataTooltip); + + headerRow.appendChild(keyHeader); + const typeHeader = document.createElement("th"); + typeHeader.textContent = LAYER_TABLE_HEADER_STRINGS.type; + headerRow.appendChild(typeHeader); + const valueHeader = document.createElement("th"); + valueHeader.textContent = LAYER_TABLE_HEADER_STRINGS.resolution; + headerRow.appendChild(valueHeader); + return resolutionTable; + } + + private populateLayerResolutionTable() { + const resolutionTable = this.layerResolutionTable; + const resolutionMap = getViewerLayerResolutions( + this.screenshotManager.viewer, + ); + for (const [key, value] of resolutionMap) { + const { name, type } = key; + if (type === "MultiscaleMeshLayer") { + continue; + } + const stringKey = `{${name}--${type}}`; + let valueCell = this.layerResolutionKeyToCellMap.get(stringKey); + if (valueCell === undefined) { + const row = resolutionTable.insertRow(); + const keyCell = row.insertCell(); + const typeCell = row.insertCell(); + valueCell = row.insertCell(); + keyCell.textContent = name; + typeCell.textContent = + layerNamesForUI[type as keyof typeof layerNamesForUI]; + this.layerResolutionKeyToCellMap.set(stringKey, valueCell); + } + valueCell.innerHTML = formatPhysicalResolution(value).resolution; + } + } + + private forceScreenshot() { + this.screenshotManager.forceScreenshot(); + } + + private cancelScreenshot() { + this.screenshotManager.cancelScreenshot(true /* shouldStayInPrevieMenu */); + this.updateUIBasedOnMode(); + } + + private screenshot() { + const filename = this.nameInput.value; + this.screenshotManager.takeScreenshot(filename); + this.updateUIBasedOnMode(); + } + + private populateStatistics( + screenshotLoadStats: ScreenshotLoadStatistics | null, + ) { + const statsRow = this.parseStatistics(screenshotLoadStats); + if (statsRow === null) { + return; + } + + for (const key in statsRow) { + this.statisticsKeyToCellMap.get(key)!.textContent = String( + statsRow[key as keyof typeof statsRow], + ); + } + } + + private handleScreenshotResize() { + const screenshotSize = + this.screenshotManager.calculatedClippedViewportSize(); + const scale = this.screenshotManager.screenshotScale.toFixed(2); + const numPixels = Math.round(Math.sqrt(MAX_RENDER_AREA_PIXELS)); + // Add a little to account for potential rounding errors + if ( + (screenshotSize.width + 2) * (screenshotSize.height + 2) >= + MAX_RENDER_AREA_PIXELS + ) { + this.warningElement.textContent = `Screenshots can't have more than ${numPixels}x${numPixels} total pixels, the scale factor was reduced to x${scale} to fit.`; + } else { + this.warningElement.textContent = ""; + } + this.screenshotWidth = screenshotSize.width; + this.screenshotHeight = screenshotSize.height; + // Update the screenshot size display whenever dimensions change + this.updateScreenshotSizeDisplay(); + } + + private updateScreenshotSizeDisplay() { + if (this.screenshotPixelSize) { + this.screenshotPixelSize.textContent = `${this.screenshotWidth} x ${this.screenshotHeight} px`; + } + } + + private parseStatistics( + currentStatistics: ScreenshotLoadStatistics | null, + ): UIScreenshotStatistics | null { + if (currentStatistics === null) { + return null; + } + + const percentLoaded = + currentStatistics.visibleChunksTotal === 0 + ? 0 + : (100 * currentStatistics.visibleChunksGpuMemory) / + currentStatistics.visibleChunksTotal; + const percentGpuUsage = + (100 * currentStatistics.visibleGpuMemory) / + currentStatistics.gpuMemoryCapacity; + const gpuMemoryUsageInMB = currentStatistics.visibleGpuMemory / 1000000; + const totalMemoryInMB = currentStatistics.gpuMemoryCapacity / 1000000; + const latency = isNaN(currentStatistics.downloadLatency) + ? 0 + : currentStatistics.downloadLatency; + + const downloadString = + currentStatistics.visibleChunksDownloading == 0 + ? "0" + : `${currentStatistics.visibleChunksDownloading} at ${latency.toFixed(0)}ms latency`; + + return { + chunkUsageDescription: `${currentStatistics.visibleChunksGpuMemory} out of ${currentStatistics.visibleChunksTotal} (${percentLoaded.toFixed(2)}%)`, + gpuMemoryUsageDescription: `${gpuMemoryUsageInMB.toFixed(0)}MB / ${totalMemoryInMB.toFixed(0)}MB (${percentGpuUsage.toFixed(2)}% of total)`, + downloadSpeedDescription: downloadString, + }; + } + + /** + Private function to copy the resolution of the screenshot to the clipboard + This will be in tsv format, with the width and height separated by an 'x' + */ + private getResolutionText() { + // Processing the Screenshot size + const screenshotSizeText = `Screenshot size\t${this.screenshotWidth} x ${this.screenshotHeight} px\n`; + + // Process the panel resolution table + const { panelResolutionData, layerResolutionData } = + getViewerResolutionMetadata(this.screenshotManager.viewer); + + let panelResolutionText = `${PANEL_TABLE_HEADER_STRINGS.type}\t${PANEL_TABLE_HEADER_STRINGS.pixelResolution}\t${PANEL_TABLE_HEADER_STRINGS.physicalResolution}\n`; + for (const resolution of panelResolutionData) { + panelResolutionText += `${resolution.type}\t${resolution.width} x ${resolution.height} px\t${resolution.resolution}\n`; + } + + // Process the layer resolution table + let layerResolutionText = `${LAYER_TABLE_HEADER_STRINGS.name}\t${LAYER_TABLE_HEADER_STRINGS.type}\t${LAYER_TABLE_HEADER_STRINGS.resolution}\n`; + for (const resolution of layerResolutionData) { + layerResolutionText += `${resolution.name}\t${layerNamesForUI[resolution.type as keyof typeof layerNamesForUI]}\t${resolution.resolution}\n`; + } + + return `${screenshotSizeText}\n${panelResolutionText}\n${layerResolutionText}`; + } + + private updateUIBasedOnMode() { + if (this.screenshotMode === ScreenshotMode.PREVIEW) { + this.nameInput.disabled = false; + for (const radio of this.scaleRadioButtonsContainer.children) { + for (const child of (radio as HTMLElement).children) { + if (child instanceof HTMLInputElement) child.disabled = false; + } + } + this.keepSliceFOVFixedCheckbox.disabled = false; + this.forceScreenshotButton.disabled = true; + this.cancelScreenshotButton.disabled = true; + this.takeScreenshotButton.disabled = false; + this.progressText.textContent = ""; + this.forceScreenshotButton.title = ""; + } else { + this.nameInput.disabled = true; + for (const radio of this.scaleRadioButtonsContainer.children) { + for (const child of (radio as HTMLElement).children) { + if (child instanceof HTMLInputElement) child.disabled = true; + } + } + this.keepSliceFOVFixedCheckbox.disabled = true; + this.forceScreenshotButton.disabled = false; + this.cancelScreenshotButton.disabled = false; + this.takeScreenshotButton.disabled = true; + this.progressText.textContent = "Screenshot in progress..."; + this.forceScreenshotButton.title = + "Force a screenshot of the current view without waiting for all data to be loaded and rendered"; + } + } + + get screenshotMode() { + return this.screenshotManager.screenshotMode; + } +} diff --git a/src/ui/tool.ts b/src/ui/tool.ts index 44bc9d5b2..84669b289 100644 --- a/src/ui/tool.ts +++ b/src/ui/tool.ts @@ -485,6 +485,10 @@ export class GlobalToolBinder extends RefCounted { this.activeTool_ = undefined; activation.dispose(); } + + public deactivate() { + this.debounceDeactivate(); + } } export class LocalToolBinder< diff --git a/src/util/screenshot_manager.ts b/src/util/screenshot_manager.ts new file mode 100644 index 000000000..5c48b6d04 --- /dev/null +++ b/src/util/screenshot_manager.ts @@ -0,0 +1,474 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use viewer 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. + * + * @file Builds upon the Python screenshot tool to allow viewer screenshots to be taken and saved. + */ + +import { throttle } from "lodash-es"; +import { numChunkStatistics } from "#src/chunk_manager/base.js"; +import type { + ScreenshotActionState, + StatisticsActionState, + ScreenshotChunkStatistics, +} from "#src/python_integration/screenshots.js"; +import { SliceViewPanel } from "#src/sliceview/panel.js"; +import { StatusMessage } from "#src/status.js"; +import { + columnSpecifications, + getChunkSourceIdentifier, + getFormattedNames, +} from "#src/ui/statistics.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { NullarySignal, Signal } from "#src/util/signal.js"; +import { ScreenshotMode } from "#src/util/trackable_screenshot_mode.js"; +import { + calculatePanelViewportBounds, + type PanelViewport, +} from "#src/util/viewer_resolution_stats.js"; +import type { Viewer } from "#src/viewer.js"; + +export const MAX_RENDER_AREA_PIXELS = 5100 * 5100; +const SCREENSHOT_TIMEOUT = 3000; + +export interface ScreenshotLoadStatistics extends ScreenshotChunkStatistics { + timestamp: number; + gpuMemoryCapacity: number; +} + +function saveBlobToFile(blob: Blob, filename: string) { + const a = document.createElement("a"); + const url = URL.createObjectURL(blob); + a.href = url; + a.download = filename; + try { + a.click(); + } finally { + URL.revokeObjectURL(url); + } +} + +function setExtension(filename: string, extension: string = ".png"): string { + function replaceExtension(filename: string): string { + const lastDot = filename.lastIndexOf("."); + if (lastDot === -1) { + return filename + extension; + } + return `${filename.substring(0, lastDot)}${extension}`; + } + + return filename.endsWith(extension) ? filename : replaceExtension(filename); +} + +function canvasToBlob(canvas: HTMLCanvasElement, type: string): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Canvas toBlob failed")); + } + }, type); + }); +} + +async function extractViewportScreenshot( + viewer: Viewer, + viewportBounds: PanelViewport, +): Promise { + const cropWidth = viewportBounds.right - viewportBounds.left; + const cropHeight = viewportBounds.bottom - viewportBounds.top; + const img = await createImageBitmap( + viewer.display.canvas, + viewportBounds.left, + viewportBounds.top, + cropWidth, + cropHeight, + ); + + const screenshotCanvas = document.createElement("canvas"); + screenshotCanvas.width = cropWidth; + screenshotCanvas.height = cropHeight; + const ctx = screenshotCanvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas context"); + ctx.drawImage(img, 0, 0); + + const croppedBlob = await canvasToBlob(screenshotCanvas, "image/png"); + return croppedBlob; +} + +/** + * Manages the screenshot functionality from the viewer viewer. + * + * Responsible for linking up the Python screenshot tool with the viewer, and handling the screenshot process. + * The screenshot manager provides information about updates in the screenshot process, and allows for the screenshot to be taken and saved. + * The screenshot UI menu listens to the signals emitted by the screenshot manager to update the UI. + */ +export class ScreenshotManager extends RefCounted { + screenshotId: number = -1; + screenshotLoadStats: ScreenshotLoadStatistics | null = null; + screenshotStartTime = 0; + screenshotMode: ScreenshotMode = ScreenshotMode.OFF; + statisticsUpdated = new Signal<(state: ScreenshotLoadStatistics) => void>(); + screenshotFinished = new NullarySignal(); + zoomMaybeChanged = new NullarySignal(); + private _shouldKeepSliceViewFOVFixed: boolean = true; + private _screenshotScale: number = 1; + private filename: string = ""; + private lastUpdateTimestamp: number = 0; + private gpuMemoryChangeTimestamp: number = 0; + throttledSendStatistics = this.registerCancellable( + throttle(async () => { + const map = await this.viewer.chunkQueueManager.getStatistics(); + if (this.wasDisposed) return; + const formattedNames = getFormattedNames( + Array.from(map, (x) => getChunkSourceIdentifier(x[0])), + ); + let i = 0; + const rows: any[] = []; + const sumStatistics = new Float64Array(numChunkStatistics); + for (const [source, statistics] of map) { + for (let i = 0; i < numChunkStatistics; ++i) { + sumStatistics[i] += statistics[i]; + } + const row: any = {}; + row.id = getChunkSourceIdentifier(source); + row.distinctId = formattedNames[i]; + for (const column of columnSpecifications) { + row[column.key] = column.getter(statistics); + } + ++i; + rows.push(row); + } + const total: any = {}; + for (const column of columnSpecifications) { + total[column.key] = column.getter(sumStatistics); + } + const screenshotLoadStats = { + ...total, + timestamp: Date.now(), + gpuMemoryCapacity: + this.viewer.chunkQueueManager.capacities.gpuMemory.sizeLimit.value, + }; + this.statisticsUpdated.dispatch(screenshotLoadStats); + }, 1000), + ); + + constructor(public viewer: Viewer) { + super(); + this.viewer = viewer; + this.registerDisposer( + this.viewer.screenshotHandler.sendScreenshotRequested.add( + (actionState) => { + this.saveScreenshot(actionState); + }, + ), + ); + this.registerDisposer( + this.viewer.screenshotHandler.sendStatisticsRequested.add( + (actionState) => { + const newLoadStats = { + ...actionState.screenshotStatistics.total, + timestamp: Date.now(), + gpuMemoryCapacity: + this.viewer.chunkQueueManager.capacities.gpuMemory.sizeLimit + .value, + }; + this.checkAndHandleStalledScreenshot(actionState, newLoadStats); + this.screenshotLoadStats = newLoadStats; + }, + ), + ); + this.registerDisposer( + this.viewer.display.updateFinished.add(() => { + this.lastUpdateTimestamp = Date.now(); + this.throttledSendStatistics(); + }), + ); + this.registerDisposer( + this.viewer.display.screenshotMode.changed.add(() => { + this.handleScreenshotModeChange(); + }), + ); + } + + public get screenshotScale() { + return this._screenshotScale; + } + + public set screenshotScale(scale: number) { + this._screenshotScale = this.handleScreenshotZoomAndResize(scale); + this.zoomMaybeChanged.dispatch(); + } + + public get shouldKeepSliceViewFOVFixed() { + return this._shouldKeepSliceViewFOVFixed; + } + + public set shouldKeepSliceViewFOVFixed(enableFixedFOV: boolean) { + const wasInFixedFOVMode = this.shouldKeepSliceViewFOVFixed; + this._shouldKeepSliceViewFOVFixed = enableFixedFOV; + if (!enableFixedFOV && wasInFixedFOVMode) { + this.handleScreenshotZoomAndResize( + this.screenshotScale, + true /* resetZoom */, + ); + this.zoomMaybeChanged.dispatch(); + } else if (enableFixedFOV && !wasInFixedFOVMode) { + this.handleScreenshotZoomAndResize( + 1 / this.screenshotScale, + true /* resetZoom */, + ); + this.zoomMaybeChanged.dispatch(); + } + } + + previewScreenshot() { + this.viewer.display.screenshotMode.value = ScreenshotMode.PREVIEW; + } + + takeScreenshot(filename: string = "") { + this.filename = filename; + this.viewer.display.screenshotMode.value = ScreenshotMode.ON; + } + + forceScreenshot() { + this.viewer.display.screenshotMode.value = ScreenshotMode.FORCE; + } + + cancelScreenshot(shouldStayInPreview: boolean = false) { + // Decrement the screenshot ID since the screenshot was cancelled + if (this.screenshotMode === ScreenshotMode.ON) { + this.screenshotId--; + } + const newMode = shouldStayInPreview + ? ScreenshotMode.PREVIEW + : ScreenshotMode.OFF; + this.viewer.display.screenshotMode.value = newMode; + } + + // Calculates the cropped area of the viewport panels + calculatedClippedViewportSize(): { + width: number; + height: number; + } { + const renderingPanelArea = calculatePanelViewportBounds( + this.viewer.display.panels, + ).totalRenderPanelViewport; + return { + width: Math.round(renderingPanelArea.right - renderingPanelArea.left), + height: Math.round(renderingPanelArea.bottom - renderingPanelArea.top), + }; + } + + private handleScreenshotStarted() { + this.screenshotStartTime = + this.lastUpdateTimestamp = + this.gpuMemoryChangeTimestamp = + Date.now(); + this.screenshotLoadStats = null; + + // Pass a new screenshot ID to the viewer to trigger a new screenshot. + this.screenshotId++; + this.viewer.screenshotHandler.requestState.value = + this.screenshotId.toString(); + } + + private resizeCanvasIfNeeded(scale: number = this.screenshotScale) { + const shouldChangeCanvasSize = scale !== 1; + const { viewer } = this; + if (shouldChangeCanvasSize) { + const oldSize = { + width: viewer.display.canvas.width, + height: viewer.display.canvas.height, + }; + const newSize = { + width: Math.round(oldSize.width * scale), + height: Math.round(oldSize.height * scale), + }; + viewer.display.canvas.width = newSize.width; + viewer.display.canvas.height = newSize.height; + viewer.display.resizeCallback(); + } + } + + private handleScreenshotModeChange() { + const { display } = this.viewer; + // If moving straight from OFF to ON, need to resize the canvas to the correct size + const mayNeedCanvasResize = this.screenshotMode === ScreenshotMode.OFF; + this.screenshotMode = display.screenshotMode.value; + switch (this.screenshotMode) { + case ScreenshotMode.OFF: + this.resetCanvasSize(); + this.resetStatistics(); + this.viewer.screenshotHandler.requestState.value = undefined; + break; + case ScreenshotMode.FORCE: + display.scheduleRedraw(); + break; + case ScreenshotMode.ON: + // If moving straight from OFF to ON, may need to resize the canvas to the correct size + // Going from PREVIEW to ON does not require a resize + if (mayNeedCanvasResize) { + this.resizeCanvasIfNeeded(); + } + this.handleScreenshotStarted(); + break; + case ScreenshotMode.PREVIEW: + // Do nothing, included for completeness + break; + } + } + + /** + * Handles the zooming of the screenshot in fixed FOV mode. + * This supports: + * 1. Updating the zoom level of the viewer to match the screenshot scale. + * 2. Resetting the zoom level of the slice views to the original level. + * 3. Resizing the canvas to match the new scale. + * @param scale - The scale factor to apply to the screenshot. + * @param resetZoom - If true, the zoom resets to the original level. + */ + private handleScreenshotZoomAndResize( + scale: number, + resetZoom: boolean = false, + ) { + const oldScale = this.screenshotScale; + + // Because the scale is applied to the canvas, we need to check if the new scale will exceed the maximum render area + // If so, that means the scale needs to be adjusted to fit within the maximum render area + let intendedScale = scale; + if (!resetZoom && scale > 1) { + const currentCanvasSize = this.calculatedClippedViewportSize(); + const numPixels = + (currentCanvasSize.width * currentCanvasSize.height) / + (oldScale * oldScale); + if (numPixels * intendedScale * intendedScale > MAX_RENDER_AREA_PIXELS) { + intendedScale = Math.sqrt(MAX_RENDER_AREA_PIXELS / numPixels); + } + } + + const scaleFactor = intendedScale / oldScale; + const zoomScaleFactor = resetZoom ? scale : 1 / scaleFactor; + const canvasScaleFactor = resetZoom ? 1 : scaleFactor; + + if (this.shouldKeepSliceViewFOVFixed || resetZoom) { + // Scale the zoom factor of each slice view panel + const { navigationState } = this.viewer; + for (const panel of this.viewer.display.panels) { + if (panel instanceof SliceViewPanel) { + const zoom = navigationState.zoomFactor.value; + navigationState.zoomFactor.value = zoom * zoomScaleFactor; + break; + } + } + } + + this.resizeCanvasIfNeeded(canvasScaleFactor); + + return intendedScale; + } + + /** + * Check if the screenshot is stuck by comparing the number of visible chunks + * in the GPU with the previous number of visible chunks. If the number of + * visible chunks has not changed after a certain timeout, and the display has not updated, force a screenshot. + */ + private checkAndHandleStalledScreenshot( + actionState: StatisticsActionState, + fullStats: ScreenshotLoadStatistics, + ) { + if (this.screenshotLoadStats === null) { + return; + } + const total = actionState.screenshotStatistics.total; + const newStats = { + visibleChunksGpuMemory: total.visibleChunksGpuMemory, + timestamp: Date.now(), + totalGpuMemory: + this.viewer.chunkQueueManager.capacities.gpuMemory.sizeLimit.value, + numDownloadingChunks: total.visibleChunksDownloading, + }; + const oldStats = this.screenshotLoadStats; + if ( + oldStats.visibleChunksGpuMemory === newStats.visibleChunksGpuMemory && + (oldStats.gpuMemoryCapacity === newStats.totalGpuMemory || + newStats.numDownloadingChunks == 0) + ) { + if ( + newStats.timestamp - this.gpuMemoryChangeTimestamp > + SCREENSHOT_TIMEOUT && + Date.now() - this.lastUpdateTimestamp > SCREENSHOT_TIMEOUT + ) { + this.statisticsUpdated.dispatch(fullStats); + const message = `Forcing screenshot: screenshot is likely stuck, no change in GPU chunks after ${SCREENSHOT_TIMEOUT}ms. Last visible chunks: ${total.visibleChunksGpuMemory}/${total.visibleChunksTotal}`; + console.warn(message); + StatusMessage.showTemporaryMessage(message, 5000); + this.forceScreenshot(); + } + } else { + this.gpuMemoryChangeTimestamp = newStats.timestamp; + } + } + + private async saveScreenshot(actionState: ScreenshotActionState) { + const { screenshot } = actionState; + const { imageType } = screenshot; + if (imageType !== "image/png") { + console.error("Image type is not PNG"); + this.viewer.display.screenshotMode.value = ScreenshotMode.OFF; + return; + } + const renderingPanelArea = calculatePanelViewportBounds( + this.viewer.display.panels, + ).totalRenderPanelViewport; + try { + const croppedImage = await extractViewportScreenshot( + this.viewer, + renderingPanelArea, + ); + this.generateFilename( + renderingPanelArea.right - renderingPanelArea.left, + renderingPanelArea.bottom - renderingPanelArea.top, + ); + saveBlobToFile(croppedImage, this.filename); + } catch (error) { + console.error("Failed to save screenshot:", error); + } finally { + this.viewer.display.screenshotMode.value = ScreenshotMode.OFF; + this.screenshotFinished.dispatch(); + } + } + + private resetCanvasSize() { + // Reset the canvas size to the original size + // No need to manually pass the correct sizes, the viewer will handle it + const { viewer } = this; + ++viewer.display.resizeGeneration; + viewer.display.resizeCallback(); + } + + private resetStatistics() { + this.screenshotLoadStats = null; + } + + private generateFilename(width: number, height: number): string { + if (!this.filename) { + const nowtime = new Date().toLocaleString().replace(", ", "-"); + this.filename = `neuroglancer-screenshot-w${width}px-h${height}px-at-${nowtime}`; + } + this.filename = setExtension(this.filename); + return this.filename; + } +} diff --git a/src/util/trackable_screenshot_mode.ts b/src/util/trackable_screenshot_mode.ts new file mode 100644 index 000000000..d6414a45d --- /dev/null +++ b/src/util/trackable_screenshot_mode.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use viewer 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 { TrackableEnum } from "#src/util/trackable_enum.js"; + +export enum ScreenshotMode { + OFF = 0, // Default mode + ON = 1, // Screenshot mode + FORCE = 2, // Force screenshot mode - used when the screenshot is stuck + PREVIEW = 3, // Preview mode - used while the user is in the screenshot menu +} + +export class TrackableScreenshotMode extends TrackableEnum { + constructor(value: ScreenshotMode, defaultValue: ScreenshotMode = value) { + super(ScreenshotMode, value, defaultValue); + } +} diff --git a/src/util/viewer_resolution_stats.ts b/src/util/viewer_resolution_stats.ts new file mode 100644 index 000000000..c31b84620 --- /dev/null +++ b/src/util/viewer_resolution_stats.ts @@ -0,0 +1,480 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use viewer 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. + * + * @file Helper functions to get the resolution of the viewer layers and panels. + */ + +import type { RenderedPanel } from "#src/display_context.js"; +import { PerspectivePanel } from "#src/perspective_view/panel.js"; +import { RenderedDataPanel } from "#src/rendered_data_panel.js"; +import { RenderLayerRole } from "#src/renderlayer.js"; +import { SliceViewPanel } from "#src/sliceview/panel.js"; +import { ImageRenderLayer } from "#src/sliceview/volume/image_renderlayer.js"; +import { SegmentationRenderLayer } from "#src/sliceview/volume/segmentation_renderlayer.js"; +import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; +import type { Viewer } from "#src/viewer.js"; +import { VolumeRenderingRenderLayer } from "#src/volume_rendering/volume_render_layer.js"; + +export interface DimensionResolutionStats { + panelType: string; + dimensionName: string; + resolutionWithUnit: string; +} + +export interface PanelViewport { + left: number; + right: number; + top: number; + bottom: number; + panelType: string; +} + +export interface ResolutionMetadata { + panelResolutionData: PanelResolutionData[]; + layerResolutionData: LayerResolutionData[]; +} + +interface PanelResolutionData { + type: string; + width: number; + height: number; + resolution: string; +} + +interface LayerResolutionData { + name: string; + type: string; + resolution: string; +} + +interface LayerIdentifier { + name: string; + type: string; +} + +interface PanelResolutionStats { + pixelResolution: PanelViewport; + physicalResolution: DimensionResolutionStats[]; +} + +interface CanvasSizeStatistics { + totalRenderPanelViewport: PanelViewport; + individualRenderPanelViewports: PanelViewport[]; +} + +/** + * For each visible data layer, returns the resolution of the voxels + * in physical units for the most detailed resolution of the data for + * which any data is actually loaded. + * + * The resolution is for loaded data, so may be lower than the resolution requested + * for the layer, such as when there are memory constraints. + * + * The key for the returned map is the layer name and type. + * A single layer name can have multiple types, such as ImageRenderLayer and + * VolumeRenderingRenderLayer from the same named layer. + * + * As the dimensions of the voxels can be the same in each dimension, the + * function will return a single resolution if all dimensions in the layer are the + * same, with the name "All_". Otherwise, it will return the resolution for + * each dimension, with the name of the dimension as per the global viewer dim names. + */ +export function getViewerLayerResolutions( + viewer: Viewer, +): Map { + function formatResolution( + resolution: Float32Array | undefined, + parentType: string, + ): DimensionResolutionStats[] { + if (resolution === undefined) return []; + + const resolutionStats: DimensionResolutionStats[] = []; + const { + globalDimensionNames, + displayDimensionUnits, + displayDimensionIndices, + } = viewer.navigationState.displayDimensionRenderInfo.value; + + // Check if all units and factors are the same. + const firstDim = displayDimensionIndices[0]; + let singleScale = true; + if (firstDim !== -1) { + const unit = displayDimensionUnits[0]; + const factor = resolution[0]; + for (let i = 1; i < 3; ++i) { + const dim = displayDimensionIndices[i]; + if (dim === -1) continue; + if (displayDimensionUnits[i] !== unit || factor !== resolution[i]) { + singleScale = false; + break; + } + } + } + + for (let i = 0; i < 3; ++i) { + const dim = displayDimensionIndices[i]; + if (dim !== -1) { + const dimensionName = globalDimensionNames[dim]; + if (i === 0 || !singleScale) { + const formattedScale = formatScaleWithUnitAsString( + resolution[i], + displayDimensionUnits[i], + { precision: 2, elide1: false }, + ); + resolutionStats.push({ + panelType: parentType, + resolutionWithUnit: `${formattedScale}`, + dimensionName: singleScale ? "All_" : dimensionName, + }); + } + } + } + return resolutionStats; + } + + const layers = viewer.layerManager.visibleRenderLayers; + const map = new Map(); + + for (const layer of layers) { + if (layer.role === RenderLayerRole.DATA) { + let isVisble = false; + const name = layer.userLayer!.managedLayer.name; + let type: string = ""; + let resolution: Float32Array | undefined; + if (layer instanceof ImageRenderLayer) { + type = "ImageRenderLayer"; + isVisble = layer.visibleSourcesList.length > 0; + resolution = layer.highestResolutionLoadedVoxelSize; + } else if (layer instanceof VolumeRenderingRenderLayer) { + type = "VolumeRenderingRenderLayer"; + isVisble = layer.visibility.visible; + resolution = layer.highestResolutionLoadedVoxelSize; + } else if (layer instanceof SegmentationRenderLayer) { + type = "SegmentationRenderLayer"; + isVisble = layer.visibleSourcesList.length > 0; + resolution = layer.highestResolutionLoadedVoxelSize; + } + if (!isVisble) continue; + map.set({ name, type }, formatResolution(resolution, type)); + } + } + return map; +} + +/** + * For each viewer panel, returns the scale in each dimension for that panel. + * + * It is quite common for all dimensions to have the same scale, so the function + * will return a single resolution for a panel if all dimensions in the panel are + * the same, with the name "All_". Otherwise, it will return the resolution for + * each dimension, with the name of the dimension as per the global dimension names. + * + * For orthographic projections or slice views, the scale is in pixels, otherwise it is in vh. + * + * @param panels The set of panels to get the resolutions for. E.g. viewer.display.panels + * @param onlyUniqueResolutions If true, only return panels with unique resolutions. + * It is quite common for all slice view panels to have the same resolution. + * + * @returns An array of resolutions for each panel, both in physical units and pixel units. + */ +export function getViewerPanelResolutions( + panels: ReadonlySet, + onlyUniqueResolutions = true, +): PanelResolutionStats[] { + /** + * Two panels are equivalent if they have the same physical and pixel resolution. + */ + function arePanelsEquivalent( + panelResolution1: PanelResolutionStats, + panelResolution2: PanelResolutionStats, + ) { + // Step 1 - Check if the physical resolution is the same. + const physicalResolution1 = panelResolution1.physicalResolution; + const physicalResolution2 = panelResolution2.physicalResolution; + + // E.g., if one panel has X, Y, Z the same (length 1) and the other + // has X, Y, Z different (length 3), they are not the same. + if (physicalResolution1.length !== physicalResolution2.length) { + return false; + } + // Compare the units and values of the physical resolution dims. + for (let i = 0; i < physicalResolution1.length; ++i) { + const res1 = physicalResolution1[i]; + const res2 = physicalResolution2[i]; + if ( + res1.resolutionWithUnit !== res2.resolutionWithUnit || + res1.panelType !== res2.panelType || + res1.dimensionName !== res2.dimensionName + ) { + return false; + } + } + const pixelResolution1 = panelResolution1.pixelResolution; + const pixelResolution2 = panelResolution2.pixelResolution; + // In some cases, the pixel resolution can be a floating point number - round. + // Particularly prevalent on high pixel density displays. + const width1 = Math.round(pixelResolution1.right - pixelResolution1.left); + const width2 = Math.round(pixelResolution2.right - pixelResolution2.left); + const height1 = Math.round(pixelResolution1.bottom - pixelResolution1.top); + const height2 = Math.round(pixelResolution2.bottom - pixelResolution2.top); + return width1 === width2 && height1 === height2; + } + + // Gather the physical and pixel resolutions for each panel. + const resolutions: PanelResolutionStats[] = []; + for (const panel of panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + const viewport = panel.renderViewport; + const { width, height } = viewport; + const panelLeft = panel.canvasRelativeClippedLeft; + const panelTop = panel.canvasRelativeClippedTop; + const panelRight = panelLeft + width; + const panelBottom = panelTop + height; + const { + panelType, + panelDimensionUnit, + }: { panelType: string; panelDimensionUnit: string } = + determinePanelTypeAndUnit(panel); + const panelResolution: PanelResolutionStats = { + pixelResolution: { + left: panelLeft, + right: panelRight, + top: panelTop, + bottom: panelBottom, + panelType, + }, + physicalResolution: [], + }; + const { physicalResolution } = panelResolution; + const { navigationState } = panel; + const { + displayDimensionIndices, + canonicalVoxelFactors, + displayDimensionUnits, + displayDimensionScales, + globalDimensionNames, + } = navigationState.displayDimensionRenderInfo.value; + const { factors } = navigationState.relativeDisplayScales.value; + const zoom = navigationState.zoomFactor.value; + // Check if all units and factors are the same. + const firstDim = displayDimensionIndices[0]; + let singleScale = true; + if (firstDim !== -1) { + const unit = displayDimensionUnits[0]; + const factor = factors[firstDim]; + for (let i = 1; i < 3; ++i) { + const dim = displayDimensionIndices[i]; + if (dim === -1) continue; + if (displayDimensionUnits[i] !== unit || factors[dim] !== factor) { + singleScale = false; + break; + } + } + } + for (let i = 0; i < 3; ++i) { + const dim = displayDimensionIndices[i]; + if (dim !== -1) { + const totalScale = + (displayDimensionScales[i] * zoom) / canonicalVoxelFactors[i]; + const dimensionName = globalDimensionNames[dim]; + if (i === 0 || !singleScale) { + const formattedScale = formatScaleWithUnitAsString( + totalScale, + displayDimensionUnits[i], + { precision: 2, elide1: false }, + ); + physicalResolution.push({ + panelType: panelType, + resolutionWithUnit: `${formattedScale}/${panelDimensionUnit}`, + dimensionName: singleScale ? "All_" : dimensionName, + }); + } + } + } + resolutions.push(panelResolution); + } + + // Filter out panels with the same resolution if onlyUniqueResolutions is true. + if (!onlyUniqueResolutions) { + return resolutions; + } + const uniqueResolutions: PanelResolutionStats[] = []; + for (const resolution of resolutions) { + let found = false; + for (const uniqueResolution of uniqueResolutions) { + if (arePanelsEquivalent(resolution, uniqueResolution)) { + found = true; + break; + } + } + if (!found) { + uniqueResolutions.push(resolution); + } + } + return uniqueResolutions; +} + +function determinePanelTypeAndUnit(panel: RenderedDataPanel) { + const isOrtographicProjection = + panel instanceof PerspectivePanel && + panel.viewer.orthographicProjection.value; + + const panelDimensionUnit = + panel instanceof SliceViewPanel || isOrtographicProjection ? "px" : "vh"; + let panelType: string; + if (panel instanceof SliceViewPanel) { + panelType = "Slice view (2D)"; + } else if (isOrtographicProjection) { + panelType = "Orthographic projection (3D)"; + } else if (panel instanceof PerspectivePanel) { + panelType = "Perspective projection (3D)"; + } else { + panelType = "Unknown"; + } + return { panelType, panelDimensionUnit }; +} + +/** + * Calculates the viewport bounds of the viewer render data panels individually. + * And also calculates the total viewport bounds of all the render data panels combined. + * + * The total bounds can contain some non-panel areas, such as the layer bar if + * the panels have been duplicated so that the layer bar sits in the middle + * of the visible rendered panels. + */ +export function calculatePanelViewportBounds( + panels: ReadonlySet, +): CanvasSizeStatistics { + const viewportBounds = { + left: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + panelType: "All", + }; + const allPanelViewports: PanelViewport[] = []; + for (const panel of panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + const viewport = panel.renderViewport; + const { width, height } = viewport; + const panelLeft = panel.canvasRelativeClippedLeft; + const panelTop = panel.canvasRelativeClippedTop; + const panelRight = panelLeft + width; + const panelBottom = panelTop + height; + viewportBounds.left = Math.floor(Math.min(viewportBounds.left, panelLeft)); + viewportBounds.right = Math.ceil( + Math.max(viewportBounds.right, panelRight), + ); + viewportBounds.top = Math.ceil(Math.min(viewportBounds.top, panelTop)); + viewportBounds.bottom = Math.floor( + Math.max(viewportBounds.bottom, panelBottom), + ); + + allPanelViewports.push({ + left: panelLeft, + right: panelRight, + top: panelTop, + bottom: panelBottom, + panelType: determinePanelTypeAndUnit(panel).panelType, + }); + } + return { + totalRenderPanelViewport: viewportBounds, + individualRenderPanelViewports: allPanelViewports, + }; +} + +/** + * Combine the resolution of all dimensions into a single string for UI display + */ +function formatPhysicalResolution(resolution: DimensionResolutionStats[]) { + if (resolution.length === 0) return null; + const firstResolution = resolution[0]; + // If the resolution is the same for all dimensions, display it as a single line + if (firstResolution.dimensionName === "All_") { + return { + type: firstResolution.panelType, + resolution: firstResolution.resolutionWithUnit, + }; + } else { + const resolutionText = resolution + .map((res) => `${res.dimensionName} ${res.resolutionWithUnit}`) + .join(" "); + return { + type: firstResolution.panelType, + resolution: resolutionText, + }; + } +} + +function formatPixelResolution(panelArea: PanelViewport) { + const width = Math.round(panelArea.right - panelArea.left); + const height = Math.round(panelArea.bottom - panelArea.top); + const type = panelArea.panelType; + return { width, height, type }; +} + +/** + * Convenience function to extract resolution metadata from the viewer. + * Returns the resolution of the viewer layers and panels. + * The resolution is displayed in the following format: + * For panel resolution: + * Panel type, width, height, resolution + * For layer resolution: + * Layer name, layer type, resolution + */ +export function getViewerResolutionMetadata( + viewer: Viewer, +): ResolutionMetadata { + // Process the panel resolution table + const panelResolution = getViewerPanelResolutions(viewer.display.panels); + const panelResolutionData: PanelResolutionData[] = []; + for (const resolution of panelResolution) { + const physicalResolution = formatPhysicalResolution( + resolution.physicalResolution, + ); + if (physicalResolution === null) { + continue; + } + const pixelResolution = formatPixelResolution(resolution.pixelResolution); + panelResolutionData.push({ + type: physicalResolution.type, + width: pixelResolution.width, + height: pixelResolution.height, + resolution: physicalResolution.resolution, + }); + } + + // Process the layer resolution table + const layerResolution = getViewerLayerResolutions(viewer); + const layerResolutionData: LayerResolutionData[] = []; + for (const [key, value] of layerResolution) { + const { name, type } = key; + if (type === "MultiscaleMeshLayer") { + continue; + } + const physicalResolution = formatPhysicalResolution(value); + if (physicalResolution === null) { + continue; + } + layerResolutionData.push({ + name, + type, + resolution: physicalResolution.resolution, + }); + } + + return { panelResolutionData, layerResolutionData }; +} diff --git a/src/viewer.ts b/src/viewer.ts index e92140238..043af7685 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -17,6 +17,7 @@ import "#src/viewer.css"; import "#src/ui/layer_data_sources_tab.js"; import "#src/noselect.css"; +import svg_camera from "ikonate/icons/camera.svg?raw"; import svg_controls_alt from "ikonate/icons/controls-alt.svg?raw"; import svg_layers from "ikonate/icons/layers.svg?raw"; import svg_list from "ikonate/icons/list.svg?raw"; @@ -68,6 +69,7 @@ import { WatchableDisplayDimensionRenderInfo, } from "#src/navigation_state.js"; import { overlaysOpen } from "#src/overlay.js"; +import { ScreenshotHandler } from "#src/python_integration/screenshots.js"; import { allRenderLayerRoles, RenderLayerRole } from "#src/renderlayer.js"; import { StatusMessage } from "#src/status.js"; import { @@ -87,6 +89,7 @@ import { } from "#src/ui/layer_list_panel.js"; import { LayerSidePanelManager } from "#src/ui/layer_side_panel.js"; import { setupPositionDropHandlers } from "#src/ui/position_drag_and_drop.js"; +import { ScreenshotDialog } from "#src/ui/screenshot_menu.js"; import { SelectionDetailsPanel } from "#src/ui/selection_details.js"; import { SidePanelManager } from "#src/ui/side_panel.js"; import { StateEditorDialog } from "#src/ui/state_editor.js"; @@ -120,6 +123,7 @@ import { EventActionMap, KeyboardEventBinder, } from "#src/util/keyboard_bindings.js"; +import { ScreenshotManager } from "#src/util/screenshot_manager.js"; import { NullarySignal } from "#src/util/signal.js"; import { CompoundTrackable, @@ -437,6 +441,9 @@ export class Viewer extends RefCounted implements ViewerState { resetInitiated = new NullarySignal(); + screenshotHandler = this.registerDisposer(new ScreenshotHandler(this)); + screenshotManager = this.registerDisposer(new ScreenshotManager(this)); + get chunkManager() { return this.dataContext.chunkManager; } @@ -834,6 +841,14 @@ export class Viewer extends RefCounted implements ViewerState { topRow.appendChild(button); } + { + const button = makeIcon({ svg: svg_camera, title: "Screenshot" }); + this.registerEventListener(button, "click", () => { + this.showScreenshotDialog(); + }); + topRow.appendChild(button); + } + { const { helpPanelState } = this; const button = this.registerDisposer( @@ -1116,10 +1131,20 @@ export class Viewer extends RefCounted implements ViewerState { this.globalToolBinder.activate(uppercase); } + deactivateTools() { + this.globalToolBinder.deactivate(); + } + editJsonState() { + this.deactivateTools(); new StateEditorDialog(this); } + showScreenshotDialog() { + this.deactivateTools(); + new ScreenshotDialog(this.screenshotManager); + } + showStatistics(value: boolean | undefined = undefined) { if (value === undefined) { value = !this.statisticsDisplayState.location.visible; diff --git a/src/volume_rendering/volume_render_layer.ts b/src/volume_rendering/volume_render_layer.ts index 51cf02533..106467339 100644 --- a/src/volume_rendering/volume_render_layer.ts +++ b/src/volume_rendering/volume_render_layer.ts @@ -218,6 +218,7 @@ export class VolumeRenderingRenderLayer extends PerspectiveViewRenderLayer { chunkResolutionHistogram: RenderScaleHistogram; mode: TrackableVolumeRenderingModeValue; backend: ChunkRenderLayerFrontend; + highestResolutionLoadedVoxelSize: Float32Array | undefined; private modeOverride: TrackableVolumeRenderingModeValue; private vertexIdHelper: VertexIdHelper; private dataHistogramSpecifications: HistogramSpecifications; @@ -878,6 +879,8 @@ outputValue = vec4(1.0, 1.0, 1.0, 1.0); curPhysicalSpacing = physicalSpacing; curOptimalSamples = optimalSamples; curHistogramInformation = histogramInformation; + this.highestResolutionLoadedVoxelSize = + transformedSource.effectiveVoxelSize; const chunkLayout = getNormalizedChunkLayout( projectionParameters, transformedSource.chunkLayout,