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.
Files changed (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. 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&hellip;</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}"