plotlet 0.6.0__tar.gz → 0.6.4__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 (223) hide show
  1. plotlet-0.6.4/PKG-INFO +72 -0
  2. plotlet-0.6.4/README.md +35 -0
  3. {plotlet-0.6.0 → plotlet-0.6.4}/pyproject.toml +6 -2
  4. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/__init__.py +14 -9
  5. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_coord_registry.py +13 -8
  6. plotlet-0.6.4/src/plotlet/_datasets/earth.npz +0 -0
  7. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_json_layer.py +62 -27
  8. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_regions.py +6 -2
  9. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_tree.py +1 -1
  10. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/__init__.py +9 -3
  11. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/_shared.py +151 -1
  12. plotlet-0.6.4/src/plotlet/artists/annotation_strip.py +742 -0
  13. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/bar.py +225 -150
  14. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/boxplot.py +80 -58
  15. {plotlet-0.6.0/src/plotlet/extensions → plotlet-0.6.4/src/plotlet/artists}/chord_links.py +9 -36
  16. {plotlet-0.6.0/src/plotlet/extensions → plotlet-0.6.4/src/plotlet/artists}/chord_ribbon.py +7 -43
  17. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/contour.py +2 -2
  18. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/dendrogram.py +37 -6
  19. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/density_1d.py +4 -4
  20. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/ecdf.py +30 -6
  21. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/errorbar.py +24 -21
  22. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/fills.py +5 -4
  23. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/freqpoly.py +4 -4
  24. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/heatmap.py +147 -72
  25. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/hexbin.py +3 -3
  26. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/hist.py +10 -9
  27. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/hist2d.py +1 -1
  28. plotlet-0.6.4/src/plotlet/artists/image_cmap.py +323 -0
  29. plotlet-0.6.4/src/plotlet/artists/image_rgba.py +236 -0
  30. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/kde_2d.py +5 -5
  31. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/line.py +53 -17
  32. {plotlet-0.6.0/src/plotlet/extensions → plotlet-0.6.4/src/plotlet/artists}/numeric_bar.py +11 -35
  33. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/pointplot.py +7 -5
  34. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/qq.py +46 -12
  35. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/references.py +10 -6
  36. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/regression.py +10 -8
  37. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/ridge.py +6 -5
  38. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/rug.py +50 -18
  39. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/scatter.py +114 -50
  40. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/shapes.py +5 -2
  41. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/strip.py +37 -15
  42. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/swarm.py +34 -13
  43. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/text.py +8 -4
  44. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/violin.py +11 -8
  45. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/cluster.py +19 -4
  46. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/datasets.py +21 -9
  47. plotlet-0.6.4/src/plotlet/docs/AI_ATTRS.md +170 -0
  48. plotlet-0.6.4/src/plotlet/docs/API.md +455 -0
  49. plotlet-0.6.4/src/plotlet/docs/ARCHITECTURE.md +201 -0
  50. plotlet-0.6.4/src/plotlet/docs/COORDINATES.md +203 -0
  51. plotlet-0.6.4/src/plotlet/docs/EXTENDING.md +271 -0
  52. plotlet-0.6.4/src/plotlet/docs/PHILOSOPHY.md +125 -0
  53. plotlet-0.6.4/src/plotlet/docs/SUBPLOTS.md +136 -0
  54. plotlet-0.6.4/src/plotlet/docs/THEMES.md +124 -0
  55. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/__init__.py +8 -2
  56. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/_png.py +44 -1
  57. plotlet-0.6.4/src/plotlet/draw/_raster.py +193 -0
  58. plotlet-0.6.4/src/plotlet/draw/_simplify.py +100 -0
  59. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/colormaps.py +28 -2
  60. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/colors.py +2 -2
  61. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/primitives.py +1 -1
  62. plotlet-0.6.4/src/plotlet/examples/01_scatter.py +24 -0
  63. plotlet-0.6.4/src/plotlet/examples/02_line.py +16 -0
  64. plotlet-0.6.4/src/plotlet/examples/03_bar.py +20 -0
  65. plotlet-0.6.4/src/plotlet/examples/04_violin.py +21 -0
  66. plotlet-0.6.4/src/plotlet/examples/05_anscombe.py +24 -0
  67. plotlet-0.6.4/src/plotlet/examples/06_heatmap.py +22 -0
  68. plotlet-0.6.4/src/plotlet/examples/07_annotated_heatmap.py +39 -0
  69. plotlet-0.6.4/src/plotlet/examples/08_tracks.py +41 -0
  70. plotlet-0.6.4/src/plotlet/examples/09_circular.py +17 -0
  71. plotlet-0.6.4/src/plotlet/examples/10_chord.py +30 -0
  72. plotlet-0.6.4/src/plotlet/examples/12_hist.py +19 -0
  73. plotlet-0.6.4/src/plotlet/examples/13_ecdf.py +14 -0
  74. plotlet-0.6.4/src/plotlet/examples/14_box_strip.py +17 -0
  75. plotlet-0.6.4/src/plotlet/examples/15_swarm.py +15 -0
  76. plotlet-0.6.4/src/plotlet/examples/16_pointplot.py +26 -0
  77. plotlet-0.6.4/src/plotlet/examples/17_ridge.py +23 -0
  78. plotlet-0.6.4/src/plotlet/examples/18_qq.py +20 -0
  79. plotlet-0.6.4/src/plotlet/examples/19_hexbin.py +20 -0
  80. plotlet-0.6.4/src/plotlet/examples/20_kde_2d.py +23 -0
  81. plotlet-0.6.4/src/plotlet/examples/21_image_cmap.py +19 -0
  82. plotlet-0.6.4/src/plotlet/examples/22_facets.py +21 -0
  83. plotlet-0.6.4/src/plotlet/examples/23_ring_tree.py +43 -0
  84. plotlet-0.6.4/src/plotlet/examples/24_image_rgba.py +19 -0
  85. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/layout_diagram.py +3 -3
  86. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/lint.py +1 -1
  87. plotlet-0.6.4/src/plotlet/record/__init__.py +12 -0
  88. {plotlet-0.6.0/src/plotlet → plotlet-0.6.4/src/plotlet/record}/chart.py +168 -74
  89. {plotlet-0.6.0/src/plotlet → plotlet-0.6.4/src/plotlet/record}/facet.py +4 -4
  90. plotlet-0.6.0/src/plotlet/_ir.py → plotlet-0.6.4/src/plotlet/record/figure_ir.py +38 -32
  91. plotlet-0.6.0/src/plotlet/_journal.py → plotlet-0.6.4/src/plotlet/record/journal.py +115 -42
  92. {plotlet-0.6.0/src/plotlet → plotlet-0.6.4/src/plotlet/record}/legend.py +1 -1
  93. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/registry.py +29 -11
  94. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/render/__init__.py +18 -12
  95. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/render/_attachments.py +6 -6
  96. plotlet-0.6.4/src/plotlet/render/_chrome_bands.py +392 -0
  97. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/render/_chrome_circular.py +81 -6
  98. plotlet-0.6.4/src/plotlet/render/_chrome_emit.py +764 -0
  99. plotlet-0.6.4/src/plotlet/render/_chrome_visibility.py +97 -0
  100. plotlet-0.6.0/src/plotlet/render/coordinates.py → plotlet-0.6.4/src/plotlet/render/_coord_circular.py +17 -343
  101. plotlet-0.6.4/src/plotlet/render/_layout_circular.py +324 -0
  102. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/render/_layout_engine.py +121 -121
  103. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/render/_legend.py +12 -9
  104. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/render/_nodes.py +19 -39
  105. plotlet-0.6.0/src/plotlet/render/core.py → plotlet-0.6.4/src/plotlet/render/_resolution.py +542 -997
  106. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/render/_validate.py +43 -15
  107. plotlet-0.6.4/src/plotlet/render/emit.py +713 -0
  108. plotlet-0.6.0/src/plotlet/render/resolved.py → plotlet-0.6.4/src/plotlet/render/resolved_ir.py +104 -96
  109. plotlet-0.6.4/src/plotlet/skill.py +36 -0
  110. plotlet-0.6.4/src/plotlet/skills/developers.md +141 -0
  111. plotlet-0.6.4/src/plotlet/skills/users.md +182 -0
  112. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/spec.json +6 -1
  113. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/themes/minimal.json +2 -1
  114. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/themes/void.json +2 -1
  115. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/utils.py +152 -17
  116. plotlet-0.6.4/src/plotlet.egg-info/PKG-INFO +72 -0
  117. plotlet-0.6.4/src/plotlet.egg-info/SOURCES.txt +208 -0
  118. plotlet-0.6.4/tests/test_aes.py +136 -0
  119. plotlet-0.6.4/tests/test_chart_annotation_strip.py +199 -0
  120. plotlet-0.6.4/tests/test_chart_axis.py +473 -0
  121. plotlet-0.6.4/tests/test_chart_bar.py +353 -0
  122. plotlet-0.6.4/tests/test_chart_boxplot.py +105 -0
  123. plotlet-0.6.4/tests/test_chart_chord_links.py +68 -0
  124. plotlet-0.6.4/tests/test_chart_chord_ribbon.py +56 -0
  125. plotlet-0.6.4/tests/test_chart_contour.py +84 -0
  126. plotlet-0.6.4/tests/test_chart_dendrogram.py +186 -0
  127. plotlet-0.6.4/tests/test_chart_density_1d.py +59 -0
  128. plotlet-0.6.4/tests/test_chart_ecdf.py +40 -0
  129. plotlet-0.6.4/tests/test_chart_errorbar.py +57 -0
  130. plotlet-0.6.4/tests/test_chart_facets.py +238 -0
  131. plotlet-0.6.4/tests/test_chart_fills.py +119 -0
  132. plotlet-0.6.4/tests/test_chart_freqpoly.py +40 -0
  133. plotlet-0.6.4/tests/test_chart_heatmap.py +378 -0
  134. plotlet-0.6.4/tests/test_chart_hexbin.py +50 -0
  135. plotlet-0.6.4/tests/test_chart_hist.py +136 -0
  136. plotlet-0.6.4/tests/test_chart_hist2d.py +108 -0
  137. plotlet-0.6.4/tests/test_chart_image_cmap.py +284 -0
  138. plotlet-0.6.4/tests/test_chart_image_rgba.py +214 -0
  139. plotlet-0.6.4/tests/test_chart_kde_2d.py +77 -0
  140. plotlet-0.6.4/tests/test_chart_labels.py +136 -0
  141. plotlet-0.6.4/tests/test_chart_line.py +240 -0
  142. plotlet-0.6.4/tests/test_chart_numeric_bar.py +50 -0
  143. plotlet-0.6.4/tests/test_chart_pointplot.py +90 -0
  144. plotlet-0.6.4/tests/test_chart_qq.py +74 -0
  145. plotlet-0.6.4/tests/test_chart_references.py +68 -0
  146. plotlet-0.6.4/tests/test_chart_regression.py +159 -0
  147. plotlet-0.6.4/tests/test_chart_ridge.py +72 -0
  148. plotlet-0.6.4/tests/test_chart_rug.py +36 -0
  149. plotlet-0.6.4/tests/test_chart_scatter.py +146 -0
  150. plotlet-0.6.4/tests/test_chart_shapes.py +189 -0
  151. plotlet-0.6.4/tests/test_chart_spines.py +52 -0
  152. plotlet-0.6.4/tests/test_chart_strip.py +46 -0
  153. plotlet-0.6.4/tests/test_chart_swarm.py +58 -0
  154. plotlet-0.6.4/tests/test_chart_text.py +89 -0
  155. plotlet-0.6.4/tests/test_chart_violin.py +46 -0
  156. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_colors.py +5 -3
  157. plotlet-0.6.0/tests/test_circular_coordinate.py → plotlet-0.6.4/tests/test_coord_circular.py +302 -116
  158. plotlet-0.6.0/tests/test_sectors.py → plotlet-0.6.4/tests/test_coord_sectors.py +106 -100
  159. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_datasets.py +7 -1
  160. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_fonts.py +34 -25
  161. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_formatters.py +5 -2
  162. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_help.py +4 -10
  163. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_import_boundary.py +120 -13
  164. plotlet-0.6.4/tests/test_io.py +187 -0
  165. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_ir.py +42 -36
  166. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_ir_resolved.py +108 -56
  167. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_ir_validate.py +60 -20
  168. plotlet-0.6.4/tests/test_journal_roundtrip.py +236 -0
  169. plotlet-0.6.0/tests/test_attachments.py → plotlet-0.6.4/tests/test_layout_attachments.py +149 -79
  170. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_layout_diagram.py +38 -19
  171. plotlet-0.6.0/tests/test_legend.py → plotlet-0.6.4/tests/test_layout_legend.py +120 -56
  172. plotlet-0.6.0/tests/test_subplots.py → plotlet-0.6.4/tests/test_layout_subplots.py +219 -110
  173. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_lint.py +8 -5
  174. plotlet-0.6.4/tests/test_record_signatures.py +225 -0
  175. plotlet-0.6.4/tests/test_skill.py +35 -0
  176. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_themes.py +22 -12
  177. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_units.py +9 -4
  178. plotlet-0.6.0/PKG-INFO +0 -95
  179. plotlet-0.6.0/README.md +0 -58
  180. plotlet-0.6.0/src/plotlet/artists/imshow.py +0 -237
  181. plotlet-0.6.0/src/plotlet/extensions/annotation_strip.py +0 -675
  182. plotlet-0.6.0/src/plotlet/extensions/curved_tree.py +0 -244
  183. plotlet-0.6.0/src/plotlet/render/_chrome.py +0 -953
  184. plotlet-0.6.0/src/plotlet.egg-info/PKG-INFO +0 -95
  185. plotlet-0.6.0/src/plotlet.egg-info/SOURCES.txt +0 -129
  186. plotlet-0.6.0/tests/test_chart.py +0 -3594
  187. plotlet-0.6.0/tests/test_extensions.py +0 -40
  188. plotlet-0.6.0/tests/test_io.py +0 -61
  189. plotlet-0.6.0/tests/test_journal_roundtrip.py +0 -127
  190. plotlet-0.6.0/tests/test_record_signatures.py +0 -75
  191. {plotlet-0.6.0 → plotlet-0.6.4}/LICENSE +0 -0
  192. {plotlet-0.6.0 → plotlet-0.6.4}/setup.cfg +0 -0
  193. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_datasets/anscombe.csv +0 -0
  194. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_datasets/flights.csv +0 -0
  195. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_datasets/penguins.csv +0 -0
  196. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_datasets/tips.csv +0 -0
  197. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_spec.py +0 -0
  198. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/_splits.py +0 -0
  199. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/artists/_marching.py +0 -0
  200. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/_cm_data.py +0 -0
  201. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/_css_colors.py +0 -0
  202. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/font.py +0 -0
  203. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/Arimo-Bold.ttf +0 -0
  204. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/Arimo-BoldItalic.ttf +0 -0
  205. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/Arimo-Italic.ttf +0 -0
  206. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/Arimo-Regular.ttf +0 -0
  207. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/DejaVuSans-Bold.ttf +0 -0
  208. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/DejaVuSans-BoldOblique.ttf +0 -0
  209. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/DejaVuSans-Oblique.ttf +0 -0
  210. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/DejaVuSans.ttf +0 -0
  211. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/LICENSE-Arimo.txt +0 -0
  212. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/fonts/LICENSE-DejaVuSans.txt +0 -0
  213. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/format.py +0 -0
  214. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/draw/linestyles.py +0 -0
  215. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/formatters.py +0 -0
  216. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/scales.py +0 -0
  217. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/sectors.py +0 -0
  218. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/themes/dark.json +0 -0
  219. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet/themes.py +0 -0
  220. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet.egg-info/dependency_links.txt +0 -0
  221. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet.egg-info/requires.txt +0 -0
  222. {plotlet-0.6.0 → plotlet-0.6.4}/src/plotlet.egg-info/top_level.txt +0 -0
  223. {plotlet-0.6.0 → plotlet-0.6.4}/tests/test_cluster.py +0 -0
plotlet-0.6.4/PKG-INFO ADDED
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: plotlet
3
+ Version: 0.6.4
4
+ Summary: Python library for reproducible, multi-panel scientific figures, with byte-identical output across machines and an extension API for custom plot types.
5
+ Author: gitbamboo42
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gitbamboo42/plotlet
8
+ Project-URL: Repository, https://github.com/gitbamboo42/plotlet
9
+ Project-URL: Issues, https://github.com/gitbamboo42/plotlet/issues
10
+ Keywords: plot,svg,scientific,visualization,jupyter,reproducible
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Visualization
21
+ Classifier: Topic :: Multimedia :: Graphics
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: fonttools>=4.0
26
+ Requires-Dist: scipy>=1.10
27
+ Requires-Dist: resvg_py>=0.3
28
+ Provides-Extra: dev
29
+ Requires-Dist: jupyter; extra == "dev"
30
+ Requires-Dist: nbconvert; extra == "dev"
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest>=7; extra == "test"
33
+ Requires-Dist: pandas; extra == "test"
34
+ Provides-Extra: pdf
35
+ Requires-Dist: cairosvg; extra == "pdf"
36
+ Dynamic: license-file
37
+
38
+ # plotlet
39
+
40
+ plotlet is a Python library for reproducible, multi-panel scientific figures, built for AI authorship: figures are written, inspected, and verified as data, not pixels. Byte-identical output across machines, a standard plotting vocabulary, built-in Cartesian and circular coordinate systems, and an easy way to add your own plot types.
41
+
42
+ ## Documentation
43
+
44
+ Online documentation is available at [gitbamboo42.github.io/plotlet](https://gitbamboo42.github.io/plotlet/).
45
+
46
+ The docs include a [tutorial](https://gitbamboo42.github.io/plotlet/tutorial.html), a [plot-type reference](https://gitbamboo42.github.io/plotlet/reference.html), a [cookbook](https://gitbamboo42.github.io/plotlet/cookbook.html), an [extensions gallery](https://gitbamboo42.github.io/plotlet/extensions.html), [deep-dive guides](https://gitbamboo42.github.io/plotlet/docs-api.html), and a [page for AI agents](https://gitbamboo42.github.io/plotlet/agents.html).
47
+
48
+ ## Dependencies
49
+
50
+ plotlet supports Python 3.10+.
51
+
52
+ Required: `fonttools`, `scipy`, `resvg_py`. numpy / pandas / polars inputs work transparently.
53
+
54
+ Optional: `cairosvg` for PDF export (`pip install plotlet[pdf]`).
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install plotlet
60
+ ```
61
+
62
+ ## For AI assistants
63
+
64
+ Tell your assistant to run plotlet's `skill()` and follow it. The guides, docs, and worked examples ship inside the installed package; the [page for AI agents](https://gitbamboo42.github.io/plotlet/agents.html) shows the full workflow.
65
+
66
+ ## Development
67
+
68
+ Development takes place on GitHub. Please submit bugs to the issue tracker with a reproducible example.
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,35 @@
1
+ # plotlet
2
+
3
+ plotlet is a Python library for reproducible, multi-panel scientific figures, built for AI authorship: figures are written, inspected, and verified as data, not pixels. Byte-identical output across machines, a standard plotting vocabulary, built-in Cartesian and circular coordinate systems, and an easy way to add your own plot types.
4
+
5
+ ## Documentation
6
+
7
+ Online documentation is available at [gitbamboo42.github.io/plotlet](https://gitbamboo42.github.io/plotlet/).
8
+
9
+ The docs include a [tutorial](https://gitbamboo42.github.io/plotlet/tutorial.html), a [plot-type reference](https://gitbamboo42.github.io/plotlet/reference.html), a [cookbook](https://gitbamboo42.github.io/plotlet/cookbook.html), an [extensions gallery](https://gitbamboo42.github.io/plotlet/extensions.html), [deep-dive guides](https://gitbamboo42.github.io/plotlet/docs-api.html), and a [page for AI agents](https://gitbamboo42.github.io/plotlet/agents.html).
10
+
11
+ ## Dependencies
12
+
13
+ plotlet supports Python 3.10+.
14
+
15
+ Required: `fonttools`, `scipy`, `resvg_py`. numpy / pandas / polars inputs work transparently.
16
+
17
+ Optional: `cairosvg` for PDF export (`pip install plotlet[pdf]`).
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install plotlet
23
+ ```
24
+
25
+ ## For AI assistants
26
+
27
+ Tell your assistant to run plotlet's `skill()` and follow it. The guides, docs, and worked examples ship inside the installed package; the [page for AI agents](https://gitbamboo42.github.io/plotlet/agents.html) shows the full workflow.
28
+
29
+ ## Development
30
+
31
+ Development takes place on GitHub. Please submit bugs to the issue tracker with a reproducible example.
32
+
33
+ ## License
34
+
35
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "plotlet"
7
- version = "0.6.0"
7
+ version = "0.6.4"
8
8
  description = "Python library for reproducible, multi-panel scientific figures, with byte-identical output across machines and an extension API for custom plot types."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -70,4 +70,8 @@ namespaces = true
70
70
  # draw/fonts/*.txt are the font licenses — DejaVu (Bitstream Vera–derived)
71
71
  # and Arimo (SIL OFL 1.1) both require the license text to accompany
72
72
  # redistribution, so they must ship in the wheel next to the TTFs.
73
- plotlet = ["spec.json", "themes/*.json", "draw/fonts/*.ttf", "draw/fonts/*.txt", "_datasets/*.csv"]
73
+ plotlet = ["spec.json", "themes/*.json", "draw/fonts/*.ttf", "draw/fonts/*.txt", "_datasets/*.csv", "_datasets/*.npz",
74
+ # AI onboarding: pt.skill() reads skills/, and the guides point
75
+ # at the shipped docs/ and examples/ so an installed package is
76
+ # self-sufficient — no repo clone, no web access needed.
77
+ "skills/*.md", "docs/*.md", "examples/*.py"]
@@ -1,15 +1,17 @@
1
1
  """plotlet — pure-Python deferred-rendering SVG plot library.
2
2
 
3
3
  import plotlet as pt
4
+ from plotlet import aes
5
+
4
6
  c = pt.chart(df, title="...", xlabel="x", ylabel="y", legend=True, gridlines=True)
5
- c.line(x="time", y="value", color="series")
7
+ c.add_line(aes(x="time", y="value", color="series"))
6
8
  c # auto-renders in Jupyter
7
9
 
8
10
  Chart methods chain for incremental composition:
9
11
 
10
12
  df = {"x": [1, 2, 3], "y": [1, 4, 9]}
11
13
  c = pt.chart()
12
- c.line(df, x="x", y="y", label="squares")
14
+ c.add_line(df, aes(x="x", y="y"), label="squares")
13
15
  c.title("Hello").legend().gridlines(True)
14
16
  c
15
17
  """
@@ -17,21 +19,23 @@ from ._spec import SPEC
17
19
  from .draw import TAB10, palette, list_palettes
18
20
  from .draw import colormap, list_colormaps, register_colormap
19
21
  from . import artists # noqa: F401 — registers built-in artists on import
20
- from .chart import Chart, Layout, chart, grid
21
- from .legend import legend
22
+ from .record.chart import Chart, Layout, chart, grid
23
+ from .utils import aes
24
+ from .record.legend import legend
22
25
  from .registry import ArtistSpec, add_artist, artist_table, declare_coord_support
23
26
  from .sectors import Sectors
24
27
  from .layout_diagram import layout_diagram
25
28
  from .themes import load_theme, list_themes, register_theme
26
- from .facet import facet, FacetGrid
29
+ from .record.facet import facet, FacetGrid
27
30
  from .datasets import load_dataset, list_datasets
28
31
  from .cluster import linkage, linkage_split, SplitTree
29
32
  from .formatters import (register_formatter, list_formatters,
30
33
  superscript, subscript)
31
34
  from ._coord_registry import register_coord_codec
32
- from ._journal import (to_journal, from_journal, to_json, from_json,
35
+ from .record.journal import (to_journal, from_journal, to_json, from_json,
33
36
  JournalNode, Journal)
34
- from ._ir import to_ir, from_ir, resolve_ir, FigureIR, IRNode
37
+ from .record.figure_ir import to_ir, from_ir, FigureIR, IRNode
38
+ from .skill import skill
35
39
  from . import draw, utils
36
40
 
37
41
 
@@ -41,7 +45,7 @@ def __getattr__(name):
41
45
  # the render half loads on first render (or first touch of this
42
46
  # name). Enforced by tests/test_import_boundary.py.
43
47
  if name == "CircularCoordinate":
44
- from .render.coordinates import CircularCoordinate
48
+ from .render._coord_circular import CircularCoordinate
45
49
  return CircularCoordinate
46
50
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
47
51
 
@@ -60,7 +64,8 @@ __all__ = ["chart", "Chart", "Layout", "SPEC", "TAB10",
60
64
  "superscript", "subscript",
61
65
  "to_json", "from_json", "register_coord_codec",
62
66
  "to_journal", "from_journal", "JournalNode", "Journal",
63
- "to_ir", "from_ir", "FigureIR", "IRNode", "resolve_ir",
67
+ "to_ir", "from_ir", "FigureIR", "IRNode",
68
+ "skill",
64
69
  "draw", "utils"]
65
70
 
66
71
  # Single source of truth: pyproject.toml. importlib.metadata reads it at
@@ -1,11 +1,16 @@
1
- """Coord codec registry — a neutral home for the `class_name → class` map
2
- that both `serialize.py` and `_journal.py` need for round-tripping coord
3
- objects like `CircularCoordinate`.
1
+ """Coord codec registry — the `class_name → class` map behind
2
+ `{"$coord": ...}` envelopes, for coord objects like
3
+ `CircularCoordinate`.
4
4
 
5
- Lives here rather than in either consumer so that removing one of them
6
- later (e.g. retiring the JSON tree serializer once the journal covers
7
- the same ground) doesn't leave the registry orphaned. The public API
8
- `plotlet.register_coord_codec` re-exports from this module directly.
5
+ `record/journal.py` encodes a coord object to its class name;
6
+ `_json_layer._decode` turns the name back into the class via
7
+ `resolve_coord`, which imports the built-in coord module on the first
8
+ miss so cold processes decode without a prior render. The render half
9
+ reads the map too (`render/_validate.py` cross-checks the `container`
10
+ flag, `render/resolved_ir.py` rehydrates coords), so the registry sits
11
+ at the package root, where `record/` and `render/` may both import it.
12
+ The public API `plotlet.register_coord_codec` re-exports from this
13
+ module directly.
9
14
  """
10
15
  from __future__ import annotations
11
16
 
@@ -38,7 +43,7 @@ def resolve_coord(name: str) -> type:
38
43
  return _COORD_REGISTRY[name]
39
44
  except KeyError:
40
45
  pass
41
- from .render import coordinates # noqa: F401 — registers built-ins
46
+ from .render import _coord_circular # noqa: F401 — registers built-ins
42
47
  try:
43
48
  return _COORD_REGISTRY[name]
44
49
  except KeyError:
@@ -1,16 +1,23 @@
1
- """JSON layer for Journal.
1
+ """JSON envelope layer — shared by `record/` and `render/`.
2
2
 
3
3
  Envelopes Python values that aren't JSON-native (tuple, set, date,
4
- datetime, dicts with non-string keys, DataFrameLite) so a Journal can
5
- be dumped through `json.dumps` and rehydrated. Kept out of `_journal.py`
6
- so the journal core has no coupling — the journal is an event log; JSON
7
- support is a separate concern.
4
+ datetime, dicts with non-string keys, DataFrameLite) so they can be
5
+ dumped through `json.dumps` and rehydrated. Four consumers: the
6
+ journal's JSON form (`record/journal.py`), the FigureIR wire format
7
+ (`record/figure_ir.py`), value-envelope decoding at render hydration
8
+ (`_decode`, `render/_nodes.py`), and the resolved IR's debug view
9
+ (`render/resolved_ir.py`). It sits at the package root because both
10
+ `record/` and `render/` need it and neither may import the other
11
+ (`tests/test_import_boundary.py`). It also keeps JSON support out of
12
+ `journal.py` itself — the journal stays a plain event log.
8
13
 
9
- DataFrame-shaped and numpy inputs never reach this layer: they're
10
- normalized to `DataFrameLite` / plain lists at the recorder boundary
11
- in `chart.py` (via `utils._normalize_data`). So the JSON layer is
12
- data-library-neutral by construction — it never imports pandas or
13
- numpy, never grows a branch per data library.
14
+ DataFrame-shaped inputs never reach this layer: they're normalized to
15
+ `DataFrameLite` / plain lists at the recorder boundary in
16
+ `record/chart.py` (via `utils._normalize_data`), so it never imports
17
+ pandas or grows a branch per data library. numpy is the one exception:
18
+ a matrix artist's data rides through record as a 2-D ndarray (numpy is
19
+ a guaranteed dependency, via scipy), and `json_safe` lowers it to
20
+ nested lists — the JSON wire format itself carries no array types.
14
21
 
15
22
  Envelope keys used here:
16
23
  $dataframe utils.DataFrameLite (canonical DataFrame form)
@@ -23,15 +30,17 @@ Envelope keys used here:
23
30
  $dict_pairs dict whose keys aren't all JSON-native strings
24
31
 
25
32
  `_decode` at the bottom handles the other envelope family — the
26
- *reference* envelopes ($node / $coord / $sectors) that journals and IRs
27
- carry whether or not they ever touch JSON. It lives here because both
28
- halves need it (the render tree's hydrator and the front half's facet
29
- expansion) and it resolves only against shared vocabulary.
33
+ *reference* envelopes ($node / $coord / $sectors / $data) that journals
34
+ and IRs carry whether or not they ever touch JSON. It lives here because
35
+ both halves need it (the render tree's hydrator and the front half's
36
+ facet expansion) and it resolves only against shared vocabulary.
30
37
  """
31
38
  from __future__ import annotations
32
39
  from typing import Any
33
40
 
34
- from .utils import DataFrameLite
41
+ import numpy as np
42
+
43
+ from .utils import DataFrameLite, all_primitive
35
44
 
36
45
 
37
46
  def json_safe(value: Any) -> Any:
@@ -63,6 +72,12 @@ def json_safe(value: Any) -> Any:
63
72
  # values that never passed through the journal emitter, e.g. a
64
73
  # resolved-IR state dict.
65
74
  return {"$sectors": json_safe(value._to_dict())}
75
+ if isinstance(value, np.ndarray):
76
+ # A matrix artist's data section entry. The wire format stays
77
+ # plain nested lists — identical JSON whether the matrix came
78
+ # in as an array or as lists. Recursing catches the rare
79
+ # object-dtype cell.
80
+ return json_safe(value.tolist())
66
81
  if isinstance(value, tuple):
67
82
  return {"$tuple": [json_safe(v) for v in value]}
68
83
  if isinstance(value, (set, frozenset)):
@@ -78,6 +93,11 @@ def json_safe(value: Any) -> Any:
78
93
  return {"$dict_pairs": [[json_safe(k), json_safe(v)]
79
94
  for k, v in value.items()]}
80
95
  if isinstance(value, list):
96
+ # Bulk numeric rows are already JSON-native — one flat scan
97
+ # instead of per-cell recursion (same bail-out in json_hydrate,
98
+ # _decode, and the journal/IR walkers).
99
+ if all_primitive(value):
100
+ return list(value)
81
101
  return [json_safe(v) for v in value]
82
102
  return value
83
103
 
@@ -110,35 +130,50 @@ def json_hydrate(value: Any) -> Any:
110
130
  for k, v in value["$dict_pairs"]}
111
131
  return {k: json_hydrate(v) for k, v in value.items()}
112
132
  if isinstance(value, list):
133
+ if all_primitive(value):
134
+ return list(value)
113
135
  return [json_hydrate(v) for v in value]
114
136
  return value
115
137
 
116
138
 
117
- def _decode(value: Any, nid_to_node: dict) -> Any:
139
+ def _decode(value: Any, nid_to_node: dict, data_table: dict) -> Any:
118
140
  """Resolve plotlet's *reference* envelopes back to live objects —
119
141
  `{"$node"}` via `nid_to_node`, `{"$coord"}` via the coord registry,
120
- `{"$sectors"}` via `Sectors`. Containers recurse; everything else
121
- passes through.
142
+ `{"$sectors"}` via `Sectors`, `{"$data"}` via `data_table` (the
143
+ Journal's / FigureIR's data section). Containers recurse; everything
144
+ else passes through.
122
145
 
123
146
  Distinct from `json_hydrate` above: that undoes the JSON-native
124
- envelopes at the JSON boundary, while these three envelopes live in
125
- journals and IRs whether or not they ever touch JSON, and decode at
126
- hydration time. Shared vocabulary — used by the render tree's
147
+ envelopes at the JSON boundary, while these reference envelopes live
148
+ in journals and IRs whether or not they ever touch JSON, and decode
149
+ at hydration time. Shared vocabulary — used by the render tree's
127
150
  hydrator (`render.hydrate`) and by the facet expansion in
128
- `_ir.py`."""
151
+ `record/figure_ir.py`."""
129
152
  if isinstance(value, dict):
130
153
  if "$node" in value and len(value) == 1:
131
154
  return nid_to_node[value["$node"]]
155
+ if "$data" in value and len(value) == 1:
156
+ return data_table[value["$data"]]
132
157
  if "$coord" in value:
133
158
  from ._coord_registry import resolve_coord
134
159
  cls = resolve_coord(value["$coord"])
135
- return cls._from_dict(_decode(value.get("kwargs", {}), nid_to_node))
160
+ return cls._from_dict(
161
+ _decode(value.get("kwargs", {}), nid_to_node, data_table))
136
162
  if "$sectors" in value:
137
163
  from .sectors import Sectors
138
- return Sectors._from_dict(_decode(value["$sectors"], nid_to_node))
139
- return {k: _decode(v, nid_to_node) for k, v in value.items()}
164
+ return Sectors._from_dict(
165
+ _decode(value["$sectors"], nid_to_node, data_table))
166
+ if "$aes" in value and len(value) == 1:
167
+ from .utils import Aes
168
+ return Aes(value["$aes"])
169
+ return {k: _decode(v, nid_to_node, data_table)
170
+ for k, v in value.items()}
140
171
  if isinstance(value, list):
141
- return [_decode(v, nid_to_node) for v in value]
172
+ if all_primitive(value):
173
+ return list(value)
174
+ return [_decode(v, nid_to_node, data_table) for v in value]
142
175
  if isinstance(value, tuple):
143
- return tuple(_decode(v, nid_to_node) for v in value)
176
+ if all_primitive(value):
177
+ return value
178
+ return tuple(_decode(v, nid_to_node, data_table) for v in value)
144
179
  return value
@@ -15,7 +15,9 @@ The mechanism enforces this:
15
15
  When set, the primitive records its bbox under that name. When
16
16
  unset (the default), nothing is recorded — so artist `draw()`
17
17
  bodies, which never pass `tag=`, produce no regions for free.
18
- * Central code (`core.py`, `legend.py`) passes `tag="title"` /
18
+ * Central code (`render/_chrome_emit.py`, `render/emit.py`,
19
+ `render/_legend.py`)
20
+ passes `tag="title"` /
19
21
  `"spine"` / `"tick-x"` / `"legend-text"` etc. at the chrome
20
22
  emission sites. The tag is right there in the call, grep-able,
21
23
  no contextvar magic.
@@ -85,7 +87,9 @@ _CURRENT: contextvars.ContextVar = contextvars.ContextVar(
85
87
  def record(kind: str, bbox, *, name: str, **meta) -> None:
86
88
  """Record a region into the active sink, if any. Called by
87
89
  `draw.*` primitives when they receive a `tag=` kwarg, and by
88
- central code (`core.py`, `legend.py`) for chrome that has no
90
+ central code (`render/_chrome_emit.py`, `render/emit.py`,
91
+ `render/_legend.py`)
92
+ for chrome that has no
89
93
  primitive call to wrap (panel boundary, canonical legend swatch).
90
94
  No-op when no sink is active — the common render path."""
91
95
  sink = _CURRENT.get()
@@ -3,7 +3,7 @@
3
3
  Both halves walk the same tree shape — the recording side at
4
4
  validation time (over `Chart` / `Layout`), the render side when
5
5
  deriving field state and building share classes (over the render
6
- nodes in `_render_nodes.py`). The protocol is structural:
6
+ nodes in `render/_nodes.py`). The protocol is structural:
7
7
  `_is_parent`, `_children`, `_effective_children()`, `_layout_kind`,
8
8
  `_grid_rows` / `_grid_cols`, `_leaf_kind`, and the `_attached_*`
9
9
  lists. Nothing here imports either half.
@@ -5,10 +5,11 @@ group mirror pairs or shared-machinery siblings — consolidation only
5
5
  where the related artists share enough structure to earn it.
6
6
 
7
7
  Single-artist files (one plot type each):
8
- scatter, line, bar, hist, imshow, heatmap, dendrogram, errorbar
8
+ scatter, line, bar, hist, image_cmap, image_rgba, heatmap, dendrogram, errorbar
9
9
  boxplot, violin, swarm, strip
10
10
  pointplot, ecdf, rug, density_1d, regression, kde_2d, hexbin, hist2d
11
- freqpoly, contour, ridge, qq
11
+ freqpoly, contour, ridge, qq, numeric_bar, annotation_strip
12
+ chord_links, chord_ribbon
12
13
 
13
14
  Category files (closely-related artists grouped):
14
15
  references — axhline, axvline, axhspan, axvspan, hlines, vlines
@@ -25,7 +26,8 @@ from . import scatter # noqa: F401
25
26
  from . import line # noqa: F401
26
27
  from . import bar # noqa: F401
27
28
  from . import hist # noqa: F401
28
- from . import imshow # noqa: F401
29
+ from . import image_cmap # noqa: F401
30
+ from . import image_rgba # noqa: F401
29
31
  from . import heatmap # noqa: F401
30
32
  from . import dendrogram # noqa: F401
31
33
  from . import errorbar # noqa: F401
@@ -45,6 +47,10 @@ from . import freqpoly # noqa: F401
45
47
  from . import contour # noqa: F401
46
48
  from . import ridge # noqa: F401
47
49
  from . import qq # noqa: F401
50
+ from . import numeric_bar # noqa: F401
51
+ from . import annotation_strip # noqa: F401
52
+ from . import chord_links # noqa: F401
53
+ from . import chord_ribbon # noqa: F401
48
54
 
49
55
  # Category files
50
56
  from . import references # noqa: F401
@@ -3,13 +3,158 @@
3
3
  Each built-in artist registers one of the `*_legend_entries` functions so
4
4
  the legend dispatch in `_render_inner` can stay generic — no type-string matching.
5
5
  Paint logic is a nested closure; there are no shared swatch helpers.
6
+
7
+ The `pool_*` / `rgb_buffer` helpers back the PNG-embedded raster paths of
8
+ image_cmap / image_rgba and heatmap: a grid denser than the display can show is box-pooled
9
+ down before encoding. All pooling rules are deterministic (fixed bin
10
+ edges, fixed iteration order, first-seen tie-breaks), so the
11
+ byte-identical-SVG guarantee holds.
6
12
  """
7
- from .._spec import _LEGSPEC
13
+ import math
14
+
15
+ import numpy as np
16
+
17
+ from .._spec import _LEGSPEC, _D
8
18
  from ..draw import marker, segment, rect
9
19
  from ..draw import TAB10
10
20
  from ..utils import to_list, resolve_aes, palette_color
11
21
 
12
22
 
23
+ def pool_target(n_cells, px):
24
+ """Output-pixel count along one raster axis: full data resolution
25
+ until cells outnumber `raster_oversample` per display pixel, then
26
+ capped at that. The oversample headroom keeps hi-dpi rendering and
27
+ moderate zoom sharp; past it the viewer can't show the extra cells
28
+ anyway, and its nearest-neighbour downscale would *subsample* them
29
+ (drop cells) where pooling summarizes them."""
30
+ cap = max(1, math.ceil(px * _D["raster_oversample"]))
31
+ return n_cells if n_cells <= cap else cap
32
+
33
+
34
+ def pool_mean(values):
35
+ """NaN/None-ignoring mean of one pooling bin; an all-absent bin → NaN
36
+ (renders as the artist's absent color, like a NaN cell)."""
37
+ fin = [v for v in values if v is not None and v == v]
38
+ if not fin:
39
+ return float("nan")
40
+ return sum(fin) / len(fin)
41
+
42
+
43
+ def pool_mode(values):
44
+ """Most frequent value in one pooling bin, for categorical cells
45
+ (a mean of category labels is meaningless). NaN folds into None —
46
+ both render as absent. Ties break to the first-seen value."""
47
+ counts = {}
48
+ for v in values:
49
+ if isinstance(v, float) and v != v:
50
+ v = None
51
+ counts[v] = counts.get(v, 0) + 1
52
+ return max(counts.items(), key=lambda kv: kv[1])[0]
53
+
54
+
55
+ def pool_grid(matrix, out_h, out_w, pool):
56
+ """Box-pool a `[row][col]` grid down to `out_h × out_w`. Bin edges by
57
+ integer rounding; every bin is non-empty because `out_* <= n_*`."""
58
+ nrows = len(matrix)
59
+ ncols = len(matrix[0])
60
+ re = [nrows * i // out_h for i in range(out_h + 1)]
61
+ ce = [ncols * j // out_w for j in range(out_w + 1)]
62
+ return [[pool([matrix[r][c]
63
+ for r in range(re[i], re[i + 1])
64
+ for c in range(ce[j], ce[j + 1])])
65
+ for j in range(out_w)]
66
+ for i in range(out_h)]
67
+
68
+
69
+ def rgb_buffer(grid, rgb_of):
70
+ """Flatten a `[row][col]` value grid into a packed RGB byte buffer
71
+ via `rgb_of(value) -> (r, g, b)` — the input `image_png` expects."""
72
+ buf = bytearray()
73
+ for row in grid:
74
+ for v in row:
75
+ r, g, b = rgb_of(v)
76
+ buf.append(r); buf.append(g); buf.append(b)
77
+ return buf
78
+
79
+
80
+ def minmax_grid(d, lo, hi):
81
+ """NaN/None-ignoring min/max over a 2-D cell grid, for autoscale.
82
+ Fills only the unset bounds; returns `(lo, hi, found)` where `found`
83
+ says whether any finite cell existed (callers keep their empty-grid
84
+ defaults). Vectorized — min/max are comparisons, not arithmetic, so
85
+ the result is exact; a ±0.0 tie canonicalizes to +0.0 (`x + 0.0`)
86
+ so it can't depend on scan order. Non-numeric cells fall back to
87
+ the scalar scan."""
88
+ try:
89
+ a = np.asarray(d, dtype=np.float64) # None cells → NaN
90
+ except (TypeError, ValueError):
91
+ flat = [v for row in d for v in row if v is not None and v == v]
92
+ if flat:
93
+ if lo is None: lo = min(flat)
94
+ if hi is None: hi = max(flat)
95
+ return lo, hi, bool(flat)
96
+ if bool(np.isnan(a).all()):
97
+ return lo, hi, False
98
+ if lo is None: lo = float(np.nanmin(a)) + 0.0
99
+ if hi is None: hi = float(np.nanmax(a)) + 0.0
100
+ return lo, hi, True
101
+
102
+
103
+ def _pool_mean_np(a, out_h, out_w):
104
+ """`pool_grid(..., pool_mean)` vectorized: box-pool a 2-D float
105
+ array to `out_h × out_w` by NaN-ignoring mean.
106
+
107
+ Bit-identical to the scalar path by construction: cells are added
108
+ in the same row-major order (vectorized *across* bins, sequential
109
+ *within* each bin via the offset loop), NaN adds 0.0 — exact, since
110
+ `x + 0.0 == x` — and one final division mirrors `sum(fin)/len(fin)`.
111
+ Only elementwise ops: `np.sum`/`np.mean` pairwise summation is a
112
+ numpy implementation detail that may change across versions, which
113
+ would break byte-identical SVG across machines."""
114
+ nrows, ncols = a.shape
115
+ re = np.array([nrows * i // out_h for i in range(out_h + 1)])
116
+ ce = np.array([ncols * j // out_w for j in range(out_w + 1)])
117
+ r0, rl = re[:-1], np.diff(re)
118
+ c0, cl = ce[:-1], np.diff(ce)
119
+ valid = ~np.isnan(a)
120
+ vals = np.where(valid, a, 0.0)
121
+ acc = np.zeros((out_h, out_w))
122
+ cnt = np.zeros((out_h, out_w), dtype=np.int64)
123
+ for dr in range(int(rl.max())):
124
+ ri = np.nonzero(dr < rl)[0]
125
+ row_v = vals[r0[ri] + dr] # gather rows once per offset
126
+ row_m = valid[r0[ri] + dr]
127
+ for dc in range(int(cl.max())):
128
+ ci = np.nonzero(dc < cl)[0]
129
+ dst = np.ix_(ri, ci)
130
+ acc[dst] += row_v[:, c0[ci] + dc]
131
+ cnt[dst] += row_m[:, c0[ci] + dc]
132
+ return np.divide(acc, cnt, out=np.full((out_h, out_w), np.nan),
133
+ where=cnt > 0)
134
+
135
+
136
+ def png_value_cells(rows, out_h, out_w, norm, lut, absent_rgb):
137
+ """Vectorized pool + colormap for a numeric cell grid → `(rgb_bytes,
138
+ downsampled)`, or None when the norm only has a scalar form (log) —
139
+ the caller then falls back to `pool_grid` + `rgb_buffer`. `rows` is
140
+ a nested list (None/NaN cells → absent color) or a 2-D ndarray."""
141
+ if norm.kind == "log":
142
+ return None
143
+ a = np.asarray(rows, dtype=np.float64) # None cells → NaN
144
+ downsampled = (out_h, out_w) != a.shape
145
+ if downsampled:
146
+ a = _pool_mean_np(a, out_h, out_w)
147
+ u = norm.to_unit_array(a)
148
+ nan_mask = np.isnan(u)
149
+ # Same rounding as the scalar `int(u * 255 + 0.5)`: values are
150
+ # >= 0 after clipping, so trunc-toward-zero == floor.
151
+ idx = (np.where(nan_mask, 0.0, u) * 255.0 + 0.5).astype(np.int64)
152
+ rgb = np.frombuffer(bytes(lut), dtype=np.uint8).reshape(256, 3)[idx]
153
+ if nan_mask.any():
154
+ rgb[nan_mask] = absent_rgb
155
+ return rgb.tobytes(), downsampled
156
+
157
+
13
158
  # Used by long-form expansion for `linestyle=` and `alpha=` column splits.
14
159
  LINESTYLE_CYCLE = (None, "--", ":", "-.")
15
160
  DEFAULT_ALPHA_RANGE = (0.3, 1.0)
@@ -104,6 +249,11 @@ def expand_xy_long_form(kind, data, x_col, y_col,
104
249
  elif alpha_value is not None:
105
250
  opts["alpha"] = alpha_value
106
251
  records.append({"type": kind, "xs": xs_g, "ys": ys_g, "opts": opts})
252
+ # The raster/simplify thresholds gate on the artist call's total mark
253
+ # count, not each fan-out slice — ten 10k-point groups strain the
254
+ # renderer exactly like one 100k-point series.
255
+ for rec in records:
256
+ rec["opts"]["_fanout_n"] = n
107
257
  return records
108
258
 
109
259