glplot 0.1.8__tar.gz → 0.1.10__tar.gz

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 (208) hide show
  1. {glplot-0.1.8 → glplot-0.1.10}/.gitignore +0 -2
  2. {glplot-0.1.8 → glplot-0.1.10}/CITATION.cff +2 -0
  3. {glplot-0.1.8 → glplot-0.1.10}/PKG-INFO +5 -3
  4. {glplot-0.1.8 → glplot-0.1.10}/README.md +2 -2
  5. {glplot-0.1.8 → glplot-0.1.10}/glplot/animation.py +215 -51
  6. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/panel.py +81 -1
  7. {glplot-0.1.8 → glplot-0.1.10}/glplot/engine.py +112 -36
  8. glplot-0.1.10/glplot/gui/background.py +111 -0
  9. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/datasets.py +37 -9
  10. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/expressions.py +17 -1
  11. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/layerops.py +323 -38
  12. glplot-0.1.10/glplot/gui/mathadvise.py +397 -0
  13. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/mathops.py +1151 -75
  14. glplot-0.1.10/glplot/gui/mathops2d.py +786 -0
  15. glplot-0.1.10/glplot/gui/mathopsnd.py +405 -0
  16. glplot-0.1.10/glplot/gui/models.py +132 -0
  17. glplot-0.1.10/glplot/gui/notifications.py +73 -0
  18. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/base.py +6 -0
  19. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/data_editor.py +466 -65
  20. glplot-0.1.10/glplot/gui/panels/mathlab.py +6037 -0
  21. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/pipeline.py +202 -2
  22. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/scene.py +6 -6
  23. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/style.py +68 -9
  24. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/styles.py +25 -0
  25. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/widgets.py +688 -34
  26. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/workspace.py +153 -12
  27. {glplot-0.1.8 → glplot-0.1.10}/glplot/managers/hud.py +8 -0
  28. {glplot-0.1.8 → glplot-0.1.10}/glplot/options.py +7 -0
  29. {glplot-0.1.8 → glplot-0.1.10}/glplot/pyplot.py +565 -84
  30. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/axis.py +40 -13
  31. glplot-0.1.10/glplot/renderers/colorbar.py +476 -0
  32. glplot-0.1.10/glplot/renderers/contour_labels.py +276 -0
  33. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/scatter.py +33 -8
  34. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/anim_export.py +11 -0
  35. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/preview.py +346 -27
  36. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/shaders.py +86 -3
  37. {glplot-0.1.8 → glplot-0.1.10}/pyproject.toml +18 -3
  38. {glplot-0.1.8 → glplot-0.1.10}/tests/test_animation_api.py +19 -0
  39. {glplot-0.1.8 → glplot-0.1.10}/tests/test_animation_integration.py +179 -0
  40. glplot-0.1.10/tests/test_contour_labels.py +132 -0
  41. {glplot-0.1.8 → glplot-0.1.10}/tests/test_encodings.py +19 -0
  42. {glplot-0.1.8 → glplot-0.1.10}/tests/test_export.py +112 -0
  43. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gallery_integration.py +34 -0
  44. glplot-0.1.10/tests/test_gui_background.py +170 -0
  45. glplot-0.1.10/tests/test_gui_data_editor.py +1844 -0
  46. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_datasets.py +48 -0
  47. glplot-0.1.10/tests/test_gui_layer_kinds.py +1130 -0
  48. glplot-0.1.10/tests/test_gui_mathadvise.py +295 -0
  49. glplot-0.1.10/tests/test_gui_mathlab.py +5921 -0
  50. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_mathops.py +1236 -0
  51. glplot-0.1.10/tests/test_gui_mathops2d.py +548 -0
  52. glplot-0.1.10/tests/test_gui_mathopsnd.py +297 -0
  53. glplot-0.1.10/tests/test_gui_models.py +87 -0
  54. glplot-0.1.10/tests/test_gui_notifications.py +102 -0
  55. glplot-0.1.10/tests/test_gui_pipeline.py +572 -0
  56. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_styles.py +50 -0
  57. glplot-0.1.10/tests/test_gui_widgets.py +538 -0
  58. {glplot-0.1.8 → glplot-0.1.10}/tests/test_mpl_compat.py +375 -33
  59. {glplot-0.1.8 → glplot-0.1.10}/tests/test_outline_2d.py +1 -0
  60. {glplot-0.1.8 → glplot-0.1.10}/tests/test_plot_style_api.py +30 -0
  61. {glplot-0.1.8 → glplot-0.1.10}/tests/test_robustness.py +38 -1
  62. glplot-0.1.10/tests/test_scatter_default_size.py +163 -0
  63. glplot-0.1.8/glplot/gui/panels/mathlab.py +0 -2498
  64. glplot-0.1.8/tests/test_gui_data_editor.py +0 -837
  65. glplot-0.1.8/tests/test_gui_layer_kinds.py +0 -537
  66. glplot-0.1.8/tests/test_gui_mathlab.py +0 -1209
  67. glplot-0.1.8/tests/test_gui_pipeline.py +0 -268
  68. {glplot-0.1.8 → glplot-0.1.10}/CHANGELOG.md +0 -0
  69. {glplot-0.1.8 → glplot-0.1.10}/LICENSE +0 -0
  70. {glplot-0.1.8 → glplot-0.1.10}/glplot/__init__.py +0 -0
  71. {glplot-0.1.8 → glplot-0.1.10}/glplot/__main__.py +0 -0
  72. {glplot-0.1.8 → glplot-0.1.10}/glplot/anim/__init__.py +0 -0
  73. {glplot-0.1.8 → glplot-0.1.10}/glplot/anim/applier.py +0 -0
  74. {glplot-0.1.8 → glplot-0.1.10}/glplot/anim/primitives.py +0 -0
  75. {glplot-0.1.8 → glplot-0.1.10}/glplot/anim/processes.py +0 -0
  76. {glplot-0.1.8 → glplot-0.1.10}/glplot/backend.py +0 -0
  77. {glplot-0.1.8 → glplot-0.1.10}/glplot/controllers.py +0 -0
  78. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/__init__.py +0 -0
  79. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/camera3d.py +0 -0
  80. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/context.py +0 -0
  81. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/layers.py +0 -0
  82. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/layout.py +0 -0
  83. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/legacy.py +0 -0
  84. {glplot-0.1.8 → glplot-0.1.10}/glplot/core/timeline.py +0 -0
  85. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/__init__.py +0 -0
  86. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/actions.py +0 -0
  87. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/app.py +0 -0
  88. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/clipboard.py +0 -0
  89. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/commands.py +0 -0
  90. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/dataio.py +0 -0
  91. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/dynamics.py +0 -0
  92. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/fuzzy.py +0 -0
  93. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/generators3d.py +0 -0
  94. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/history.py +0 -0
  95. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/icons.py +0 -0
  96. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/keys.py +0 -0
  97. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/layerops3d.py +0 -0
  98. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/__init__.py +0 -0
  99. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/annotate.py +0 -0
  100. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/dynamics.py +0 -0
  101. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/functions.py +0 -0
  102. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/help.py +0 -0
  103. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/objects3d.py +0 -0
  104. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/palette.py +0 -0
  105. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/presentation.py +0 -0
  106. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/selection.py +0 -0
  107. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/timeline.py +0 -0
  108. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/panels/view3d.py +0 -0
  109. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/pipeline.py +0 -0
  110. {glplot-0.1.8 → glplot-0.1.10}/glplot/gui/theme.py +0 -0
  111. {glplot-0.1.8 → glplot-0.1.10}/glplot/managers/__init__.py +0 -0
  112. {glplot-0.1.8 → glplot-0.1.10}/glplot/managers/axis.py +0 -0
  113. {glplot-0.1.8 → glplot-0.1.10}/glplot/managers/effects.py +0 -0
  114. {glplot-0.1.8 → glplot-0.1.10}/glplot/managers/hud_state.py +0 -0
  115. {glplot-0.1.8 → glplot-0.1.10}/glplot/managers/picking.py +0 -0
  116. {glplot-0.1.8 → glplot-0.1.10}/glplot/managers/renderer_manager.py +0 -0
  117. {glplot-0.1.8 → glplot-0.1.10}/glplot/policy.py +0 -0
  118. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/__init__.py +0 -0
  119. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/axes3d.py +0 -0
  120. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/base.py +0 -0
  121. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/density.py +0 -0
  122. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/exact.py +0 -0
  123. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/fractal.py +0 -0
  124. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/geometry3d.py +0 -0
  125. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/interaction.py +0 -0
  126. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/legend.py +0 -0
  127. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/line_family.py +0 -0
  128. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/patch.py +0 -0
  129. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/polyline.py +0 -0
  130. {glplot-0.1.8 → glplot-0.1.10}/glplot/renderers/text.py +0 -0
  131. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/__init__.py +0 -0
  132. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/blending.py +0 -0
  133. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/export.py +0 -0
  134. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/gl_utils.py +0 -0
  135. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/mpl_bridge.py +0 -0
  136. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/mpl_process.py +0 -0
  137. {glplot-0.1.8 → glplot-0.1.10}/glplot/utils/scale.py +0 -0
  138. {glplot-0.1.8 → glplot-0.1.10}/tests/conftest.py +0 -0
  139. {glplot-0.1.8 → glplot-0.1.10}/tests/test_anim_applier.py +0 -0
  140. {glplot-0.1.8 → glplot-0.1.10}/tests/test_anim_export.py +0 -0
  141. {glplot-0.1.8 → glplot-0.1.10}/tests/test_anim_primitives.py +0 -0
  142. {glplot-0.1.8 → glplot-0.1.10}/tests/test_anim_processes.py +0 -0
  143. {glplot-0.1.8 → glplot-0.1.10}/tests/test_api_consistency.py +0 -0
  144. {glplot-0.1.8 → glplot-0.1.10}/tests/test_axes.py +0 -0
  145. {glplot-0.1.8 → glplot-0.1.10}/tests/test_axes3d.py +0 -0
  146. {glplot-0.1.8 → glplot-0.1.10}/tests/test_axis_margins.py +0 -0
  147. {glplot-0.1.8 → glplot-0.1.10}/tests/test_blending_3d.py +0 -0
  148. {glplot-0.1.8 → glplot-0.1.10}/tests/test_camera3d.py +0 -0
  149. {glplot-0.1.8 → glplot-0.1.10}/tests/test_camera_anisotropy.py +0 -0
  150. {glplot-0.1.8 → glplot-0.1.10}/tests/test_camera_projections.py +0 -0
  151. {glplot-0.1.8 → glplot-0.1.10}/tests/test_compositing_3d.py +0 -0
  152. {glplot-0.1.8 → glplot-0.1.10}/tests/test_compositing_api.py +0 -0
  153. {glplot-0.1.8 → glplot-0.1.10}/tests/test_dataio.py +0 -0
  154. {glplot-0.1.8 → glplot-0.1.10}/tests/test_density_tint.py +0 -0
  155. {glplot-0.1.8 → glplot-0.1.10}/tests/test_edge_cases.py +0 -0
  156. {glplot-0.1.8 → glplot-0.1.10}/tests/test_effects_post.py +0 -0
  157. {glplot-0.1.8 → glplot-0.1.10}/tests/test_engine.py +0 -0
  158. {glplot-0.1.8 → glplot-0.1.10}/tests/test_fractal.py +0 -0
  159. {glplot-0.1.8 → glplot-0.1.10}/tests/test_function_layer.py +0 -0
  160. {glplot-0.1.8 → glplot-0.1.10}/tests/test_generators3d.py +0 -0
  161. {glplot-0.1.8 → glplot-0.1.10}/tests/test_generators3d_extra.py +0 -0
  162. {glplot-0.1.8 → glplot-0.1.10}/tests/test_geometry3d.py +0 -0
  163. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_3d_panels.py +0 -0
  164. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_actions.py +0 -0
  165. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_annotate.py +0 -0
  166. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_clipboard.py +0 -0
  167. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_commands.py +0 -0
  168. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_dynamics.py +0 -0
  169. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_engine_integration.py +0 -0
  170. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_expressions.py +0 -0
  171. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_fields.py +0 -0
  172. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_fuzzy.py +0 -0
  173. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_history.py +0 -0
  174. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_import_safety.py +0 -0
  175. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_impostor_alignment.py +0 -0
  176. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_input_regressions.py +0 -0
  177. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_keyboard_capture.py +0 -0
  178. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_keys.py +0 -0
  179. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_layer_colormaps.py +0 -0
  180. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_layer_params.py +0 -0
  181. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_legend.py +0 -0
  182. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_regression_r2.py +0 -0
  183. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_scene_delete.py +0 -0
  184. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_scene_order.py +0 -0
  185. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_style_layer_editor.py +0 -0
  186. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_styles_extra.py +0 -0
  187. {glplot-0.1.8 → glplot-0.1.10}/tests/test_gui_timeline_panel.py +0 -0
  188. {glplot-0.1.8 → glplot-0.1.10}/tests/test_helpers.py +0 -0
  189. {glplot-0.1.8 → glplot-0.1.10}/tests/test_impostor_scatter_fidelity.py +0 -0
  190. {glplot-0.1.8 → glplot-0.1.10}/tests/test_layerops3d.py +0 -0
  191. {glplot-0.1.8 → glplot-0.1.10}/tests/test_layers.py +0 -0
  192. {glplot-0.1.8 → glplot-0.1.10}/tests/test_mpl_bridge.py +0 -0
  193. {glplot-0.1.8 → glplot-0.1.10}/tests/test_mpl_compat_3d.py +0 -0
  194. {glplot-0.1.8 → glplot-0.1.10}/tests/test_mpl_parity_surface.py +0 -0
  195. {glplot-0.1.8 → glplot-0.1.10}/tests/test_mpl_process.py +0 -0
  196. {glplot-0.1.8 → glplot-0.1.10}/tests/test_objects3d_preview.py +0 -0
  197. {glplot-0.1.8 → glplot-0.1.10}/tests/test_options.py +0 -0
  198. {glplot-0.1.8 → glplot-0.1.10}/tests/test_outline.py +0 -0
  199. {glplot-0.1.8 → glplot-0.1.10}/tests/test_panels.py +0 -0
  200. {glplot-0.1.8 → glplot-0.1.10}/tests/test_performance_benchmarks.py +0 -0
  201. {glplot-0.1.8 → glplot-0.1.10}/tests/test_polyline_lod.py +0 -0
  202. {glplot-0.1.8 → glplot-0.1.10}/tests/test_pyplot.py +0 -0
  203. {glplot-0.1.8 → glplot-0.1.10}/tests/test_pyplot_3d.py +0 -0
  204. {glplot-0.1.8 → glplot-0.1.10}/tests/test_pyplot_integration.py +0 -0
  205. {glplot-0.1.8 → glplot-0.1.10}/tests/test_regression.py +0 -0
  206. {glplot-0.1.8 → glplot-0.1.10}/tests/test_scatter_renderer.py +0 -0
  207. {glplot-0.1.8 → glplot-0.1.10}/tests/test_selection_model.py +0 -0
  208. {glplot-0.1.8 → glplot-0.1.10}/tests/test_timeline.py +0 -0
@@ -1,7 +1,5 @@
1
1
  # System files
2
2
  .DS_Store
3
- .vscode/
4
- .idea/
5
3
  *.swp
6
4
  *.swo
7
5
  *~
@@ -12,8 +12,10 @@ authors:
12
12
  orcid: "https://orcid.org/0009-0002-3961-1426"
13
13
  - family-names: Riccius
14
14
  given-names: Felix
15
+ orcid: "https://orcid.org/0000-0002-1331-4469"
15
16
  - family-names: Holland
16
17
  given-names: Julian
18
+ orcid: "https://orcid.org/0000-0003-0134-1790"
17
19
  - family-names: Ducci
18
20
  given-names: Gianmarco
19
21
  identifiers:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glplot
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: High-performance OpenGL plotting library for Python
5
5
  Project-URL: Homepage, https://github.com/AkarisDimitry/GLPlot
6
6
  Project-URL: Repository, https://github.com/AkarisDimitry/GLPlot
@@ -32,11 +32,13 @@ Provides-Extra: dev
32
32
  Requires-Dist: build>=1.0; extra == 'dev'
33
33
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
34
34
  Requires-Dist: pytest-mock>=3.10; extra == 'dev'
35
+ Requires-Dist: pytest-timeout>=2.1; extra == 'dev'
35
36
  Requires-Dist: pytest>=7.0; extra == 'dev'
36
37
  Requires-Dist: twine>=4.0; extra == 'dev'
37
38
  Provides-Extra: test
38
39
  Requires-Dist: pytest-cov>=4.0; extra == 'test'
39
40
  Requires-Dist: pytest-mock>=3.10; extra == 'test'
41
+ Requires-Dist: pytest-timeout>=2.1; extra == 'test'
40
42
  Requires-Dist: pytest>=7.0; extra == 'test'
41
43
  Description-Content-Type: text/markdown
42
44
 
@@ -545,7 +547,7 @@ interactive path never reads pixels back to the CPU.
545
547
 
546
548
  ![GLPlot rendering pipeline](examples/dataflow.png)
547
549
 
548
- See [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
550
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
549
551
  for the full derivation of each stage, including the density-accumulation math and the
550
552
  viewport-relative projection that keeps zoom numerically stable.
551
553
 
@@ -566,7 +568,7 @@ for GPU/environment diagnostics.
566
568
  ## Documentation
567
569
 
568
570
  - API reference: docstrings in `glplot.pyplot`, or the built docs — see [docs/README.md](docs/README.md)
569
- - Architecture: [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
571
+ - Architecture: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
570
572
  - Dev tools: [tools/README.md](tools/README.md)
571
573
  - Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) · Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
572
574
 
@@ -503,7 +503,7 @@ interactive path never reads pixels back to the CPU.
503
503
 
504
504
  ![GLPlot rendering pipeline](examples/dataflow.png)
505
505
 
506
- See [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
506
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
507
507
  for the full derivation of each stage, including the density-accumulation math and the
508
508
  viewport-relative projection that keeps zoom numerically stable.
509
509
 
@@ -524,7 +524,7 @@ for GPU/environment diagnostics.
524
524
  ## Documentation
525
525
 
526
526
  - API reference: docstrings in `glplot.pyplot`, or the built docs — see [docs/README.md](docs/README.md)
527
- - Architecture: [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
527
+ - Architecture: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
528
528
  - Dev tools: [tools/README.md](tools/README.md)
529
529
  - Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) · Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
530
530
 
@@ -33,18 +33,24 @@ How a frame is produced
33
33
  :func:`figure_to_rgb` is the whole bridge, and it has two modes:
34
34
 
35
35
  * **Live GL** — when ``fig.window`` exists, ``fig.export.savefig()`` renders the scene
36
- offscreen through the real pipeline at full quality.
37
- * **Headless** when it does not, :func:`glplot.utils.preview.render_preview` draws the
38
- same scene through matplotlib's Agg backend. This is the path that makes
39
- ``ani.save(...)`` work in CI, in a notebook, and in any script that never called
40
- ``show()``.
41
-
42
- Both render *to a PNG file* and read it back, because that is the only frame-producing
43
- interface the engine exposes; there is no "give me the pixels" entry point to call
44
- instead. The cost is one temp-file round trip per frame, which is real but is dwarfed by
45
- the render itself. Rejected: reimplementing ``ExportManager.savefig``'s ``glReadPixels``
46
- here to skip the file. It would duplicate the panel/scissor/projection logic and would go
47
- stale the first time the render pipeline changed.
36
+ offscreen through the real pipeline at full quality, to a temporary PNG that is read back
37
+ immediately after. That round trip is a deliberate trade: reimplementing
38
+ ``ExportManager.savefig``'s ``glReadPixels`` here to skip the file would duplicate the
39
+ panel/scissor/projection logic and go stale the first time the render pipeline changed
40
+ there is no "give me the pixels" entry point to call instead, and this is the one GL render
41
+ mode this module has, so paying that cost once here beats maintaining a second copy.
42
+ * **Headless** when it does not,
43
+ :func:`glplot.utils.preview.render_preview_array` draws the same scene through
44
+ matplotlib's Agg backend and hands back the rendered pixels directly, with no file
45
+ involved. This is the path that makes ``ani.save(...)`` work in CI, in a notebook, and in
46
+ any script that never called ``show()`` — the common case for a saved animation, and the
47
+ one with hundreds of frames to pay a per-frame cost on. It used to round-trip through a
48
+ PNG the same way the GL path still does; profiling a real animation loop found PIL's PNG
49
+ encoder alone cost as much as matplotlib's entire draw, so that encode plus the disk
50
+ write/read it required were pure overhead for a caller (this one) that only ever wanted
51
+ the array. :func:`glplot.utils.preview.render_preview` (the file-writing function a plain
52
+ ``savefig()`` call uses) is unchanged; :func:`render_preview_array` is a second entry
53
+ point onto the same figure-building code, not a rewrite of it — see its docstring.
48
54
 
49
55
  **The headless path is not pixel-identical to the GL path.** ``render_preview`` is a
50
56
  matplotlib re-drawing of the scene, not the GPU renderer; it approximates. An animation
@@ -59,13 +65,17 @@ Limitations, stated plainly
59
65
  changes nothing except that it does not raise. Output is identical either way — blitting
60
66
  is an optimisation in matplotlib, not a correctness flag — so ignoring it cannot make a
61
67
  saved animation wrong. It is recorded in ``ani._blit`` if you want to assert on it.
62
- * **There is no live on-screen playback.** ``GPULinePlot.run()`` owns its main loop and
63
- exposes no per-frame user hook, so an ``Animation`` cannot drive an open window the way
64
- matplotlib's timer drives a GUI canvas. Constructing an animation and calling
65
- ``plt.show()`` shows the *scene*, not the animation. :meth:`Animation.save`,
66
- :meth:`Animation.to_html5_video` and :meth:`Animation.to_jshtml` all work fully; only
67
- interactive playback does not. :meth:`Animation.pause` / :meth:`Animation.resume` set the
68
- documented flag and are otherwise inert.
68
+ * **Live on-screen playback rides the engine's frame callback, not a real timer.**
69
+ ``GPULinePlot.run()`` owns its main loop and has no toolkit timer for a
70
+ ``TimedAnimation`` to hook the way matplotlib's does but it does expose
71
+ ``add_frame_callback`` (:meth:`glplot.engine.GPULinePlot.add_frame_callback`), a per-frame
72
+ hook driven by wall-clock time, and :class:`TimedAnimation` uses exactly that to step
73
+ itself at ``interval``. A ``FuncAnimation``/``ArtistAnimation`` built against a live
74
+ GLPlot figure therefore *does* play when you call ``plt.show()`` — no ``draw_event``, no
75
+ blitting, just the same full-scene re-render :meth:`Animation.save` uses, paced in real
76
+ time instead of frame-by-frame. :meth:`Animation.pause` / :meth:`Animation.resume` stop and
77
+ restart that stepping. A real matplotlib ``Figure`` is untouched by any of this: it keeps
78
+ using its own canvas and timer, exactly as before.
69
79
  * **In-place array mutation is assumed.** A user callback that writes into
70
80
  ``layer.pts[:, 1]`` leaves no trace numpy can report, so after every callback this module
71
81
  marks every layer dirty rather than trying to detect what changed. Cheap, and the only
@@ -275,46 +285,59 @@ def figure_to_rgb(fig: Any, dpi: Optional[float] = None) -> np.ndarray:
275
285
 
276
286
  The single bridge between "a figure" and "a frame", and the only function in this
277
287
  module that knows a GLPlot engine from a matplotlib one. See the module docstring for
278
- the two GLPlot render modes and why both go through a temporary PNG.
288
+ the two GLPlot render modes, and :func:`_render_glplot_frame` for which of them still
289
+ goes through a temporary PNG and which does not.
279
290
 
280
291
  Raises :class:`RuntimeError` naming the figure type if neither renderer applies —
281
292
  passing something that is not a figure at all is otherwise diagnosed several frames
282
293
  later as a confusing Pillow error.
283
294
  """
284
- with tempfile.TemporaryDirectory(prefix="glplot-anim-") as tmpdir:
285
- target = os.path.join(tmpdir, "frame.png")
286
-
287
- if _is_mpl_figure(fig):
295
+ if _is_mpl_figure(fig):
296
+ with tempfile.TemporaryDirectory(prefix="glplot-anim-") as tmpdir:
297
+ target = os.path.join(tmpdir, "frame.png")
288
298
  fig.savefig(target, dpi=dpi, format="png")
289
- elif hasattr(fig, "scene"):
290
- _render_glplot_figure(fig, target, _dpi_to_scale(dpi))
291
- else:
292
- raise RuntimeError(
293
- f"Cannot grab a frame from {type(fig).__name__}: expected a "
294
- f"glplot.engine.GPULinePlot (what glplot.pyplot.figure() returns) or a "
295
- f"matplotlib Figure. Pass the figure object itself, not an axes or a "
296
- f"layer."
297
- )
299
+ return anim_export.normalize_frame(_read_png(target))
298
300
 
299
- return anim_export.normalize_frame(_read_png(target))
301
+ if hasattr(fig, "scene"):
302
+ return anim_export.normalize_frame(_render_glplot_frame(fig, _dpi_to_scale(dpi)))
300
303
 
304
+ raise RuntimeError(
305
+ f"Cannot grab a frame from {type(fig).__name__}: expected a "
306
+ f"glplot.engine.GPULinePlot (what glplot.pyplot.figure() returns) or a "
307
+ f"matplotlib Figure. Pass the figure object itself, not an axes or a "
308
+ f"layer."
309
+ )
301
310
 
302
- def _render_glplot_figure(fig: Any, target: str, scale: float) -> None:
303
- """Render a GLPlot engine to *target*, through GL if there is a context and Agg if not.
311
+
312
+ def _render_glplot_frame(fig: Any, scale: float) -> np.ndarray:
313
+ """Render one frame of a GLPlot engine, through GL if there is a context and Agg if not.
314
+
315
+ **Live GL** still round-trips through a temporary PNG — see the module docstring's
316
+ "Rejected: reimplementing ExportManager.savefig's glReadPixels here", unchanged reasoning
317
+ so this stays unchanged.
318
+
319
+ **Headless** does not: :func:`glplot.utils.preview.render_preview_array` hands back the
320
+ rendered pixels directly from matplotlib's Agg canvas, skipping the PNG encode and the
321
+ disk write/read a file-based caller needs. Profiled: PIL's PNG encoder alone cost as much
322
+ as matplotlib's entire draw, so for an animation's hundreds of headless frames — the
323
+ common case, since a saved animation is usually built without ever calling ``fig.run()``
324
+ — this removes roughly a third of each frame's cost with no change in what gets drawn.
304
325
 
305
326
  ``ExportManager.savefig`` prints a "Exported high-res image to ..." line on every call.
306
327
  That is fine for one interactive export and intolerable at 300 frames, so stdout is
307
- swallowed for the duration — this is the only place that print reaches, and suppressing
308
- it here beats every alternative that would require editing the engine.
328
+ swallowed for the duration of the GL branch — this is the only place that print reaches,
329
+ and suppressing it here beats every alternative that would require editing the engine.
309
330
  """
310
331
  if getattr(fig, "window", None) is not None and hasattr(fig, "export"):
311
- with contextlib.redirect_stdout(io.StringIO()):
312
- fig.export.savefig(target, scale=scale)
313
- return
332
+ with tempfile.TemporaryDirectory(prefix="glplot-anim-") as tmpdir:
333
+ target = os.path.join(tmpdir, "frame.png")
334
+ with contextlib.redirect_stdout(io.StringIO()):
335
+ fig.export.savefig(target, scale=scale)
336
+ return _read_png(target)
314
337
 
315
- from .utils.preview import render_preview
338
+ from .utils.preview import render_preview_array
316
339
 
317
- render_preview(fig, target, scale)
340
+ return render_preview_array(fig, scale)
318
341
 
319
342
 
320
343
  def _read_png(path: str) -> np.ndarray:
@@ -419,9 +442,9 @@ class _NullTimer:
419
442
 
420
443
  :class:`TimedAnimation` needs *an* event source; it does not need one that works in
421
444
  order for :meth:`Animation.save` to render every frame, because saving drives the frame
422
- sequence directly and never consults the timer. Live playback is what a real timer
423
- would buy, and live playback is unavailable for the separate reason given in the module
424
- docstring.
445
+ sequence directly and never consults the timer. Live playback does not go through this
446
+ timer either -- :meth:`TimedAnimation._start_live_playback` drives it straight off
447
+ ``fig.add_frame_callback`` instead, since that is the hook the engine actually has.
425
448
  """
426
449
 
427
450
  def __init__(self, interval: Optional[float] = None, callbacks: Any = None) -> None:
@@ -1195,9 +1218,9 @@ class Animation:
1195
1218
  """Base class for animations, with matplotlib's constructor and public methods.
1196
1219
 
1197
1220
  Subclasses supply the frame data (:meth:`new_frame_seq`) and what to do with one
1198
- (:meth:`_draw_frame`); this class supplies :meth:`save` and the HTML exports, which is
1199
- where all the real work happens given that live playback is unavailable (module
1200
- docstring).
1221
+ (:meth:`_draw_frame`); this class supplies :meth:`save` and the HTML exports. Live
1222
+ playback (:class:`TimedAnimation`'s ``fig.add_frame_callback`` hook, module docstring)
1223
+ is the other consumer of that same frame-stepping machinery.
1201
1224
  """
1202
1225
 
1203
1226
  def __init__(self, fig: Any, event_source: Any = None, blit: bool = False) -> None:
@@ -1557,10 +1580,30 @@ class TimedAnimation(Animation):
1557
1580
 
1558
1581
  ``interval`` (milliseconds per frame) is what :meth:`Animation.save` turns into ``fps``
1559
1582
  when the caller does not pass one, which is why ``FuncAnimation(..., interval=20)``
1560
- followed by a bare ``save()`` produces a 50 fps file. That conversion is the only place
1561
- ``interval`` has any effect here, since there is no live loop for it to pace.
1583
+ followed by a bare ``save()`` produces a 50 fps file.
1584
+
1585
+ It is also what makes live on-screen playback possible, and *controllable*: when ``fig``
1586
+ is a GLPlot figure (no real matplotlib canvas), :meth:`_start_live_playback` hands this
1587
+ animation's frames to ``fig.active_panel.timeline`` -- the same playhead the GUI's
1588
+ Timeline panel already has Play/Pause/Stop buttons and a scrub bar for -- instead of
1589
+ running its own independent clock. ``plt.show()`` after building a ``FuncAnimation``
1590
+ plays it, and if the Timeline panel is open, its transport controls drive this animation
1591
+ exactly as they drive a hand-keyed one. See :meth:`_start_live_playback`.
1562
1592
  """
1563
1593
 
1594
+ #: A frame sequence at most this long is materialised into a concrete list so the
1595
+ #: Timeline's scrub bar can jump to any frame directly. Past this (or for an unbounded
1596
+ #: ``frames=None`` generator), only forward playback is exact -- see
1597
+ #: :meth:`_materialize_frames`.
1598
+ _MAX_SEEKABLE_FRAMES = 100_000
1599
+
1600
+ #: Duration assigned to the panel's timeline when the frame count is unknowable
1601
+ #: (``frames=None``) or too large to materialise. Arbitrary but not consequential: it
1602
+ #: only sets how far the scrub bar's grid extends before it loops, not how long the
1603
+ #: animation itself runs, since :meth:`_start_live_playback`'s callback keeps stepping
1604
+ #: forward regardless of where in that grid the playhead sits.
1605
+ _UNKNOWN_DURATION_SECONDS = 60.0
1606
+
1564
1607
  def __init__(
1565
1608
  self,
1566
1609
  fig: Any,
@@ -1576,16 +1619,137 @@ class TimedAnimation(Animation):
1576
1619
  self._repeat_delay = repeat_delay if repeat_delay is not None else 0
1577
1620
  self._repeat = repeat
1578
1621
  self._paused = False
1622
+ self._live_timeline: Any = None
1579
1623
  if event_source is None:
1580
1624
  canvas = _canvas_for(fig)
1581
1625
  event_source = canvas.new_timer(interval=self._interval)
1582
1626
  super().__init__(fig, event_source=event_source, *args, **kwargs)
1627
+ self._start_live_playback(fig)
1583
1628
 
1584
1629
  @property
1585
1630
  def repeat(self) -> bool:
1586
1631
  """Whether the animation loops. Read-only, as in matplotlib."""
1587
1632
  return self._repeat
1588
1633
 
1634
+ def pause(self) -> None:
1635
+ """Pause the animation.
1636
+
1637
+ Proxies to ``fig.active_panel.timeline.pause()`` when live playback is bound (see
1638
+ :meth:`_start_live_playback`), so this has the same effect as pressing Pause in the
1639
+ Timeline panel. Otherwise a documented no-op, as it always was; see the module
1640
+ docstring.
1641
+ """
1642
+ self._paused = True
1643
+ if self._live_timeline is not None:
1644
+ self._live_timeline.pause()
1645
+ if self.event_source is not None:
1646
+ with contextlib.suppress(Exception):
1647
+ self.event_source.stop()
1648
+
1649
+ def resume(self) -> None:
1650
+ """Resume the animation. See :meth:`pause`; proxies to ``timeline.play()`` likewise."""
1651
+ self._paused = False
1652
+ if self._live_timeline is not None:
1653
+ self._live_timeline.play()
1654
+ if self.event_source is not None:
1655
+ with contextlib.suppress(Exception):
1656
+ self.event_source.start()
1657
+
1658
+ def _materialize_frames(self) -> Optional[List[Any]]:
1659
+ """This animation's frame data as a concrete, randomly-indexable list, or ``None``.
1660
+
1661
+ ``None`` means the Timeline can only step forward through the sequence (an
1662
+ unbounded ``frames=None`` generator, or a declared length past
1663
+ :data:`_MAX_SEEKABLE_FRAMES`) -- scrubbing backward then restarts it from frame 0
1664
+ rather than landing on the exact frame, since an arbitrary generator cannot be
1665
+ rewound. Every other case -- :class:`ArtistAnimation` (already a concrete list),
1666
+ an integer or sized ``frames=``, an explicit ``save_count`` -- seeks exactly,
1667
+ because the whole sequence is known and cheap to hold: it is usually the caller's
1668
+ own list or array already sitting in memory, and ``new_frame_seq()`` just re-reads
1669
+ it rather than duplicating anything expensive.
1670
+ """
1671
+ existing = getattr(self, "_framedata", None)
1672
+ if isinstance(existing, (list, tuple)):
1673
+ return list(existing)
1674
+ save_count = getattr(self, "_save_count", None)
1675
+ if save_count is not None and 0 < save_count <= self._MAX_SEEKABLE_FRAMES:
1676
+ return list(itertools.islice(self.new_frame_seq(), save_count))
1677
+ return None
1678
+
1679
+ def _start_live_playback(self, fig: Any) -> None:
1680
+ """Bind this animation's frames to ``fig.active_panel.timeline``'s playhead.
1681
+
1682
+ A real matplotlib ``Figure`` already has a toolkit timer driving it -- ``self._canvas``
1683
+ is the real canvas in that case (:func:`_canvas_for`), so this does nothing and that
1684
+ timer keeps doing its job unmodified. Only a GLPlot figure (``_NullCanvas``) with a
1685
+ panel to bind to gets this treatment.
1686
+
1687
+ The bound timeline is configured to this animation's own cadence (``fps = 1000 /
1688
+ interval``, ``duration`` from the frame count or :data:`_UNKNOWN_DURATION_SECONDS`
1689
+ when that is not knowable, ``loop`` from ``repeat``) and started playing -- an
1690
+ animation you just built plays immediately, same as before. From there the engine's
1691
+ own per-frame hook (``fig.add_frame_callback``, invoked *after*
1692
+ ``GPULinePlot._advance_timelines`` already moved the playhead this frame -- see
1693
+ ``engine.py``) does one thing every frame: compute which frame the playhead's
1694
+ *current* time corresponds to, and draw it if that differs from what is already on
1695
+ screen. It does not advance the clock itself. That is deliberate: Play/Pause only
1696
+ has to flip ``timeline.playing`` (which gates whether the playhead moves at all) and
1697
+ Stop/seek/step only have to move ``timeline.time`` directly (which the GUI's
1698
+ transport buttons already do) for this animation to follow suit exactly the way a
1699
+ hand-keyed one does -- there is no separate "is it playing" state here to keep in
1700
+ sync with the Timeline panel's.
1701
+ """
1702
+ register = getattr(fig, "add_frame_callback", None)
1703
+ panel = getattr(fig, "active_panel", None)
1704
+ timeline = getattr(panel, "timeline", None)
1705
+ if not callable(register) or not isinstance(self._canvas, _NullCanvas) or timeline is None:
1706
+ return
1707
+
1708
+ frames = self._materialize_frames()
1709
+ interval_s = max(float(self._interval), 1.0) / MS_PER_SECOND
1710
+ duration = (
1711
+ max(len(frames) - 1, 0) * interval_s if frames else self._UNKNOWN_DURATION_SECONDS
1712
+ )
1713
+ timeline.set_fps(1.0 / interval_s)
1714
+ if timeline.is_empty():
1715
+ # No hand-authored keyframes here yet -- the overwhelmingly common case for a
1716
+ # freshly-built animation -- so this animation may as well own the grid exactly:
1717
+ # duration matches its real length, and the scrub bar has nothing past the last
1718
+ # frame to run into. A timeline that already has keyframes on it keeps its own
1719
+ # length (never shrinks) rather than have this animation truncate them.
1720
+ timeline.set_duration(duration)
1721
+ else:
1722
+ timeline.fit_duration(minimum=duration)
1723
+ timeline.set_loop("loop" if self._repeat else "once")
1724
+ timeline.play()
1725
+ self._live_timeline = timeline
1726
+
1727
+ live_seq = {"iter": self.frame_seq}
1728
+ state = {"last_index": None}
1729
+
1730
+ def _tick(now: float) -> None:
1731
+ index = timeline.frame_index()
1732
+ if index == state["last_index"]:
1733
+ return
1734
+ if frames is not None:
1735
+ framedata = frames[min(index, len(frames) - 1)]
1736
+ else:
1737
+ if state["last_index"] is not None and index < state["last_index"]:
1738
+ live_seq["iter"] = self.new_frame_seq() # scrubbed back / looped: restart
1739
+ try:
1740
+ framedata = next(live_seq["iter"])
1741
+ except StopIteration:
1742
+ if not self._repeat:
1743
+ fig.remove_frame_callback(_tick)
1744
+ return
1745
+ live_seq["iter"] = self.new_frame_seq()
1746
+ framedata = next(live_seq["iter"])
1747
+ state["last_index"] = index
1748
+ self._draw_was_started = True
1749
+ self._draw_next_frame(framedata, blit=False)
1750
+
1751
+ register(_tick)
1752
+
1589
1753
 
1590
1754
  class FuncAnimation(TimedAnimation):
1591
1755
  """Animation built by repeatedly calling *func*. The class almost everyone uses.
@@ -18,7 +18,8 @@ origin, which is what ``glViewport`` wants).
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
- from typing import TYPE_CHECKING, Optional, Tuple
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Any, List, Optional, Tuple
22
23
 
23
24
  from ..controllers import CameraController
24
25
  from .camera3d import Axes3DOptions, Camera3D
@@ -29,6 +30,61 @@ if TYPE_CHECKING:
29
30
  from ..options import EngineOptions
30
31
 
31
32
 
33
+ @dataclass
34
+ class ColorbarSpec:
35
+ """One colorbar attached to a :class:`Panel`, drawn as a screen-space overlay.
36
+
37
+ Deliberately not a :class:`Panel` itself: any rect registered on ``fig.panels`` is
38
+ click-to-activate and pan/zoom-able (see ``GPULinePlot._panel_index_at``), so a
39
+ colorbar built that way would silently steal "current axes" the moment a script did
40
+ ``im = plt.imshow(...); plt.colorbar(); plt.title(...)`` -- the title would land on
41
+ the colorbar. A ``ColorbarSpec`` instead just describes a strip of the figure and is
42
+ drawn by :func:`glplot.renderers.colorbar.draw_colorbars`, in the same whole-window,
43
+ camera-free pixel space :meth:`GPULinePlot._draw_panel_borders` already draws in.
44
+
45
+ ``norm`` is always a concrete, bounded ``matplotlib.colors.Normalize`` instance (never
46
+ a bare scale name and never unbounded) -- the single source of truth the gradient fill,
47
+ the tick locator, and the headless ``savefig()`` reconstruction all read alike, so the
48
+ live bar and the exported PNG cannot independently drift from what was actually asked
49
+ for.
50
+ """
51
+
52
+ #: (x0, y0, w, h) in figure fractions, bottom-left origin -- same convention as
53
+ #: :attr:`Panel.rect_frac`, and the strip vacated from the host panel's own rect.
54
+ rect_frac: Tuple[float, float, float, float]
55
+ #: "vertical" for a right/left bar, "horizontal" for a top/bottom one.
56
+ orientation: str
57
+ #: Which edge of the host panel this bar sits against: "right", "left", "top" or
58
+ #: "bottom". Distinct from ``orientation`` because a caller can ask for either
59
+ #: independently, as matplotlib's own ``colorbar()`` does.
60
+ location: str
61
+ #: Colormap name, resolved (never ``None``) at ``colorbar()`` call time.
62
+ cmap: str
63
+ #: A concrete ``matplotlib.colors.Normalize`` (or subclass) instance with real
64
+ #: ``vmin``/``vmax`` already resolved.
65
+ norm: Any
66
+ #: Explicit tick positions, or ``None`` to auto-locate from ``norm``'s type.
67
+ ticks: Optional[Any] = None
68
+ #: A tick label format (matplotlib ``Formatter``, format string, or ``None`` to
69
+ #: auto-format from the locator).
70
+ format: Optional[Any] = None
71
+ #: The colorbar's own axis label (``Colorbar.set_label``), or ``None``.
72
+ label: Optional[str] = None
73
+ #: Matplotlib-parity placement knobs, stored for the headless reconstruction (which
74
+ #: rebuilds a real matplotlib colorbar and can honour them precisely); the live
75
+ #: renderer's own bar geometry does not re-derive a precise aspect-ratio width from
76
+ #: these -- see ``colorbar()``'s docstring.
77
+ fraction: float = 0.15
78
+ pad: float = 0.05
79
+ shrink: float = 1.0
80
+ aspect: float = 20.0
81
+ #: When True, the bar sits *inside* the host panel's own rect (over the plotted
82
+ #: content, near ``location``'s edge) instead of shrinking the panel to make room
83
+ #: outside it. Ticks/label flip to point inward, toward the panel's centre, since
84
+ #: there is no room outside the panel for them to point into.
85
+ inset: bool = False
86
+
87
+
32
88
  class Panel:
33
89
  """One plotting region: its scene, camera, interaction state, and window rectangle."""
34
90
 
@@ -64,6 +120,30 @@ class Panel:
64
120
  #: panel that is never animated costs one object and no frame time.
65
121
  self.timeline = Timeline()
66
122
 
123
+ #: Colorbars drawn against this panel's edges. Metadata only -- never a
124
+ #: :class:`Panel` of its own, never registered on ``fig.panels`` -- so a
125
+ #: colorbar can never become the active/pannable axes. See
126
+ #: :class:`ColorbarSpec` for why.
127
+ self.colorbars: List["ColorbarSpec"] = []
128
+
129
+ #: This panel's own axis names and axes title.
130
+ #:
131
+ #: Per panel for the same reason the cameras and scenes are: a split figure plots
132
+ #: four different quantities and has to be able to *name* them four different
133
+ #: ways. These were figure-global until they lived here, so a 2x2 grid could only
134
+ #: ever carry one x-name, one y-name and one title between all four panels --
135
+ #: whichever call ran last won, and the other three axes went unlabelled.
136
+ #:
137
+ #: The engine forwards ``xlabel``/``ylabel`` to the active panel's copy (the same
138
+ #: delegation ``scene``/``camera``/``cache`` use), so ``gplt.xlabel(...)`` keeps
139
+ #: writing "the current axes' x-name" and every existing single-panel caller is
140
+ #: unaffected. ``title`` is *not* forwarded that way -- ``engine.title`` is also
141
+ #: the GLFW window caption and is assigned in ``__init__`` before any panel
142
+ #: exists -- so ``set_title`` writes both, and readers prefer this one.
143
+ self.xlabel: str = ""
144
+ self.ylabel: str = ""
145
+ self.title: str = ""
146
+
67
147
  #: Explicit dimensionality: 2, 3, or ``None`` for "infer from the layers".
68
148
  #:
69
149
  #: None is the default and reproduces GLPlot's historical behaviour exactly — a