plexora 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- plexora-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* miniMap.js -- the viewer's overview lens.
|
|
3
|
+
*
|
|
4
|
+
* A subtle circular button in the bottom-left corner of the viewer that
|
|
5
|
+
* expands into a circular overview of the whole tissue, drawn from the same
|
|
6
|
+
* channels, colours and contrast ranges as the main view, with a rectangle
|
|
7
|
+
* showing what is currently on screen. Dragging the rectangle pans; clicking
|
|
8
|
+
* elsewhere on the map recentres; the wheel zooms.
|
|
9
|
+
*
|
|
10
|
+
* The whole design is shaped by one constraint: the main viewer's frame budget
|
|
11
|
+
* is ~8.3 ms at 15 channels and SKILL.md records three plausible-looking ideas
|
|
12
|
+
* that were built, measured and reverted for making frames worse. So:
|
|
13
|
+
*
|
|
14
|
+
* - Nothing here runs per frame except _syncIndicator(), which reads one
|
|
15
|
+
* rectangle and writes four style properties on one element.
|
|
16
|
+
* - Nothing here runs AT ALL until the user first opens the lens: the
|
|
17
|
+
* constructor builds DOM and registers guarded handlers, and that is it.
|
|
18
|
+
* - Colour and contrast changes recomposite from a cached greyscale array.
|
|
19
|
+
* Only a channel coming on costs a request.
|
|
20
|
+
*
|
|
21
|
+
* The pixels come from GET /generated/overview/<datasource>/<channel>, which
|
|
22
|
+
* serves the server's already-resident downsampled array quantized through the
|
|
23
|
+
* same window as the tiles -- see data_model.generate_channel_overview for why
|
|
24
|
+
* no tile level is a usable substitute.
|
|
25
|
+
*/
|
|
26
|
+
class MiniMap {
|
|
27
|
+
/** Per-channel alpha the fragment shader emits (frag.glsl's
|
|
28
|
+
* `u8_r_range(0.9)`), which canvas `lighter` then multiplies in. Matching
|
|
29
|
+
* it is what makes the lens read at the same brightness as the viewer. */
|
|
30
|
+
static TILE_ALPHA = 0.9;
|
|
31
|
+
|
|
32
|
+
/** Overview requests in flight at once. The server serialises all image
|
|
33
|
+
* I/O anyway (SKILL.md: zarr 3 funnels reads through one event-loop
|
|
34
|
+
* thread), so a wider fan-out only competes with tile requests for the
|
|
35
|
+
* connection pool while the user is panning. */
|
|
36
|
+
static FETCH_CONCURRENCY = 3;
|
|
37
|
+
|
|
38
|
+
/** A ring with a viewport box inside it -- the same two things the
|
|
39
|
+
* expanded map shows, which is the whole hint the collapsed state gets. */
|
|
40
|
+
static LENS_ICON =
|
|
41
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
|
42
|
+
'<circle cx="12" cy="12" r="8.4" fill="none" stroke="currentColor" stroke-width="1.5"/>' +
|
|
43
|
+
'<rect x="8.4" y="9.3" width="7.2" height="5.4" rx="1.1" fill="none" stroke="currentColor" stroke-width="1.4"/>' +
|
|
44
|
+
"</svg>";
|
|
45
|
+
|
|
46
|
+
constructor(imageViewer) {
|
|
47
|
+
this.imageViewer = imageViewer;
|
|
48
|
+
this.viewer = imageViewer.viewer;
|
|
49
|
+
this.config = imageViewer.config;
|
|
50
|
+
|
|
51
|
+
this.expanded = false;
|
|
52
|
+
this.geom = null;
|
|
53
|
+
|
|
54
|
+
/** srcIdx -> { data: Uint8Array, width, height }. Survives a collapse,
|
|
55
|
+
* so reopening the lens is instant and costs no requests. */
|
|
56
|
+
this._gray = new Map();
|
|
57
|
+
this._pending = new Set();
|
|
58
|
+
/** srcIdx -> HTTP status of its last failed fetch. Read by _updateNote
|
|
59
|
+
* to tell a missing route from a transient error. */
|
|
60
|
+
this._failed = new Map();
|
|
61
|
+
/** The rebuild currently in flight, if any. Exposed so a caller --
|
|
62
|
+
* or a test -- can wait for the map to settle. */
|
|
63
|
+
this._loading = null;
|
|
64
|
+
this._drag = null;
|
|
65
|
+
this._scratch = null;
|
|
66
|
+
this._accum = null;
|
|
67
|
+
this._image = null;
|
|
68
|
+
|
|
69
|
+
if (!this._buildDom()) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this._bindViewer();
|
|
73
|
+
this._bindPointer();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// -- construction ----------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builds the collapsed lens plus the (hidden) map, as a sibling of
|
|
80
|
+
* #openseadragon inside #openseadragon_wrapper -- the same host and the
|
|
81
|
+
* same shape as initProjectLabel()/initLegend(). Being a sibling rather
|
|
82
|
+
* than a child matters: OSD binds its mouse tracker to #openseadragon, so
|
|
83
|
+
* pointer events here never reach it and nothing needs stopPropagation.
|
|
84
|
+
*
|
|
85
|
+
* The canvas element exists from the start but carries no backing store
|
|
86
|
+
* until _layout() sizes it, so a viewer whose lens is never opened holds
|
|
87
|
+
* a few empty nodes and no pixels.
|
|
88
|
+
*/
|
|
89
|
+
_buildDom() {
|
|
90
|
+
const wrapper = document.getElementById("openseadragon_wrapper");
|
|
91
|
+
if (!wrapper || document.getElementById("viewer_mini_map")) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const root = document.createElement("div");
|
|
96
|
+
root.id = "viewer_mini_map";
|
|
97
|
+
root.className = "viewer-mini-map";
|
|
98
|
+
|
|
99
|
+
const stage = document.createElement("div");
|
|
100
|
+
stage.className = "viewer-mini-map-stage";
|
|
101
|
+
|
|
102
|
+
const canvas = document.createElement("canvas");
|
|
103
|
+
canvas.className = "viewer-mini-map-canvas";
|
|
104
|
+
canvas.width = 0;
|
|
105
|
+
canvas.height = 0;
|
|
106
|
+
|
|
107
|
+
const indicator = document.createElement("div");
|
|
108
|
+
indicator.className = "viewer-mini-map-indicator";
|
|
109
|
+
|
|
110
|
+
// Empty until something goes wrong. A black circle is indistinguishable
|
|
111
|
+
// from dark tissue, so a failed overview has to say so somewhere the
|
|
112
|
+
// user actually looks -- a console.warn is not that place.
|
|
113
|
+
const note = document.createElement("div");
|
|
114
|
+
note.className = "viewer-mini-map-note";
|
|
115
|
+
|
|
116
|
+
const lens = document.createElement("button");
|
|
117
|
+
lens.type = "button";
|
|
118
|
+
lens.className = "viewer-mini-map-lens";
|
|
119
|
+
lens.setAttribute("aria-expanded", "false");
|
|
120
|
+
MiniMap._label(lens, false);
|
|
121
|
+
lens.innerHTML = MiniMap.LENS_ICON;
|
|
122
|
+
lens.addEventListener("click", () => this.toggle());
|
|
123
|
+
|
|
124
|
+
stage.appendChild(canvas);
|
|
125
|
+
stage.appendChild(indicator);
|
|
126
|
+
stage.appendChild(note);
|
|
127
|
+
root.appendChild(stage);
|
|
128
|
+
root.appendChild(lens);
|
|
129
|
+
wrapper.appendChild(root);
|
|
130
|
+
|
|
131
|
+
this.root = root;
|
|
132
|
+
this.stage = stage;
|
|
133
|
+
this.canvas = canvas;
|
|
134
|
+
this.indicator = indicator;
|
|
135
|
+
this.note = note;
|
|
136
|
+
this.lens = lens;
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Viewer subscriptions, registered ONCE and guarded on `expanded` rather
|
|
142
|
+
* than added on expand and removed on collapse.
|
|
143
|
+
*
|
|
144
|
+
* That is deliberate and load-bearing. OSD's removeHandler needs the
|
|
145
|
+
* identical function reference, so an add/remove pair written with inline
|
|
146
|
+
* arrows silently leaks one closure per expand/collapse cycle, each of
|
|
147
|
+
* them calling getBounds() on every animation frame for the rest of the
|
|
148
|
+
* session -- which is exactly the per-frame cost this feature exists to
|
|
149
|
+
* avoid. A guarded call that returns immediately is unmeasurable by
|
|
150
|
+
* comparison.
|
|
151
|
+
*/
|
|
152
|
+
_bindViewer() {
|
|
153
|
+
this._onAnimation = () => {
|
|
154
|
+
if (this.expanded) {
|
|
155
|
+
this._syncIndicator();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
this._onResize = () => {
|
|
159
|
+
if (this.expanded && this._layout()) {
|
|
160
|
+
this._draw();
|
|
161
|
+
this._syncIndicator();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
this.viewer.addHandler("animation", this._onAnimation);
|
|
165
|
+
this.viewer.addHandler("animation-finish", this._onAnimation);
|
|
166
|
+
this.viewer.addHandler("resize", this._onResize);
|
|
167
|
+
|
|
168
|
+
// Flipping HD moves every slot's range between the byte and raw
|
|
169
|
+
// domains (viewerSidebar.onHdModeChanged), so the cached greyscale is
|
|
170
|
+
// still good but every LUT built from a range is not.
|
|
171
|
+
window.addEventListener("plexora:hd-mode-changed", () => this.invalidate());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_bindPointer() {
|
|
175
|
+
this.stage.addEventListener("pointerdown", (e) => this._onPointerDown(e));
|
|
176
|
+
this.stage.addEventListener("pointermove", (e) => this._onPointerMove(e));
|
|
177
|
+
this.stage.addEventListener("pointerup", (e) => this._onPointerUp(e));
|
|
178
|
+
this.stage.addEventListener("pointercancel", (e) => this._onPointerUp(e));
|
|
179
|
+
// Not passive: this wheel drives the viewer's zoom, so the page must
|
|
180
|
+
// not also scroll.
|
|
181
|
+
this.stage.addEventListener("wheel", (e) => this._onWheel(e), { passive: false });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// -- open / close ----------------------------------------------------
|
|
185
|
+
|
|
186
|
+
toggle() {
|
|
187
|
+
if (this.expanded) {
|
|
188
|
+
this.collapse();
|
|
189
|
+
} else {
|
|
190
|
+
this.expand();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
expand() {
|
|
195
|
+
if (this.expanded || !this.root) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.expanded = true;
|
|
199
|
+
this.root.classList.add("is-expanded");
|
|
200
|
+
this.lens.setAttribute("aria-expanded", "true");
|
|
201
|
+
MiniMap._label(this.lens, true);
|
|
202
|
+
if (this._layout()) {
|
|
203
|
+
this._syncIndicator();
|
|
204
|
+
this._loading = this._ensureData();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
collapse() {
|
|
209
|
+
if (!this.expanded || !this.root) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.expanded = false;
|
|
213
|
+
this.root.classList.remove("is-expanded");
|
|
214
|
+
this.lens.setAttribute("aria-expanded", "false");
|
|
215
|
+
MiniMap._label(this.lens, false);
|
|
216
|
+
this._drag = null;
|
|
217
|
+
// Requests already in flight are deliberately left to finish and fill
|
|
218
|
+
// the cache. Aborting throws away bytes that have already been paid
|
|
219
|
+
// for and makes reopening cost them again.
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Something about what should be drawn changed. Free while collapsed,
|
|
224
|
+
* which is what lets ImageViewer call this from its channel mutators
|
|
225
|
+
* without thinking about it.
|
|
226
|
+
*
|
|
227
|
+
* @param options.refetch - true when the set of active channels may have
|
|
228
|
+
* changed and greyscale may be missing. Colour and contrast changes are
|
|
229
|
+
* satisfied entirely from cache.
|
|
230
|
+
*/
|
|
231
|
+
invalidate(options) {
|
|
232
|
+
if (!this.expanded) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (options && options.refetch) {
|
|
236
|
+
this._loading = this._ensureData();
|
|
237
|
+
} else {
|
|
238
|
+
this._draw();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Note there is deliberately no cache-invalidation hook. The greyscale is
|
|
243
|
+
// keyed on a channel that cannot change under a live session: main.js's
|
|
244
|
+
// refreshDataset() re-reads the config but assigns only `config.dataset`,
|
|
245
|
+
// precisely because adopting a new imageData mid-session would shift every
|
|
246
|
+
// index the tile path and the channel sliders are keyed on. The image
|
|
247
|
+
// behind a loaded project is fixed for its lifetime.
|
|
248
|
+
|
|
249
|
+
// -- geometry --------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Works out how the image sits inside the circle. Returns false when
|
|
253
|
+
* there is nothing sensible to lay out yet.
|
|
254
|
+
*
|
|
255
|
+
* Uses offsetWidth/offsetHeight, NOT getBoundingClientRect: the collapsed
|
|
256
|
+
* state is a CSS transform (a scale down into the lens button) rather than
|
|
257
|
+
* a real size change, and getBoundingClientRect reports the transformed
|
|
258
|
+
* box. Keeping the layout box constant is what lets the geometry be
|
|
259
|
+
* computed at any time regardless of where the open/close transition is.
|
|
260
|
+
*/
|
|
261
|
+
_layout() {
|
|
262
|
+
const width = Number(this.config.width) || 0;
|
|
263
|
+
const height = Number(this.config.height) || 0;
|
|
264
|
+
const size = Math.min(this.stage.offsetWidth, this.stage.offsetHeight);
|
|
265
|
+
if (!width || !height || !size) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fit the LONG side across the full diameter, letting the image's four
|
|
270
|
+
// corners fall outside the circle to be clipped by border-radius. The
|
|
271
|
+
// strict "no clipping" fit is size/hypot(width, height), which on a
|
|
272
|
+
// square image spends 29% of the diameter on empty space to protect
|
|
273
|
+
// corners that are background in every whole-slide image.
|
|
274
|
+
const scale = size / Math.max(width, height);
|
|
275
|
+
const drawWidth = width * scale;
|
|
276
|
+
const drawHeight = height * scale;
|
|
277
|
+
this.geom = {
|
|
278
|
+
size,
|
|
279
|
+
scale,
|
|
280
|
+
width,
|
|
281
|
+
height,
|
|
282
|
+
drawWidth,
|
|
283
|
+
drawHeight,
|
|
284
|
+
offsetX: (size - drawWidth) / 2,
|
|
285
|
+
offsetY: (size - drawHeight) / 2,
|
|
286
|
+
// OSD viewport coordinates put the image at x in [0, 1] and y in
|
|
287
|
+
// [0, height/width]. Everything below normalises through this so
|
|
288
|
+
// both axes are [0, 1].
|
|
289
|
+
aspect: height / width,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Capped at 2: past that the display canvas grows quadratically for
|
|
293
|
+
// detail nobody can see in a 220 px circle.
|
|
294
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
295
|
+
const style = this.canvas.style;
|
|
296
|
+
style.left = `${this.geom.offsetX}px`;
|
|
297
|
+
style.top = `${this.geom.offsetY}px`;
|
|
298
|
+
style.width = `${drawWidth}px`;
|
|
299
|
+
style.height = `${drawHeight}px`;
|
|
300
|
+
this.canvas.width = Math.max(1, Math.round(drawWidth * dpr));
|
|
301
|
+
this.canvas.height = Math.max(1, Math.round(drawHeight * dpr));
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Normalised [0, 1] image coordinates for a pointer event. */
|
|
306
|
+
_stagePoint(event) {
|
|
307
|
+
const geom = this.geom;
|
|
308
|
+
const rect = this.stage.getBoundingClientRect();
|
|
309
|
+
return {
|
|
310
|
+
u: (event.clientX - rect.left - geom.offsetX) / geom.drawWidth,
|
|
311
|
+
v: (event.clientY - rect.top - geom.offsetY) / geom.drawHeight,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Current viewport centre in normalised [0, 1] image coordinates. */
|
|
316
|
+
_viewportCentre() {
|
|
317
|
+
const bounds = this.viewer.viewport.getBounds(true);
|
|
318
|
+
return {
|
|
319
|
+
u: bounds.x + bounds.width / 2,
|
|
320
|
+
v: (bounds.y + bounds.height / 2) / this.geom.aspect,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_panToNormalized(u, v, immediately) {
|
|
325
|
+
this.viewer.viewport.panTo(
|
|
326
|
+
new OpenSeadragon.Point(
|
|
327
|
+
MiniMap._clamp01(u),
|
|
328
|
+
MiniMap._clamp01(v) * this.geom.aspect
|
|
329
|
+
),
|
|
330
|
+
immediately
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Tooltip and accessible name together -- a title that still says
|
|
335
|
+
* "Show" over an open map is the one people actually read. */
|
|
336
|
+
static _label(lens, expanded) {
|
|
337
|
+
const text = expanded ? "Hide overview map" : "Show overview map";
|
|
338
|
+
lens.title = text;
|
|
339
|
+
lens.setAttribute("aria-label", text);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
static _clamp01(value) {
|
|
343
|
+
if (!Number.isFinite(value)) {
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
return Math.min(Math.max(value, 0), 1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Positions the "you are here" rectangle.
|
|
351
|
+
*
|
|
352
|
+
* Deliberately NOT via world.getItemAt(0).viewportToImageRectangle, which
|
|
353
|
+
* is how getVisibleCentroidTileState does the same job: there is no world
|
|
354
|
+
* item when every channel is switched off, and the mini-map still has to
|
|
355
|
+
* draw an indicator in that state. Normalising the viewport bounds needs
|
|
356
|
+
* no item and is immune to extraZoomLevels.
|
|
357
|
+
*/
|
|
358
|
+
_syncIndicator() {
|
|
359
|
+
if (!this.geom) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const geom = this.geom;
|
|
363
|
+
const bounds = this.viewer.viewport.getBounds(true);
|
|
364
|
+
const left = MiniMap._clamp01(bounds.x);
|
|
365
|
+
const top = MiniMap._clamp01(bounds.y / geom.aspect);
|
|
366
|
+
const right = MiniMap._clamp01(bounds.x + bounds.width);
|
|
367
|
+
const bottom = MiniMap._clamp01((bounds.y + bounds.height) / geom.aspect);
|
|
368
|
+
|
|
369
|
+
const style = this.indicator.style;
|
|
370
|
+
style.left = `${geom.offsetX + left * geom.drawWidth}px`;
|
|
371
|
+
style.top = `${geom.offsetY + top * geom.drawHeight}px`;
|
|
372
|
+
style.width = `${Math.max(right - left, 0) * geom.drawWidth}px`;
|
|
373
|
+
style.height = `${Math.max(bottom - top, 0) * geom.drawHeight}px`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// -- data ------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The channels to draw, as { slot, srcIdx, fullName }.
|
|
380
|
+
*
|
|
381
|
+
* Read through ImageViewer.getActiveLegendChannels() -- the same helper the
|
|
382
|
+
* channel legend uses -- so the lens and the legend can never disagree
|
|
383
|
+
* about what is on.
|
|
384
|
+
*/
|
|
385
|
+
_activeChannels() {
|
|
386
|
+
const slots = this.imageViewer.getActiveLegendChannels
|
|
387
|
+
? this.imageViewer.getActiveLegendChannels()
|
|
388
|
+
: [];
|
|
389
|
+
const imageData = this.config.imageData || [];
|
|
390
|
+
const dataLayer = this.imageViewer.dataLayer;
|
|
391
|
+
const channels = [];
|
|
392
|
+
for (const slot of slots) {
|
|
393
|
+
const fullName = dataLayer && dataLayer.getFullChannelName
|
|
394
|
+
? dataLayer.getFullChannelName(slot.name)
|
|
395
|
+
: slot.name;
|
|
396
|
+
const srcIdx = imageData.findIndex((entry) => entry.fullname === fullName);
|
|
397
|
+
if (srcIdx < 0) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
channels.push({ slot, srcIdx, fullName });
|
|
401
|
+
}
|
|
402
|
+
return channels;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
_overviewUrl(fullName) {
|
|
406
|
+
const name = typeof datasource !== "undefined" && datasource
|
|
407
|
+
? datasource
|
|
408
|
+
: (window.flaskVariables && window.flaskVariables.datasource) || "";
|
|
409
|
+
return plexoraUrl(
|
|
410
|
+
`generated/overview/${encodeURIComponent(name)}/${encodeURIComponent(fullName)}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Fetches whatever active channel has no cached greyscale, then draws.
|
|
416
|
+
*
|
|
417
|
+
* There is deliberately no "is this rebuild still current" guard, which
|
|
418
|
+
* is the usual shape for this (see updateVisibleCentroidTiles). It would
|
|
419
|
+
* be actively wrong here: _draw() captures nothing -- it re-reads the
|
|
420
|
+
* active channels, their colours and their ranges every time -- so a draw
|
|
421
|
+
* from a superseded rebuild paints exactly what a fresh one would.
|
|
422
|
+
* Suppressing those draws instead loses them, because a rebuild that
|
|
423
|
+
* arrives while every channel is already in flight finds nothing missing
|
|
424
|
+
* and returns immediately: its predecessor's redraws were the only ones
|
|
425
|
+
* left to do, and the map stays black until an unrelated event repaints
|
|
426
|
+
* it. Toggling a channel OFF mid-load is enough to reach that.
|
|
427
|
+
*
|
|
428
|
+
* The one property that does matter: colours and ranges are read at DRAW
|
|
429
|
+
* time, never captured before an await. That is what makes a colour
|
|
430
|
+
* change, a contrast drag or a channel removal during a fetch correct for
|
|
431
|
+
* free.
|
|
432
|
+
*/
|
|
433
|
+
async _ensureData() {
|
|
434
|
+
// Paint what is already cached first, so reopening the lens fills in
|
|
435
|
+
// the same frame rather than after a round trip.
|
|
436
|
+
this._draw();
|
|
437
|
+
|
|
438
|
+
const missing = this._activeChannels().filter(
|
|
439
|
+
(channel) => !this._gray.has(channel.srcIdx) && !this._pending.has(channel.srcIdx)
|
|
440
|
+
);
|
|
441
|
+
if (!missing.length) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let cursor = 0;
|
|
446
|
+
const drain = async () => {
|
|
447
|
+
while (cursor < missing.length) {
|
|
448
|
+
const channel = missing[cursor];
|
|
449
|
+
cursor += 1;
|
|
450
|
+
this._pending.add(channel.srcIdx);
|
|
451
|
+
try {
|
|
452
|
+
const gray = await this._fetchOverview(channel.fullName);
|
|
453
|
+
if (gray) {
|
|
454
|
+
this._gray.set(channel.srcIdx, gray);
|
|
455
|
+
this._failed.delete(channel.srcIdx);
|
|
456
|
+
}
|
|
457
|
+
} catch (error) {
|
|
458
|
+
// One channel that will not load is a missing colour in an
|
|
459
|
+
// overview, not a reason to have no overview.
|
|
460
|
+
//
|
|
461
|
+
// Recorded per channel rather than as one last-error field:
|
|
462
|
+
// these drain concurrently, so a single field is won by
|
|
463
|
+
// whichever request happens to finish last and a failure
|
|
464
|
+
// beside a success reads as either one, at random.
|
|
465
|
+
this._failed.set(channel.srcIdx, error.status || 0);
|
|
466
|
+
console.warn("mini-map: overview unavailable for", channel.fullName, error);
|
|
467
|
+
} finally {
|
|
468
|
+
this._pending.delete(channel.srcIdx);
|
|
469
|
+
}
|
|
470
|
+
this._draw();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const workers = Math.min(MiniMap.FETCH_CONCURRENCY, missing.length);
|
|
475
|
+
await Promise.all(Array.from({ length: workers }, drain));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* One channel's overview as a single-byte-per-pixel array.
|
|
480
|
+
*
|
|
481
|
+
* premultiplyAlpha and colorSpaceConversion are both "none", matching
|
|
482
|
+
* workers/tileDecoder.js. These bytes are measurements that a contrast
|
|
483
|
+
* window is about to be applied to, not a photograph: letting the browser
|
|
484
|
+
* apply a colour profile to them changes the numbers.
|
|
485
|
+
*/
|
|
486
|
+
async _fetchOverview(fullName) {
|
|
487
|
+
const response = await fetch(this._overviewUrl(fullName));
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
const error = new Error(`HTTP ${response.status}`);
|
|
490
|
+
// Carried so _updateNote can tell "this Plexora has no overview
|
|
491
|
+
// route" (404 -- a server older than the feature, which waitress
|
|
492
|
+
// will not pick up without a restart) from a transient failure.
|
|
493
|
+
error.status = response.status;
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
const blob = await response.blob();
|
|
497
|
+
const bitmap = await createImageBitmap(blob, {
|
|
498
|
+
premultiplyAlpha: "none",
|
|
499
|
+
colorSpaceConversion: "none",
|
|
500
|
+
});
|
|
501
|
+
const width = bitmap.width;
|
|
502
|
+
const height = bitmap.height;
|
|
503
|
+
const scratch = document.createElement("canvas");
|
|
504
|
+
scratch.width = width;
|
|
505
|
+
scratch.height = height;
|
|
506
|
+
const context = scratch.getContext("2d", { willReadFrequently: true });
|
|
507
|
+
context.drawImage(bitmap, 0, 0);
|
|
508
|
+
if (bitmap.close) {
|
|
509
|
+
bitmap.close();
|
|
510
|
+
}
|
|
511
|
+
const rgba = context.getImageData(0, 0, width, height).data;
|
|
512
|
+
// The server writes mode 'L' WebP, so r == g == b; one byte per pixel
|
|
513
|
+
// is the whole signal and a quarter of the memory.
|
|
514
|
+
const data = new Uint8Array(width * height);
|
|
515
|
+
for (let pixel = 0, offset = 0; pixel < data.length; pixel += 1, offset += 4) {
|
|
516
|
+
data[pixel] = rgba[offset];
|
|
517
|
+
}
|
|
518
|
+
return { data, width, height };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// -- drawing ---------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A channel's contrast window as [min, max] fractions of the byte domain,
|
|
525
|
+
* ready to feed the same arithmetic as frag.glsl's range_clamp.
|
|
526
|
+
*
|
|
527
|
+
* slot.range is in byte units by default and raw 16-bit units in HD mode
|
|
528
|
+
* (viewerSidebar.onHdModeChanged), while these overview pixels are always
|
|
529
|
+
* quantized into the byte domain -- so HD needs converting back through
|
|
530
|
+
* the same window the server quantized against.
|
|
531
|
+
*/
|
|
532
|
+
_byteRange(slot) {
|
|
533
|
+
let range = Array.isArray(slot.range) ? slot.range : null;
|
|
534
|
+
if (!range) {
|
|
535
|
+
return [0, 1];
|
|
536
|
+
}
|
|
537
|
+
const sidebar = window.__plexora && window.__plexora.viewerSidebar;
|
|
538
|
+
if (sidebar && sidebar.isHdMode && sidebar.isHdMode()) {
|
|
539
|
+
const packet = sidebar.quantWindow ? sidebar.quantWindow(slot.name) : null;
|
|
540
|
+
if (!packet || !sidebar.rawToByteRange) {
|
|
541
|
+
// quantWindow is absent until a channel's stats land. Showing
|
|
542
|
+
// the channel at full range is wrong by a little; treating it
|
|
543
|
+
// as black is wrong by everything.
|
|
544
|
+
return [0, 1];
|
|
545
|
+
}
|
|
546
|
+
range = sidebar.rawToByteRange(range, packet);
|
|
547
|
+
}
|
|
548
|
+
return [range[0] / 255, range[1] / 255];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Three 256-entry tables, one per colour component, folding the contrast
|
|
553
|
+
* window and the channel colour into a single lookup.
|
|
554
|
+
*
|
|
555
|
+
* This is frag.glsl's u8_r_range with the per-pixel work hoisted out:
|
|
556
|
+
* range_clamp(byte / 255) * u_tile_color, at the 0.9 alpha the shader
|
|
557
|
+
* emits and canvas `lighter` multiplies in.
|
|
558
|
+
*/
|
|
559
|
+
_luts(slot) {
|
|
560
|
+
const [low, high] = this._byteRange(slot);
|
|
561
|
+
// GLSL's clamp absorbs a zero-width window; JS division gives NaN and
|
|
562
|
+
// a fully transparent canvas.
|
|
563
|
+
const span = Math.max(high - low, 1 / 255);
|
|
564
|
+
const color = d3.color(slot.colorHex) || d3.color("white");
|
|
565
|
+
const floatColor = toFloatColor(color);
|
|
566
|
+
const tables = [new Float32Array(256), new Float32Array(256), new Float32Array(256)];
|
|
567
|
+
for (let value = 0; value < 256; value += 1) {
|
|
568
|
+
const clamped = Math.min(Math.max((value / 255 - low) / span, 0), 1);
|
|
569
|
+
const scaled = clamped * MiniMap.TILE_ALPHA * 255;
|
|
570
|
+
tables[0][value] = scaled * floatColor[0];
|
|
571
|
+
tables[1][value] = scaled * floatColor[1];
|
|
572
|
+
tables[2][value] = scaled * floatColor[2];
|
|
573
|
+
}
|
|
574
|
+
return tables;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Says why the circle is empty, when it is empty for a reason.
|
|
579
|
+
*
|
|
580
|
+
* Only speaks up once every active channel has been tried and none of them
|
|
581
|
+
* produced any greyscale -- a partial load is a map with a colour missing,
|
|
582
|
+
* which draws fine and needs no commentary, and a load still in flight is
|
|
583
|
+
* not a failure yet.
|
|
584
|
+
*
|
|
585
|
+
* 404 is called out specifically because it has one cause in practice:
|
|
586
|
+
* /generated/overview is younger than the running server. server_cli hands
|
|
587
|
+
* the app to waitress.serve, which has no reloader, and Flask registers
|
|
588
|
+
* routes at import -- so a Plexora updated underneath a live process keeps
|
|
589
|
+
* serving the new templates and static JS (both read from disk per request)
|
|
590
|
+
* while 404ing the new route. The lens then draws, tracks the viewport
|
|
591
|
+
* correctly, and stays black.
|
|
592
|
+
*/
|
|
593
|
+
_updateNote() {
|
|
594
|
+
if (!this.note) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const channels = this._activeChannels();
|
|
598
|
+
const cached = channels.some((channel) => this._gray.has(channel.srcIdx));
|
|
599
|
+
const pending = channels.some((channel) => this._pending.has(channel.srcIdx));
|
|
600
|
+
const failed = channels.filter((channel) => this._failed.has(channel.srcIdx));
|
|
601
|
+
let text = "";
|
|
602
|
+
if (channels.length && !cached && !pending && failed.length) {
|
|
603
|
+
const missingRoute = failed.every(
|
|
604
|
+
(channel) => this._failed.get(channel.srcIdx) === 404
|
|
605
|
+
);
|
|
606
|
+
text = missingRoute
|
|
607
|
+
? "Overview unavailable — restart the Plexora server to enable it."
|
|
608
|
+
: "Overview unavailable.";
|
|
609
|
+
}
|
|
610
|
+
if (this.note.textContent !== text) {
|
|
611
|
+
this.note.textContent = text;
|
|
612
|
+
this.root.classList.toggle("has-note", Boolean(text));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Composites every cached channel into the map.
|
|
618
|
+
*
|
|
619
|
+
* Done at the OVERVIEW's own resolution (~300 px) and scaled up on the way
|
|
620
|
+
* to the display canvas, rather than at the display canvas's device-pixel
|
|
621
|
+
* resolution. That keeps the cost of this loop independent of both the
|
|
622
|
+
* lens's size and the device pixel ratio -- at dpr 3 the direct version
|
|
623
|
+
* would be doing seven times the work for detail a 220 px circle cannot
|
|
624
|
+
* show.
|
|
625
|
+
*/
|
|
626
|
+
_draw() {
|
|
627
|
+
if (!this.expanded || !this.geom || !this.canvas.width) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const context = this.canvas.getContext("2d");
|
|
631
|
+
context.fillStyle = "#000";
|
|
632
|
+
context.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
633
|
+
|
|
634
|
+
const channels = this._activeChannels().filter((channel) => this._gray.has(channel.srcIdx));
|
|
635
|
+
this._updateNote();
|
|
636
|
+
if (!channels.length) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const first = this._gray.get(channels[0].srcIdx);
|
|
641
|
+
const width = first.width;
|
|
642
|
+
const height = first.height;
|
|
643
|
+
const pixels = width * height;
|
|
644
|
+
const accumulator = this._accumulatorFor(pixels);
|
|
645
|
+
|
|
646
|
+
for (const channel of channels) {
|
|
647
|
+
const gray = this._gray.get(channel.srcIdx);
|
|
648
|
+
if (gray.width !== width || gray.height !== height) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const tables = this._luts(channel.slot);
|
|
652
|
+
const red = tables[0];
|
|
653
|
+
const green = tables[1];
|
|
654
|
+
const blue = tables[2];
|
|
655
|
+
const data = gray.data;
|
|
656
|
+
for (let pixel = 0, offset = 0; pixel < pixels; pixel += 1, offset += 3) {
|
|
657
|
+
const value = data[pixel];
|
|
658
|
+
// Background is most of a slide, and a zero byte contributes
|
|
659
|
+
// nothing for any window whose minimum is >= 0 (all of them).
|
|
660
|
+
if (!value) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
accumulator[offset] += red[value];
|
|
664
|
+
accumulator[offset + 1] += green[value];
|
|
665
|
+
accumulator[offset + 2] += blue[value];
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const image = this._imageFor(width, height);
|
|
670
|
+
const out = image.data;
|
|
671
|
+
for (let pixel = 0, offset = 0, index = 0; pixel < pixels; pixel += 1, offset += 3, index += 4) {
|
|
672
|
+
// Uint8ClampedArray rounds and clamps on assignment, which is the
|
|
673
|
+
// saturating add that `lighter` performs in the main viewer.
|
|
674
|
+
out[index] = accumulator[offset];
|
|
675
|
+
out[index + 1] = accumulator[offset + 1];
|
|
676
|
+
out[index + 2] = accumulator[offset + 2];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const scratch = this._scratchFor(width, height);
|
|
680
|
+
scratch.getContext("2d").putImageData(image, 0, 0);
|
|
681
|
+
context.imageSmoothingEnabled = true;
|
|
682
|
+
context.imageSmoothingQuality = "high";
|
|
683
|
+
context.drawImage(scratch, 0, 0, this.canvas.width, this.canvas.height);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
_accumulatorFor(pixels) {
|
|
687
|
+
if (!this._accum || this._accum.length !== pixels * 3) {
|
|
688
|
+
this._accum = new Float32Array(pixels * 3);
|
|
689
|
+
} else {
|
|
690
|
+
this._accum.fill(0);
|
|
691
|
+
}
|
|
692
|
+
return this._accum;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
_imageFor(width, height) {
|
|
696
|
+
if (!this._image || this._image.width !== width || this._image.height !== height) {
|
|
697
|
+
this._image = new ImageData(width, height);
|
|
698
|
+
// Opaque for the life of the buffer: only rgb is rewritten above.
|
|
699
|
+
const data = this._image.data;
|
|
700
|
+
for (let index = 3; index < data.length; index += 4) {
|
|
701
|
+
data[index] = 255;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return this._image;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
_scratchFor(width, height) {
|
|
708
|
+
if (!this._scratch) {
|
|
709
|
+
this._scratch = document.createElement("canvas");
|
|
710
|
+
}
|
|
711
|
+
if (this._scratch.width !== width || this._scratch.height !== height) {
|
|
712
|
+
this._scratch.width = width;
|
|
713
|
+
this._scratch.height = height;
|
|
714
|
+
}
|
|
715
|
+
return this._scratch;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// -- interaction -----------------------------------------------------
|
|
719
|
+
|
|
720
|
+
_onPointerDown(event) {
|
|
721
|
+
if (event.button !== 0 || !this.expanded || !this.geom) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const point = this._stagePoint(event);
|
|
725
|
+
if (event.target === this.indicator) {
|
|
726
|
+
// Grab the rectangle where it was grabbed, so it does not jump its
|
|
727
|
+
// centre to the cursor.
|
|
728
|
+
const centre = this._viewportCentre();
|
|
729
|
+
this._drag = { u: centre.u - point.u, v: centre.v - point.v };
|
|
730
|
+
} else {
|
|
731
|
+
// Anywhere else on the map: recentre there, then keep dragging
|
|
732
|
+
// from that point.
|
|
733
|
+
this._drag = { u: 0, v: 0 };
|
|
734
|
+
this._panToNormalized(point.u, point.v, false);
|
|
735
|
+
}
|
|
736
|
+
// Capture keeps the gesture alive when the cursor leaves the circle,
|
|
737
|
+
// which on a 220 px target is most drags. It is an improvement, not a
|
|
738
|
+
// requirement -- the move and up handlers are on the stage either way
|
|
739
|
+
// -- so a browser that refuses the capture must not take the drag down
|
|
740
|
+
// with it, or throw out of a pointerdown handler.
|
|
741
|
+
try {
|
|
742
|
+
this.stage.setPointerCapture(event.pointerId);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
/* not capturable; the drag still works, just not off-element */
|
|
745
|
+
}
|
|
746
|
+
event.preventDefault();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
_onPointerMove(event) {
|
|
750
|
+
if (!this._drag) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const point = this._stagePoint(event);
|
|
754
|
+
// Immediately, not animated: a navigation gesture should track the
|
|
755
|
+
// cursor rather than easing behind it.
|
|
756
|
+
this._panToNormalized(point.u + this._drag.u, point.v + this._drag.v, true);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
_onPointerUp(event) {
|
|
760
|
+
if (!this._drag) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
this._drag = null;
|
|
764
|
+
if (this.stage.hasPointerCapture && this.stage.hasPointerCapture(event.pointerId)) {
|
|
765
|
+
this.stage.releasePointerCapture(event.pointerId);
|
|
766
|
+
}
|
|
767
|
+
// panTo(immediately) skips OSD's constraints, so re-apply them once
|
|
768
|
+
// the gesture ends rather than fighting them during it.
|
|
769
|
+
this.viewer.viewport.applyConstraints();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
_onWheel(event) {
|
|
773
|
+
if (!this.expanded || !this.geom) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
event.preventDefault();
|
|
777
|
+
const point = this._stagePoint(event);
|
|
778
|
+
// Zoom about the point under the cursor, so the wheel reads as
|
|
779
|
+
// "magnify here" rather than "magnify wherever I happen to be".
|
|
780
|
+
const reference = new OpenSeadragon.Point(
|
|
781
|
+
MiniMap._clamp01(point.u),
|
|
782
|
+
MiniMap._clamp01(point.v) * this.geom.aspect
|
|
783
|
+
);
|
|
784
|
+
this.viewer.viewport.zoomBy(Math.pow(1.0015, -event.deltaY), reference);
|
|
785
|
+
this.viewer.viewport.applyConstraints();
|
|
786
|
+
}
|
|
787
|
+
}
|