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