anyplotlib 0.1.0b1__tar.gz → 0.3.0b1__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 (250) hide show
  1. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/CHANGELOG.rst +142 -0
  2. anyplotlib-0.3.0b1/LARGE_IMAGE_WEBGPU_PLAN.md +223 -0
  3. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/PKG-INFO +1 -1
  4. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/FIGURE_ESM.md +225 -1
  5. anyplotlib-0.3.0b1/anyplotlib/_binary_frame.py +78 -0
  6. anyplotlib-0.3.0b1/anyplotlib/_electron.py +201 -0
  7. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/_repr_utils.py +119 -9
  8. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/_utils.py +49 -13
  9. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/axes/_axes.py +11 -3
  10. anyplotlib-0.3.0b1/anyplotlib/axes/_inset_axes.py +254 -0
  11. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/callbacks.py +13 -1
  12. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/figure/_figure.py +222 -8
  13. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/figure_esm.js +2283 -96
  14. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/markers.py +7 -0
  15. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plot1d/_plot1d.py +48 -24
  16. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plot2d/__init__.py +3 -2
  17. anyplotlib-0.3.0b1/anyplotlib/plot2d/_layer.py +121 -0
  18. anyplotlib-0.3.0b1/anyplotlib/plot2d/_plot2d.py +1911 -0
  19. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plot2d/_plotmesh.py +1 -1
  20. anyplotlib-0.3.0b1/anyplotlib/plot2d/_tile_backend.py +181 -0
  21. anyplotlib-0.3.0b1/anyplotlib/tests/_gpu_clim_check.cjs +51 -0
  22. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/_png_utils.py +32 -0
  23. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/conftest.py +149 -0
  24. anyplotlib-0.3.0b1/anyplotlib/tests/test_electron/test_binary_frame.py +245 -0
  25. anyplotlib-0.3.0b1/anyplotlib/tests/test_embed/test_export_png.py +777 -0
  26. anyplotlib-0.3.0b1/anyplotlib/tests/test_interactive/test_edit_chrome.py +435 -0
  27. anyplotlib-0.3.0b1/anyplotlib/tests/test_interactive/test_edit_chrome_playwright.py +842 -0
  28. anyplotlib-0.3.0b1/anyplotlib/tests/test_interactive/test_tile_parity_playwright.py +315 -0
  29. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_title.py +3 -3
  30. anyplotlib-0.3.0b1/anyplotlib/tests/test_layouts/test_inset_callout.py +636 -0
  31. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_layouts/test_visual.py +60 -1
  32. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_markers/test_marker_transforms.py +32 -0
  33. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_detail_tile.py +419 -0
  34. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_first_paint_race.py +259 -0
  35. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_gpu_image.py +98 -0
  36. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_gpu_parity_playwright.py +425 -0
  37. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot2d/test_imshow.py +65 -3
  38. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_layers.py +427 -0
  39. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_layers_playwright.py +313 -0
  40. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot2d/test_plot2d_api.py +15 -0
  41. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_rapid_frame_update.py +160 -0
  42. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_tile_backend.py +99 -0
  43. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_tiled_imshow.py +386 -0
  44. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d/test_zoom_view_write.py +76 -0
  45. anyplotlib-0.3.0b1/anyplotlib/tests/test_plot3d/__init__.py +0 -0
  46. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/widgets/__init__.py +2 -2
  47. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/widgets/_widgets2d.py +69 -12
  48. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/_root/index.html +3 -3
  49. anyplotlib-0.3.0b1/docs/_root/switcher.json +22 -0
  50. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/conf.py +1 -1
  51. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/pyproject.toml +1 -1
  52. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/upcoming_changes/README.rst +7 -1
  53. anyplotlib-0.1.0b1/anyplotlib/_electron.py +0 -74
  54. anyplotlib-0.1.0b1/anyplotlib/axes/_inset_axes.py +0 -126
  55. anyplotlib-0.1.0b1/anyplotlib/plot2d/_plot2d.py +0 -726
  56. anyplotlib-0.1.0b1/docs/_root/switcher.json +0 -12
  57. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/.github/workflows/ci.yml +0 -0
  58. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/.github/workflows/docs.yml +0 -0
  59. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/.github/workflows/prepare_release.yml +0 -0
  60. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/.github/workflows/release.yml +0 -0
  61. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/.github/workflows/tests.yml +0 -0
  62. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/.gitignore +0 -0
  63. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/AGENTS.md +0 -0
  64. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Benchmarks/README.rst +0 -0
  65. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Benchmarks/plot_benchmark_comparison.py +0 -0
  66. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/README.rst +0 -0
  67. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_3d_spectral_viewer.py +0 -0
  68. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_eels_explorer.py +0 -0
  69. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_interactive_fft.py +0 -0
  70. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_interactive_fitting.py +0 -0
  71. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_ipf_density_map.py +0 -0
  72. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_ipf_explorer.py +0 -0
  73. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_key_bindings.py +0 -0
  74. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_particle_picker.py +0 -0
  75. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_point_widget.py +0 -0
  76. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_segment_by_contrast.py +0 -0
  77. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_segment_by_contrast_advanced.py +0 -0
  78. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_spectra_roi_inspector.py +0 -0
  79. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_threshold_explorer.py +0 -0
  80. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Interactive/plot_voxel_grain_explorer.py +0 -0
  81. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/README.rst +0 -0
  82. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_arrows.py +0 -0
  83. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_circles.py +0 -0
  84. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_ellipses.py +0 -0
  85. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_horizontal_lines.py +0 -0
  86. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_line_segments.py +0 -0
  87. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_points.py +0 -0
  88. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_polygons.py +0 -0
  89. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_rectangles.py +0 -0
  90. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_squares.py +0 -0
  91. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_texts.py +0 -0
  92. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Markers/plot_vertical_lines.py +0 -0
  93. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/README.rst +0 -0
  94. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_3d.py +0 -0
  95. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_bar.py +0 -0
  96. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_gridspec_custom.py +0 -0
  97. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_image2d.py +0 -0
  98. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_inset.py +0 -0
  99. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_label_formatting.py +0 -0
  100. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_line_styles.py +0 -0
  101. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_pcolormesh.py +0 -0
  102. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/PlotTypes/plot_spectra1d.py +0 -0
  103. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/README.rst +0 -0
  104. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/README.rst +0 -0
  105. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget1d_hline.py +0 -0
  106. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget1d_range.py +0 -0
  107. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget1d_vline.py +0 -0
  108. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget2d_annular.py +0 -0
  109. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget2d_circle.py +0 -0
  110. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget2d_crosshair.py +0 -0
  111. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget2d_label.py +0 -0
  112. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget2d_polygon.py +0 -0
  113. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Examples/Widgets/plot_widget2d_rectangle.py +0 -0
  114. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/LICENSE +0 -0
  115. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/Makefile +0 -0
  116. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/ORIX_BACKEND_PLAN.md +0 -0
  117. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/README.md +0 -0
  118. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/RELEASE_NOTES.md +0 -0
  119. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/RELEASE_PLAN.md +0 -0
  120. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/WEBGPU_PLAN.md +0 -0
  121. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/__init__.py +0 -0
  122. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/_base_plot.py +0 -0
  123. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/axes/__init__.py +0 -0
  124. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/conftest.py +0 -0
  125. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/embed.py +0 -0
  126. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/figure/__init__.py +0 -0
  127. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/figure/_gridspec.py +0 -0
  128. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/figure/_subplots.py +0 -0
  129. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plot1d/__init__.py +0 -0
  130. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plot1d/_plotbar.py +0 -0
  131. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plot3d/__init__.py +0 -0
  132. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plot3d/_plot3d.py +0 -0
  133. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plotxy/__init__.py +0 -0
  134. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/plotxy/_plotxy.py +0 -0
  135. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/__init__.py +0 -0
  136. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/_directive.py +0 -0
  137. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/_repr_utils.py +0 -0
  138. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/_scraper.py +0 -0
  139. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/_wheel_builder.py +0 -0
  140. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +0 -0
  141. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +0 -0
  142. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/tests/__init__.py +0 -0
  143. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/tests/conftest.py +0 -0
  144. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/tests/test_directive.py +0 -0
  145. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/tests/test_init.py +0 -0
  146. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/tests/test_repr_utils.py +0 -0
  147. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/tests/test_scraper.py +0 -0
  148. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py +0 -0
  149. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/__init__.py +0 -0
  150. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/bar_basic.png +0 -0
  151. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png +0 -0
  152. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png +0 -0
  153. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png +0 -0
  154. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/gridspec_image_two_spectra.png +0 -0
  155. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/gridspec_side_by_side_1d.png +0 -0
  156. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/gridspec_spanning_top_two_bottom.png +0 -0
  157. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/imshow_axis_off.png +0 -0
  158. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/imshow_checkerboard.png +0 -0
  159. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/imshow_gradient.png +0 -0
  160. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/imshow_labels.png +0 -0
  161. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/imshow_viridis.png +0 -0
  162. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/inset_1d.png +0 -0
  163. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/inset_maximized.png +0 -0
  164. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/inset_minimized.png +0 -0
  165. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/inset_normal_2d.png +0 -0
  166. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/inset_stacked.png +0 -0
  167. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/inset_stacked_one_minimized.png +0 -0
  168. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/pcolormesh_uniform.png +0 -0
  169. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_all_linestyles.png +0 -0
  170. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_alpha.png +0 -0
  171. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_axis_off.png +0 -0
  172. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_dashed.png +0 -0
  173. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_marker_symbols.png +0 -0
  174. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_markers.png +0 -0
  175. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_multi.png +0 -0
  176. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_sine.png +0 -0
  177. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot1d_title.png +0 -0
  178. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/plot3d_surface.png +0 -0
  179. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/baselines/subplots_2x1.png +0 -0
  180. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_benchmarks/__init__.py +0 -0
  181. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_benchmarks/baselines.json +0 -0
  182. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_benchmarks/benchmarks/baselines.json +0 -0
  183. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_benchmarks/test_benchmarks.py +0 -0
  184. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_benchmarks/test_benchmarks_py.py +0 -0
  185. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_documentation/__init__.py +0 -0
  186. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_documentation/test_bridge.py +0 -0
  187. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_documentation/test_push_hook.py +0 -0
  188. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_documentation/test_scraper.py +0 -0
  189. {anyplotlib-0.1.0b1/anyplotlib/tests/test_embed → anyplotlib-0.3.0b1/anyplotlib/tests/test_electron}/__init__.py +0 -0
  190. {anyplotlib-0.1.0b1/anyplotlib/tests/test_examples → anyplotlib-0.3.0b1/anyplotlib/tests/test_embed}/__init__.py +0 -0
  191. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_embed/test_embed_api.py +0 -0
  192. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_embed/test_embed_mount.py +0 -0
  193. {anyplotlib-0.1.0b1/anyplotlib/tests/test_interactive → anyplotlib-0.3.0b1/anyplotlib/tests/test_examples}/__init__.py +0 -0
  194. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_examples/test_interactive_examples.py +0 -0
  195. {anyplotlib-0.1.0b1/anyplotlib/tests/test_labels → anyplotlib-0.3.0b1/anyplotlib/tests/test_interactive}/__init__.py +0 -0
  196. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/_event_test_utils.py +0 -0
  197. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_blit_audit.py +0 -0
  198. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_callbacks_playwright.py +0 -0
  199. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_callbacks_unit.py +0 -0
  200. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_event_pause_hold.py +0 -0
  201. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_event_plots.py +0 -0
  202. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_event_settled.py +0 -0
  203. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_events_regression.py +0 -0
  204. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_touch.py +0 -0
  205. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_interactive/test_widgets.py +0 -0
  206. {anyplotlib-0.1.0b1/anyplotlib/tests/test_layouts → anyplotlib-0.3.0b1/anyplotlib/tests/test_labels}/__init__.py +0 -0
  207. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_labels/test_label_api.py +0 -0
  208. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_labels/test_label_rendering.py +0 -0
  209. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_labels/test_no_clipping.py +0 -0
  210. {anyplotlib-0.1.0b1/anyplotlib/tests/test_markers → anyplotlib-0.3.0b1/anyplotlib/tests/test_layouts}/__init__.py +0 -0
  211. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_layouts/test_batch.py +0 -0
  212. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_layouts/test_geom_channel.py +0 -0
  213. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_layouts/test_gridspec.py +0 -0
  214. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_layouts/test_inset.py +0 -0
  215. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_layouts/test_interaction.py +0 -0
  216. {anyplotlib-0.1.0b1/anyplotlib/tests/test_plot1d → anyplotlib-0.3.0b1/anyplotlib/tests/test_markers}/__init__.py +0 -0
  217. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_markers/test_markers.py +0 -0
  218. {anyplotlib-0.1.0b1/anyplotlib/tests/test_plot2d → anyplotlib-0.3.0b1/anyplotlib/tests/test_plot1d}/__init__.py +0 -0
  219. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot1d/test_plot1d.py +0 -0
  220. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot1d/test_plotbar.py +0 -0
  221. {anyplotlib-0.1.0b1/anyplotlib/tests/test_plot3d → anyplotlib-0.3.0b1/anyplotlib/tests/test_plot2d}/__init__.py +0 -0
  222. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot2d/test_imshow_rgb.py +0 -0
  223. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot2d/test_pcolormesh.py +0 -0
  224. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot3d/test_colors_highlight.py +0 -0
  225. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot3d/test_gpu_fallback.py +0 -0
  226. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot3d/test_plot3d.py +0 -0
  227. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plot3d/test_voxels_planes.py +0 -0
  228. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/tests/test_plotxy/test_plotxy.py +0 -0
  229. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/widgets/_base.py +0 -0
  230. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/widgets/_widgets1d.py +0 -0
  231. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/anyplotlib/widgets/_widgets3d.py +0 -0
  232. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/_sg_html_scraper.py +0 -0
  233. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/_static/anyplotlib.svg +0 -0
  234. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/_static/custom.css +0 -0
  235. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/_static/pyodide_bridge.js +0 -0
  236. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/_templates/autosummary/class.rst +0 -0
  237. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/api/callbacks.rst +0 -0
  238. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/api/figure.rst +0 -0
  239. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/api/figure_plots.rst +0 -0
  240. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/api/index.rst +0 -0
  241. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/api/markers.rst +0 -0
  242. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/api/widgets.rst +0 -0
  243. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/benchmarking.rst +0 -0
  244. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/dev/index.rst +0 -0
  245. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/embedding.rst +0 -0
  246. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/events.rst +0 -0
  247. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/getting_started.rst +0 -0
  248. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/docs/index.rst +0 -0
  249. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/make.bat +0 -0
  250. {anyplotlib-0.1.0b1 → anyplotlib-0.3.0b1}/uv.lock +0 -0
@@ -10,6 +10,148 @@ Fragment files in ``upcoming_changes/`` are assembled into this file by
10
10
 
11
11
  .. towncrier release notes start
12
12
 
13
+ 0.3.0b1 (2026-07-13)
14
+ ====================
15
+
16
+ New Features
17
+ ------------
18
+
19
+ - Added :class:`~anyplotlib.widgets.ArrowWidget` (draggable arrow overlay, tail
20
+ at ``(x, y)`` and head at ``(x + u, y + v)``) via ``Plot2D.add_arrow_widget`` /
21
+ ``add_widget("arrow")``, and a ``show_handles`` option (default ``True``) on
22
+ every 2-D overlay widget to hide the grab-handle dots without affecting drag.
23
+ - Added figure-level edit-mode chrome to :class:`~anyplotlib.Figure`: the
24
+ ``edit_chrome`` and ``selected_panel`` traits (per-panel hover / selection
25
+ outlines), figure-background click events, and a figure-level annotation layer
26
+ (``set_figure_markers`` / ``figure_markers``, positioned in figure fractions and
27
+ always included in ``exportPNG``) with figure-level callbacks via
28
+ ``add_event_handler``.
29
+ - Extended :class:`~anyplotlib.Figure` edit-mode interaction:
30
+
31
+ * Circle and rectangle overlay widgets are now **resizable via visible nodes** —
32
+ a circle draws a centre (move) node and an east-point radius node; a rectangle
33
+ draws all four corner nodes (opposite corner anchored on drag). Drawn only
34
+ when ``show_handles`` is ``True``, with matching resize cursors.
35
+ * :class:`~anyplotlib.widgets.ArrowWidget` **tail is now a reshape node**:
36
+ dragging the tail moves it while the head stays anchored (dragging the shaft
37
+ still moves the whole arrow; the head node still re-aims it).
38
+ * The selected-panel and hover **outlines are fully inset** (``outline-offset:
39
+ -2px``) so an edge/corner panel's ring is no longer clipped at the figure's
40
+ right/bottom edge.
41
+ * **Panel drag-swap** under ``edit_chrome``: each grid panel shows a move grip
42
+ in its top-left corner; dragging it over a *different* panel emits a
43
+ figure-level ``pointer_up`` event with ``panel_swap: true`` and
44
+ ``source_panel_id`` / ``target_panel_id`` (new :class:`~anyplotlib.Event`
45
+ fields). anyplotlib performs no layout change itself — the host swaps and
46
+ rebuilds. Releasing on the source panel or empty space cancels cleanly; the
47
+ grip is inert when ``edit_chrome`` is off.
48
+ * The JS ``mount()`` embedding entry point accepts an ``onResize({width,
49
+ height})`` callback, fired (debounced) when the **root container resizes**, so
50
+ an embedding host can relayout the figure to its new box.
51
+
52
+
53
+ 0.2.0 (2026-07-10)
54
+ ==================
55
+
56
+ New Features
57
+ ------------
58
+
59
+ - 2-D scalar images can now render on the **GPU via WebGPU** (``imshow(...,
60
+ gpu="auto"|True|False)``): the image uploads as an R8 texture and a WGSL fragment
61
+ shader applies the colormap LUT + contrast (clim) in one draw, replacing the
62
+ per-pixel JavaScript colormap loop. Large images (≳1 megapixel) take the GPU path
63
+ automatically; everything below the threshold, RGB images, ``gpu=False``, and any
64
+ device without WebGPU keep the identical Canvas2D path. ``plot.gpu_active`` reports
65
+ which path ran. Verified on an NVIDIA Pascal GPU.
66
+ - Arbitrarily large images can now display through **tile mode**
67
+ (``imshow(..., tile="auto")``): the figure shows a downsampled overview as its
68
+ base and, after each zoom/pan settles, samples a high-resolution detail tile of
69
+ just the visible region at panel resolution — deep zooms stay crisp without
70
+ ever shipping the full-resolution frame. ``Plot2D.enable_tile`` /
71
+ ``update_tile_source`` swap the underlying frame while the zoom and
72
+ subselection persist (live-data contract), and a pluggable ``TileBackend``
73
+ (default: a fast vectorised numpy box-mean) lets out-of-core or GPU sources
74
+ own the sampling.
75
+ - Markers gained a ``clip_display`` option controlling whether they draw
76
+ outside the current axes view or are clipped to it.
77
+ - ``set_extent`` now updates the axes state (calibrated units / scale bar), so
78
+ applying a calibrated extent after figure creation renders labelled axes
79
+ instead of bare pixels.
80
+ - Regular ``pcolormesh`` meshes are detected and rasterized to an image for
81
+ display (fast IPF-style heatmaps) instead of drawing per-quad.
82
+
83
+
84
+ Bug Fixes
85
+ ---------
86
+
87
+ - Fixed a display freeze under the Electron binary pixel transport: the routing
88
+ layer stripped the pixel key out of the slimmed geom JSON, so the renderer's
89
+ "unchanged → skip re-upload" caches (Canvas2D blit cache, WebGPU texture,
90
+ overlay-mask cache) fell back to a 4-sampled-byte fingerprint of the buffer —
91
+ two frames differing anywhere else collided and the display stayed frozen on
92
+ the old frame (seen as a stale overview after a movie scrub). The slimmed geom
93
+ now carries a small ``\x00bin:<checksum>`` content token under the pixel key,
94
+ binary buffers are additionally stamped with an arrival sequence as a fallback
95
+ key, and the overlay-mask draw path now reads the binary byte side-channel
96
+ (it previously only decoded base64, so masks never displayed over the binary
97
+ transport).
98
+ - Fixed the WebGPU 2-D image path sampling a vertically MIRRORED window when the
99
+ view was panned off-centre: the shader applied a global ``1 - v`` flip after
100
+ interpolating the ``[v0, v1]`` uv window, which sampled ``[1-v1, 1-v0]``
101
+ instead — correct only for a full or vertically-centred view. Symptoms: pan-y
102
+ moved the image the wrong way on GPU-rendered panels, and markers/widgets
103
+ (drawn by the shared Canvas2D overlay transform, which was always correct)
104
+ appeared detached from the image features they marked. The base and
105
+ detail-tile passes share the shader, so both are fixed. GPU-vs-CPU screenshot
106
+ parity tests (zoom, pan, markers, widgets, detail tile) now run on real WebGPU
107
+ in headless Chromium (``channel="chromium"`` + ``--enable-unsafe-webgpu``) and
108
+ skip on machines with no adapter.
109
+ - ``Plot2D.set_data`` no longer makes a float64 copy of every incoming frame.
110
+ The float64 cast now happens lazily in the ``.data`` property (the only reader),
111
+ so a frame stream — e.g. scrubbing an in-situ movie — keeps the source dtype and
112
+ skips a ~12 ms float64 copy of a 4k frame per tick. ``.data`` still returns a
113
+ read-only float64 copy, unchanged for callers.
114
+ - The Electron binary pixel transport now ships the RAW uint8 image bytes end to
115
+ end, instead of base64-encoding them in ``set_data`` only to base64-decode them
116
+ straight back in the routing layer. ``Plot2D.set_data`` stashes the raw bytes on
117
+ the Figure's ``_raw_pixels`` side-table and leaves a tiny content-checksum
118
+ change-token in ``image_b64``; ``_electron._route_change`` ships those bytes to a
119
+ PLOTBIN frame directly. This removes the ~20 ms base64 encode, the ~17 ms decode,
120
+ and the megabyte ``json.dumps`` of the pixel string from every scrub frame — a
121
+ 2.2x faster ``set_data`` (≈98 ms → ≈44 ms on a 2048² frame) and ~25% less
122
+ bytes-on-wire. Non-Electron hosts (Jupyter / Pyodide / standalone / ``save_html``)
123
+ are unchanged: they have no PLOTBIN channel, so the token is resolved back to
124
+ inline base64 via ``Plot2D.resolve_pixel_tokens`` when the figure state is
125
+ serialised for them.
126
+ - Tile mode: a data update while zoomed in (``update_tile_source`` with a detail
127
+ tile shown) refreshes only the detail tile, leaving the overview base on the
128
+ old frame — zooming out then flashed the pre-update frame. The skipped
129
+ overview is now marked stale and re-sampled once on the next view settle
130
+ (riding the same push as the detail/clear), preserving the per-frame skip
131
+ optimisation while never exposing stale base pixels.
132
+ - Interactive zoom/pan on a 2-D image no longer re-serialises (and re-transmits)
133
+ the full image on every mouse tick. The wheel/pan/orbit handlers write only the
134
+ light *view* state back to the ``panel_<id>_json`` trait now, excluding the
135
+ cached geometry (pixels, colormap LUT) that ``_applyGeom`` splices into the panel
136
+ state for drawing. Previously the whole frame was ``JSON.stringify``-d per tick —
137
+ catastrophically so on the binary transport, where the pixel buffer is a
138
+ ``Uint8Array`` that stringifies to a ``{"0":..,"1":..}`` object with one key per
139
+ byte — which stalled zoom on large images.
140
+ - Fixed a first-paint race under the Electron binary transport: binary
141
+ side-table bytes that arrived before ``render()`` attached its listeners were
142
+ stranded, leaving the first frame blank until the next update — they are now
143
+ spliced into the initial paint.
144
+
145
+
146
+ Maintenance
147
+ -----------
148
+
149
+ - ``anyplotlib.__version__`` is now exposed from the package metadata.
150
+ - Per-frame hot-path costs trimmed: the colormap LUT is cached instead of being
151
+ rebuilt every frame (~100 ms), and small-range data rescales in float32
152
+ (~60 ms → ~27 ms per 2048² frame).
153
+
154
+
13
155
  0.1.0 (2026-06-24)
14
156
  ==================
15
157
 
@@ -0,0 +1,223 @@
1
+ # Large-image WebGPU 2D rendering + binary transport — scoping document
2
+
3
+ Status: **Phase 0 + core Phase 1/2 DONE, hardware-verified** (2026-07-06). Branch:
4
+ `feat/webgpu-2d-images`. Owner: @CSSFrancis
5
+
6
+ ## DONE (verified on an NVIDIA Pascal GPU via the SpyDE Electron consumer)
7
+ - **WebGPU 2-D image render path**: a `gpuCanvas` below `plotCanvas`; the normalized
8
+ uint8 frame → R8 texture, the 256-entry colormap → a 256×1 RGBA LUT texture, a
9
+ fullscreen-quad WGSL fragment shader re-stretches by clim (dmin/dmax uniform) and
10
+ samples the LUT. Replaces the 64M-iteration Canvas2D atob+LUT loop with one GPU draw.
11
+ - **Reuses the 3D contract**: the `_gpuDevice()` singleton, first-frame-canvas-then-
12
+ async-swap, `device.lost` → permanent Canvas2D fallback (extended to 2-D panels).
13
+ - **API**: `imshow(..., gpu="auto"|True|False)` → `gpu_mode`; `GPU_IMAGE_THRESHOLD`
14
+ (~1 Mpx) auto-gate; `plot.gpu_active` echo via the `gpu_status` event.
15
+ - **Fallback intact**: RGB images, sub-threshold images, `gpu=False`, and no-device
16
+ all render on Canvas2D (anyplotlib suite green; the DOM keeps `plotCanvas` first so
17
+ `querySelector('canvas')` still resolves the image canvas).
18
+ - **Correctness (review-hardened)**: the shader is a plain IDENTITY LUT lookup —
19
+ `_buildLut32` already bakes clim + scale_mode into the LUT, so the shader must NOT
20
+ re-apply the window (an earlier version double-applied clim; correct only at
21
+ full-range, wrong for any narrowed contrast/log/symlog — fixed). A zoomed/panned
22
+ view falls back to Canvas2D so the base image stays registered with the axes/
23
+ overlays (the GPU quad is full-extent). Nearest sampling on both textures matches
24
+ Canvas2D's `imageSmoothingEnabled=false` pixel-for-pixel.
25
+ - **Zoom/pan v-window fix + headless GPU parity tests (2026-07-09)**: the shader
26
+ applied a global `1 - v` flip AFTER interpolating the `[v0,v1]` window, sampling
27
+ the MIRRORED window `[1-v1, 1-v0]` — correct only when v0+v1==1 (rest / centred
28
+ zoom), so it survived the readback tests but inverted pan-y and detached the
29
+ image from markers/overlays on any vertically off-centre view (base AND detail
30
+ passes; fixed by interpolating v from v1 at the screen bottom to v0 at the top).
31
+ Guarded by `tests/test_plot2d/test_gpu_parity_playwright.py`: GPU-vs-Canvas2D
32
+ PNG parity (rest/zoom/pan/markers/widgets/detail-tile) on REAL WebGPU in
33
+ headless Chromium — `channel="chromium"` (the full build; the default headless
34
+ shell has no `navigator.gpu`) + `--enable-unsafe-webgpu`, skipped when no
35
+ adapter. NB `page.screenshot()` DOES capture the WebGPU canvas there; the
36
+ "swapchain reads black" caveat below applies to Electron offscreen capture.
37
+ - **Verification**: `__apl_gpuReadback` renders the active panel to an OFFSCREEN
38
+ texture (the live swapchain reads black under automation) and copies it to CPU.
39
+ On a real 4k movie frame: min 0 / max 255 / 96% non-black / correct gray values.
40
+ A **narrowed clim [0.3,0.7]** matches the numpy windowed-colormap to meanDiff
41
+ 0.65/255 (regression guard for the double-apply bug; `tests/_gpu_clim_check.cjs`).
42
+ Movie scrub + playback re-upload the texture per frame and stay correct (5/5
43
+ scrub, 5 distinct played frames). GPU resources are freed on panel close / figure
44
+ dispose / device loss (no leak on repeated large-image open/close).
45
+
46
+ ## DEFERRED (documented; not blockers now)
47
+ - **Mipmaps** (smooth downscale-on-zoom): the sampler uses `minFilter:'linear'`
48
+ without a mip chain. Marginal here because LOD already caps the uploaded texture
49
+ near display size; matters for deep zoom-out. Needs an R8 render-based mip chain.
50
+ - **Binary pixel transport** (base64-in-JSON → binary buffer): the Phase-0 headline,
51
+ but LOD decimation already cut the shipped payload ~35× (≤1536 px, ~2 MB not
52
+ ~85 MB) and the GPU shader removed the render cost, so this is now an incremental
53
+ transport optimization spanning Jupyter/Pyodide/standalone/Electron — do it with
54
+ the multi-environment verification it needs.
55
+
56
+ Prerequisite reading: `WEBGPU_PLAN.md` (the 3D points/voxels WebGPU path this extends),
57
+ `anyplotlib/FIGURE_ESM.md` (the `figure_esm.js` section map), `AGENTS.md` (repo conventions).
58
+ Prerequisite reading: `WEBGPU_PLAN.md` (the 3D points/voxels WebGPU path this extends),
59
+ `anyplotlib/FIGURE_ESM.md` (the `figure_esm.js` section map), `AGENTS.md` (repo conventions).
60
+
61
+ This extends the repo's existing hardware-verified `WEBGPU_PLAN.md` (3D instanced points +
62
+ voxels) to **2D large images**, deliberately lifting that doc's "No 2D pipeline changes"
63
+ non-goal (§2). Motivating consumer: a SpyDE in-situ movie viewer that must scrub/play through
64
+ 8k×8k image frames smoothly.
65
+
66
+ ## 1. Goal
67
+
68
+ Render **large 2D image frames** (up to 8k×8k) interactively — smooth scrub/playback of a
69
+ frame stream and smooth zoom — by moving the 2D image path from Canvas2D to **WebGPU** (texture
70
+ upload + WGSL colormap LUT + mipmap downscale) and moving the pixel bytes off **base64-in-JSON**
71
+ onto a **binary transport**.
72
+
73
+ | Workload | Today (Canvas2D) | Target (WebGPU) |
74
+ |---|---|---|
75
+ | 8k×8k frame colormap+draw | ~64M-iter JS LUT loop → OffscreenCanvas | shader LUT, **<5 ms** |
76
+ | Scrub (new frame/tick) | ~85 MB base64/frame + full rebuild | binary uint8 + texture upload, **≥15–30 fps** |
77
+ | Zoom-in on a still | re-blit from OffscreenCanvas | GPU **mipmap**, **60 fps** |
78
+
79
+ ## 2. Non-goals
80
+
81
+ - **Not** replacing Canvas2D for images — it stays the universal baseline, the fallback, the
82
+ small-image path, and the fully-CI-tested path. Default behaviour for small images is
83
+ byte-identical to today.
84
+ - No WebGL2, no three.js, no bundler — raw WebGPU + inline WGSL, mirroring `WEBGPU_PLAN.md`.
85
+ - 1D lines / bars stay Canvas2D. Only the 2D **image** path is affected.
86
+
87
+ ## 3. Coverage & the fallback contract
88
+
89
+ Inherited verbatim from `WEBGPU_PLAN.md` §3: WebGPU is a progressive enhancement. `navigator.gpu`
90
+ present → `requestAdapter()` resolves → device created → *then* a panel may switch. Any failure at
91
+ any point (including mid-session device loss) lands on the Canvas2D path silently and permanently
92
+ for that session. **A figure must never render nothing because GPU was attempted.**
93
+
94
+ ## 4. Reuse (do NOT rebuild)
95
+
96
+ - **Device singleton + fallback state machine**: `_gpuDevice()` (`figure_esm.js` ~L1955,
97
+ module-level `_gpuDevicePromise`), the `p._gpu ∈ {pending,active,unavailable}` per-panel state,
98
+ the "first frame always Canvas2D → swap on device resolve" pattern, and `device.lost` → permanent
99
+ per-session Canvas2D. All proven by the 3D path — the 2D image path plugs into the exact same
100
+ machinery.
101
+ - **The `gpuCanvas`-below-`plotCanvas` split**: decorations (axes, ticks, colorbar, scale bar,
102
+ overlay mask, markers, widgets) keep drawing on the 2D `plotCanvas` **verbatim** — only the image
103
+ raster moves to `gpuCanvas`. Mirrors `WEBGPU_PLAN.md` §4.3.
104
+ - **`gpu_mode` / `_gpu_active` plumbing**: the state field + echo already exist (`st.gpu_mode`,
105
+ ~L1998). Add an image-megapixel `GPU_IMAGE_THRESHOLD` alongside the existing point threshold.
106
+ - **The zoom/letterbox model**: `_imgFitRect` (~L1262) — the GPU draw honors the same fit-rect /
107
+ `zoom` semantics; no new zoom math.
108
+ - **The colormap LUT**: `_build_colormap_lut` (`_utils.py:118`) → `st.colormap_data`
109
+ ([[r,g,b]×256]) already ships to JS. Upload it as a 256×1 texture; the shader samples it.
110
+ - **The normalized payload**: `set_data` already produces `img_u8` (single-channel uint8 via
111
+ `_normalize_image`, `_utils.py:82`) + `display_min/max` (clim) + `raw_min/max`. The WebGPU path
112
+ uploads that **same uint8** (8× smaller than raw float64; keeps current visual behaviour) as an
113
+ **R8 texture**; the shader does the clim re-stretch + LUT — exactly what the JS loop at
114
+ `draw2d` L1379–1381 does today, but on the GPU.
115
+
116
+ ## 5. The function being replaced
117
+
118
+ `draw2d` (`figure_esm.js` ~L1344): today it `atob`-decodes `image_b64`, runs a per-pixel LUT loop
119
+ (L1379–1381, 64M iters at 8k) into an `OffscreenCanvas` 2D context, caches the bitmap in
120
+ `blitCache`, and `_blit2d`-down-blits to the panel. The WebGPU path replaces the decode+LUT+blit
121
+ with: upload R8 texture → WGSL fragment shader (clim uniform + LUT texture) → mipmapped textured
122
+ quad over the `_imgFitRect`. `blitCache`'s "bytes unchanged → reuse" logic maps to "texture
123
+ unchanged → skip re-upload". Canvas2D `draw2d` remains as the fallback when `p._gpu !== 'active'`.
124
+
125
+ ## 6. Binary transport (generalize `WEBGPU_PLAN.md` §4.7 to images)
126
+
127
+ Today `set_data` → `_encode_bytes(img_u8)` → `image_b64` string on the `panel_<id>_geom` trait
128
+ (base64-in-JSON). Change: send the raw `img_u8` **bytes** as an anywidget **binary buffer**
129
+ (`_repr_utils._widget_state` already handles `bytes`; the geom-trait split already isolates heavy
130
+ keys — `FIGURE_ESM.md` ~L237), with a small JSON header (`image_width/height`, `display_min/max`,
131
+ `raw_min/max`, dtype, an optional `lod` level). Keep base64 for **small images and the
132
+ standalone/Pyodide/`save_html` paths** (no binary channel there). Keep `FigureBridge` (`embed.py`)
133
+ transport-agnostic so an Electron embed can supply an even faster channel (shared memory / a
134
+ transferable `ArrayBuffer`) without the library caring. JS: pick up the binary buffer, upload to
135
+ the GPU texture; on the Canvas2D fallback, build the `ImageData` from the same bytes (no atob).
136
+
137
+ ## 7. API surface
138
+
139
+ - `Axes.imshow(..., gpu="auto"|True|False)` and `Plot2D.set_data(..., gpu=...)` — mirror
140
+ `scatter3d`/`voxels`. Default `"auto"`: attempt WebGPU only above `GPU_IMAGE_THRESHOLD`
141
+ (initial ~4 megapixels — below it Canvas2D is already instant). `True` forces an attempt (still
142
+ falls back); `False` never attempts.
143
+ - `plot.gpu_active` — bool echo after first render (reuse `_gpu_active`).
144
+ - Optional `set_data(..., lod=k)` affordance so a consumer can mark a frame as decimated (scrub)
145
+ vs full-res (settle); at minimum, an uploaded full-res texture gets **free** GPU downscale-on-zoom
146
+ via mipmaps, so LOD-on-zoom needs no consumer work.
147
+
148
+ ## 8. Phases (with decision gates, mirroring WEBGPU_PLAN.md style)
149
+
150
+ ### Phase 0 — Binary transport + minimal GPU texture-blit (risk-retire; do first)
151
+
152
+ Add the binary image trait + header (keep b64 fallback). Add a minimal WebGPU 2D image pipeline:
153
+ R8 texture + 256×1 LUT texture + clim uniform + a textured quad over `_imgFitRect`, **no mipmaps
154
+ yet**. Verify a single static 8k image renders identically to Canvas2D via **offscreen-texture
155
+ readback** (the 3D path's proven test method — the WebGPU swapchain doesn't snapshot reliably under
156
+ automation, per `FIGURE_ESM.md` ~L256).
157
+
158
+ **Gate A:** GPU image matches Canvas2D within tolerance on a real GPU, and `gpu=False`/adapter-absent
159
+ renders identically via Canvas2D (automated).
160
+
161
+ ### Phase 1 — Mipmaps + zoom + scrub
162
+
163
+ Generate mipmaps on upload; sample with trilinear filtering so zoom-out/downscale is smooth and
164
+ zoom-in honors `_imgFitRect`. Wire the scrub path: new bytes → texture re-upload (reuse
165
+ `blitCache`-style "unchanged → skip").
166
+
167
+ **Acceptance:** 8k still zooms at 60 fps; a frame stream scrubs ≥15–30 fps on a real GPU; benchmark
168
+ `js_gpu_image_8k` recorded.
169
+
170
+ ### Phase 2 — API + fallback hardening + tests
171
+
172
+ `gpu="auto"|True|False` on `imshow`/`set_data`, `GPU_IMAGE_THRESHOLD`, `plot.gpu_active`, optional
173
+ `lod=`. Canvas2D-parity tests in the normal suite (assert `_gpu_active` false + pixel parity when
174
+ GPU absent); flagged headless-GPU smoke job (skip-on-null-adapter); `upcoming_changes/*.rst`
175
+ towncrier fragment.
176
+
177
+ **Gate B:** full fallback matrix green (adapter-absent, mid-session device loss via forced
178
+ `device.destroy()`, `gpu=False`) + real-GPU image benchmark, before this is released.
179
+
180
+ ### Phase 3 (gated) — raw-float precision path
181
+
182
+ If uint8 quantization proves visibly lossy for scientific contrast adjustment, add an optional R32F
183
+ texture upload (raw floats, 4× bytes) with the clim applied in-shader over true data range. Only
184
+ build on a concrete need (Gate C).
185
+
186
+ ## 9. Biggest risks / do-not-break
187
+
188
+ - **The 3D WebGPU path and the Canvas2D 2D path must not regress** — the 2D image GPU path is
189
+ additive and plugs into the shared `_gpuDevice()` singleton; keep the device/fallback code shared,
190
+ not forked.
191
+ - **Canvas2D stays the default + fallback forever** — no figure may render nothing because GPU was
192
+ attempted (the `WEBGPU_PLAN.md` §3 contract).
193
+ - **Automation can't snapshot the WebGPU swapchain** — test via offscreen-texture readback or state
194
+ echo, not screenshot diffing of the live canvas (per `FIGURE_ESM.md`).
195
+ - **Small-image / standalone / Pyodide paths unchanged** — binary transport is large-image-only;
196
+ b64 remains for the rest.
197
+ - Repo norms (`AGENTS.md`): OO API only, state in `_state` dicts (no new traitlets on Plot2D — the
198
+ Figure adds panel traits dynamically), end every `_state` mutation with `_push()`, `uv run
199
+ pytest`, `uv run playwright install chromium` first, add the towncrier fragment.
200
+
201
+ ## 10. Verify
202
+
203
+ `uv run pytest` (Canvas2D parity + fallback in the normal suite); the flagged GPU smoke job;
204
+ offscreen-readback comparison GPU-vs-Canvas2D on a large image; `js_gpu_image_8k` benchmark on a
205
+ real-GPU machine; a manual Electron-embed pass (the real consumer) once SpyDE pins the new version.
206
+
207
+ ## 11. API sketch
208
+
209
+ ```python
210
+ # Python
211
+ plot = ax.imshow(frame, gpu="auto") # auto-attempt WebGPU above GPU_IMAGE_THRESHOLD
212
+ plot.set_data(next_frame, clim=(lo, hi)) # scrub: binary bytes → texture re-upload
213
+ plot.set_data(decimated, lod=2) # scrub-time LOD hint (optional)
214
+ plot.gpu_active # bool, after first render echo
215
+ ```
216
+
217
+ ```js
218
+ // JS internals (figure_esm.js)
219
+ _gpuDevice() // existing module singleton → Promise<GPUDevice|null>
220
+ p._gpu // 'pending' | 'active' | 'unavailable' (existing)
221
+ _buildImagePipeline(device, p) // NEW: R8 sampled texture + LUT texture + clim uniform
222
+ _drawGpu2d(p) // NEW: image raster on gpuCanvas; decorations still 2D via draw2d
223
+ ```
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anyplotlib
3
- Version: 0.1.0b1
3
+ Version: 0.3.0b1
4
4
  Summary: A plotting library using python, javascript and anywidget for performant in browser plotting.
5
5
  Project-URL: Homepage, https://cssfrancis.github.io/anyplotlib/
6
6
  Project-URL: Repository, https://github.com/CSSFrancis/anyplotlib
@@ -63,6 +63,8 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
63
63
  | `drawScaleBar2d` / `drawColorbar2d` | 1360 / 1436 |
64
64
  | `_drawAxes2d` (ticks, labels, title) | 1491 |
65
65
  | `drawOverlay2d` / `drawMarkers2d` | 1629 / 1685 |
66
+ | **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 1553 / 1577 / 1629 |
67
+ | Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 675 / 706 |
66
68
  | **3D drawing**: `draw3d` | 1833 |
67
69
  | Event emission `_emitEvent` | 2031 |
68
70
  | 3D event handlers `_attachEvents3d` | 2059 |
@@ -134,7 +136,66 @@ geometry changes (visibility, label, sizes) re-layout automatically.
134
136
  #### `applyLayout()` (line 590)
135
137
  Reads `layout_json`. Builds CSS grid tracks from `panel_specs[].panel_width/height`.
136
138
  Creates panels that don't exist yet, resizes existing ones, removes stale ones.
137
- Also creates/updates inset panels from `inset_specs`.
139
+ Also creates/updates inset panels from `inset_specs`, then draws region
140
+ indications from `layout.indications` (on the next frame — see below).
141
+
142
+ #### Inset placement (`_applyAllInsetStates`)
143
+ Each `inset_specs[]` entry carries EITHER `corner` (one of the four corners;
144
+ `anchor` is `null`) OR `anchor` (`[x_frac, y_frac]` of the inset's top-left in
145
+ figure fraction; `corner` is `null`). Corner insets stack per-corner with
146
+ `INSET_GAP`; anchored insets are placed directly at their fraction (clamped
147
+ inside the figure). Minimize / maximize / restore work for both — a maximized
148
+ inset floats centred at ~72 % (z 45); a minimized one collapses to its title
149
+ bar in place.
150
+
151
+ #### Region indications (callouts — `_drawCallouts`)
152
+ `layout.indications` is an array of mark_inset-style callouts, each
153
+ `{inset_id, parent_id, region:[x,y,w,h], color, linestyle, linewidth}`.
154
+ `_drawCallouts()` renders them onto a figure-level `calloutCanvas` (z 30, above
155
+ panels + insets, below maximized-inset float and the resize handle,
156
+ `pointer-events:none`):
157
+ - The **dashed source rect** maps `region` (parent DATA coords) through the
158
+ parent's `_imgToCanvas2d` every draw, so it tracks the parent's zoom/pan; it
159
+ is clipped to the parent's image area.
160
+ - Two **leader lines** connect the rect's corners facing the inset to the
161
+ inset's nearest corners (loc1/loc2-auto by comparing centres); they follow
162
+ the inset's live DOM rect and are **hidden while the inset is minimized**.
163
+
164
+ `_drawCallouts()` is called at the end of `_redrawPanel` / `redrawAll` (tracks
165
+ zoom/pan), at the end of `_applyAllInsetStates` (inset moved), on `applyLayout`
166
+ (deferred one rAF so `getBoundingClientRect` is real), and inside `exportPNG`
167
+ (forced fresh draw, then `calloutCanvas` composited last). All coordinates go
168
+ through element bounding rects relative to the callout canvas, so no layout math
169
+ is duplicated. Cheap no-op when `indications` is empty (just clears the canvas).
170
+ The `if (!parent || !parent.state || !inset || !inset.isInset) continue;` guard
171
+ per indication is defensive/permanent — kept even though a foreign-figure
172
+ `parent_plot` can no longer reach this array at all (see validation below); it
173
+ still protects against other edge cases (e.g. a panel mid-teardown).
174
+
175
+ **`InsetAxes.indicate_region(parent_plot, region, …)` validates both arguments**
176
+ before recording an indication: `parent_plot` must be a panel registered on
177
+ THIS inset's own `Figure` (`self._fig._plots_map.get(pid) is parent_plot` —
178
+ not just "has some `_id`", which is the pre-existing check for "never attached
179
+ to any figure") — a plot that belongs to a *different* `Figure` raises
180
+ `ValueError`. `region` must be exactly 4 finite numbers `(x, y, w, h)` with
181
+ `w > 0` and `h > 0` — `NaN`/`inf`, a degenerate/negative size, or the wrong
182
+ number of values raises `ValueError`. A region that extends OUTSIDE the
183
+ parent's data bounds is explicitly **allowed** (clipping is a visual concern
184
+ handled by `_drawCallouts`'s clip-to-image-area, not a validation error). See
185
+ `test_indicate_region_foreign_figure_parent_raises`,
186
+ `test_indicate_region_foreign_inset_parent_raises`,
187
+ `test_indicate_region_degenerate_region_raises`, and
188
+ `test_indicate_region_out_of_bounds_is_allowed` in
189
+ `tests/test_layouts/test_inset_callout.py`.
190
+
191
+ **Inset removal**: as of this writing there is no `remove_inset` (or
192
+ equivalent) API — `Figure._insets_map` / `_plots_map` are only ever appended
193
+ to, never deleted from, so `indications` (rebuilt fresh from `_insets_map` on
194
+ every `_push_layout()` call) cannot go stale from a removed inset today. If a
195
+ removal API is added later, it MUST also delete the inset's entry from both
196
+ maps — otherwise `layout.indications` would keep emitting an entry whose
197
+ `inset_id` no longer resolves to a live panel (caught by the `_drawCallouts`
198
+ guard above, but a dangling entry all the same).
138
199
 
139
200
  #### `_createPanelDOM(id, kind, pw, ph, spec)` (line 763)
140
201
  Builds all canvas/DOM elements for one panel (via `_buildCanvasStack`),
@@ -196,6 +257,111 @@ inverses of the blit geometry.
196
257
 
197
258
  ---
198
259
 
260
+ ## Image layers (multi-image overlay)
261
+
262
+ `Plot2D.add_layer(data, cmap=, alpha=, clim=, visible=)` composites a second
263
+ (third, …) scalar image OVER the base image in the same panel, each with its own
264
+ colormap / clim / alpha. Distinct from `set_overlay_mask` (single-colour boolean
265
+ mask). `Layer.set(...)`, `Layer.set_data(frame)`, `Layer.remove()`,
266
+ `Plot2D.layers`, `Plot2D.remove_layer(layer)`. **Layers and tile mode are mutually
267
+ exclusive** (guard raises in both directions: `add_layer` on a tiled plot, and
268
+ `enable_tile` / `set_data(tile=True)` / auto-tile on a layered plot).
269
+
270
+ **A shape-changing `Plot2D.set_data` on a plot with layers raises `ValueError`.**
271
+ Each layer entry keeps the `(width, height)` it had at `add_layer` / its last
272
+ `Layer.set_data` time (`_encode_layer_pixels`), but `_drawLayers2d` always fits
273
+ every layer's bitmap into the BASE image's *current* `_imgFitRect(iw, ih, …)`
274
+ (`iw`/`ih` = the live `image_width`/`image_height`). So a base `set_data` that
275
+ changes shape while a stale-sized layer is still attached would silently stretch
276
+ that layer's old pixels over the new image instead of erroring — `set_data`
277
+ now checks `data.shape[:2]` against the current `image_height`/`image_width`
278
+ whenever `st.layers` is non-empty and raises before touching any state if they
279
+ differ (same-shape updates, the common live-update case, are unaffected). Remove
280
+ all layers first (`remove_layer`), change the base shape, then re-add them at the
281
+ new size. A layer-FREE plot's shape-changing `set_data` is unaffected and always
282
+ refreshes `image_width`/`image_height` (they're set unconditionally in the
283
+ pushed `fields` dict). See `TestTileGuards` / `TestShapeChangeNoLayers` in
284
+ `tests/test_plot2d/test_layers.py`.
285
+
286
+ **`Layer.set(clim=…)` has three distinct meanings** — `None` (default) leaves
287
+ the clim UNCHANGED (a no-op on that field, not "reset to auto"); a `(vmin, vmax)`
288
+ tuple sets an explicit range and re-quantises the cached frame over it;
289
+ `"auto"` is the sentinel to explicitly RESET to auto — recomputes the display
290
+ range from the layer's own current data (`self._layer_raw[layer_id]`) min/max,
291
+ the same auto-ranging `add_layer(..., clim=None)` does at creation time, and
292
+ re-quantises. Before this, `clim=None` was documented as "auto" but actually
293
+ behaved as a no-op, and there was no way to get back to auto range after
294
+ setting an explicit clim short of `remove()` + `add_layer()` again. See
295
+ `TestSet::test_set_clim_auto_resets_to_data_range` /
296
+ `test_set_clim_auto_matches_add_layer_auto` /
297
+ `test_set_clim_none_is_a_noop` in `tests/test_plot2d/test_layers.py`.
298
+
299
+ ### State + transport (dynamic per-layer pixel keys)
300
+
301
+ The layer *metadata* lives in `st.layers` (a list of small dicts on the light view
302
+ trait):
303
+
304
+ ```
305
+ st.layers = [{ id, cmap, clim_min, clim_max, alpha, visible,
306
+ width, height, colormap_data, image_b64 }, …] # z-order
307
+ ```
308
+
309
+ `image_b64` in each entry is the layer's pixels: a base64 string (Jupyter /
310
+ standalone / `save_html`) OR a `"\x00bin:<adler32>"` change-token (Electron binary
311
+ transport). The JS reads pixels from this entry field on the base64 path.
312
+
313
+ The *heavy pixel bytes* additionally ride a **DYNAMIC geometry key**
314
+ `layer_<id>_b64` — one per layer — mirroring how the base image `image_b64` rides
315
+ the geom channel. The dynamic-key mechanism:
316
+
317
+ - **`Plot2D._GEOM_KEYS` is a PROPERTY** (not a plain frozenset): it returns the
318
+ fixed base set (`image_b64`, `colormap_data`, `overlay_mask_b64`, `detail_b64`)
319
+ UNION the current `layer_<id>_b64` keys. So `Figure._push` splits every layer's
320
+ pixels off the light view trait onto `panel_<id>_geom` and dedup-caches them
321
+ exactly like the base image; a removed layer's key drops out automatically.
322
+ - **`_electron._route_change`** ships each layer key as its own PLOTBIN frame:
323
+ `_is_binary_pixel_key(k)` matches `k in _BINARY_KEYS` OR `layer_*_b64`. The
324
+ binary frame header carries `{"geom": "panel_<id>_geom"}` and `key=layer_<id>_b64`,
325
+ so the receiver builds slot `panel_<id>_geom::layer_<id>_b64` (the same
326
+ `awi_state_binary` handler as the base image — already generic on `hdr.geom` +
327
+ `e.data.key`, no change needed there).
328
+ - **`resolve_pixel_tokens`** (cold path: `save_html` / standalone) materialises
329
+ real base64 for every `layer_<id>_b64` key AND the entry `image_b64` mirror, so a
330
+ snapshot is self-contained.
331
+ - **JS `_spliceBinaryBytes`** scans `__apl_pixbytes` by the `panel_<id>_geom::`
332
+ PREFIX (not the old hardcoded 3-key list) so it splices any `layer_<id>_b64_bytes`
333
+ into `p2._geomCache`. The per-slot binary listeners are registered only for the
334
+ fixed keys (`_registerBinaryPixelListeners`); dynamic layer bytes are consumed by
335
+ the **geom-JSON change handler**, which now also calls `_spliceBinaryBytes` — the
336
+ geom trait always re-pushes when layers change, so a layer's bytes converge into
337
+ the cache regardless of trait arrival order.
338
+
339
+ ### JS compositing (`_drawLayers2d`, called from `draw2d`)
340
+
341
+ After the base image (Canvas2D blit OR WebGPU) and the overlay mask, and BEFORE
342
+ `_drawAxes2d` / markers / widgets, `_drawLayers2d(p, st, imgW, imgH, ctx, iw, ih)`
343
+ draws each **visible** layer bottom-up on `plotCanvas`:
344
+
345
+ - `_layerBytes(st, layer)` prefers `layer_<id>_b64_bytes` (binary) over the entry
346
+ `image_b64` base64;
347
+ - `_layerBitmap(p, st, layer)` builds a LUT-colormapped RGBA `OffscreenCanvas`,
348
+ **cached per layer id** by `(pixel key, cmap, clim)` — rebuilt only when the
349
+ layer's data or appearance changes (a live scrub that only swaps one layer's
350
+ data rebuilds just that layer);
351
+ - it blits with the SAME fit-rect + zoom/pan transform as the base blit
352
+ (`_imgFitRect` + the `zoom>=1` window math) at `ctx.globalAlpha = layer.alpha`,
353
+ so zoom/pan track the base exactly.
354
+
355
+ Because layers draw on `plotCanvas`, they sit UNDER `markersCanvas` /
356
+ `overlayCanvas` (z-order) and are captured by `exportPNG` for free (plotCanvas is
357
+ z1 in the composite). Over a WebGPU base the layers still composite in Canvas2D on
358
+ `plotCanvas` (which sits above the transparent `gpuCanvas`) — verified by
359
+ `test_layers_playwright.py::TestGpuBaseWithLayer`. Per-move perf: only the changed
360
+ layer's LUT bitmap is rebuilt (the box-loop is ~one pass over H×W uint8 → uint32,
361
+ comparable to the base image's `_buildLut32` blit).
362
+
363
+ ---
364
+
199
365
  ## 3D drawing (line ~1840)
200
366
  Orthographic projection; geometry b64-decoded and cached. `draw3d` sorts
201
367
  triangles, draws axes with per-axis `_drawTex` labels (`x/y/z_label_size`).
@@ -309,3 +475,61 @@ Python → JS (set widget position from Python):
309
475
  widget.set(…) → Figure._push_widget → event_json with source:'python'
310
476
  → model.on('change:event_json') patches overlay_widgets + redraws
311
477
  ```
478
+
479
+ ---
480
+
481
+ ## PNG export (`exportPNG`)
482
+
483
+ `render()` now RETURNS an internal API object `{ panels, exportPNG,
484
+ _gpuDisposeImagePanel, _gpuDisposePanel }` (anywidget ignores render()'s return;
485
+ `mount()` captures it — this also fixed a latent bug where the old
486
+ mount-handle `dispose()` referenced `panels`/`_gpuDispose*` from module scope,
487
+ which are inside `render`'s closure, and silently threw). The mount handle
488
+ exposes `handle.exportPNG({scale=1, includeWidgets=false}) →
489
+ Promise<{dataUrl,width,height}>`.
490
+
491
+ `exportPNG(opts)` (inside `render`, near `redrawAll`) composites the WHOLE
492
+ figure onto one offscreen canvas at `devicePixelRatio × scale`:
493
+
494
+ - **WebGPU hazard first**: a WebGPU canvas's drawing buffer is only valid right
495
+ after its render pass, so exportPNG force-calls `draw2d(p)` on every
496
+ active-GPU 2-D panel (`p._gpu==='active' && p.gpuCanvas` visible) to re-submit
497
+ its pass, THEN composites in the SAME synchronous task — so
498
+ `drawImage(gpuCanvas,…)` reads live pixels, not a blank buffer.
499
+ - **Extent**: `fig_width/height + 2×8 px` gridDiv padding (NOT the measured
500
+ `gridDiv` width — a bare `mount()` page has no `.apl-outer` inline-block CSS,
501
+ so the grid container can stretch to the viewport). **Origin**: `gridDiv`'s
502
+ top-left (grid tracks are fixed-px + left-anchored, so panels sit correctly).
503
+ - **Per-panel z-order** (`_drawEl` positions each canvas by its
504
+ `getBoundingClientRect()` relative to the root): gpuCanvas (z0) → plotCanvas
505
+ (z1) → x/yAxisCanvas → cbCanvas → [overlayCanvas z5 only if `includeWidgets`]
506
+ → markersCanvas (z6) → scaleBar (z7) → titleCanvas (z8). Grid panels first,
507
+ then insets (`p.isInset`) on top — **each titled inset's title bar text is
508
+ drawn directly onto the output canvas right after its canvas stack**
509
+ (`_drawInsetTitle`; the title bar is plain DOM — a `<div>`/`<span>`, not a
510
+ canvas — so `_drawEl` alone never captures it; approximates the on-screen
511
+ CSS: 11px sans-serif, `theme.tickText` colour, left-padded to the titleBar's
512
+ rect) — then the figure-level `calloutCanvas` (region indications) composited
513
+ LAST. Status bars / stats overlays are excluded.
514
+ - **Coordinate snapping** (`_drawEl`): `dx`/`dy` are `Math.round()`ed from the
515
+ element's `left`/`top`, and `dw`/`dh` are the ROUNDED `right`/`bottom` edge
516
+ minus the rounded `dx`/`dy` — never `Math.round(width)` directly. This makes
517
+ two elements that share a CSS edge (e.g. adjacent grid panels, or a
518
+ panel's axis-gutter canvas against its plotCanvas) round that shared edge to
519
+ the *same* output pixel on both sides. Without it, at a fractional effective
520
+ scale (`devicePixelRatio × opts.scale` — e.g. a real 150% Windows display, or
521
+ `scale: 1.25`), each element's `dx`/`dw` were computed independently as raw
522
+ floats, and adjacent elements could round their common boundary to different
523
+ output pixels — a 1px background-coloured seam (or overlap) exactly at the
524
+ join. See `TestExportMultiPanel::test_fractional_scale_no_seam_between_panels`
525
+ in `tests/test_embed/test_export_png.py` (reproduced with
526
+ `device_scale_factor=1.5`).
527
+ - Ends with `out.toDataURL('image/png')`; rejects the promise with a message on
528
+ failure (no 2-D context, `toDataURL` throw).
529
+
530
+ The standalone HTML template (`_repr_utils.build_standalone_html`) captures
531
+ render()'s api into `_aplRenderApi` and adds a `message` listener:
532
+ `{type:'anyplotlib_export_png', requestId, opts}` → `exportPNG(opts)` → replies
533
+ `{type:'anyplotlib_export_png_result', requestId, dataUrl, width, height}` (or
534
+ `{…, error}`) to `event.source` (targetOrigin `'*'`) — the same channel the
535
+ `awi_state` postMessages ride. Tests: `tests/test_embed/test_export_png.py`.