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,367 @@
1
+ """Which columns can be coloured by, and whether each is categories or numbers.
2
+
3
+ The inference lives here, on the server, for two reasons. It needs the values,
4
+ which the browser does not have until it asks for them -- and asking for a
5
+ column in order to find out whether it is worth offering defeats the point. And
6
+ it has to give the same answer to every caller: the legend's category order, the
7
+ codes in the binary payload and the colour a category gets are all derived from
8
+ one descriptor, so a second opinion computed anywhere else would silently
9
+ mislabel cells.
10
+
11
+ Three judgements are made, in increasing order of how wrong they can be:
12
+
13
+ **Categorical or continuous.** Strings, booleans and anything the source
14
+ declared as a categorical are categories. Floats are numbers. The awkward middle
15
+ is a low-cardinality integer -- `leiden` 0..12, `grade` 1..3, a 0/1 flag -- which
16
+ is stored as a number and means a category. Those are called categorical and
17
+ marked `ambiguous`, which is what puts a two-button override in the panel. Only
18
+ for those: an override control on every variable is noise on the ninety percent
19
+ that were never in doubt.
20
+
21
+ **Identifier-like.** A column with a distinct value for nearly every cell is not
22
+ a variable, it is a name. Colouring by it produces a picture with as many
23
+ colours as cells, which is indistinguishable from noise and takes a while to
24
+ draw. Those are flagged `identifier_like` and marked in the dropdown, but they
25
+ are still offered and still selectable -- "nearly every" is a heuristic, and
26
+ somebody's 40-sample cohort in a million-cell table is a real thing to want.
27
+ The flag's one hard effect is that such a column is never chosen automatically.
28
+
29
+ **Too many categories.** Between roughly thirty and a hundred, colours stop
30
+ being tellable apart but the picture is still useful with search and isolation,
31
+ so it renders with a notice. Past that it needs an explicit confirmation.
32
+
33
+ Descriptor fields, and one deliberate omission: there is no `inferred_kind`
34
+ alongside `kind`. They would always have been equal -- the server does not see
35
+ the user's overrides, which live in the plugin's own state and are applied in
36
+ the browser. What the client needs is what inference decided (`kind`) and
37
+ whether to offer the override at all (`ambiguous`), and for an ambiguous column
38
+ BOTH the category list and the numeric stats are sent, so flipping the override
39
+ is instant and needs no second request.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import math
45
+ import re
46
+
47
+ import numpy as np
48
+
49
+ #: How a missing value arrives once the values have been flattened to text.
50
+ #: One list, used both to build the category list and to encode the values, so
51
+ #: a value counted as missing in the legend is the same one drawn transparent.
52
+ MISSING_TOKENS = ("", "nan", "NaN", "NAN", "None", "NA", "N/A", "<NA>",
53
+ "null", "NULL", "NaT")
54
+
55
+ #: At or below this many distinct whole numbers, a numeric column is treated as
56
+ #: categories rather than a scale -- and flagged, since it is a guess. Chosen
57
+ #: to cover cluster labels and scores comfortably (leiden rarely exceeds ~30)
58
+ #: while leaving a genuine measurement recorded in whole units alone.
59
+ CATEGORICAL_INTEGER_LIMIT = 32
60
+
61
+ #: Above this share of distinct values, over a table big enough for the share to
62
+ #: mean anything, a column behaves like a name rather than a variable.
63
+ IDENTIFIER_UNIQUE_RATIO = 0.95
64
+ IDENTIFIER_MIN_ROWS = 1000
65
+
66
+ #: Where a legend stops being readable, and where it stops being sensible.
67
+ NOTICE_CATEGORY_COUNT = 30
68
+ CONFIRM_CATEGORY_COUNT = 100
69
+
70
+
71
+ def eligible_columns(dataset) -> list[str]:
72
+ """Every annotation column, in the source's own order.
73
+
74
+ Nothing is held back. An earlier version of this dropped the columns that
75
+ say how the table is READ -- the cell id, x, y -- on the grounds that
76
+ colouring by x draws a gradient across the slide rather than a finding. That
77
+ reasoning is sound about what is USUALLY wanted and wrong about what is
78
+ sometimes needed: a gradient across the slide is exactly how you check that
79
+ coordinates were imported the right way up, and a table's `sample_id` is a
80
+ perfectly good thing to colour by. A prefilter also cannot be argued with
81
+ from the panel, so a column it decided against simply was not there, with
82
+ nothing on screen to say why.
83
+
84
+ What replaced it is a flag, not a filter: `identifier_like` marks the
85
+ columns that will draw as many colours as cells, the dropdown says so, and
86
+ the choice stays the user's. See `_looks_like_an_identifier`.
87
+
88
+ Source order rather than sorted: a table's column order usually means
89
+ something to whoever produced it, and re-sorting makes a familiar file
90
+ unfamiliar. The dropdown searches by name for finding one in a wide table.
91
+ """
92
+ seen = set()
93
+ columns = []
94
+ for name in dataset.table.metadata_columns:
95
+ if name in seen:
96
+ continue
97
+ seen.add(name)
98
+ columns.append(name)
99
+ return columns
100
+
101
+
102
+ def describe_all(dataset) -> list[dict]:
103
+ """A descriptor per eligible column.
104
+
105
+ Cached against the datasource, so opening the dropdown on a five-million-row
106
+ table does not re-scan every column each time. `Dataset.cached` entries die
107
+ with the load generation, so a project whose data was re-read cannot be
108
+ served descriptors derived from the previous table.
109
+ """
110
+ return dataset.cached(
111
+ ("cell_explorer", "variables"),
112
+ lambda: [describe(dataset, name) for name in eligible_columns(dataset)],
113
+ )
114
+
115
+
116
+ def describe(dataset, column: str) -> dict:
117
+ """One column's descriptor. Raises KeyError if the project has no such
118
+ column."""
119
+ values = dataset.table.metadata_values(column)
120
+ return _describe_values(
121
+ column,
122
+ values.values,
123
+ declared=values.categories,
124
+ is_cell_id=bool(dataset.schema and dataset.schema.cell_id == column),
125
+ )
126
+
127
+
128
+ def find(dataset, column: str) -> dict | None:
129
+ """The descriptor for one column out of the cached set, or None.
130
+
131
+ Read from the cached list rather than recomputed, because the category order
132
+ in it is what the value codes are indexed against -- deriving it a second
133
+ time invites the two drifting apart, which is not an error anywhere, just
134
+ every cell labelled as its neighbour.
135
+ """
136
+ for descriptor in describe_all(dataset):
137
+ if descriptor["name"] == column:
138
+ return descriptor
139
+ return None
140
+
141
+
142
+ # --------------------------------------------------------------------------
143
+ # Inference
144
+ # --------------------------------------------------------------------------
145
+
146
+ def _describe_values(column, values, declared=None, is_cell_id=False) -> dict:
147
+ values = np.asarray(values)
148
+ total = int(values.size)
149
+ text, missing = as_text(values)
150
+ valid = int(total - int(missing.sum()))
151
+
152
+ descriptor = {
153
+ "name": column,
154
+ "n": total,
155
+ "n_missing": int(total - valid),
156
+ "ambiguous": False,
157
+ "identifier_like": False,
158
+ "notice": None,
159
+ }
160
+
161
+ numeric = _numeric_values(values)
162
+ distinct = int(np.unique(text[~missing]).size) if valid else 0
163
+ descriptor["n_unique"] = distinct
164
+
165
+ if _looks_like_an_identifier(column, distinct, valid, is_cell_id):
166
+ # A label, not a veto. The panel marks it and declines to choose it on
167
+ # the user's behalf; picking it deliberately works like any other
168
+ # column, which is why it is described just as fully as one.
169
+ descriptor["identifier_like"] = True
170
+
171
+ kind = _infer_kind(values, numeric, declared, distinct, valid)
172
+ # The override is offered exactly where the guess was a guess: a numeric
173
+ # column called categorical because its values are few and whole. Everything
174
+ # else was decided by something that is not in doubt -- text is labels,
175
+ # floats with a spread are a scale, a declared categorical says so itself.
176
+ ambiguous = numeric is not None and kind == "categorical" and declared is None
177
+ descriptor["kind"] = kind
178
+ descriptor["ambiguous"] = ambiguous
179
+
180
+ if kind == "categorical":
181
+ descriptor.update(_categorical_payload(text, missing, declared))
182
+ # Both halves for an ambiguous column, which costs nothing (it has at most
183
+ # CATEGORICAL_INTEGER_LIMIT categories) and makes flipping the override a
184
+ # client-side relabel of data already in hand rather than a refetch.
185
+ if kind == "continuous" or ambiguous:
186
+ descriptor["stats"] = _continuous_stats(numeric)
187
+
188
+ return descriptor
189
+
190
+
191
+ def _infer_kind(values, numeric, declared, distinct, valid) -> str:
192
+ if declared is not None:
193
+ # The source called it a categorical. Nothing derived from the values
194
+ # beats the file saying so -- that is the one signal that survives a
195
+ # column of stringified integers.
196
+ return "categorical"
197
+ if values.dtype.kind == "b":
198
+ # Two categories, not a 0-1 gradient. Rendering True/False on a
199
+ # continuous ramp gives two colours that both look like "some of the
200
+ # scale" rather than two labels.
201
+ return "categorical"
202
+ if numeric is None:
203
+ return "categorical"
204
+ if valid and _all_whole(numeric) and distinct <= CATEGORICAL_INTEGER_LIMIT:
205
+ return "categorical"
206
+ return "continuous"
207
+
208
+
209
+ def _looks_like_an_identifier(column, distinct, valid, is_cell_id) -> bool:
210
+ if is_cell_id:
211
+ return True
212
+ if valid < IDENTIFIER_MIN_ROWS:
213
+ # Below this, a high distinct ratio says nothing: forty cells with forty
214
+ # phenotypes is a small experiment, not a barcode column.
215
+ return False
216
+ return distinct / valid > IDENTIFIER_UNIQUE_RATIO
217
+
218
+
219
+ def _numeric_values(values):
220
+ """The column as float64, or None if it is not numbers.
221
+
222
+ Text is deliberately NOT parsed back into numbers here. A CSV column of
223
+ "1"/"2"/"3" is a column of labels as far as this is concerned -- and it is
224
+ already handled, because a text column is categorical, which is what those
225
+ are. Coercing would turn cluster ids into a gradient.
226
+ """
227
+ if values.dtype.kind in "iuf":
228
+ return values.astype(np.float64, copy=False)
229
+ if values.dtype.kind == "b":
230
+ return values.astype(np.float64)
231
+ return None
232
+
233
+
234
+ def _all_whole(numeric) -> bool:
235
+ finite = numeric[np.isfinite(numeric)]
236
+ return bool(finite.size) and bool(np.all(finite == np.round(finite)))
237
+
238
+
239
+ def as_text(values):
240
+ """(text, missing) for any column.
241
+
242
+ One normalization, shared with the encoder, so a value that becomes the
243
+ category "Tumor" in the legend becomes the code for "Tumor" in the payload.
244
+ Doing this twice with two rules is how a legend ends up describing a picture
245
+ it does not match.
246
+ """
247
+ values = np.asarray(values)
248
+ if values.size == 0:
249
+ return np.empty(0, dtype="<U1"), np.zeros(0, dtype=bool)
250
+
251
+ if values.dtype.kind in "fc":
252
+ numeric = values.astype(np.float64, copy=False)
253
+ missing = ~np.isfinite(numeric)
254
+ finite = numeric[~missing]
255
+ text = np.empty(values.shape, dtype=object)
256
+ if finite.size and np.all(finite == np.round(finite)):
257
+ # Whole numbers get whole-number labels. A cluster column stored as
258
+ # float otherwise reads "3.0" in the legend, which looks like a
259
+ # measurement rather than the label it is.
260
+ text[~missing] = finite.astype(np.int64).astype(str)
261
+ else:
262
+ text[~missing] = finite.astype(str)
263
+ text[missing] = ""
264
+ return text.astype(str), missing
265
+
266
+ if values.dtype.kind in "iub":
267
+ return values.astype(str), np.zeros(values.shape, dtype=bool)
268
+
269
+ # Object or string. None, pandas' NA and an empty CSV cell all survive the
270
+ # cast as text, so they are recognised by name rather than by dtype.
271
+ text = values.astype(str)
272
+ return text, np.isin(text, MISSING_TOKENS)
273
+
274
+
275
+ # --------------------------------------------------------------------------
276
+ # Payloads
277
+ # --------------------------------------------------------------------------
278
+
279
+ def _categorical_payload(text, missing, declared) -> dict:
280
+ present = text[~missing]
281
+ if present.size:
282
+ labels, counts = np.unique(present, return_counts=True)
283
+ tally = dict(zip(labels.tolist(), counts.tolist()))
284
+ else:
285
+ tally = {}
286
+
287
+ order = _category_order(tally.keys(), declared)
288
+ categories = [{"value": value, "count": tally[value]} for value in order]
289
+ n_categories = len(categories)
290
+ return {
291
+ "categories": categories,
292
+ "n_categories": n_categories,
293
+ "notice": _cardinality_notice(n_categories),
294
+ }
295
+
296
+
297
+ def _category_order(present, declared) -> list[str]:
298
+ """Legend order: what the file said, else a natural sort.
299
+
300
+ Declared levels that no cell actually has are dropped. They are real levels
301
+ in the file, but a legend row that cannot be shown or hidden and colours
302
+ nothing is a row that only takes up space -- and on a subset project (one
303
+ image out of twenty) most of the declared levels are typically absent.
304
+
305
+ A value present in the data but missing from the declared list is appended
306
+ rather than dropped, because dropping it would leave those cells silently
307
+ uncoloured.
308
+ """
309
+ present = set(present)
310
+ if declared:
311
+ known = [str(value) for value in declared if str(value) in present]
312
+ extra = sorted(present - set(known), key=natural_key)
313
+ return known + extra
314
+ return sorted(present, key=natural_key)
315
+
316
+
317
+ _NUMBER_RUN = re.compile(r"(\d+)")
318
+
319
+
320
+ def natural_key(value: str):
321
+ """Sort "Cluster 2" before "Cluster 10".
322
+
323
+ Plain lexicographic order puts 10 before 2, which makes a legend of numbered
324
+ clusters read as though the numbering were arbitrary. The tuples never
325
+ compare a number against a string: the leading 0/1 separates them first.
326
+ """
327
+ return tuple(
328
+ (1, int(part), "") if part.isdigit() else (0, 0, part.lower())
329
+ for part in _NUMBER_RUN.split(str(value)) if part != ""
330
+ )
331
+
332
+
333
+ def _cardinality_notice(n_categories):
334
+ if n_categories > CONFIRM_CATEGORY_COUNT:
335
+ return "high_cardinality"
336
+ if n_categories > NOTICE_CATEGORY_COUNT:
337
+ return "many_categories"
338
+ return None
339
+
340
+
341
+ def _continuous_stats(numeric) -> dict:
342
+ """min/max plus the robust range the display defaults to.
343
+
344
+ p01/p99 rather than the literal extremes, because one outlying cell
345
+ compresses every other cell into a few percent of the ramp -- a picture that
346
+ is technically correct and shows nothing. The true min and max are still
347
+ reported so the panel can say what was clipped away.
348
+ """
349
+ if numeric is None:
350
+ return {}
351
+ finite = numeric[np.isfinite(numeric)]
352
+ if not finite.size:
353
+ return {}
354
+ low, mid, high = (float(v) for v in np.percentile(finite, [1, 50, 99]))
355
+ if not math.isfinite(low) or not math.isfinite(high):
356
+ return {}
357
+ return {
358
+ "min": float(finite.min()),
359
+ "max": float(finite.max()),
360
+ "p01": low,
361
+ "p50": mid,
362
+ "p99": high,
363
+ # A column where every cell holds the same number has a zero-width
364
+ # scale. Said here rather than left for the client to rediscover by
365
+ # dividing by it.
366
+ "constant": bool(finite.min() == finite.max()),
367
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * cellExplorerApi.js - the four server calls, and the value cache.
3
+ *
4
+ * Two things here are load-bearing beyond "fetch some JSON".
5
+ *
6
+ * **Stale responses can never win.** Selecting a column starts a request;
7
+ * selecting another one starts a second. They can finish in either order, and
8
+ * the first finishing last would repaint the image with the previous variable's
9
+ * colours under the current variable's legend. Every request carries the
10
+ * generation it was issued at, the caller compares it on arrival, and the
11
+ * outgoing one is aborted besides. Both, not either: an abort is best-effort
12
+ * and a response already in flight still resolves.
13
+ *
14
+ * **Values are cached, so the cheap interactions stay cheap.** Palette,
15
+ * opacity, category colour, visibility and range never refetch -- they rebuild
16
+ * the lookup table from the arrays already here. Switching back to a column
17
+ * looked at a moment ago is likewise free. Bounded to eight columns, least
18
+ * recently used first, because each entry is a value per cell and a browser tab
19
+ * holding twenty of them on a five-million-cell table is a tab that stops.
20
+ */
21
+ class CellExplorerApi {
22
+
23
+ /** How many decoded columns to keep. Enough that going back and forth
24
+ * between the two or three variables somebody is comparing is instant. */
25
+ static CACHE_LIMIT = 8;
26
+
27
+ constructor(ctx) {
28
+ this.ctx = ctx;
29
+ this.datasource = ctx.datasource;
30
+ this._cache = new Map();
31
+ }
32
+
33
+ url(path, params) {
34
+ const query = new URLSearchParams({ datasource: this.datasource, ...params });
35
+ return `${this.ctx.url(`plugins/cell_explorer/api/${path}`)}?${query}`;
36
+ }
37
+
38
+ async variables(signal) {
39
+ const response = await fetch(this.url("variables"), { signal });
40
+ if (!response.ok) throw new Error(`variables: ${response.status}`);
41
+ return response.json();
42
+ }
43
+
44
+ async state(signal) {
45
+ const response = await fetch(this.url("state"), { signal });
46
+ if (response.status === 422) {
47
+ // Written by a newer Plexora. Reported rather than thrown, because
48
+ // the panel still works -- it just must not save over it.
49
+ return { ...(await response.json()), unreadable: true };
50
+ }
51
+ if (!response.ok) throw new Error(`state: ${response.status}`);
52
+ return response.json();
53
+ }
54
+
55
+ async save(revision, settings) {
56
+ const response = await fetch(
57
+ this.ctx.url("plugins/cell_explorer/api/state"),
58
+ {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ datasource: this.datasource, revision, settings }),
62
+ },
63
+ );
64
+ const payload = await response.json().catch(() => ({}));
65
+ if (response.status === 409) {
66
+ return { ...payload, conflict: true };
67
+ }
68
+ if (!response.ok) throw new Error(payload.error || `save: ${response.status}`);
69
+ return payload;
70
+ }
71
+
72
+ cacheKey(column, kind) {
73
+ return `${this.datasource}:${column}:${kind || "auto"}`;
74
+ }
75
+
76
+ /**
77
+ * One column's values, from the cache or the network.
78
+ *
79
+ * @param column the metadata column
80
+ * @param kind "categorical" | "continuous" to override the server's
81
+ * inference, or null to take it
82
+ * @param signal an AbortSignal for the caller's current generation
83
+ */
84
+ async values(column, kind, signal) {
85
+ const key = this.cacheKey(column, kind);
86
+ const cached = this._cache.get(key);
87
+ if (cached) {
88
+ // Re-inserted so it counts as recently used. A Map iterates in
89
+ // insertion order, which is what makes the eviction below LRU
90
+ // rather than first-in.
91
+ this._cache.delete(key);
92
+ this._cache.set(key, cached);
93
+ return cached;
94
+ }
95
+
96
+ const params = kind ? { column, kind } : { column };
97
+ const response = await fetch(this.url("values", params), { signal });
98
+ if (!response.ok) {
99
+ const detail = await response.json().catch(() => ({}));
100
+ throw new Error(detail.error || `values: ${response.status}`);
101
+ }
102
+
103
+ const decoded = CellExplorerApi.decode(
104
+ await response.arrayBuffer(),
105
+ response.headers.get("X-Value-Kind") || "categorical",
106
+ Number(response.headers.get("X-Cell-Count") || 0),
107
+ );
108
+
109
+ this._cache.set(key, decoded);
110
+ while (this._cache.size > CellExplorerApi.CACHE_LIMIT) {
111
+ this._cache.delete(this._cache.keys().next().value);
112
+ }
113
+ return decoded;
114
+ }
115
+
116
+ /** Drop everything. For when the table underneath has changed. */
117
+ clearCache() {
118
+ this._cache.clear();
119
+ }
120
+
121
+ /**
122
+ * Unpack one of the two record layouts (see server/values.py).
123
+ *
124
+ * categorical id uint32, code uint16 -- 6 bytes, no alignment
125
+ * continuous id uint32, value float32 -- 8 bytes, 4-byte aligned
126
+ *
127
+ * The continuous case is read as two interleaved typed-array views over the
128
+ * same buffer, which is a handful of milliseconds for a million cells. The
129
+ * categorical stride is 6, so no typed array can be laid over it and a
130
+ * DataView loop is the honest way to read it.
131
+ */
132
+ static decode(buffer, kind, count) {
133
+ if (kind === "continuous") {
134
+ const stride = 8;
135
+ const rows = count || Math.floor(buffer.byteLength / stride);
136
+ const asUint = new Uint32Array(buffer, 0, rows * 2);
137
+ const asFloat = new Float32Array(buffer, 0, rows * 2);
138
+ const ids = new Uint32Array(rows);
139
+ const values = new Float32Array(rows);
140
+ for (let i = 0; i < rows; i += 1) {
141
+ ids[i] = asUint[i * 2];
142
+ values[i] = asFloat[i * 2 + 1];
143
+ }
144
+ return { kind, ids, values, count: rows };
145
+ }
146
+
147
+ const stride = 6;
148
+ const rows = count || Math.floor(buffer.byteLength / stride);
149
+ const view = new DataView(buffer);
150
+ const ids = new Uint32Array(rows);
151
+ const codes = new Uint16Array(rows);
152
+ for (let i = 0; i < rows; i += 1) {
153
+ ids[i] = view.getUint32(i * stride, true);
154
+ codes[i] = view.getUint16(i * stride + 4, true);
155
+ }
156
+ return { kind: "categorical", ids, codes, count: rows };
157
+ }
158
+ }