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,137 @@
|
|
|
1
|
+
<section class="sidebar-section" id="cell_explorer_panel_section">
|
|
2
|
+
<div class="section-heading">
|
|
3
|
+
<h2><span class="fas fa-palette section-heading-icon"></span>Cell Explorer</h2>
|
|
4
|
+
<div class="section-actions">
|
|
5
|
+
<button id="cell_explorer_close" class="icon-button" title="Close Cell Explorer" type="button">
|
|
6
|
+
<span class="fas fa-xmark"></span>
|
|
7
|
+
</button>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
{# The two ways this tool cannot work, kept apart because the fix differs.
|
|
12
|
+
No metadata is a data-setup problem; nowhere to draw is a missing file. #}
|
|
13
|
+
<div id="cell_explorer_empty" class="cex-empty-state" hidden>
|
|
14
|
+
<p>No cell metadata is available to display.</p>
|
|
15
|
+
<p class="cex-hint">If this project's columns were all classified as markers,
|
|
16
|
+
the marker and metadata split can be corrected from the project's edit page.</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div id="cell_explorer_nowhere" class="cex-empty-state" hidden>
|
|
20
|
+
<p>There is nothing to draw these cells on.</p>
|
|
21
|
+
<p class="cex-hint">Colouring cells needs a segmentation mask, or X/Y
|
|
22
|
+
coordinates to place centroids at.</p>
|
|
23
|
+
<button id="cell_explorer_requirements" class="sidebar-action" type="button">
|
|
24
|
+
Add one of those
|
|
25
|
+
</button>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
{# A mask was supplied and is still being converted.
|
|
29
|
+
|
|
30
|
+
Cells here are drawn as the project's mask, and until the pyramid lands
|
|
31
|
+
there is nothing to draw them on. Centroids would fill the gap, and that
|
|
32
|
+
is what used to happen -- silently, and often for minutes, so the panel
|
|
33
|
+
opened showing dots for a project whose whole point was cell shape, and
|
|
34
|
+
nothing on screen said why or that it would change. This waits instead,
|
|
35
|
+
and says how far along the job is. Anyone who would rather not wait can
|
|
36
|
+
take the centroids deliberately; the mask still replaces them when it
|
|
37
|
+
arrives. Fed by core's poll -- see main.js's plexora:segmentation-*. #}
|
|
38
|
+
<div id="cell_explorer_mask_wait" class="cex-mask-wait" hidden>
|
|
39
|
+
<p class="cex-mask-wait-title">Preparing segmentation mask</p>
|
|
40
|
+
<div class="cex-mask-wait-track">
|
|
41
|
+
<div id="cell_explorer_mask_wait_bar" class="cex-mask-wait-bar"></div>
|
|
42
|
+
</div>
|
|
43
|
+
<p id="cell_explorer_mask_wait_message" class="cex-hint">Converting segmentation mask…</p>
|
|
44
|
+
<button id="cell_explorer_mask_wait_skip" class="sidebar-action secondary" type="button" hidden>
|
|
45
|
+
Show centroids meanwhile
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{# One line: what to colour by, and -- when that column is categorical --
|
|
50
|
+
the two controls that act on the list below it.
|
|
51
|
+
|
|
52
|
+
No visible captions. The colour-by control is core's SearchableSelect in
|
|
53
|
+
its button shape, so it reads as the column it is showing, and its
|
|
54
|
+
dropdown carries the search that finds another one. The other two are a
|
|
55
|
+
search field and a two-state All/None, which are what they look like.
|
|
56
|
+
Each carries an aria-label, since there is no `for` to point a <label>
|
|
57
|
+
at a control that mounts its own generated elements.
|
|
58
|
+
|
|
59
|
+
The category pair is hidden rather than moved for a continuous column:
|
|
60
|
+
it belongs to the legend, and the legend is not there. #}
|
|
61
|
+
<div id="cell_explorer_toolbar" class="cex-toolbar">
|
|
62
|
+
<div id="cell_explorer_variable" class="cex-variable"></div>
|
|
63
|
+
<div id="cell_explorer_category_controls" class="cex-category-controls" hidden>
|
|
64
|
+
{# Filters the legend only. Typing "T" must not hide the macrophages
|
|
65
|
+
on the image -- that distinction is the whole reason this is not
|
|
66
|
+
a visibility control. #}
|
|
67
|
+
<input id="cell_explorer_search" class="cex-search" type="search"
|
|
68
|
+
autocomplete="off" spellcheck="false" placeholder="Filter..."
|
|
69
|
+
aria-label="Filter categories" title="Filter the list below">
|
|
70
|
+
{# Show-everything / show-nothing, as a two-state control rather than
|
|
71
|
+
two commands. Which one is lit says what the legend is currently
|
|
72
|
+
in -- and "neither" is a real and common answer, which is why
|
|
73
|
+
these are radios that can both be unchecked rather than a switch. #}
|
|
74
|
+
<div id="cell_explorer_visibility" class="cex-legend-actions"
|
|
75
|
+
role="radiogroup" aria-label="Category visibility">
|
|
76
|
+
<button id="cell_explorer_show_all" class="cex-toggle" type="button"
|
|
77
|
+
role="radio" aria-checked="false" data-visibility="all"
|
|
78
|
+
title="Show every category">All</button>
|
|
79
|
+
<button id="cell_explorer_hide_all" class="cex-toggle" type="button"
|
|
80
|
+
role="radio" aria-checked="false" data-visibility="none"
|
|
81
|
+
title="Hide every category">None</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{# Opens the ROI tool ALONGSIDE this one rather than in place of it -- see
|
|
87
|
+
toolLoader's openToolAlongside. Regions are only worth hovering while the
|
|
88
|
+
overlay they are summarised against is still drawn, so the usual
|
|
89
|
+
single-active switch would take away the thing that makes the answer mean
|
|
90
|
+
anything.
|
|
91
|
+
|
|
92
|
+
A named button on a line of its own rather than another icon in the
|
|
93
|
+
heading. Nothing about a polygon glyph says that hovering a region will
|
|
94
|
+
break its cells down by the variable on screen, and an unlabelled control
|
|
95
|
+
for a feature nobody knows exists is a feature nobody finds. The hint is
|
|
96
|
+
the discoverable part; the button is just how it is reached. #}
|
|
97
|
+
<div id="cell_explorer_roi_launch" class="cex-roi-launch">
|
|
98
|
+
<button id="cell_explorer_open_roi" class="sidebar-action-fancy" type="button">
|
|
99
|
+
<span class="fas fa-draw-polygon" aria-hidden="true"></span>
|
|
100
|
+
<span>Open ROIs</span>
|
|
101
|
+
</button>
|
|
102
|
+
<p class="cex-roi-launch-hint">Hover a region to see what it is made of.</p>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{# Shown only for a column whose kind was genuinely a guess -- a numeric one
|
|
106
|
+
with few whole values. See server/variables.py. #}
|
|
107
|
+
<div id="cell_explorer_override" class="cex-override" role="radiogroup"
|
|
108
|
+
aria-label="Treat this column as" hidden>
|
|
109
|
+
<span class="control-label">Treat as</span>
|
|
110
|
+
<div class="cex-override-options">
|
|
111
|
+
<button type="button" class="cex-override-option" data-kind="categorical"
|
|
112
|
+
role="radio" aria-checked="false">Categories</button>
|
|
113
|
+
<button type="button" class="cex-override-option" data-kind="continuous"
|
|
114
|
+
role="radio" aria-checked="false">Numbers</button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<p id="cell_explorer_status" class="cex-status" role="status" hidden></p>
|
|
119
|
+
|
|
120
|
+
{# Just the list. Its filter and its All/None live in the toolbar above,
|
|
121
|
+
sharing that line with the colour-by control. #}
|
|
122
|
+
<div id="cell_explorer_legend_section" hidden>
|
|
123
|
+
<div id="cell_explorer_legend" class="cex-legend"></div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div id="cell_explorer_continuous" class="cex-continuous" hidden></div>
|
|
127
|
+
|
|
128
|
+
{# The opacity slider used to be here. It is core's now, in the Cells section
|
|
129
|
+
at the bottom of the sidebar, beside the mode buttons -- see index.html.
|
|
130
|
+
Compositing is the viewer's, the same way geometry is, so every plugin
|
|
131
|
+
that colours cells gets one slider rather than each shipping its own; and
|
|
132
|
+
with several layers stacked, a slider inside one panel that moved
|
|
133
|
+
whichever layer happened to be active was pointing at the wrong thing as
|
|
134
|
+
often as not. This plugin still remembers the value per project: it
|
|
135
|
+
listens for plexora:cell-layer-opacity-changed and saves the ones tagged
|
|
136
|
+
with its own name. #}
|
|
137
|
+
</section>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Figure Builder: publication figures assembled from reproducible viewer scenes.
|
|
2
|
+
|
|
3
|
+
A captured panel looks like a screenshot and behaves like a saved viewer state.
|
|
4
|
+
It records the source image's identity, the region in FULL-RESOLUTION image
|
|
5
|
+
pixels, the channels and their display windows, the overlays, and whatever the
|
|
6
|
+
open plugins contributed -- so the same field can be reopened, re-edited, and
|
|
7
|
+
re-rendered at publication resolution long after the screen it was captured on
|
|
8
|
+
is gone. The raster on the canvas is a preview; it is never the master.
|
|
9
|
+
|
|
10
|
+
Two things make this plugin unlike the others in this tree.
|
|
11
|
+
|
|
12
|
+
**A figure is not datasource state.** `plexora.api.store` is scoped to one
|
|
13
|
+
datasource by construction, and a figure legitimately spans several -- a
|
|
14
|
+
composite from one slide, an H&E from another, a schematic from no project at
|
|
15
|
+
all. So figures live in their own single-file SQLite databases under
|
|
16
|
+
`data_path/.figures/`, one per figure, holding references and scene state and
|
|
17
|
+
never a pixel of image data. See server/repository.py.
|
|
18
|
+
|
|
19
|
+
**It has a life outside a project.** The library, and a figure opened from it,
|
|
20
|
+
work with no datasource loaded: `GET /plugins/figure_builder/figure/<id>` is a
|
|
21
|
+
page of its own. The in-viewer half is the ordinary plugin tool, and the two
|
|
22
|
+
halves talk through the same REST surface.
|
|
23
|
+
|
|
24
|
+
Kept import-light like every descriptor module: discovery imports this whenever
|
|
25
|
+
the plugin is activated, and building the Blueprint (which pulls in sqlite, the
|
|
26
|
+
schema and the operation vocabulary) is left to the factory.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from plexora.api.plugin import NavItem, Plugin, Requires
|
|
30
|
+
|
|
31
|
+
VERSION = "20260825_text_sidebar_polish"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _blueprint():
|
|
35
|
+
from plexora.plugins.figure_builder.server.routes import figure_builder_bp
|
|
36
|
+
|
|
37
|
+
return figure_builder_bp
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PLUGIN = Plugin(
|
|
41
|
+
name="figure_builder",
|
|
42
|
+
label="Figure Builder",
|
|
43
|
+
version=VERSION,
|
|
44
|
+
blueprint_factory=_blueprint,
|
|
45
|
+
# NO slots at all -- the only plugin in the tree with nothing in core's
|
|
46
|
+
# markup.
|
|
47
|
+
#
|
|
48
|
+
# Figure Builder's controls belong on the image: capturing is something you
|
|
49
|
+
# do while looking at it, and the sidebar is three hundred pixels away, next
|
|
50
|
+
# to the channel controls people use without thinking. Splitting them --
|
|
51
|
+
# capture on the image, "which figure?" in the panel -- put the two halves
|
|
52
|
+
# of one decision in two places, which was worse than either. So the
|
|
53
|
+
# in-viewer half is the dock over the image (figureCaptureDock.js, built in
|
|
54
|
+
# JavaScript because core has no slot over the image), and the sidebar has
|
|
55
|
+
# no Figure card at all.
|
|
56
|
+
#
|
|
57
|
+
# The canvas used to be rendered into a second slot beside the viewer. It is
|
|
58
|
+
# not any more: composing a figure is a different activity from looking down
|
|
59
|
+
# a microscope, and half a window was not enough room for either. The canvas
|
|
60
|
+
# has the whole page and its own URL, and the dock's "Figure Canvas"
|
|
61
|
+
# navigates to it.
|
|
62
|
+
#
|
|
63
|
+
# Declaring no tool_panel_slot means no card, and no card means no X to
|
|
64
|
+
# close the tool with -- so the dock carries one and it removes the plugin
|
|
65
|
+
# outright.
|
|
66
|
+
panels={},
|
|
67
|
+
# Order is for reading, not for the browser: every cross-file reference is
|
|
68
|
+
# inside a method or a constructor, and both the tool loader and
|
|
69
|
+
# DOMContentLoaded run after all of them have arrived. What matters is that
|
|
70
|
+
# all of them are here -- one omitted is a tool that loads and does nothing,
|
|
71
|
+
# which is what tests/test_figure_builder_boot.py exists to catch.
|
|
72
|
+
#
|
|
73
|
+
# The same set serves three pages: the sidebar panel inside the viewer, the
|
|
74
|
+
# figure library, and one figure's own page. Each controller boots only when
|
|
75
|
+
# its own root element is on the page, so loading all of them everywhere
|
|
76
|
+
# costs a few kilobytes and buys one asset list instead of three.
|
|
77
|
+
scripts=(
|
|
78
|
+
"figureBuilderApi.js",
|
|
79
|
+
"figureSchema.js",
|
|
80
|
+
"figureRichText.js",
|
|
81
|
+
"figureSceneSnapshot.js",
|
|
82
|
+
"figureCaptureTool.js",
|
|
83
|
+
"figureCaptureBoxes.js",
|
|
84
|
+
"figureCaptureDock.js",
|
|
85
|
+
"figureDocumentState.js",
|
|
86
|
+
"figureConfirm.js",
|
|
87
|
+
"figureActions.js",
|
|
88
|
+
"figureCanvas.js",
|
|
89
|
+
"figureTextEditor.js",
|
|
90
|
+
"figureTextPanel.js",
|
|
91
|
+
"figureContextBar.js",
|
|
92
|
+
"figureContextMenu.js",
|
|
93
|
+
"figureViewOptions.js",
|
|
94
|
+
"figureQuickEdit.js",
|
|
95
|
+
"figureLibrary.js",
|
|
96
|
+
"figureExportUi.js",
|
|
97
|
+
"figureWorkspace.js",
|
|
98
|
+
"figureSidebarController.js",
|
|
99
|
+
),
|
|
100
|
+
styles=("figure_builder.css",),
|
|
101
|
+
# Nothing required, and for the same reason ROI requires nothing: capturing
|
|
102
|
+
# a field needs the image and nothing else. A project that is one OME-TIFF
|
|
103
|
+
# can build a figure from it, which a table requirement would have silently
|
|
104
|
+
# ruled out.
|
|
105
|
+
#
|
|
106
|
+
# Nothing optional either. The inputs this plugin can make use of -- a
|
|
107
|
+
# segmentation mask, a cell table -- are already asked for by whichever
|
|
108
|
+
# tool actually draws with them; Figure Builder only records what those
|
|
109
|
+
# tools put on screen, so asking for them again here would put a form in
|
|
110
|
+
# front of a user who wants to take a picture.
|
|
111
|
+
requires=Requires(),
|
|
112
|
+
# The library is not about any one datasource, so it cannot be reached
|
|
113
|
+
# through the Tools menu -- that menu is built per-project and is empty
|
|
114
|
+
# when nothing is open, which is exactly the state a user is in when they
|
|
115
|
+
# want to reopen a figure. These two entries are the way in, and they are
|
|
116
|
+
# data: core renders them with its own classes.
|
|
117
|
+
nav_items=(
|
|
118
|
+
NavItem(menu="file", label="Open Figures…", path="/figures"),
|
|
119
|
+
NavItem(menu="open_project", label="Figures", path="/figures"),
|
|
120
|
+
),
|
|
121
|
+
# Figure Builder never colours cells. It captures whatever layer another
|
|
122
|
+
# plugin registered; claiming the layer would evict the plugin whose
|
|
123
|
+
# colours are the thing being captured.
|
|
124
|
+
owns_cell_layer=False,
|
|
125
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Server side of Figure Builder.
|
|
2
|
+
|
|
3
|
+
Split the same way ROI's is, and for the same reason: `schema` says what a
|
|
4
|
+
figure IS, `operations` says what may be done to one, `repository` says where it
|
|
5
|
+
lives and who may overwrite whom, and `routes` only translates. One place to
|
|
6
|
+
read each rule, and no route that can quietly disagree with another.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""What goes where on a page, in millimetres, independent of the output format.
|
|
2
|
+
|
|
3
|
+
One layout, several backends. The PDF writer emits these as vector objects and
|
|
4
|
+
the PNG/TIFF writer rasterises them, and because both read the same list they
|
|
5
|
+
cannot disagree about where a label sits -- which is exactly what happens when
|
|
6
|
+
each format grows its own copy of the placement arithmetic and one of them is
|
|
7
|
+
fixed.
|
|
8
|
+
|
|
9
|
+
Everything here is millimetres from the page's TOP-LEFT, which is how the
|
|
10
|
+
document stores geometry and how the canvas draws it. Turning that into
|
|
11
|
+
PDF's bottom-left origin is the PDF writer's business and nowhere else's.
|
|
12
|
+
|
|
13
|
+
Instructions are deliberately dumb -- a rectangle, a string, a swatch. Anything
|
|
14
|
+
a backend has to interpret is a decision made twice.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from plexora.plugins.figure_builder.server import textmetrics
|
|
20
|
+
|
|
21
|
+
MM_PER_INCH = 25.4
|
|
22
|
+
|
|
23
|
+
#: Padding between a panel's edge and the furniture drawn inside it.
|
|
24
|
+
INSET_MM = 1.2
|
|
25
|
+
|
|
26
|
+
#: Height of a legend row and of a scale bar, in millimetres.
|
|
27
|
+
LEGEND_ROW_MM = 3.0
|
|
28
|
+
LEGEND_SWATCH_MM = 2.0
|
|
29
|
+
SCALEBAR_HEIGHT_MM = 0.8
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def page_instructions(document, page, panels, annotations):
|
|
33
|
+
"""Everything drawn on one page, back to front.
|
|
34
|
+
|
|
35
|
+
Order is the order it is drawn in: panels first, then their furniture, then
|
|
36
|
+
annotations on top. Within panels, `placement.z` -- which is what the canvas
|
|
37
|
+
z-orders by, so a figure exports stacked the way it looked.
|
|
38
|
+
"""
|
|
39
|
+
style = document["settings"]["style"]
|
|
40
|
+
label_style = document["settings"]["label_style"]
|
|
41
|
+
instructions = []
|
|
42
|
+
|
|
43
|
+
for index, panel in enumerate(sorted(panels, key=_reading_order)):
|
|
44
|
+
place = panel["placement"]
|
|
45
|
+
instructions.append({
|
|
46
|
+
"kind": "panel", "panel_id": panel["panel_id"],
|
|
47
|
+
"x": place["x_mm"], "y": place["y_mm"],
|
|
48
|
+
"w": place["w_mm"], "h": place["h_mm"],
|
|
49
|
+
})
|
|
50
|
+
instructions.extend(_panel_furniture(document, panel, index, label_style, style))
|
|
51
|
+
|
|
52
|
+
for annotation in sorted(annotations, key=lambda a: a["z"]):
|
|
53
|
+
instructions.extend(_annotation(annotation, style))
|
|
54
|
+
|
|
55
|
+
return instructions
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _reading_order(panel):
|
|
59
|
+
place = panel["placement"]
|
|
60
|
+
# Rows before columns, matching the canvas: a reader numbers a grid
|
|
61
|
+
# left-to-right and top-to-bottom, not by when each field was captured.
|
|
62
|
+
return (place["y_mm"], place["x_mm"], place["z"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _panel_furniture(document, panel, index, label_style, style):
|
|
66
|
+
place = panel["placement"]
|
|
67
|
+
family = style["font_family"]
|
|
68
|
+
out = []
|
|
69
|
+
cursor = place["y_mm"] + INSET_MM
|
|
70
|
+
|
|
71
|
+
label = panel["label"]["text"] if not panel["label"]["auto"] else label_for(index, label_style)
|
|
72
|
+
if panel["label"]["visible"] and label:
|
|
73
|
+
out.append(_furniture(label, place["x_mm"] + INSET_MM, cursor,
|
|
74
|
+
place["w_mm"] - 2 * INSET_MM, "left",
|
|
75
|
+
style["label_size_pt"], "#ffffff", family, bold=True))
|
|
76
|
+
cursor += _pt_to_mm(style["label_size_pt"]) + 0.6
|
|
77
|
+
|
|
78
|
+
for row in legend_rows(panel):
|
|
79
|
+
out.append({
|
|
80
|
+
"kind": "swatch", "x": place["x_mm"] + INSET_MM, "y": cursor,
|
|
81
|
+
"w": LEGEND_SWATCH_MM, "h": LEGEND_SWATCH_MM,
|
|
82
|
+
"color": row.get("color", "#ffffff"), "ramp": row.get("ramp"),
|
|
83
|
+
})
|
|
84
|
+
out.append(_furniture(
|
|
85
|
+
row["label"], place["x_mm"] + INSET_MM + LEGEND_SWATCH_MM + 0.8, cursor,
|
|
86
|
+
place["w_mm"], "left", style["font_size_pt"], "#ffffff", family))
|
|
87
|
+
cursor += LEGEND_ROW_MM
|
|
88
|
+
|
|
89
|
+
bar = scale_bar(document, panel)
|
|
90
|
+
if bar:
|
|
91
|
+
width = bar["fraction"] * place["w_mm"]
|
|
92
|
+
x = place["x_mm"] + place["w_mm"] - INSET_MM - width
|
|
93
|
+
y = place["y_mm"] + place["h_mm"] - INSET_MM - SCALEBAR_HEIGHT_MM
|
|
94
|
+
out.append({"kind": "rect", "x": x, "y": y, "w": width, "h": SCALEBAR_HEIGHT_MM,
|
|
95
|
+
"fill": "#ffffff", "stroke": None, "line_width_pt": 0})
|
|
96
|
+
out.append(_furniture(
|
|
97
|
+
bar["label"], x, y - _pt_to_mm(style["font_size_pt"]) - 0.4, width, "right",
|
|
98
|
+
style["font_size_pt"], "#ffffff", family))
|
|
99
|
+
|
|
100
|
+
if panel["title"]:
|
|
101
|
+
out.append(_furniture(
|
|
102
|
+
panel["title"], place["x_mm"], place["y_mm"] + place["h_mm"] + 0.8,
|
|
103
|
+
place["w_mm"], "center", style["title_size_pt"], style["text_color"], family))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: Where one line of furniture sits below the y it is anchored to, in ems.
|
|
108
|
+
#:
|
|
109
|
+
#: Both backends used to compute this for themselves, from this same 0.8, and it
|
|
110
|
+
#: is kept to the digit rather than replaced with the font's real ascent so that
|
|
111
|
+
#: re-exporting a figure made before this change does not move its panel labels.
|
|
112
|
+
#: A text annotation is laid out properly instead -- see `_text_layout` -- but
|
|
113
|
+
#: that is new behaviour rather than a silent shift under an existing figure.
|
|
114
|
+
FURNITURE_ASCENT_EM = 0.8
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _furniture(text, x, y_top, w, align, size_pt, colour, family, bold=False):
|
|
118
|
+
"""One line of panel furniture, as a one-run text instruction.
|
|
119
|
+
|
|
120
|
+
Furniture and annotation text share the instruction so that each backend has
|
|
121
|
+
exactly one text branch. `y` is the BASELINE here as everywhere else, so the
|
|
122
|
+
cap-height offset each backend used to apply is applied once, here.
|
|
123
|
+
"""
|
|
124
|
+
return {
|
|
125
|
+
"kind": "text", "x": x, "w": w, "align": align,
|
|
126
|
+
"y": y_top + size_pt * textmetrics.MM_PER_PT * FURNITURE_ASCENT_EM,
|
|
127
|
+
"runs": [_run(text, family, size_pt, colour, bold=bold)],
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _run(text, family, size_pt, colour, bold=False, italic=False,
|
|
132
|
+
underline=False, strike=False):
|
|
133
|
+
"""A run with every key present. Backends never see an absent one."""
|
|
134
|
+
return {"text": text, "family": textmetrics.family(family), "size_pt": size_pt,
|
|
135
|
+
"bold": bold, "italic": italic, "underline": underline,
|
|
136
|
+
"strike": strike, "color": colour}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _resolved_run(run, style):
|
|
140
|
+
"""One of an annotation's runs, with the box's style filled in.
|
|
141
|
+
|
|
142
|
+
A run stores only what it OVERRIDES, so that raising the box's font size
|
|
143
|
+
still reaches every run the user never touched. Nothing downstream is
|
|
144
|
+
allowed to see that half-specified form: this is the only way a run leaves
|
|
145
|
+
this module.
|
|
146
|
+
"""
|
|
147
|
+
return _run(
|
|
148
|
+
run["text"],
|
|
149
|
+
run.get("family") or style["font_family"],
|
|
150
|
+
style["font_size_pt"] if run.get("size_pt") is None else run["size_pt"],
|
|
151
|
+
run.get("color") or style["color"],
|
|
152
|
+
bold=bool(run.get("bold")), italic=bool(run.get("italic")),
|
|
153
|
+
underline=bool(run.get("underline")), strike=bool(run.get("strike")))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _text_layout(annotation):
|
|
157
|
+
"""Every line of one text annotation, positioned. Pure arithmetic.
|
|
158
|
+
|
|
159
|
+
Where the lines BREAK is not decided here -- it cannot be, because measuring
|
|
160
|
+
a string needs a font engine and `reportlab` is optional while this module
|
|
161
|
+
is on the PNG path too. The browser breaks them and the break is stored, so
|
|
162
|
+
all this does is stack lines that already exist.
|
|
163
|
+
|
|
164
|
+
Mirrored by `FigureCanvas.textLayout`, and
|
|
165
|
+
`test_the_canvas_and_the_exporter_put_the_baseline_in_the_same_place`
|
|
166
|
+
compares the two. Returns block height and, per line, the baseline in
|
|
167
|
+
millimetres from the page top.
|
|
168
|
+
"""
|
|
169
|
+
style = annotation["style"]
|
|
170
|
+
geometry = annotation["geometry"]
|
|
171
|
+
lines = (annotation.get("rich") or {}).get("lines") or []
|
|
172
|
+
|
|
173
|
+
measured = []
|
|
174
|
+
for line in lines:
|
|
175
|
+
runs = [_resolved_run(run, style) for run in line["runs"]]
|
|
176
|
+
# An empty line still occupies one, at the box's own size: it is the
|
|
177
|
+
# blank line between two paragraphs, and collapsing it would close a gap
|
|
178
|
+
# the author put there on purpose.
|
|
179
|
+
lead, ascent, descent = textmetrics.line_metrics(
|
|
180
|
+
runs or [_run("", style["font_family"], style["font_size_pt"], style["color"])],
|
|
181
|
+
style.get("line_height", textmetrics.LINE_HEIGHT))
|
|
182
|
+
measured.append({"runs": runs, "lead": lead, "ascent": ascent,
|
|
183
|
+
"descent": descent, "hard": bool(line.get("hard", True))})
|
|
184
|
+
|
|
185
|
+
block = sum(line["lead"] for line in measured)
|
|
186
|
+
valign = style.get("valign", "top")
|
|
187
|
+
if valign == "middle":
|
|
188
|
+
top = geometry["y_mm"] + (geometry["h_mm"] - block) / 2
|
|
189
|
+
elif valign == "bottom":
|
|
190
|
+
top = geometry["y_mm"] + geometry["h_mm"] - block
|
|
191
|
+
else:
|
|
192
|
+
top = geometry["y_mm"]
|
|
193
|
+
|
|
194
|
+
out = []
|
|
195
|
+
cursor = top
|
|
196
|
+
for index, line in enumerate(measured):
|
|
197
|
+
# The line sits centred in its own box, half the leading above and half
|
|
198
|
+
# below, so a line mixing an 8 pt caption with a 6 pt superscript lands
|
|
199
|
+
# where a reader expects instead of riding the bottom of the box.
|
|
200
|
+
half_lead = (line["lead"] - (line["ascent"] + line["descent"])) / 2
|
|
201
|
+
following = measured[index + 1] if index + 1 < len(measured) else None
|
|
202
|
+
out.append({
|
|
203
|
+
"baseline_mm": cursor + half_lead + line["ascent"],
|
|
204
|
+
"lead_mm": line["lead"],
|
|
205
|
+
"runs": line["runs"],
|
|
206
|
+
# The last line of a paragraph is never justified -- stretching it
|
|
207
|
+
# to the box is what makes a two-word final line read as a mistake.
|
|
208
|
+
"last_of_paragraph": following is None or following["hard"],
|
|
209
|
+
})
|
|
210
|
+
cursor += line["lead"]
|
|
211
|
+
return {"block_h_mm": block, "lines": out}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _annotation(annotation, style):
|
|
215
|
+
geometry = annotation["geometry"]
|
|
216
|
+
own = annotation["style"]
|
|
217
|
+
common = {"x": geometry["x_mm"], "y": geometry["y_mm"],
|
|
218
|
+
"w": geometry["w_mm"], "h": geometry["h_mm"]}
|
|
219
|
+
if annotation["type"] == "text":
|
|
220
|
+
align = own["align"]
|
|
221
|
+
out = []
|
|
222
|
+
for line in _text_layout(annotation)["lines"]:
|
|
223
|
+
if not line["runs"]:
|
|
224
|
+
continue # a blank line takes height and draws nothing
|
|
225
|
+
out.append({
|
|
226
|
+
"kind": "text", "x": geometry["x_mm"], "y": line["baseline_mm"],
|
|
227
|
+
"w": geometry["w_mm"], "runs": line["runs"],
|
|
228
|
+
"align": "left" if (align == "justify" and line["last_of_paragraph"])
|
|
229
|
+
else align,
|
|
230
|
+
})
|
|
231
|
+
return _rotated(out, annotation)
|
|
232
|
+
if annotation["type"] in ("line", "arrow"):
|
|
233
|
+
return [{"kind": annotation["type"], **common,
|
|
234
|
+
"stroke": own["color"], "line_width_pt": own["line_width_pt"]}]
|
|
235
|
+
return _rotated([{"kind": annotation["type"], **common,
|
|
236
|
+
"fill": own["fill"] or None, "stroke": own["color"],
|
|
237
|
+
"line_width_pt": own["line_width_pt"]}], annotation)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _rotated(instructions, annotation):
|
|
241
|
+
"""Stamp a rotation onto instructions that need one.
|
|
242
|
+
|
|
243
|
+
Rotation is applied AROUND a finished layout and is never an input to it:
|
|
244
|
+
the lines are stacked square, and each backend then turns the whole block
|
|
245
|
+
about the box's centre. One rule, applied three times, that the arithmetic
|
|
246
|
+
above never has to know about -- and the reason a rotated caption cannot
|
|
247
|
+
break its lines differently from an upright one.
|
|
248
|
+
"""
|
|
249
|
+
rotation = annotation["geometry"].get("rotation") or 0.0
|
|
250
|
+
if not rotation:
|
|
251
|
+
return instructions
|
|
252
|
+
geometry = annotation["geometry"]
|
|
253
|
+
pivot = {"x": geometry["x_mm"] + geometry["w_mm"] / 2,
|
|
254
|
+
"y": geometry["y_mm"] + geometry["h_mm"] / 2}
|
|
255
|
+
return [{**item, "rotation": rotation, "pivot": pivot} for item in instructions]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def legend_rows(panel):
|
|
259
|
+
"""The legend a panel asks for, from what was recorded at capture time.
|
|
260
|
+
|
|
261
|
+
Never recomputed from a live plugin: the plugin may be a version on, a
|
|
262
|
+
palette on, or not installed at all, and a legend that disagrees with the
|
|
263
|
+
panel above it is worse than no legend.
|
|
264
|
+
"""
|
|
265
|
+
rows = []
|
|
266
|
+
if panel["legend"]["channels"]:
|
|
267
|
+
for channel in panel["scene"]["channels"]:
|
|
268
|
+
colour = channel["color"]
|
|
269
|
+
rows.append({
|
|
270
|
+
"label": channel["fullname_at_capture"] or channel["key"],
|
|
271
|
+
"color": "#{:02x}{:02x}{:02x}".format(colour["r"], colour["g"], colour["b"]),
|
|
272
|
+
})
|
|
273
|
+
if panel["legend"]["plugins"]:
|
|
274
|
+
for contribution in panel["scene"]["plugins"].values():
|
|
275
|
+
for entry in contribution["legend"]:
|
|
276
|
+
if entry["kind"] == "continuous":
|
|
277
|
+
low, high = (entry.get("domain") or [0, 1])[:2]
|
|
278
|
+
rows.append({"label": f"{_number(low)}–{_number(high)}",
|
|
279
|
+
"ramp": entry.get("ramp") or [], "color": "#ffffff"})
|
|
280
|
+
else:
|
|
281
|
+
rows.append({"label": entry["label"], "color": entry.get("color", "#ffffff")})
|
|
282
|
+
return rows
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def scale_bar(document, panel):
|
|
286
|
+
"""The bar's length as a fraction of the panel, and its label -- or None.
|
|
287
|
+
|
|
288
|
+
None whenever the source has no physical calibration. Nothing here invents
|
|
289
|
+
one: a scale bar drawn from an assumed pixel size is wrong and looks exactly
|
|
290
|
+
like one that is right, which is the single worst thing a figure tool can
|
|
291
|
+
produce.
|
|
292
|
+
"""
|
|
293
|
+
if not panel["scalebar"]["visible"]:
|
|
294
|
+
return None
|
|
295
|
+
source = document["sources"].get(panel["source_id"])
|
|
296
|
+
pixel_size = source and source.get("pixel_size")
|
|
297
|
+
if not pixel_size or not pixel_size.get("value"):
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
span_um = panel["scene"]["viewport"]["w"] * pixel_size["value"]
|
|
301
|
+
length = panel["scalebar"]["target_um"] or round_length(span_um)
|
|
302
|
+
if not length or length > span_um:
|
|
303
|
+
return None
|
|
304
|
+
return {"fraction": length / span_um, "label": format_microns(length), "length_um": length}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def round_length(span_um):
|
|
308
|
+
"""A round number of microns that sits comfortably inside the field.
|
|
309
|
+
|
|
310
|
+
A quarter of the width, snapped down to something a reader recognises. The
|
|
311
|
+
same sequence the canvas uses, so the preview and the export agree.
|
|
312
|
+
"""
|
|
313
|
+
if span_um <= 0:
|
|
314
|
+
return None
|
|
315
|
+
target = span_um / 4
|
|
316
|
+
best = None
|
|
317
|
+
for value in (1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500,
|
|
318
|
+
1000, 2000, 5000, 10000):
|
|
319
|
+
if value <= target:
|
|
320
|
+
best = value
|
|
321
|
+
return best
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def format_microns(value):
|
|
325
|
+
if value >= 1000:
|
|
326
|
+
text = f"{value / 1000:.1f}".rstrip("0").rstrip(".")
|
|
327
|
+
return f"{text} mm"
|
|
328
|
+
if value >= 10:
|
|
329
|
+
return f"{round(value)} µm"
|
|
330
|
+
return f"{value:.1f} µm"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def label_for(index, style="A"):
|
|
334
|
+
"""A..Z, then AA, AB -- the spreadsheet-column sequence, not plain base 26.
|
|
335
|
+
|
|
336
|
+
Position 26 is "AA" and not "BA", which is what a reader expects and what
|
|
337
|
+
plain base conversion gets wrong.
|
|
338
|
+
"""
|
|
339
|
+
if style == "A1":
|
|
340
|
+
return "A" + str(index + 1)
|
|
341
|
+
number = max(0, int(index))
|
|
342
|
+
out = ""
|
|
343
|
+
while True:
|
|
344
|
+
out = chr(65 + (number % 26)) + out
|
|
345
|
+
number = number // 26 - 1
|
|
346
|
+
if number < 0:
|
|
347
|
+
break
|
|
348
|
+
return out.lower() if style == "a" else out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def panel_pixels(width_mm, height_mm, dpi):
|
|
352
|
+
"""How many pixels a panel of this size needs at this DPI."""
|
|
353
|
+
return (max(1, int(round(width_mm / MM_PER_INCH * dpi))),
|
|
354
|
+
max(1, int(round(height_mm / MM_PER_INCH * dpi))))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _pt_to_mm(points):
|
|
358
|
+
return points * MM_PER_INCH / 72.0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _number(value):
|
|
362
|
+
magnitude = abs(value)
|
|
363
|
+
if magnitude >= 1000 or (0 < magnitude < 0.01):
|
|
364
|
+
return f"{value:.1e}"
|
|
365
|
+
return f"{round(value, 2):g}"
|