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,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigureScene - reading the viewer's state, and putting it back.
|
|
3
|
+
*
|
|
4
|
+
* This is the file the whole product claim rests on. A captured panel looks
|
|
5
|
+
* like a screenshot; what makes it something else is that everything affecting
|
|
6
|
+
* what is on screen is written down here in a form that survives the screen.
|
|
7
|
+
*
|
|
8
|
+
* Two rules govern what goes in:
|
|
9
|
+
*
|
|
10
|
+
* **Only what changes the science.** Channels, their colours, their display
|
|
11
|
+
* windows, the overlays, the region of the image. Not the sidebar's scroll
|
|
12
|
+
* position, not the cursor, not which panel was expanded -- storing those would
|
|
13
|
+
* make two identical captures compare unequal and would restore a UI the user
|
|
14
|
+
* has since rearranged.
|
|
15
|
+
*
|
|
16
|
+
* **Nothing in screen units.** The viewport is full-resolution image pixels and
|
|
17
|
+
* the windows are raw 16-bit, whatever the slider happened to be showing. Both
|
|
18
|
+
* are stable under zoom, under HD mode and under a different monitor; the
|
|
19
|
+
* alternatives are indistinguishable from them once written down, and only
|
|
20
|
+
* these two can be re-rendered at an arbitrary DPI years later.
|
|
21
|
+
*
|
|
22
|
+
* A channel is identified by the last segment of its tile URL rather than by
|
|
23
|
+
* its name. A name is something the user renames; the key is generated at
|
|
24
|
+
* import from the file and the channel's position and does not move. A figure
|
|
25
|
+
* that identified channels by name would lose them the first time somebody
|
|
26
|
+
* relabelled "LSP20209_2" as "CD8a" -- which is a thing people do on the way to
|
|
27
|
+
* making a figure.
|
|
28
|
+
*/
|
|
29
|
+
const FigureScene = {
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The stable id of one image channel, from the config entry the viewer was
|
|
33
|
+
* built with.
|
|
34
|
+
*/
|
|
35
|
+
channelKey(config, fullname) {
|
|
36
|
+
const entry = (config?.imageData || []).find((channel) => channel.fullname === fullname);
|
|
37
|
+
if (!entry || !entry.src) return "";
|
|
38
|
+
return String(entry.src).replace(/\/+$/, "").split("/").pop();
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every channel currently contributing to the image, as the scene records
|
|
43
|
+
* them.
|
|
44
|
+
*
|
|
45
|
+
* Read from the sidebar's slots rather than from the saved channel list,
|
|
46
|
+
* because the saved list is what was last PERSISTED and the slots are what
|
|
47
|
+
* is on screen -- and the gap between them is exactly the unsaved
|
|
48
|
+
* adjustment the user is capturing.
|
|
49
|
+
*/
|
|
50
|
+
channels(ctx) {
|
|
51
|
+
const sidebar = window.__plexora?.viewerSidebar;
|
|
52
|
+
const config = ctx.config;
|
|
53
|
+
if (!sidebar || !Array.isArray(sidebar.channelSlots)) return [];
|
|
54
|
+
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const slot of sidebar.channelSlots) {
|
|
57
|
+
if (!slot.name || !slot.enabled) continue;
|
|
58
|
+
const fullname = ctx.dataLayer?.getFullChannelName
|
|
59
|
+
? ctx.dataLayer.getFullChannelName(slot.name)
|
|
60
|
+
: slot.name;
|
|
61
|
+
const key = this.channelKey(config, fullname);
|
|
62
|
+
if (!key) continue;
|
|
63
|
+
// Raw 16-bit, always. slot.range is byte-domain outside HD mode, so
|
|
64
|
+
// storing it directly would produce a window that means nothing the
|
|
65
|
+
// next time the viewer is in the other mode.
|
|
66
|
+
const window_ = sidebar.toRawRangeForSlot
|
|
67
|
+
? sidebar.toRawRangeForSlot(slot)
|
|
68
|
+
: slot.range;
|
|
69
|
+
out.push({
|
|
70
|
+
key: key,
|
|
71
|
+
fullname_at_capture: fullname,
|
|
72
|
+
color: { r: slot.color.r, g: slot.color.g, b: slot.color.b },
|
|
73
|
+
window: [Number(window_[0]), Number(window_[1])],
|
|
74
|
+
visible: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Core's own overlays: the cell layers, HD tiles, the scale bar.
|
|
82
|
+
*
|
|
83
|
+
* The layers are recorded in stacking order with their modes and
|
|
84
|
+
* opacities, but WITHOUT the colours -- those belong to whichever plugin
|
|
85
|
+
* computed them, and that plugin records its own state and its own legend
|
|
86
|
+
* through the capture bridge. Copying a lookup table of half a million
|
|
87
|
+
* cells into every panel would put megabytes of derived data into a
|
|
88
|
+
* document whose whole point is that it holds none.
|
|
89
|
+
*/
|
|
90
|
+
coreOverlays(ctx) {
|
|
91
|
+
const viewer = ctx.viewer;
|
|
92
|
+
const layers = (viewer?.cellLayers?.() || []).map((layer, index) => ({
|
|
93
|
+
name: layer.name,
|
|
94
|
+
mode: layer.mode,
|
|
95
|
+
opacity: layer.opacity,
|
|
96
|
+
visible: Boolean(layer.visible),
|
|
97
|
+
z: index,
|
|
98
|
+
}));
|
|
99
|
+
return {
|
|
100
|
+
cell_layers: layers,
|
|
101
|
+
hd_tiles: Boolean(document.getElementById("viewer_controls_hd")?.checked),
|
|
102
|
+
scalebar_visible: Boolean(viewer?.show_scalebar),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ask every open plugin what it is drawing.
|
|
108
|
+
*
|
|
109
|
+
* A DOM CustomEvent rather than a call, because Figure Builder must not
|
|
110
|
+
* know any plugin's internals -- the same reason ROI and Cell Explorer talk
|
|
111
|
+
* through `plexora:roi-hover` rather than through each other's classes. A
|
|
112
|
+
* plugin that is not loaded contributes nothing and the capture proceeds;
|
|
113
|
+
* that is the honest outcome, and it is what makes a figure built today
|
|
114
|
+
* still openable in a build where that plugin was uninstalled.
|
|
115
|
+
*
|
|
116
|
+
* Each contribution carries a `legend` the plugin computed NOW, which is
|
|
117
|
+
* what lets export draw a legend with no plugin JavaScript running at all.
|
|
118
|
+
*/
|
|
119
|
+
pluginStates() {
|
|
120
|
+
const contributions = {};
|
|
121
|
+
try {
|
|
122
|
+
window.dispatchEvent(new CustomEvent("plexora:figure-capture-state", {
|
|
123
|
+
detail: {
|
|
124
|
+
contribute(name, payload) {
|
|
125
|
+
if (!name || !payload) return;
|
|
126
|
+
contributions[String(name)] = {
|
|
127
|
+
version: String(payload.version || ""),
|
|
128
|
+
state: payload.state || {},
|
|
129
|
+
legend: Array.isArray(payload.legend) ? payload.legend : [],
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// A plugin that throws while describing itself must not cost the
|
|
136
|
+
// user their capture. The panel is still worth having without that
|
|
137
|
+
// plugin's overlay recorded, and the omission is visible.
|
|
138
|
+
console.error("figure_builder: a plugin failed to describe its state", error);
|
|
139
|
+
}
|
|
140
|
+
return contributions;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The whole snapshot for a captured region.
|
|
145
|
+
*
|
|
146
|
+
* `viewport` is already in full-resolution image pixels -- converted by the
|
|
147
|
+
* capture tool, which is the only place that touches screen coordinates.
|
|
148
|
+
*/
|
|
149
|
+
capture(ctx, sourceId, viewport) {
|
|
150
|
+
return {
|
|
151
|
+
snapshot_version: FigureSchema.SNAPSHOT_VERSION,
|
|
152
|
+
source_id: sourceId,
|
|
153
|
+
viewport: {
|
|
154
|
+
x: viewport.x, y: viewport.y, w: viewport.w, h: viewport.h,
|
|
155
|
+
},
|
|
156
|
+
channels: this.channels(ctx),
|
|
157
|
+
core_overlays: this.coreOverlays(ctx),
|
|
158
|
+
plugins: this.pluginStates(),
|
|
159
|
+
captured_at: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What the viewer is looking at right now, in full-resolution image pixels.
|
|
165
|
+
*
|
|
166
|
+
* Used to stash the state a panel edit is about to displace, so Cancel puts
|
|
167
|
+
* the user back where they were rather than leaving them somewhere they
|
|
168
|
+
* never chose.
|
|
169
|
+
*/
|
|
170
|
+
currentViewport(ctx) {
|
|
171
|
+
const viewer = ctx.viewer?.viewer;
|
|
172
|
+
const item = viewer?.world?.getItemAt(0);
|
|
173
|
+
if (!item) return { x: 0, y: 0, w: 1, h: 1 };
|
|
174
|
+
const bounds = item.viewportToImageRectangle(viewer.viewport.getBounds(true));
|
|
175
|
+
const scale = 2 ** (ctx.config?.extraZoomLevels || 0);
|
|
176
|
+
return {
|
|
177
|
+
x: bounds.x / scale, y: bounds.y / scale,
|
|
178
|
+
w: Math.max(1, bounds.width / scale), h: Math.max(1, bounds.height / scale),
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// -- putting it back -------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The scene's channels as the rows the sidebar's own restore path takes.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately reuses `applySavedChannels` rather than driving the viewer
|
|
188
|
+
* directly. That function already handles the things a second
|
|
189
|
+
* implementation would get wrong on a project nobody tested it on: it
|
|
190
|
+
* prefetches each channel's statistics in parallel, converts the raw window
|
|
191
|
+
* into whichever domain the slider is currently in, and stands down the
|
|
192
|
+
* auto-level that a marker change would otherwise schedule on top of the
|
|
193
|
+
* range being restored. A parallel path would be a second answer to all
|
|
194
|
+
* three.
|
|
195
|
+
*
|
|
196
|
+
* Returns the rows plus the channels that could not be resolved, which are
|
|
197
|
+
* reported and never guessed at: a figure that silently substituted a
|
|
198
|
+
* different channel would look right and be wrong.
|
|
199
|
+
*/
|
|
200
|
+
restoreRows(ctx, scene) {
|
|
201
|
+
const byKey = new Map();
|
|
202
|
+
for (const channel of ctx.config?.imageData || []) {
|
|
203
|
+
const key = String(channel.src || "").replace(/\/+$/, "").split("/").pop();
|
|
204
|
+
if (key) byKey.set(key, channel);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const rows = [];
|
|
208
|
+
const missing = [];
|
|
209
|
+
for (const channel of scene.channels || []) {
|
|
210
|
+
const entry = byKey.get(channel.key);
|
|
211
|
+
if (!entry) {
|
|
212
|
+
missing.push(channel.fullname_at_capture || channel.key);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
rows.push({
|
|
216
|
+
// The SHORT name, which is what the slots are keyed by -- the
|
|
217
|
+
// same field `persistChannelList` writes.
|
|
218
|
+
channel: entry.name || entry.fullname,
|
|
219
|
+
channel_active: true,
|
|
220
|
+
r: channel.color.r, g: channel.color.g, b: channel.color.b,
|
|
221
|
+
opacity: 1,
|
|
222
|
+
// Raw 16-bit both ways: this is the form the saved channel list
|
|
223
|
+
// uses, so the conversion into the live domain is the sidebar's
|
|
224
|
+
// and there is one of it.
|
|
225
|
+
start: channel.window[0],
|
|
226
|
+
end: channel.window[1],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return { rows: rows, missing: missing };
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Put a captured scene back into the live viewer.
|
|
234
|
+
*
|
|
235
|
+
* Returns a report -- what was restored, what could not be, which plugins
|
|
236
|
+
* answered. Nothing here throws on a missing piece: a panel captured with a
|
|
237
|
+
* plugin that is no longer installed still shows its image and its
|
|
238
|
+
* channels, and saying which part is absent is more useful than refusing
|
|
239
|
+
* the whole restore.
|
|
240
|
+
*
|
|
241
|
+
* The project's own saved channel list is NOT touched. That is what
|
|
242
|
+
* `suspendPersistence` is for, and it is the difference between looking at
|
|
243
|
+
* a figure panel and permanently overwriting the settings the user had.
|
|
244
|
+
*/
|
|
245
|
+
async restore(ctx, scene) {
|
|
246
|
+
const sidebar = window.__plexora?.viewerSidebar;
|
|
247
|
+
const report = { channels: 0, missing_channels: [], plugins: {}, viewport: false };
|
|
248
|
+
if (!scene) return report;
|
|
249
|
+
|
|
250
|
+
const resolved = this.restoreRows(ctx, scene);
|
|
251
|
+
report.missing_channels = resolved.missing;
|
|
252
|
+
|
|
253
|
+
sidebar?.suspendPersistence?.();
|
|
254
|
+
try {
|
|
255
|
+
if (sidebar && resolved.rows.length) {
|
|
256
|
+
await sidebar.applySavedChannels(resolved.rows);
|
|
257
|
+
report.channels = resolved.rows.length;
|
|
258
|
+
}
|
|
259
|
+
this.restoreOverlays(ctx, scene.core_overlays);
|
|
260
|
+
report.viewport = this.restoreViewport(ctx, scene.viewport);
|
|
261
|
+
report.plugins = this.restorePlugins(scene.plugins);
|
|
262
|
+
} finally {
|
|
263
|
+
// In a `finally`, because a restore that throws halfway must not
|
|
264
|
+
// leave the project's channel list permanently unsaveable -- the
|
|
265
|
+
// symptom would be a viewer that quietly stops remembering
|
|
266
|
+
// anything, hours later, with no way to connect it to this.
|
|
267
|
+
sidebar?.resumePersistence?.();
|
|
268
|
+
}
|
|
269
|
+
return report;
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Move the viewer to the captured region.
|
|
274
|
+
*
|
|
275
|
+
* The inverse of the capture conversion, and new code: nothing in the
|
|
276
|
+
* viewer restored a viewport before this, because nothing had a viewport
|
|
277
|
+
* worth restoring.
|
|
278
|
+
*/
|
|
279
|
+
restoreViewport(ctx, viewport) {
|
|
280
|
+
const viewer = ctx.viewer?.viewer;
|
|
281
|
+
const item = viewer?.world?.getItemAt(0);
|
|
282
|
+
if (!item || !viewport) return false;
|
|
283
|
+
const scale = 2 ** (ctx.config?.extraZoomLevels || 0);
|
|
284
|
+
const bounds = item.imageToViewportRectangle(new OpenSeadragon.Rect(
|
|
285
|
+
viewport.x * scale, viewport.y * scale,
|
|
286
|
+
viewport.w * scale, viewport.h * scale));
|
|
287
|
+
// Immediately rather than animated: this is a jump to a recorded place,
|
|
288
|
+
// and a two-second pan across a slide to get there is a two-second wait
|
|
289
|
+
// that tells the user nothing.
|
|
290
|
+
viewer.viewport.fitBounds(bounds, true);
|
|
291
|
+
return true;
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Core's overlays, as far as they still exist.
|
|
296
|
+
*
|
|
297
|
+
* A layer belongs to whichever plugin registered it, so a scene captured
|
|
298
|
+
* with Cell Explorer open restores nothing here when it is closed -- and
|
|
299
|
+
* says so, rather than conjuring a layer with no colours in it.
|
|
300
|
+
*/
|
|
301
|
+
restoreOverlays(ctx, overlays) {
|
|
302
|
+
if (!overlays) return;
|
|
303
|
+
const viewer = ctx.viewer;
|
|
304
|
+
const controls = window.__plexora?.viewerControls;
|
|
305
|
+
|
|
306
|
+
let restored = null;
|
|
307
|
+
for (const layer of overlays.cell_layers || []) {
|
|
308
|
+
if (!viewer?.cellLayer?.(layer.name)) continue;
|
|
309
|
+
// Per layer, because each has its own mode: a phenotype map drawn
|
|
310
|
+
// filled and a gate drawn as outlines are a legitimate pair, and
|
|
311
|
+
// restoring one mode for all of them would flatten that.
|
|
312
|
+
viewer.setCellLayerMode?.(layer.name, layer.mode);
|
|
313
|
+
viewer.setLayerOpacity?.(layer.name, layer.opacity);
|
|
314
|
+
viewer.setCellLayerVisible?.(layer.name, layer.visible);
|
|
315
|
+
if (layer.visible && layer.mode && layer.mode !== "none") restored = layer;
|
|
316
|
+
}
|
|
317
|
+
// setCellLayerMode changes how the mask is DRAWN; it does not fetch a
|
|
318
|
+
// mask that is not loaded. viewerControls.selectMode is the path that
|
|
319
|
+
// does, so it is called once for whichever layer is actually showing --
|
|
320
|
+
// otherwise a panel captured with outlines on reopens with the layer
|
|
321
|
+
// switched on and nothing in it.
|
|
322
|
+
if (restored && controls?.selectMode) controls.selectMode(restored.mode);
|
|
323
|
+
|
|
324
|
+
const hd = document.getElementById("viewer_controls_hd");
|
|
325
|
+
if (hd && hd.checked !== overlays.hd_tiles) {
|
|
326
|
+
hd.checked = overlays.hd_tiles;
|
|
327
|
+
// Through the element's own change event, so the reload work stays
|
|
328
|
+
// in viewerControls rather than being reimplemented here.
|
|
329
|
+
hd.dispatchEvent(new Event("change", { bubbles: true }));
|
|
330
|
+
}
|
|
331
|
+
viewer?.setScalebarVisible?.(Boolean(overlays.scalebar_visible));
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Hand each plugin its own state back.
|
|
336
|
+
*
|
|
337
|
+
* The mirror of the capture bridge, and equally ignorant: Figure Builder
|
|
338
|
+
* passes the blob to whoever answers to that name and records what they
|
|
339
|
+
* said. A plugin that is not open reports nothing, which becomes a notice
|
|
340
|
+
* on the panel rather than an error -- the image and the channels are
|
|
341
|
+
* restored either way, and that is most of the view.
|
|
342
|
+
*/
|
|
343
|
+
restorePlugins(plugins) {
|
|
344
|
+
const report = {};
|
|
345
|
+
if (!plugins || !Object.keys(plugins).length) return report;
|
|
346
|
+
try {
|
|
347
|
+
window.dispatchEvent(new CustomEvent("plexora:figure-restore-state", {
|
|
348
|
+
detail: {
|
|
349
|
+
plugins: plugins,
|
|
350
|
+
report(name, outcome) { report[String(name)] = String(outcome); },
|
|
351
|
+
},
|
|
352
|
+
}));
|
|
353
|
+
} catch (error) {
|
|
354
|
+
console.error("figure_builder: a plugin failed to restore its state", error);
|
|
355
|
+
}
|
|
356
|
+
for (const name of Object.keys(plugins)) {
|
|
357
|
+
if (!(name in report)) report[name] = "skipped";
|
|
358
|
+
}
|
|
359
|
+
return report;
|
|
360
|
+
},
|
|
361
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigureSchema - the parts of the figure format the client has to agree with.
|
|
3
|
+
*
|
|
4
|
+
* Ids are generated HERE rather than by the server so a captured panel appears
|
|
5
|
+
* the instant the pointer is released, not after a round trip. That is the same
|
|
6
|
+
* bargain ROI makes, and it carries the same obligation: the server validates
|
|
7
|
+
* every id it is sent (server/schema.py ID_PATTERN), because these end up in
|
|
8
|
+
* DOM attributes and selectors.
|
|
9
|
+
*
|
|
10
|
+
* The two coordinate systems are kept apart on this side too. `mm` is page
|
|
11
|
+
* geometry -- placements, annotations, page size. Full-resolution image pixels
|
|
12
|
+
* are scene geometry, and nothing here converts between them: they only meet at
|
|
13
|
+
* export, where mm x DPI decides how many source pixels to read. A helper that
|
|
14
|
+
* turned one into the other would be a helper somebody eventually used to store
|
|
15
|
+
* a panel's crop in screen units, which is exactly the mistake that makes a
|
|
16
|
+
* figure un-re-renderable.
|
|
17
|
+
*/
|
|
18
|
+
const FigureSchema = {
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION: 1,
|
|
21
|
+
SNAPSHOT_VERSION: 1,
|
|
22
|
+
|
|
23
|
+
/** Millimetres per inch, and per CSS pixel at the nominal 96 dpi. */
|
|
24
|
+
MM_PER_INCH: 25.4,
|
|
25
|
+
|
|
26
|
+
PAGE_PRESETS: {
|
|
27
|
+
a4: { w: 210.0, h: 297.0, label: "A4" },
|
|
28
|
+
letter: { w: 215.9, h: 279.4, label: "US Letter" },
|
|
29
|
+
square: { w: 200.0, h: 200.0, label: "Square" },
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A short random id with a readable prefix.
|
|
34
|
+
*
|
|
35
|
+
* crypto.randomUUID where it exists; the fallback matters because Plexora
|
|
36
|
+
* is served over plain http on 127.0.0.1 in some deployments, and
|
|
37
|
+
* `crypto.randomUUID` is a secure-context API. Without a fallback, capture
|
|
38
|
+
* would simply stop working there with a TypeError nobody could interpret.
|
|
39
|
+
*/
|
|
40
|
+
newId(prefix) {
|
|
41
|
+
const random = (window.crypto && window.crypto.randomUUID)
|
|
42
|
+
? window.crypto.randomUUID().replace(/-/g, "")
|
|
43
|
+
: (Date.now().toString(36) + Math.random().toString(36).slice(2, 10));
|
|
44
|
+
return prefix + "_" + random.slice(0, 12);
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
newPanelId() { return this.newId("pnl"); },
|
|
48
|
+
newPageId() { return this.newId("pg"); },
|
|
49
|
+
newSourceId() { return this.newId("src"); },
|
|
50
|
+
newAnnotationId() { return this.newId("ann"); },
|
|
51
|
+
newGroupId() { return this.newId("grp"); },
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The panel label for a zero-based position: A..Z, then AA, AB, ...
|
|
55
|
+
*
|
|
56
|
+
* Base-26 with no zero digit, which is the spreadsheet-column sequence
|
|
57
|
+
* rather than plain base conversion -- position 26 is "AA", not "BA".
|
|
58
|
+
*/
|
|
59
|
+
labelFor(index, style) {
|
|
60
|
+
if (style === "A1") return "A" + String(index + 1);
|
|
61
|
+
let n = Math.max(0, Math.floor(index));
|
|
62
|
+
let out = "";
|
|
63
|
+
do {
|
|
64
|
+
out = String.fromCharCode(65 + (n % 26)) + out;
|
|
65
|
+
n = Math.floor(n / 26) - 1;
|
|
66
|
+
} while (n >= 0);
|
|
67
|
+
return style === "a" ? out.toLowerCase() : out;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/** Panels on one page, in the order their labels should run. */
|
|
71
|
+
panelsOnPage(document_, pageId) {
|
|
72
|
+
return Object.values(document_.panels || {})
|
|
73
|
+
.filter((panel) => panel.placement && panel.placement.page_id === pageId)
|
|
74
|
+
// Reading order, then z as the tiebreak. A user who lays out a 3x2
|
|
75
|
+
// grid expects A B C / D E F, which is rows before columns; sorting
|
|
76
|
+
// by z alone would number them by the order they happened to be
|
|
77
|
+
// captured in.
|
|
78
|
+
.sort((a, b) => (a.placement.y_mm - b.placement.y_mm)
|
|
79
|
+
|| (a.placement.x_mm - b.placement.x_mm)
|
|
80
|
+
|| (a.placement.z - b.placement.z));
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/** Panels not on any page: captured, kept, not laid out. */
|
|
84
|
+
panelsInTray(document_) {
|
|
85
|
+
return Object.values(document_.panels || {}).filter((panel) => !panel.placement);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
pageById(document_, pageId) {
|
|
89
|
+
return (document_.pages || []).find((page) => page.page_id === pageId) || null;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A scene snapshot with nothing captured yet.
|
|
94
|
+
*
|
|
95
|
+
* Exists so that a panel built by any path -- capture, split, an imported
|
|
96
|
+
* asset -- has the same shape, and so a field added to the format has one
|
|
97
|
+
* place to be defaulted rather than N call sites to be remembered in.
|
|
98
|
+
*/
|
|
99
|
+
emptyScene(sourceId) {
|
|
100
|
+
return {
|
|
101
|
+
snapshot_version: this.SNAPSHOT_VERSION,
|
|
102
|
+
source_id: sourceId || "",
|
|
103
|
+
viewport: { x: 0, y: 0, w: 1, h: 1 },
|
|
104
|
+
channels: [],
|
|
105
|
+
core_overlays: { cell_layers: [], hd_tiles: false, scalebar_visible: false },
|
|
106
|
+
plugins: {},
|
|
107
|
+
captured_at: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How many microns across a captured region is, or null.
|
|
113
|
+
*
|
|
114
|
+
* Null propagates rather than defaulting: a scale bar drawn from an assumed
|
|
115
|
+
* pixel size is wrong and looks exactly like one that is right, which is the
|
|
116
|
+
* single worst failure mode a figure tool has.
|
|
117
|
+
*/
|
|
118
|
+
physicalWidthUm(source, viewport) {
|
|
119
|
+
const pixelSize = source && source.pixel_size;
|
|
120
|
+
if (!pixelSize || !(pixelSize.value > 0) || !viewport) return null;
|
|
121
|
+
return viewport.w * pixelSize.value;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The source region a panel of THIS shape should show.
|
|
126
|
+
*
|
|
127
|
+
* A panel is captured at one proportion and then dragged into another --
|
|
128
|
+
* a square field cropped into a wide strip is the commonest thing anybody
|
|
129
|
+
* does to a figure. Reopening it for editing has to frame the shape the
|
|
130
|
+
* panel is NOW, not the shape it was captured at, or the edit would quietly
|
|
131
|
+
* put back a proportion the user deliberately changed.
|
|
132
|
+
*
|
|
133
|
+
* Centre and WIDTH are preserved and the height follows the panel's aspect.
|
|
134
|
+
* Width rather than height because the user framed the field by what is
|
|
135
|
+
* across it: re-deriving the width would move the left and right edges they
|
|
136
|
+
* chose, which is the part of the framing they can see.
|
|
137
|
+
*
|
|
138
|
+
* Used by both routes into editing -- Quick Edit's mini viewer and the main
|
|
139
|
+
* viewer's outline -- so the two cannot disagree about what a panel is
|
|
140
|
+
* looking at.
|
|
141
|
+
*
|
|
142
|
+
* Pure, and `image` is optional: with it the result is nudged back inside
|
|
143
|
+
* the slide rather than hanging over the edge.
|
|
144
|
+
*/
|
|
145
|
+
aspectViewport(viewport, aspect, image) {
|
|
146
|
+
const width = viewport.w;
|
|
147
|
+
const height = aspect > 0 ? width / aspect : viewport.h;
|
|
148
|
+
let x = viewport.x;
|
|
149
|
+
let y = viewport.y + viewport.h / 2 - height / 2;
|
|
150
|
+
|
|
151
|
+
if (image && image.width > 0 && image.height > 0) {
|
|
152
|
+
// Slid back inside where it fits, and left alone where it does not:
|
|
153
|
+
// a region wider than the slide is a legitimate thing to have
|
|
154
|
+
// captured at the edge of a small image, and shrinking it here
|
|
155
|
+
// would change the field rather than move it.
|
|
156
|
+
if (width <= image.width) x = Math.max(0, Math.min(x, image.width - width));
|
|
157
|
+
if (height <= image.height) y = Math.max(0, Math.min(y, image.height - height));
|
|
158
|
+
}
|
|
159
|
+
return { x: x, y: y, w: width, h: height };
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/** A round number of microns that fits comfortably inside `spanUm`. */
|
|
163
|
+
scaleBarLength(spanUm) {
|
|
164
|
+
if (!(spanUm > 0)) return null;
|
|
165
|
+
const target = spanUm / 4;
|
|
166
|
+
const candidates = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500,
|
|
167
|
+
1000, 2000, 5000, 10000];
|
|
168
|
+
let best = candidates[0];
|
|
169
|
+
for (const value of candidates) {
|
|
170
|
+
if (value <= target) best = value;
|
|
171
|
+
}
|
|
172
|
+
return best;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
formatMicrons(value) {
|
|
176
|
+
if (!(value > 0)) return "";
|
|
177
|
+
if (value >= 1000) return (value / 1000).toFixed(value % 1000 ? 1 : 0) + " mm";
|
|
178
|
+
if (value >= 10) return Math.round(value) + " µm";
|
|
179
|
+
return value.toFixed(1) + " µm";
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Text that is about to be put in the DOM as markup.
|
|
184
|
+
*
|
|
185
|
+
* Every user-supplied string on this side goes through here or through
|
|
186
|
+
* textContent. The server strips control characters and caps length but
|
|
187
|
+
* deliberately does not HTML-escape -- escaping at the store double-escapes
|
|
188
|
+
* on every round trip -- so escaping is this side's job, at the point of
|
|
189
|
+
* use.
|
|
190
|
+
*/
|
|
191
|
+
escapeHtml(value) {
|
|
192
|
+
return String(value === undefined || value === null ? "" : value)
|
|
193
|
+
.replace(/[&<>"']/g, (c) => ({
|
|
194
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
195
|
+
}[c]));
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
/** "3 minutes ago" for a timestamp, "" for anything unparseable. */
|
|
199
|
+
timeAgo(iso) {
|
|
200
|
+
if (!iso) return "";
|
|
201
|
+
const then = new Date(iso).getTime();
|
|
202
|
+
if (Number.isNaN(then)) return "";
|
|
203
|
+
const seconds = Math.round((Date.now() - then) / 1000);
|
|
204
|
+
if (seconds < 60) return "just now";
|
|
205
|
+
const minutes = Math.round(seconds / 60);
|
|
206
|
+
if (minutes < 60) return minutes + (minutes === 1 ? " minute ago" : " minutes ago");
|
|
207
|
+
const hours = Math.round(minutes / 60);
|
|
208
|
+
if (hours < 24) return hours + (hours === 1 ? " hour ago" : " hours ago");
|
|
209
|
+
const days = Math.round(hours / 24);
|
|
210
|
+
if (days < 30) return days + (days === 1 ? " day ago" : " days ago");
|
|
211
|
+
return new Date(iso).toLocaleDateString(undefined,
|
|
212
|
+
{ year: "numeric", month: "short", day: "numeric" });
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
countPhrase(count, singular, plural) {
|
|
216
|
+
return count + " " + (count === 1 ? singular : (plural || singular + "s"));
|
|
217
|
+
},
|
|
218
|
+
};
|