@rho-workspace/rho 0.5.6 → 0.5.7

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 (211) hide show
  1. package/dist/agent-connect.d.ts +36 -2
  2. package/dist/agent-connect.js +64 -11
  3. package/dist/answer-board.js +3 -3
  4. package/dist/bridge.js +6 -1
  5. package/dist/browser-access.d.ts +1 -0
  6. package/dist/browser-access.js +5 -3
  7. package/dist/capabilities.d.ts +21 -5
  8. package/dist/capabilities.js +6 -4
  9. package/dist/cli.js +76 -11
  10. package/dist/composition.d.ts +1 -1
  11. package/dist/composition.js +13 -3
  12. package/dist/connect-cli.d.ts +50 -0
  13. package/dist/connect-cli.js +128 -0
  14. package/dist/contract.generated.d.ts +1191 -278
  15. package/dist/contract.generated.js +1 -1
  16. package/dist/contracts/owners.generated.js +845 -47
  17. package/dist/conversation-presentation.d.ts +9 -2
  18. package/dist/conversation-presentation.js +3 -1
  19. package/dist/conversations.d.ts +154 -8
  20. package/dist/conversations.js +147 -24
  21. package/dist/discovery.js +2 -2
  22. package/dist/extension-contract.d.ts +19 -0
  23. package/dist/extension-contract.js +15 -6
  24. package/dist/extension-markets.d.ts +74 -0
  25. package/dist/extension-markets.js +166 -0
  26. package/dist/extensions.d.ts +29 -1
  27. package/dist/extensions.js +13 -4
  28. package/dist/external-work.d.ts +22 -0
  29. package/dist/external-work.js +91 -0
  30. package/dist/plugin-cli.js +75 -3
  31. package/dist/plugin-inventory.d.ts +56 -0
  32. package/dist/plugin-inventory.js +41 -0
  33. package/dist/plugin-inventory.json +1581 -102
  34. package/dist/plugin-presentation.d.ts +105 -2
  35. package/dist/plugin-presentation.js +59 -5
  36. package/dist/plugins.d.ts +14 -3
  37. package/dist/plugins.js +12 -6
  38. package/dist/project-notes.js +1 -1
  39. package/dist/protocol.d.ts +6 -0
  40. package/dist/protocol.js +30 -12
  41. package/dist/replay.js +4 -0
  42. package/dist/response-contracts.js +26 -11
  43. package/dist/running.d.ts +10 -0
  44. package/dist/running.js +45 -0
  45. package/dist/server.d.ts +2 -0
  46. package/dist/server.js +71 -8
  47. package/dist/state.d.ts +8 -0
  48. package/dist/state.js +8 -0
  49. package/dist/studio-layout.js +3 -3
  50. package/dist/task-frame.d.ts +1 -1
  51. package/dist/timeline-events.d.ts +1 -0
  52. package/dist/timeline-events.js +8 -1
  53. package/dist/ui-context.d.ts +73 -0
  54. package/dist/ui-context.js +98 -0
  55. package/dist/ui-contract.d.ts +23 -0
  56. package/dist/web/.vite/manifest.json +133 -74
  57. package/dist/web/assets/answer-board-CeYS8k8y.js +1 -0
  58. package/dist/web/assets/choices-D8vGwsl2.js +1 -0
  59. package/dist/web/assets/{console-Trqc0swD.js → console-Dyuo1q4N.js} +4 -4
  60. package/dist/web/assets/{dist-pF902jHA.js → dist-CSiCyBml.js} +1 -1
  61. package/dist/web/assets/{dist-DpIHRk3B.js → dist-D9FLb9Hm.js} +1 -1
  62. package/dist/web/assets/{dist-eMx6b8N1.js → dist-FXbTBwML.js} +1 -1
  63. package/dist/web/assets/{dist-DZmszrjG.js → dist-l3DUhbw5.js} +1 -1
  64. package/dist/web/assets/{explore-in-BCHqjJ7B.js → explore-in-BfSFGRh2.js} +1 -1
  65. package/dist/web/assets/graph-BU2_o5uA.js +2 -0
  66. package/dist/web/assets/index-C16mXVvk.css +1 -0
  67. package/dist/web/assets/index-CWg9XYXx.js +952 -0
  68. package/dist/web/assets/markdown.reader-Ct2-e2Zt.png +0 -0
  69. package/dist/web/assets/{notes-meaning-DcVGUBBY.js → notes-meaning-T2m5zQfv.js} +1 -1
  70. package/dist/web/assets/overview-PzfLJCo-.js +6 -0
  71. package/dist/web/assets/preview-BguEdsza.js +1 -0
  72. package/dist/web/assets/previews-pWNik-2g.js +3 -0
  73. package/dist/web/assets/{review-DYP0aCXb.js → review-Dy_85lcH.js} +1 -1
  74. package/dist/web/assets/rho.editor-CCJeyHma.png +0 -0
  75. package/dist/web/assets/rho.flow-WkMj6fRS.png +0 -0
  76. package/dist/web/assets/rho.media-Cc6amodO.png +0 -0
  77. package/dist/web/assets/rho.notebook-6vsZKGDq.png +0 -0
  78. package/dist/web/assets/rho.object-BGoeXjte.png +0 -0
  79. package/dist/web/assets/rho.plots-yIiISCkb.png +0 -0
  80. package/dist/web/assets/rho.scatter-DwHxesCs.png +0 -0
  81. package/dist/web/assets/rho.servers-DKf_mnNq.png +0 -0
  82. package/dist/web/assets/rho.table-DWOGaWDa.png +0 -0
  83. package/dist/web/assets/rho.variables-CKcWczhQ.png +0 -0
  84. package/dist/web/assets/{skills-1ec0Z-jx.js → skills-Dyz7mBlc.js} +1 -1
  85. package/dist/web/assets/{skills-work-DNexV4OJ.js → skills-work-BEGqlCkx.js} +1 -1
  86. package/dist/web/assets/studio-process-D-ykbC4D.js +2 -0
  87. package/dist/web/assets/timeline-events-DKTho9fU.js +1 -0
  88. package/dist/web/assets/work-turns-DSabCogR.js +1 -0
  89. package/dist/web/assets/{workspace-COmCbGV3.js → workspace-DfjBL--r.js} +2 -2
  90. package/dist/web/composition.json +138 -83
  91. package/dist/web/index.html +3 -3
  92. package/dist/web/renderer.json +1 -1
  93. package/dist/web-assets.js +2 -2
  94. package/dist/work-graph.d.ts +11 -1
  95. package/dist/work-graph.js +16 -5
  96. package/dist/workspace.d.ts +2 -2
  97. package/dist/workspace.js +8 -7
  98. package/docs/FRONTEND.md +53 -11
  99. package/docs/HEADLESS.md +104 -11
  100. package/docs/THIRD-PARTY-PLUGINS.md +75 -3
  101. package/node_modules/@rho-workspace/plugin-agent/README.md +5 -0
  102. package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.d.ts +31 -0
  103. package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.js +45 -10
  104. package/node_modules/@rho-workspace/plugin-agent/dist/acp-conversation.js +19 -3
  105. package/node_modules/@rho-workspace/plugin-agent/dist/external-session-contract.js +3 -2
  106. package/node_modules/@rho-workspace/plugin-agent/dist/external-session.d.ts +1 -1
  107. package/node_modules/@rho-workspace/plugin-agent/dist/index.d.ts +8 -0
  108. package/node_modules/@rho-workspace/plugin-agent/dist/index.js +3 -2
  109. package/node_modules/@rho-workspace/plugin-agent/dist/mcp-entry.d.ts +13 -0
  110. package/node_modules/@rho-workspace/plugin-agent/dist/mcp-entry.js +13 -0
  111. package/node_modules/@rho-workspace/plugin-agent/dist/mcp.js +16 -8
  112. package/node_modules/@rho-workspace/plugin-agent/dist/native-sessions.d.ts +1 -1
  113. package/node_modules/@rho-workspace/plugin-agent/package.json +1 -1
  114. package/node_modules/@rho-workspace/plugin-figures/LICENSE +661 -0
  115. package/node_modules/@rho-workspace/plugin-figures/README.md +191 -0
  116. package/node_modules/@rho-workspace/plugin-figures/dist/index.d.ts +236 -0
  117. package/node_modules/@rho-workspace/plugin-figures/dist/index.js +165 -0
  118. package/node_modules/@rho-workspace/plugin-figures/dist/operations.d.ts +44 -0
  119. package/node_modules/@rho-workspace/plugin-figures/dist/operations.js +39 -0
  120. package/node_modules/@rho-workspace/plugin-figures/dist/presentation.d.ts +19 -0
  121. package/node_modules/@rho-workspace/plugin-figures/dist/presentation.js +26 -0
  122. package/node_modules/@rho-workspace/plugin-figures/dist/record.d.ts +188 -0
  123. package/node_modules/@rho-workspace/plugin-figures/dist/record.js +143 -0
  124. package/node_modules/@rho-workspace/plugin-figures/dist/ui-styles.d.ts +1 -0
  125. package/node_modules/@rho-workspace/plugin-figures/dist/ui-styles.js +68 -0
  126. package/node_modules/@rho-workspace/plugin-figures/dist/ui.d.ts +56 -0
  127. package/node_modules/@rho-workspace/plugin-figures/dist/ui.js +363 -0
  128. package/node_modules/@rho-workspace/plugin-figures/package.json +41 -0
  129. package/node_modules/@rho-workspace/plugin-figures/py/figures.py +856 -0
  130. package/node_modules/@rho-workspace/plugin-figures/r/figures.R +775 -0
  131. package/node_modules/@rho-workspace/plugin-kernel/README.md +27 -3
  132. package/node_modules/@rho-workspace/plugin-kernel/dist/contract.d.ts +12 -0
  133. package/node_modules/@rho-workspace/plugin-kernel/dist/contract.js +8 -1
  134. package/node_modules/@rho-workspace/plugin-kernel/dist/index.d.ts +61 -0
  135. package/node_modules/@rho-workspace/plugin-kernel/dist/index.js +125 -9
  136. package/node_modules/@rho-workspace/plugin-kernel/dist/session-scope.d.ts +14 -0
  137. package/node_modules/@rho-workspace/plugin-kernel/dist/session-scope.js +9 -0
  138. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-base.d.ts +23 -5
  139. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook-work.js +1 -1
  140. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook.d.ts +6 -4
  141. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook.js +44 -17
  142. package/node_modules/@rho-workspace/plugin-kernel/dist/ui-styles.js +6 -0
  143. package/node_modules/@rho-workspace/plugin-kernel/package.json +1 -1
  144. package/node_modules/@rho-workspace/plugin-kernel/runners/runner.R +144 -4
  145. package/node_modules/@rho-workspace/plugin-kernel/runners/runner.py +106 -1
  146. package/node_modules/@rho-workspace/plugin-tables/README.md +5 -0
  147. package/node_modules/@rho-workspace/plugin-tables/dist/index.d.ts +1 -0
  148. package/node_modules/@rho-workspace/plugin-tables/dist/index.js +19 -3
  149. package/node_modules/@rho-workspace/plugin-tables/dist/ui.js +1 -1
  150. package/node_modules/@rho-workspace/plugin-tables/package.json +1 -1
  151. package/node_modules/jose/dist/webapi/jwe/general/decrypt.js +3 -2
  152. package/node_modules/jose/dist/webapi/jwks/remote.js +1 -1
  153. package/node_modules/jose/dist/webapi/jws/general/verify.js +2 -8
  154. package/node_modules/jose/dist/webapi/lib/jwe_decrypt.js +11 -8
  155. package/node_modules/jose/dist/webapi/lib/key_management.js +0 -1
  156. package/node_modules/jose/package.json +1 -1
  157. package/package.json +6 -4
  158. package/protocol/index.json +91 -55
  159. package/protocol/operations/agent.sessions.list.json +13 -5
  160. package/protocol/operations/agent.sessions.read.json +13 -5
  161. package/protocol/operations/capability.list.json +382 -6
  162. package/protocol/operations/composition.apply.json +10 -2
  163. package/protocol/operations/composition.delete.json +845 -0
  164. package/protocol/operations/composition.read.json +10 -2
  165. package/protocol/operations/composition.undo.json +10 -2
  166. package/protocol/operations/conversation.agents.add.json +346 -0
  167. package/protocol/operations/conversation.agents.remove.json +340 -0
  168. package/protocol/operations/conversation.decide.json +196 -0
  169. package/protocol/operations/conversation.permission.json +196 -0
  170. package/protocol/operations/conversation.read.json +196 -0
  171. package/protocol/operations/conversation.send.json +265 -0
  172. package/protocol/operations/conversation.settings.check.json +213 -495
  173. package/protocol/operations/conversation.settings.read.json +211 -490
  174. package/protocol/operations/conversation.settings.update.json +235 -554
  175. package/protocol/operations/conversation.start.json +267 -5
  176. package/protocol/operations/conversation.stop.json +196 -0
  177. package/protocol/operations/extension.describe.json +39 -0
  178. package/protocol/operations/extension.install.json +62 -0
  179. package/protocol/operations/extension.list.json +164 -1
  180. package/protocol/operations/extension.market.change.json +210 -0
  181. package/protocol/operations/figure.read.json +265 -0
  182. package/protocol/operations/figure.select.json +157 -0
  183. package/protocol/operations/kernel.interrupt.json +119 -0
  184. package/protocol/operations/kernel.read.json +119 -0
  185. package/protocol/operations/kernel.run.json +119 -0
  186. package/protocol/operations/observation.snapshot.json +416 -8
  187. package/protocol/operations/project.openFile.json +10 -2
  188. package/protocol/operations/session.grant.json +4 -0
  189. package/protocol/operations/shared.json +1 -0
  190. package/protocol/operations/view.close.json +10 -2
  191. package/protocol/operations/view.protection.set.json +10 -2
  192. package/vendor/composition.json +177 -112
  193. package/vendor/rho-workspace-plugin-agent-0.4.33.tgz +0 -0
  194. package/vendor/rho-workspace-plugin-figures-0.5.1.tgz +0 -0
  195. package/vendor/rho-workspace-plugin-kernel-0.5.13.tgz +0 -0
  196. package/vendor/rho-workspace-plugin-tables-0.5.2.tgz +0 -0
  197. package/web/theme.css +5 -0
  198. package/dist/web/assets/answer-board-CBkGbSLD.js +0 -1
  199. package/dist/web/assets/canvas-D8rDdfDz.js +0 -1
  200. package/dist/web/assets/choices-D-YfnFP6.js +0 -1
  201. package/dist/web/assets/graph-lENgfQNH.js +0 -2
  202. package/dist/web/assets/index-B3_OVy91.css +0 -1
  203. package/dist/web/assets/index-BpL_Kl_s.js +0 -892
  204. package/dist/web/assets/overview-CDnVKlOt.js +0 -6
  205. package/dist/web/assets/preview-BEC1E9MK.js +0 -1
  206. package/dist/web/assets/studio-process-EYQgikxq.js +0 -2
  207. package/dist/web/assets/timeline-events-Chj2Gbx7.js +0 -1
  208. package/dist/web/assets/work-turns-CT9RVcWY.js +0 -1
  209. package/vendor/rho-workspace-plugin-agent-0.4.30.tgz +0 -0
  210. package/vendor/rho-workspace-plugin-kernel-0.5.5.tgz +0 -0
  211. package/vendor/rho-workspace-plugin-tables-0.5.0.tgz +0 -0
@@ -0,0 +1,856 @@
1
+ # Semantic figures for a live Python session: figures drawn with matplotlib, and with what draws
2
+ # through it (seaborn, pandas .plot). The Notebook's runner runs this file in a namespace of its own
3
+ # and calls
4
+ #
5
+ # describe_figure(figure, into, namespace, **saving)
6
+ # after the figure's picture is written: writes the figure record (rho.figure/1) to `into`
7
+ # and returns its summary, {'problem': why} when it could not, or None for an object this file
8
+ # does not describe. `namespace` is the session's; `saving` holds the savefig arguments the
9
+ # picture was written with (bbox_inches, pad_inches), so the record draws the same page.
10
+ #
11
+ # matplotlib keeps what each Axes drew as artists: a scatter's points as one collection with their
12
+ # offsets, a line with its vertices, each bar a rectangle, a heatmap a mesh of cells, a label with
13
+ # its text and position. Each artist is given an id for the drawing, the figure is written as SVG,
14
+ # and each element matplotlib wrote inside that artist's group is named after the marks it stands
15
+ # for (data-rho="unit.element"). Where the elements cannot be matched to the artist's marks one for
16
+ # one, the layer says why and nothing is guessed.
17
+ #
18
+ # A mark keeps the values it was drawn at, in the data's units (a log axis is read in counts). Points,
19
+ # lines and labels whose x and y are two columns of a table in the session (a pandas DataFrame, a
20
+ # numeric Series with its index, a 2-D numeric array) are bound to its rows, value for value and in
21
+ # the table's order; rows that were not drawn are skipped. A position drawn from a column through a
22
+ # common transform (-log10, log10, log2, log2(x + 1), log1p) is matched through it, and the layer's
23
+ # mapping says so; when an earlier layer of the same Axes draws that column on the same axis through
24
+ # another transform, the later layer's note says the two place their marks on different scales. A
25
+ # label that names rows of a table another layer of the same plot is bound to is bound by its text.
26
+ # A heatmap's cells name their row, column and value, and the row of the session table drawn when
27
+ # one holds the same values.
28
+ #
29
+ # A describer never changes the session: the ids it gives artists are taken back, and its output and
30
+ # warnings are discarded. The protected resources are the session's time and the state folder: it
31
+ # stops after maxSeconds, refuses more than maxMarks marks and writes no record over maxBytes.
32
+ import contextlib, io, json, math, os, platform, re, sys, time, uuid, warnings
33
+ import xml.etree.ElementTree as ET
34
+
35
+ limits = {'maxMarks': 50000, 'maxSeconds': 20, 'maxBytes': 16 * 1024 * 1024, 'maxColumns': 40,
36
+ 'maxTables': 64, 'maxTableCells': 5000000, 'maxComparedColumns': 64, 'maxCellChars': 160}
37
+ schema = 'rho.figure/1'
38
+ SVG, XLINK = 'http://www.w3.org/2000/svg', 'http://www.w3.org/1999/xlink'
39
+ drawn_tags = {'path', 'use', 'text', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'image'}
40
+ hidden_tags = {'defs', 'clipPath', 'mask', 'pattern', 'marker', 'metadata', 'style', 'title', 'desc', 'symbol'}
41
+
42
+ class Late(Exception): pass
43
+
44
+ def _np():
45
+ import numpy
46
+ return numpy
47
+
48
+ def clip(text, n=None):
49
+ n = n or limits['maxCellChars']
50
+ text = str(text)
51
+ return text if len(text) <= n else text[:n - 1] + '…'
52
+
53
+ def num(v):
54
+ try: v = float(v)
55
+ except (TypeError, ValueError): return None
56
+ return float('%.7g' % v) if math.isfinite(v) else None
57
+
58
+ def hex_colour(rgba):
59
+ try:
60
+ r, g, b = (max(0, min(255, int(round(float(c) * 255)))) for c in list(rgba)[:3])
61
+ return '#%02x%02x%02x' % (r, g, b)
62
+ except Exception: return None
63
+
64
+ # A column as the record keeps it: numbers to 7 significant digits, missing values as null, text cut.
65
+ def column(name, values):
66
+ np = _np()
67
+ arr = values if isinstance(values, np.ndarray) else np.asarray(list(values), dtype=object)
68
+ kind = arr.dtype.kind
69
+ if kind == 'b': return {'name': name, 'type': 'logical', 'values': [bool(v) for v in arr]}
70
+ if kind in 'iu': return {'name': name, 'type': 'integer', 'values': [int(v) for v in arr]}
71
+ if kind == 'f': return {'name': name, 'type': 'number', 'values': [num(v) for v in arr]}
72
+ missing = lambda v: v is None or (isinstance(v, float) and math.isnan(v))
73
+ present = [v for v in arr if not missing(v)]
74
+ if present and all(isinstance(v, (bool, np.bool_)) for v in present):
75
+ return {'name': name, 'type': 'logical', 'values': [None if missing(v) else bool(v) for v in arr]}
76
+ if present and all(isinstance(v, (int, float, np.integer, np.floating)) and not isinstance(v, (bool, np.bool_)) for v in present):
77
+ return {'name': name, 'type': 'number', 'values': [None if missing(v) else num(v) for v in arr]}
78
+ return {'name': name, 'type': 'text', 'values': [None if missing(v) else clip(v) for v in arr]}
79
+
80
+ # Tables -------------------------------------------------------------------------------------------
81
+ # A table of the session, read only for the columns a comparison asks for.
82
+ class Table:
83
+ def __init__(self, name, rows, names, get, index=None, index_name=None):
84
+ self.name, self.rows, self.names, self.get = name, rows, names, get
85
+ self.index, self.index_name, self.cache = index, index_name, {}
86
+ def values(self, column):
87
+ if column not in self.cache:
88
+ self.cache[column] = self.index if column == '.index' else self.get(column)
89
+ return self.cache[column]
90
+ def numeric(self):
91
+ np = _np()
92
+ out = [c for c in self.names[:4 * limits['maxComparedColumns']] if getattr(self.values(c), 'dtype', None) is not None and self.values(c).dtype.kind in 'iuf'][:limits['maxComparedColumns']]
93
+ if self.index is not None and self.index.dtype.kind in 'iuf': out.append('.index')
94
+ return out
95
+ # The column that names each row once: a text index, else a unique text column, else a unique
96
+ # numeric id column.
97
+ def key(self):
98
+ np = _np()
99
+ unique = lambda v: len(v) == len(set(v.tolist())) and not any(x is None or (isinstance(x, float) and math.isnan(x)) for x in v.tolist())
100
+ if self.index is not None and self.index.dtype.kind in 'OUS' and unique(self.index): return self.index_name
101
+ for c in self.names:
102
+ v = self.values(c)
103
+ if v.dtype.kind in 'OUS' and unique(v): return c
104
+ for c in self.names:
105
+ v = self.values(c)
106
+ if v.dtype.kind in 'iu' and re.match(r'^(node|id|.*[._]id|ID)$', c) and unique(v): return c
107
+ return None
108
+ def source(self, rows):
109
+ np = _np()
110
+ rows = np.asarray(sorted(set(int(r) for r in rows)), dtype=np.int64)
111
+ key = self.key()
112
+ names = [c for c in self.names][:limits['maxColumns']]
113
+ cols = [column(c, self.values(c)[rows]) for c in names]
114
+ if self.index is not None and (self.index.dtype.kind in 'OUS' or self.index_name != '.rownames'):
115
+ cols.insert(0, column(self.index_name, self.index[rows]))
116
+ return {'id': self.name, 'rows': int(self.rows), 'columnsTotal': len(self.names), 'key': key, 'index': [int(r) for r in rows], 'columns': cols}
117
+
118
+ def series_values(series):
119
+ np, pd = _np(), sys.modules.get('pandas')
120
+ try:
121
+ if pd is not None:
122
+ if isinstance(series.dtype, pd.CategoricalDtype): return series.astype(object).to_numpy()
123
+ if pd.api.types.is_datetime64_any_dtype(series) or pd.api.types.is_timedelta64_dtype(series): return series.astype(str).to_numpy()
124
+ if pd.api.types.is_extension_array_dtype(series):
125
+ if pd.api.types.is_numeric_dtype(series) and not pd.api.types.is_bool_dtype(series): return series.to_numpy(dtype=float, na_value=np.nan)
126
+ return series.to_numpy(dtype=object, na_value=None)
127
+ return series.to_numpy()
128
+ except Exception: return np.asarray(series, dtype=object)
129
+
130
+ def as_table(name, value):
131
+ np, pd = _np(), sys.modules.get('pandas')
132
+ if pd is not None and isinstance(value, pd.DataFrame):
133
+ if not len(value) or value.size > limits['maxTableCells']: return None
134
+ labels, seen = [], set()
135
+ for i, c in enumerate(value.columns):
136
+ text = c if isinstance(c, str) else ' '.join(map(str, c)) if isinstance(c, tuple) else str(c)
137
+ if text in seen: continue
138
+ seen.add(text); labels.append((text, i))
139
+ at = dict(labels)
140
+ index = value.index
141
+ index_name = index.name if isinstance(index.name, str) and index.name not in at else '.rownames'
142
+ return Table(name, len(value), [t for t, _ in labels], lambda c: series_values(value.iloc[:, at[c]]), series_values(index.to_series()) if index.nlevels == 1 else None, index_name)
143
+ if pd is not None and isinstance(value, pd.Series):
144
+ if not len(value) or len(value) > limits['maxTableCells'] or not value.index.is_unique or not pd.api.types.is_numeric_dtype(value): return None
145
+ label = value.name if isinstance(value.name, str) else 'value'
146
+ index_name = value.index.name if isinstance(value.index.name, str) and value.index.name != label else '.rownames'
147
+ return Table(name, len(value), [label], lambda c: series_values(value), series_values(value.index.to_series()), index_name)
148
+ if isinstance(value, np.ndarray) and value.ndim == 2 and value.dtype.kind in 'iuf' and 0 < value.size <= limits['maxTableCells']:
149
+ return Table(name, value.shape[0], [str(j) for j in range(value.shape[1])], lambda c: value[:, int(c)])
150
+ return None
151
+
152
+ # The tables the cell's code names come first: a figure is bound to what its code drew from.
153
+ def session_tables(namespace, prefer=()):
154
+ out = []
155
+ first = [n for n in prefer if n in namespace]
156
+ for name, value in [(n, namespace[n]) for n in first] + [(n, v) for n, v in list(namespace.items()) if n not in first]:
157
+ if len(out) >= limits['maxTables']: break
158
+ if name.startswith('_'): continue
159
+ try: t = as_table(name, value)
160
+ except Exception: t = None
161
+ if t is not None: out.append(t)
162
+ return out
163
+
164
+ # A drawn position matches a column through one of these, in this order.
165
+ def transforms(np):
166
+ return [('{}', lambda v: v), ('-log10({})', lambda v: -np.log10(v)), ('log10({})', np.log10), ('log2({})', np.log2),
167
+ ('log2({} + 1)', lambda v: np.log2(v + 1)), ('log1p({})', np.log1p)]
168
+
169
+ # Equal, or within 1e-9 of each other relative to their size. A value that is not finite matches only
170
+ # itself: -log10(0) is inf, and inf is not near every number.
171
+ def near(a, b):
172
+ np = _np()
173
+ with np.errstate(all='ignore'):
174
+ return (a == b) | (np.isfinite(a) & np.isfinite(b) & (np.abs(a - b) <= 1e-9 * np.maximum(np.abs(a), np.abs(b))))
175
+
176
+ # The rows of a column, in order, whose values are the drawn ones; rows that were not drawn are
177
+ # skipped. None when the drawn values are not the column's.
178
+ def rows_of(drawn, col, u=None):
179
+ np = _np()
180
+ n = len(drawn)
181
+ if u is None: u = np.unique(drawn)
182
+ j = np.searchsorted(u, col)
183
+ member = near(col, u[np.clip(j - 1, 0, len(u) - 1)]) | near(col, u[np.clip(j, 0, len(u) - 1)])
184
+ kept = np.flatnonzero(member)
185
+ if len(kept) < n: return None
186
+ if len(kept) == n: return kept if near(col[kept], drawn).all() else None
187
+ values = col[kept]
188
+ if len(np.unique(values)) == len(values):
189
+ order = np.argsort(values, kind='stable')
190
+ at = np.clip(np.searchsorted(values[order], drawn), 0, len(values) - 1)
191
+ lower = np.clip(at - 1, 0, len(values) - 1)
192
+ pick = np.where(near(values[order][at], drawn), at, lower)
193
+ rows = kept[order[pick]]
194
+ return rows if near(col[rows], drawn).all() and (np.diff(rows) > 0).all() else None
195
+ if len(kept) > 200000: return None
196
+ out, k = [], 0
197
+ for r, v in zip(kept.tolist(), values.tolist()):
198
+ d = drawn[k]
199
+ if v == d or (math.isfinite(v) and abs(v - d) <= 1e-9 * max(abs(v), abs(d))):
200
+ out.append(r); k += 1
201
+ if k == n: break
202
+ return np.asarray(out, dtype=np.int64) if k == n else None
203
+
204
+ def match_table(cols, tables, deadline):
205
+ np = _np()
206
+ x, y = cols['x'], cols['y']
207
+ n = len(x)
208
+ if n < 3: return None
209
+ best = None
210
+ every = transforms(np)
211
+ unique = {a: np.unique(cols[a]) for a in ('x', 'y')}
212
+ for allowed in (every[:1], every):
213
+ # A table with fewer rows than the marks drawn cannot hold them.
214
+ for t in [t for t in tables if t.rows >= n]:
215
+ numeric = t.numeric()
216
+ with np.errstate(all='ignore'):
217
+ for a, b in (('x', 'y'), ('y', 'x')):
218
+ for cn in numeric:
219
+ for name, f in allowed:
220
+ if time.monotonic() > deadline: raise Late()
221
+ source = np.asarray(t.values(cn), dtype=float)
222
+ rows = rows_of(cols[a], f(source), unique[a])
223
+ if rows is None: continue
224
+ fit, how = -1, None
225
+ for c2 in numeric:
226
+ v2 = np.asarray(t.values(c2), dtype=float)[rows]
227
+ for name2, f2 in every:
228
+ k = int(near(f2(v2), cols[b]).sum())
229
+ if k > fit: fit, how = k, (c2, name2)
230
+ if fit == n: break
231
+ if fit == n: break
232
+ label = lambda c, form: form.format('row' if c == '.index' else c)
233
+ if fit < 0.99 * n:
234
+ # Marks all drawn at one value of the other axis (genes not tested, drawn
235
+ # at 0) are bound by this axis alone, where it names each row once.
236
+ drawn = f(source)[rows]
237
+ if np.ptp(cols[b]) == 0 and len(np.unique(drawn)) >= max(3, n / 2):
238
+ found = {'table': t, 'rows': rows, 'differ': n, 'other': b, 'at': float(cols[b][0]), 'mapping': {a: label(cn, name)}}
239
+ if best is None: best = found
240
+ continue
241
+ found = {'table': t, 'rows': rows, 'differ': n - fit, 'other': b,
242
+ 'mapping': {a: label(cn, name), b: label(how[0], how[1])}, 'how': {a: (cn, name), b: how}}
243
+ if best is None or found['differ'] < best['differ'] or (found['differ'] == best['differ'] and best['table'].key() is None and t.key() is not None and 'at' not in best): best = found
244
+ if best['differ'] == 0: break
245
+ if best and best['differ'] == 0: break
246
+ if best and best['differ'] == 0: break
247
+ if best and best['differ'] == 0: break
248
+ if best: break
249
+ if best: best['mapping'] = {k: best['mapping'][k] for k in ('x', 'y') if k in best['mapping']}
250
+ return best
251
+
252
+ # The row of a table each drawn position is, through the columns and transforms a layer was bound by;
253
+ # None unless every position is exactly one row.
254
+ def look_up(xs, ys, table, how):
255
+ np = _np()
256
+ if len(xs) > 500: return None
257
+ f = dict(transforms(np))
258
+ with np.errstate(all='ignore'):
259
+ X = f[how['x'][1]](np.asarray(table.values(how['x'][0]), dtype=float))
260
+ Y = f[how['y'][1]](np.asarray(table.values(how['y'][0]), dtype=float))
261
+ rows = []
262
+ for x, y in zip(xs, ys):
263
+ hit = np.flatnonzero(near(X, x) & near(Y, y))
264
+ if len(hit) != 1: return None
265
+ rows.append(int(hit[0]))
266
+ return np.asarray(rows, dtype=np.int64)
267
+
268
+ # Labels whose texts name rows of a table: its key, or another column whose values are unique.
269
+ def match_labels(labels, tables):
270
+ np = _np()
271
+ texts = [str(v).strip() for v in labels]
272
+ if not texts or any(not t for t in texts): return None
273
+ for t in tables:
274
+ names = ([t.index_name] if t.index is not None and t.index.dtype.kind in 'OUS' else []) + [c for c in t.names if t.values(c).dtype.kind in 'OUS']
275
+ for c in names:
276
+ values = (t.index if c == t.index_name and t.index is not None else t.values(c)).tolist()
277
+ where = {}
278
+ for i, v in enumerate(values):
279
+ if isinstance(v, str): where.setdefault(v.strip(), []).append(i)
280
+ if all(len(where.get(x, ())) == 1 for x in texts):
281
+ return {'table': t, 'rows': np.asarray([where[x][0] for x in texts], dtype=np.int64), 'mapping': {'label': 'row' if c == '.rownames' else c}}
282
+ return None
283
+
284
+ # Artists --------------------------------------------------------------------------------------------
285
+ # Each artist drawn is a unit: an id is set on it for the drawing, and the elements matplotlib writes
286
+ # in its group are named after its marks. `spec` says how: 'each' (element i is mark i), 'all' (every
287
+ # element stands for all its marks) or 'line' (a line through all marks, then one marker per mark).
288
+ class Layer:
289
+ def __init__(self, plot, kind, **columns):
290
+ self.plot, self.kind, self.columns, self.units = plot, kind, {k: v for k, v in columns.items() if v is not None}, []
291
+ self.group, self.reasons, self.mapping, self.source, self.rows, self.notes = None, [], {}, None, None, []
292
+ @property
293
+ def n(self): return len(next(iter(self.columns.values()))) if self.columns else 0
294
+
295
+ def same(a, b):
296
+ return a is b or a == b
297
+
298
+ def finite_rows(np, *arrays):
299
+ keep = np.ones(len(arrays[0]), dtype=bool)
300
+ for a in arrays: keep &= np.isfinite(a)
301
+ return keep
302
+
303
+ def to_data(ax, transform, xy):
304
+ np = _np()
305
+ if same(transform, ax.transData): return xy
306
+ return ax.transData.inverted().transform(transform.transform(xy))
307
+
308
+ def colours(np, values, n):
309
+ values = np.asarray(values)
310
+ if values.ndim != 2 or not len(values): return None
311
+ return [hex_colour(values[i % len(values)]) for i in range(n)]
312
+
313
+ def collection_layer(ax, k, coll):
314
+ np = _np()
315
+ import matplotlib.collections as mc
316
+ paths = coll.get_paths()
317
+ offsets = np.ma.filled(np.ma.asarray(coll.get_offsets(), dtype=float), np.nan).reshape(-1, 2)
318
+ by_offset = len(offsets) > 1 or (len(offsets) == 1 and len(paths) <= 1 and not np.allclose(offsets, 0))
319
+ if isinstance(coll, mc.QuadMesh): return mesh_layer(ax, k, coll)
320
+ if by_offset:
321
+ trf = coll.get_offset_transform()
322
+ shown = trf.transform(offsets)
323
+ keep = np.isfinite(shown).all(axis=1)
324
+ xy = to_data(ax, trf, offsets[keep])
325
+ n = int(keep.sum())
326
+ idx = np.flatnonzero(keep)
327
+ fc = colours(np, coll.get_facecolors(), len(offsets))
328
+ sizes = np.asarray(coll.get_sizes(), dtype=float) if hasattr(coll, 'get_sizes') else np.asarray([])
329
+ value = coll.get_array()
330
+ value = np.ma.filled(np.ma.asarray(value, dtype=float), np.nan).ravel() if value is not None and np.size(value) == len(offsets) else None
331
+ kind = 'points' if isinstance(coll, mc.PathCollection) else 'polygon'
332
+ layer = Layer(k, kind, x=np.asarray(xy[:, 0], dtype=float), y=np.asarray(xy[:, 1], dtype=float),
333
+ value=value[idx] if value is not None else None,
334
+ colour=[fc[i] for i in idx] if fc else None,
335
+ size=np.asarray([sizes[i % len(sizes)] for i in idx], dtype=float) if len(sizes) else None)
336
+ layer.units.append((coll, 'each', list(range(n))))
337
+ return layer
338
+ # A collection of paths (segments, polygons) without offsets: each path is a mark.
339
+ trf = coll.get_transform()
340
+ ext = []
341
+ for p in paths:
342
+ v = np.asarray(p.vertices, dtype=float)
343
+ v = to_data(ax, trf, v) if len(v) else v
344
+ ext.append(v)
345
+ two = all(len(v) == 2 for v in ext)
346
+ value = coll.get_array()
347
+ value = np.ma.filled(np.ma.asarray(value, dtype=float), np.nan).ravel() if value is not None and np.size(value) == len(paths) else None
348
+ fc = colours(np, coll.get_facecolors(), len(paths)) if not isinstance(coll, mc.LineCollection) else colours(np, coll.get_edgecolors(), len(paths))
349
+ pick = lambda f: np.asarray([f(v) if len(v) else np.nan for v in ext], dtype=float)
350
+ with np.errstate(all='ignore'), warnings.catch_warnings():
351
+ warnings.simplefilter('ignore')
352
+ if two: layer = Layer(k, 'segments', x=pick(lambda v: v[0, 0]), y=pick(lambda v: v[0, 1]), xend=pick(lambda v: v[1, 0]), yend=pick(lambda v: v[1, 1]), value=value, colour=fc)
353
+ else: layer = Layer(k, 'lines' if isinstance(coll, mc.LineCollection) else 'polygon', xmin=pick(lambda v: np.nanmin(v[:, 0])), xmax=pick(lambda v: np.nanmax(v[:, 0])), ymin=pick(lambda v: np.nanmin(v[:, 1])), ymax=pick(lambda v: np.nanmax(v[:, 1])), value=value, colour=fc)
354
+ layer.units.append((coll, 'each', list(range(len(paths)))))
355
+ return layer
356
+
357
+ # Tick labels drawn at each position of an axis, when they name categories rather than numbers.
358
+ def tick_names(ax, axis):
359
+ locator = type((ax.xaxis if axis == 'x' else ax.yaxis).get_major_locator()).__name__
360
+ if locator not in ('FixedLocator', 'StrCategoryLocator'): return {}
361
+ try:
362
+ locs = list((ax.get_xticks if axis == 'x' else ax.get_yticks)())
363
+ texts = [t.get_text() for t in (ax.get_xticklabels if axis == 'x' else ax.get_yticklabels)()]
364
+ except Exception: return {}
365
+ if len(locs) != len(texts) or not texts: return {}
366
+ numeric = re.compile(r'^[\s−\-+]*[\d.,]+(e[−\-+]?\d+)?%?\s*$', re.I)
367
+ if all(not t or numeric.match(t) or t.startswith('$') for t in texts): return {}
368
+ return {float(l): t for l, t in zip(locs, texts) if t}
369
+
370
+ def named_at(names, values):
371
+ out = []
372
+ for v in values:
373
+ hit = [t for l, t in names.items() if abs(l - v) <= 1e-9 * max(1.0, abs(v))]
374
+ out.append(hit[0] if len(hit) == 1 else None)
375
+ return out
376
+
377
+ def mesh_layer(ax, k, mesh):
378
+ np = _np()
379
+ array = mesh.get_array()
380
+ coords = np.asarray(mesh.get_coordinates(), dtype=float)
381
+ r, c = coords.shape[0] - 1, coords.shape[1] - 1
382
+ values = np.ma.filled(np.ma.asarray(array, dtype=float), np.nan).reshape(-1) if array is not None else None
383
+ if values is None or len(values) != r * c:
384
+ layer = Layer(k, 'heatmap'); layer.reasons.append('Its cells have no values.'); return layer
385
+ grid = values.reshape(r, c)
386
+ cy, cx = (coords[:-1, :-1, 1] + coords[1:, 1:, 1]) / 2, (coords[:-1, :-1, 0] + coords[1:, 1:, 0]) / 2
387
+ layer = Layer(k, 'heatmap', value=grid.reshape(-1))
388
+ # Tick labels name the rows (columns) whose middles they sit at. Labels set on the cells' edges
389
+ # instead, one for each, are read in their order when that names every row once, and noted.
390
+ def names(axis, middle, low, high, word):
391
+ ticks = tick_names(ax, axis)
392
+ at = named_at(ticks, middle)
393
+ if any(at) or len(ticks) != len(middle): return at
394
+ for edge in (low, high):
395
+ moved = named_at(ticks, edge)
396
+ if all(moved) and len(set(moved)) == len(moved):
397
+ layer.notes.append('Its %s labels sit on the %ss\' edges, not their middles; label k is read as naming %s k.' % (word, word, word))
398
+ return moved
399
+ return at
400
+ rows_named = names('y', cy[:, 0], coords[:-1, 0, 1], coords[1:, 0, 1], 'row')
401
+ cols_named = names('x', cx[0, :], coords[0, :-1, 0], coords[0, 1:, 0], 'column')
402
+ layer.grid, layer.ticks = grid, (rows_named, cols_named)
403
+ layer.units.append((mesh, 'each', list(range(r * c))))
404
+ return layer
405
+
406
+ # Rows and columns of a session table holding the heatmap's values: the same table, or the same
407
+ # values with rows and columns reordered (a clustered heatmap).
408
+ def match_grid(grid, tables):
409
+ np = _np()
410
+ r, c = grid.shape
411
+ def equal(a, b): return bool(((a == b) | (np.isnan(a) & np.isnan(b)) | near(a, b)).all())
412
+ for t in tables:
413
+ if t.rows != r or len(t.names) < c: continue
414
+ numeric = [n for n in t.names if t.values(n).dtype.kind in 'iuf']
415
+ if len(numeric) != c: continue
416
+ m = np.column_stack([np.asarray(t.values(n), dtype=float) for n in numeric])
417
+ if equal(m, grid): return t, list(range(r)), numeric
418
+ # Columns by their sorted values, then rows by their values in that order.
419
+ order = []
420
+ for j in range(c):
421
+ target = np.sort(grid[:, j])
422
+ hit = [n for i, n in enumerate(numeric) if n not in order and equal(np.sort(m[:, i]), target)]
423
+ if not hit: break
424
+ order.append(hit[0])
425
+ if len(order) != c: continue
426
+ m2 = np.column_stack([np.asarray(t.values(n), dtype=float) for n in order])
427
+ keys = {}
428
+ for i, row in enumerate(m2): keys.setdefault(tuple(np.round(row, 9).tolist()), []).append(i)
429
+ rows = []
430
+ for row in grid:
431
+ hit = keys.get(tuple(np.round(row, 9).tolist()), [])
432
+ if len(hit) != 1: break
433
+ rows.append(hit[0])
434
+ if len(rows) == r and len(set(rows)) == r: return t, rows, order
435
+ return None
436
+
437
+ # Row labels drawn beside a heatmap that name other rows of its table than the values they sit beside.
438
+ # A label is looked up in the table's index and text columns; the one naming most labels is used. A
439
+ # label found nowhere says nothing.
440
+ def mislabelled(t, rows, labels):
441
+ if not any(labels): return None
442
+ names = ([t.index_name] if t.index is not None and t.index.dtype.kind in 'OUS' else []) + [c for c in t.names if t.values(c).dtype.kind in 'OUS']
443
+ best = None
444
+ for c in names:
445
+ values = (t.index if c == t.index_name and t.index is not None else t.values(c)).tolist()
446
+ where = {}
447
+ for i, v in enumerate(values):
448
+ if isinstance(v, str): where.setdefault(v, []).append(i)
449
+ found = [(i, where[l]) for i, l in enumerate(labels) if l and len(where.get(l, ())) == 1]
450
+ if found and (best is None or len(found) > len(best[1])): best = (c, found, values)
451
+ if not best: return None
452
+ c, found, values = best
453
+ wrong = [(i, at[0]) for i, at in found if at[0] != rows[i]]
454
+ if not wrong: return None
455
+ i, other = wrong[0]
456
+ return '%d of %d rows are labelled with another row of %s: row %d is labelled %s and draws the values of %s.' % (
457
+ len(wrong), len(found), t.name, i + 1, labels[i], values[rows[i]])
458
+
459
+ def distinct(names):
460
+ seen, out = {}, []
461
+ for n in names:
462
+ seen[n] = seen.get(n, 0) + 1
463
+ out.append(n if seen[n] == 1 else '%s (%d)' % (n, seen[n]))
464
+ return out
465
+
466
+ def fill_heatmap(layer, tables, name):
467
+ np = _np()
468
+ grid, (rows_named, cols_named) = layer.grid, layer.ticks
469
+ r, c = grid.shape
470
+ found = match_grid(grid, tables)
471
+ if found:
472
+ t, rows, order = found
473
+ index = t.index.tolist() if t.index is not None and t.index.dtype.kind in 'OUS' else None
474
+ row_names = [str(index[i]) if index else (rows_named[i] or str(i + 1)) for i in rows]
475
+ col_names = list(order)
476
+ layer.source, layer.table, layer.rows = t.name, t, np.repeat(np.asarray(rows, dtype=np.int64), c)
477
+ wrong = mislabelled(t, rows, rows_named)
478
+ if wrong: layer.notes.append(wrong)
479
+ else:
480
+ row_names = [rows_named[i] or str(i + 1) for i in range(r)]
481
+ col_names = distinct([cols_named[j] or str(j + 1) for j in range(c)])
482
+ t = Table(name, r, col_names, lambda n: grid[:, col_names.index(n)], np.asarray(row_names, dtype=object), 'row')
483
+ layer.source, layer.table, layer.rows = name, t, np.repeat(np.arange(r, dtype=np.int64), c)
484
+ layer.columns = {'row': np.asarray([row_names[i] for i in range(r) for _ in range(c)], dtype=object),
485
+ 'column': np.asarray([col_names[j] for _ in range(r) for j in range(c)], dtype=object),
486
+ 'value': grid.reshape(-1)}
487
+ layer.mapping = {'x': 'column', 'y': 'row', 'fill': 'value'}
488
+
489
+ def line_kind(ax, line):
490
+ trf = line.get_transform()
491
+ if same(trf, ax.transData): return 'lines'
492
+ if same(trf, ax.get_yaxis_transform(which='grid')): return 'hline'
493
+ if same(trf, ax.get_xaxis_transform(which='grid')): return 'vline'
494
+ return None
495
+
496
+ def line_layers(ax, k):
497
+ np = _np()
498
+ from matplotlib.colors import to_rgba
499
+ out, run, key = [], [], None
500
+ def style(line, kind):
501
+ return (kind, str(line.get_color()), str(line.get_linestyle()), str(line.get_marker()), float(line.get_linewidth()))
502
+ lines = [l for l in ax.get_lines() if l.get_visible()]
503
+ for line in lines:
504
+ kind = line_kind(ax, line)
505
+ s = style(line, kind)
506
+ if run and s != key: out.append(run); run = []
507
+ run.append((line, kind)); key = s
508
+ if run: out.append(run)
509
+ layers = []
510
+ for run in out:
511
+ kind = run[0][1]
512
+ if kind is None:
513
+ layer = Layer(k, 'lines'); layer.reasons.append('It is drawn in coordinates other than the data\'s.')
514
+ for line, _ in run: layer.units.append((line, 'none', 0))
515
+ layers.append(layer); continue
516
+ if kind in ('hline', 'vline'):
517
+ name = 'yintercept' if kind == 'hline' else 'xintercept'
518
+ vals = np.asarray([float(np.asarray(l.get_ydata() if kind == 'hline' else l.get_xdata(), dtype=float)[0]) for l, _ in run])
519
+ layer = Layer(k, 'abline', **{name: vals, 'colour': [hex_colour(to_rgba(l.get_color())) for l, _ in run]})
520
+ for i, (line, _) in enumerate(run): layer.units.append((line, 'all', [i]))
521
+ layers.append(layer); continue
522
+ xs, ys, groups, units, start = [], [], [], [], 0
523
+ for g, (line, _) in enumerate(run):
524
+ xy = np.asarray(line.get_xydata(), dtype=float).reshape(-1, 2)
525
+ keep = finite_rows(np, xy[:, 0], xy[:, 1])
526
+ xy = xy[keep]
527
+ xs.append(xy[:, 0]); ys.append(xy[:, 1]); groups.append(np.full(len(xy), g + 1))
528
+ marks = list(range(start, start + len(xy))); start += len(xy)
529
+ drawn_line = str(line.get_linestyle()) not in ('None', '', ' ', 'none') and line.get_linewidth() > 0
530
+ markers = str(line.get_marker()) not in ('None', '', ' ', 'none') and line.get_markersize() > 0 and line.get_markevery() is None
531
+ units.append((line, 'line' if drawn_line and markers else 'all' if drawn_line else 'each' if markers else 'none', marks))
532
+ geom = 'points' if all(u[1] == 'each' for u in units) else 'lines'
533
+ layer = Layer(k, geom, x=np.concatenate(xs), y=np.concatenate(ys))
534
+ layer.group = np.concatenate(groups) if geom == 'lines' else None
535
+ colour = run[0][0].get_color()
536
+ layer.constant = {'colour': hex_colour(to_rgba(colour))}
537
+ layer.units.extend(units)
538
+ layers.append(layer)
539
+ return layers
540
+
541
+ def patch_extent(ax, patch):
542
+ np = _np()
543
+ v = np.asarray(patch.get_path().vertices, dtype=float)
544
+ if not len(v): return [np.nan] * 4
545
+ # A bar's rectangle is placed in data coordinates by its own transform: read them there, exactly.
546
+ if same(patch.get_data_transform(), ax.transData): d = patch.get_patch_transform().transform(v)
547
+ else: d = ax.transData.inverted().transform(patch.get_transform().transform(v))
548
+ with warnings.catch_warnings():
549
+ warnings.simplefilter('ignore')
550
+ return [float(np.nanmin(d[:, 0])), float(np.nanmax(d[:, 0])), float(np.nanmin(d[:, 1])), float(np.nanmax(d[:, 1]))]
551
+
552
+ def patch_layers(ax, k):
553
+ np = _np()
554
+ import matplotlib.patches as mp, matplotlib.container as mcont
555
+ layers, taken = [], set()
556
+ xnames, ynames = tick_names(ax, 'x'), tick_names(ax, 'y')
557
+ for cont in ax.containers:
558
+ if not isinstance(cont, mcont.BarContainer): continue
559
+ patches = [p for p in cont.patches if p.get_visible()]
560
+ if not patches: continue
561
+ taken.update(id(p) for p in patches)
562
+ ext = np.asarray([patch_extent(ax, p) for p in patches], dtype=float)
563
+ horizontal = getattr(cont, 'orientation', 'vertical') == 'horizontal'
564
+ middle = (ext[:, 2] + ext[:, 3]) / 2 if horizontal else (ext[:, 0] + ext[:, 1]) / 2
565
+ label = named_at(ynames if horizontal else xnames, middle)
566
+ layer = Layer(k, 'rect', xmin=ext[:, 0], xmax=ext[:, 1], ymin=ext[:, 2], ymax=ext[:, 3],
567
+ label=np.asarray(label, dtype=object) if any(label) else None,
568
+ fill=[hex_colour(p.get_facecolor()) for p in patches])
569
+ cl = cont.get_label()
570
+ if isinstance(cl, str) and cl and not cl.startswith('_'): layer.series = cl
571
+ for i, p in enumerate(patches): layer.units.append((p, 'all', [i]))
572
+ layers.append(layer)
573
+ rest = [p for p in ax.patches if p.get_visible() and id(p) not in taken]
574
+ for kind in ('rect', 'polygon'):
575
+ mine = [p for p in rest if isinstance(p, mp.Rectangle) == (kind == 'rect')]
576
+ if not mine: continue
577
+ ext = np.asarray([patch_extent(ax, p) for p in mine], dtype=float)
578
+ layer = Layer(k, kind, xmin=ext[:, 0], xmax=ext[:, 1], ymin=ext[:, 2], ymax=ext[:, 3], fill=[hex_colour(p.get_facecolor()) for p in mine])
579
+ for i, p in enumerate(mine): layer.units.append((p, 'all', [i]))
580
+ layers.append(layer)
581
+ return layers
582
+
583
+ # Texts at data positions (labels of points) are one layer; texts placed in the axes' own
584
+ # coordinates (a note in a corner) are another, with no position in the data.
585
+ def text_layers(ax, k):
586
+ np = _np()
587
+ import matplotlib.text as mt
588
+ placed, other = [], []
589
+ for t in ax.texts:
590
+ if not t.get_visible() or not t.get_text().strip(): continue
591
+ if isinstance(t, mt.Annotation) and t.xycoords == 'data': placed.append((t, tuple(float(v) for v in t.xy)))
592
+ elif same(t.get_transform(), ax.transData): placed.append((t, tuple(float(v) for v in t.get_position())))
593
+ else: other.append(t)
594
+ layers = []
595
+ if placed:
596
+ layer = Layer(k, 'text', x=np.asarray([p[1][0] for p in placed], dtype=float), y=np.asarray([p[1][1] for p in placed], dtype=float),
597
+ label=np.asarray([p[0].get_text() for p in placed], dtype=object))
598
+ for i, (t, _) in enumerate(placed): layer.units.append((t, 'all', [i]))
599
+ layers.append(layer)
600
+ if other:
601
+ layer = Layer(k, 'text', label=np.asarray([t.get_text() for t in other], dtype=object))
602
+ for i, t in enumerate(other): layer.units.append((t, 'all', [i]))
603
+ layers.append(layer)
604
+ return layers
605
+
606
+ # Plots ----------------------------------------------------------------------------------------------
607
+ def legend_scale(ax):
608
+ from matplotlib.colors import to_rgba
609
+ leg = ax.get_legend()
610
+ if leg is None: return None
611
+ handles = getattr(leg, 'legend_handles', None) or getattr(leg, 'legendHandles', None) or []
612
+ def colour(h):
613
+ for getter in ('get_color', 'get_facecolor'):
614
+ try:
615
+ c = getattr(h, getter)()
616
+ c = c[0] if hasattr(c, '__len__') and len(c) and hasattr(c[0], '__len__') else c
617
+ return hex_colour(to_rgba(c))
618
+ except Exception: continue
619
+ return None
620
+ return {'aesthetic': 'colour', 'title': leg.get_title().get_text(), 'breaks': [t.get_text() for t in leg.get_texts()], 'values': [colour(h) or '' for h in handles][:len(leg.get_texts())]}
621
+
622
+ def facets_of(title):
623
+ out = {}
624
+ for part in title.split(' | '):
625
+ m = re.match(r'^\s*(.+?)\s*=\s*(.+?)\s*$', part)
626
+ if not m: return {}
627
+ out[m.group(1)] = m.group(2)
628
+ return out
629
+
630
+ def position(ax, axis):
631
+ lim = (ax.get_xlim if axis == 'x' else ax.get_ylim)()
632
+ out = {'discrete': False, 'range': [num(lim[0]), num(lim[1])]}
633
+ scale = (ax.get_xscale if axis == 'x' else ax.get_yscale)()
634
+ if scale == 'log': out['transform'] = 'log-10'
635
+ elif scale != 'linear': out['transform'] = scale
636
+ names = tick_names(ax, axis)
637
+ if names: out = {'discrete': True, 'levels': list(names.values())}
638
+ return out
639
+
640
+ # The SVG ----------------------------------------------------------------------------------------------
641
+ def leaves(node, token):
642
+ out = []
643
+ for child in node:
644
+ tag = child.tag.rsplit('}', 1)[-1]
645
+ if tag in hidden_tags: continue
646
+ if tag == 'g' and (child.get('id') or '').startswith(token): continue
647
+ if tag in drawn_tags: out.append(child)
648
+ else: out.extend(leaves(child, token))
649
+ return out
650
+
651
+ def tag_svg(text, token, units):
652
+ ET.register_namespace('', SVG); ET.register_namespace('xlink', XLINK)
653
+ root = ET.fromstring(text)
654
+ for parent in list(root.iter()):
655
+ for child in list(parent):
656
+ if child.tag in ('{%s}metadata' % SVG, '{%s}style' % SVG): parent.remove(child)
657
+ # matplotlib's sheet sets these on every element; on the root they are inherited instead, and a
658
+ # sheet in an inline SVG would reach the rest of the page.
659
+ root.set('style', 'stroke-linejoin: round; stroke-linecap: butt')
660
+ root.attrib.pop('width', None); root.attrib.pop('height', None)
661
+ groups = {g.get('id'): g for g in root.iter('{%s}g' % SVG) if (g.get('id') or '').startswith(token)}
662
+ named, lost = {}, {}
663
+ for u, (layer_i, artist, spec, marks) in enumerate(units):
664
+ g = groups.get('%s-%d' % (token, u))
665
+ if g is None:
666
+ if spec != 'none': lost[u] = 'It was not drawn.'
667
+ continue
668
+ g.attrib.pop('id', None)
669
+ els = leaves(g, token)
670
+ if spec == 'none' or not els: continue
671
+ if spec == 'each':
672
+ if len(els) != len(marks): lost[u] = 'Its %d marks were drawn as %d elements; they are not matched.' % (len(marks), len(els)); continue
673
+ each = [[m] for m in marks]
674
+ elif spec == 'all': each = [list(marks)] * len(els)
675
+ elif spec == 'line':
676
+ if len(els) == len(marks) + 1: each = [list(marks)] + [[m] for m in marks]
677
+ elif len(els) == len(marks): each = [[m] for m in marks]
678
+ else: lost[u] = 'Its %d markers were drawn as %d elements; they are not matched.' % (len(marks), len(els) - 1); continue
679
+ uses = [e for e in els if e.tag == '{%s}use' % SVG]
680
+ if uses:
681
+ # A marker's shape is defined once and used for each point; its own stroke would hide
682
+ # the highlight a selection puts on the use.
683
+ for d in g.iter('{%s}defs' % SVG):
684
+ for p in d: p.attrib.pop('style', None)
685
+ for e_i, el in enumerate(els): el.set('data-rho', '%d.%d' % (u, e_i))
686
+ named[u] = each
687
+ return ET.tostring(root, encoding='unicode'), named, lost
688
+
689
+ # Describing -----------------------------------------------------------------------------------------
690
+ def describe(fig, namespace, saving, deadline, prefer=()):
691
+ np = _np()
692
+ import matplotlib
693
+ axes = [ax for ax in fig.get_axes() if ax.get_visible() and not hasattr(ax, '_colorbar')]
694
+ colourbars = [ax for ax in fig.get_axes() if hasattr(ax, '_colorbar')]
695
+ plots, layers, problems = [], [], []
696
+ for k, ax in enumerate(axes, start=1):
697
+ title = ax.get_title() or ax.get_title('left') or ax.get_title('right') or None
698
+ labels = {a: t for a, t in (('x', ax.get_xlabel()), ('y', ax.get_ylabel())) if t}
699
+ scales = [s for s in [legend_scale(ax)] if s]
700
+ for cax in colourbars:
701
+ cb = cax._colorbar
702
+ if getattr(getattr(cb, 'mappable', None), 'axes', None) is ax:
703
+ label = (cax.get_ylabel() if cb.orientation == 'vertical' else cax.get_xlabel()) or ''
704
+ ticks = [t.get_text() for t in (cax.get_yticklabels() if cb.orientation == 'vertical' else cax.get_xticklabels())]
705
+ scales.append({'aesthetic': 'fill', 'title': label, 'breaks': [t for t in ticks if t], 'values': []})
706
+ facets = facets_of(title) if title else {}
707
+ plots.append({'plot': k, 'title': title, 'subtitle': None, 'caption': None, 'labels': labels,
708
+ 'panels': [{'panel': 1, 'facets': facets}], 'facet': 'Grid' if facets else 'Null', 'coord': 'cartesian', 'scales': scales,
709
+ 'positions': {'x': position(ax, 'x'), 'y': position(ax, 'y')}})
710
+ for coll in ax.collections:
711
+ if coll.get_visible(): layers.append(collection_layer(ax, k, coll))
712
+ layers.extend(line_layers(ax, k))
713
+ layers.extend(patch_layers(ax, k))
714
+ layers.extend(text_layers(ax, k))
715
+ if any(im.get_visible() for im in ax.images): problems.append('An image drawn with imshow is a picture: its cells are not bound.')
716
+ if time.monotonic() > deadline: raise Late()
717
+ total = sum(l.n for l in layers)
718
+ if total > limits['maxMarks']: raise ValueError('The plot draws %d marks, more than the %d a figure binds.' % (total, limits['maxMarks']))
719
+
720
+ # Bind points, lines and labels to the session's tables, then labels by their text.
721
+ tables = None
722
+ form = lambda how: how[1].format('row' if how[0] == '.index' else how[0])
723
+ for i, l in enumerate(layers):
724
+ if l.kind in ('points', 'lines', 'text') and l.n and 'x' in l.columns and np.isfinite(l.columns['x']).all() and np.isfinite(l.columns['y']).all():
725
+ if tables is None: tables = session_tables(namespace, prefer)
726
+ # A table another layer of the same plot is bound to is compared first.
727
+ mine = [o.table for o in layers if o.plot == l.plot and o.source is not None]
728
+ found = match_table({'x': l.columns['x'], 'y': l.columns['y']}, mine + [t for t in tables if t not in mine], deadline)
729
+ if found:
730
+ l.source, l.table, l.rows, l.mapping, l.how = found['table'].name, found['table'], found['rows'], found['mapping'], found.get('how')
731
+ if 'at' in found: l.notes.append('All %d marks are drawn at %s = %s.' % (l.n, found['other'], ('%.6g' % found['at'])))
732
+ elif found['differ']: l.notes.append('%d of %d marks are drawn at a %s other than their %s.' % (found['differ'], l.n, found['other'], l.mapping[found['other']]))
733
+ # A column an earlier layer of this plot draws on the same axis through another
734
+ # transform: the two layers place their marks on different scales.
735
+ for j in range(i - 1, -1, -1) if l.how else ():
736
+ o = layers[j]
737
+ if o.plot != l.plot or getattr(o, 'how', None) is None or o.table is not l.table: continue
738
+ for a in ('x', 'y'):
739
+ if l.how[a][0] == o.how[a][0] and l.how[a][1] != o.how[a][1]:
740
+ l.notes.append('Its %s is %s, while layer %d of this plot draws %s as %s on the same axis.' % (a, form(l.how[a]), j, a, form(o.how[a])))
741
+ # A label drawn at a bound point's position names that point's row, in whatever order the labels
742
+ # were drawn: each is looked up through the point layer's own columns.
743
+ for l in layers:
744
+ if l.kind == 'text' and l.source is None and l.n and 'x' in l.columns and np.isfinite(l.columns['x']).all() and np.isfinite(l.columns['y']).all():
745
+ for o in layers:
746
+ if o.plot != l.plot or o is l or getattr(o, 'how', None) is None: continue
747
+ found = look_up(l.columns['x'], l.columns['y'], o.table, o.how)
748
+ if found is not None: l.source, l.table, l.rows, l.mapping = o.source, o.table, found, dict(o.mapping); break
749
+ for l in layers:
750
+ if l.kind == 'text' and l.source is None and l.n:
751
+ bound = [o.table for o in layers if o.plot == l.plot and o.source is not None and o.kind != 'heatmap']
752
+ mine = [t for i, t in enumerate(bound) if t not in bound[:i]]
753
+ found = match_labels(l.columns['label'], mine) if mine else None
754
+ if found: l.source, l.table, l.rows, l.mapping = found['table'].name, found['table'], found['rows'], found['mapping']
755
+ # A position on an axis of categories is named by its category.
756
+ for l in layers:
757
+ for a in ('x', 'y'):
758
+ if a in l.columns and l.kind != 'heatmap' and plots[l.plot - 1]['positions'][a].get('discrete'):
759
+ names = named_at(tick_names(axes[l.plot - 1], a), l.columns[a])
760
+ if all(v is not None for v in names): l.columns[a] = np.asarray(names, dtype=object)
761
+ for i, l in enumerate(layers):
762
+ if l.kind == 'heatmap' and hasattr(l, 'grid'):
763
+ if tables is None: tables = session_tables(namespace, prefer)
764
+ fill_heatmap(l, tables, 'heatmap' + ('' if i == 0 else str(i + 1)))
765
+
766
+ # Draw with each artist named, and take the names back.
767
+ token = 'rho' + uuid.uuid4().hex[:10]
768
+ # A rasterized artist is written as one image; for the figure it is drawn as what it is.
769
+ units, old, raster, zorders = [], [], [], [(ax, ax.get_rasterization_zorder()) for ax in axes]
770
+ for i, l in enumerate(layers):
771
+ for artist, spec, marks in l.units:
772
+ old.append((artist, artist.get_gid()))
773
+ artist.set_gid('%s-%d' % (token, len(units)))
774
+ if artist.get_rasterized(): raster.append(artist); artist.set_rasterized(False)
775
+ units.append((i, artist, spec, marks))
776
+ for ax, _ in zorders: ax.set_rasterization_zorder(None)
777
+ buf = io.StringIO()
778
+ try:
779
+ with matplotlib.rc_context({'svg.fonttype': 'none', 'svg.hashsalt': 'rho', 'svg.image_inline': True}):
780
+ options = {k: v for k, v in saving.items() if k in ('bbox_inches', 'pad_inches', 'facecolor', 'edgecolor', 'transparent', 'bbox_extra_artists')}
781
+ fig.savefig(buf, format='svg', metadata={'Date': None}, **options)
782
+ finally:
783
+ for artist, gid in old: artist.set_gid(gid)
784
+ for artist in raster: artist.set_rasterized(True)
785
+ for ax, z in zorders: ax.set_rasterization_zorder(z)
786
+ if time.monotonic() > deadline: raise Late()
787
+ svg, named, lost = tag_svg(buf.getvalue(), token, units)
788
+
789
+ out_units, out_layers, out_marks, sources, used = [], [], [], [], {}
790
+ for i, l in enumerate(layers):
791
+ mine = [u for u, unit in enumerate(units) if unit[0] == i and u in named]
792
+ reasons = list(dict.fromkeys(l.reasons + [lost[u] for u, unit in enumerate(units) if unit[0] == i and u in lost]))
793
+ n = l.n
794
+ constant = dict(getattr(l, 'constant', {}))
795
+ cols = {}
796
+ for name, values in l.columns.items():
797
+ vals = list(values) if not hasattr(values, 'dtype') else values
798
+ if name in ('colour', 'fill', 'size') and n > 1 and len(set(map(str, vals))) == 1:
799
+ constant[name] = vals[0] if not isinstance(vals[0], (float, np.floating)) else num(vals[0])
800
+ continue
801
+ if name in ('colour', 'fill', 'size') and n == 1:
802
+ constant[name] = vals[0] if not isinstance(vals[0], (float, np.floating)) else num(vals[0]); continue
803
+ if hasattr(values, 'dtype') and values.dtype.kind == 'f' and not np.isfinite(values).any(): continue
804
+ cols[name] = values
805
+ if l.source is not None:
806
+ used.setdefault(l.source, (l.table, set()))[1].update(int(r) for r in l.rows)
807
+ bound = 'none' if not mine else 'mark' if all(len(m) == 1 for u in mine for m in named[u]) else 'group'
808
+ layer = {'plot': l.plot, 'layer': i + 1, 'geom': l.kind, 'stat': 'identity', 'position': 'identity', 'mapping': l.mapping,
809
+ 'constants': {k: v for k, v in constant.items() if v is not None}, 'source': l.source, 'marks': n, 'units': len(mine), 'bound': bound}
810
+ if getattr(l, 'series', None): layer['constants']['series'] = l.series
811
+ if reasons: layer['unbound'] = reasons
812
+ if l.notes: layer['notes'] = l.notes
813
+ out_layers.append(layer)
814
+ out_marks.append({'panel': [1] * n, 'group': [int(g) for g in l.group] if l.group is not None else [-1] * n,
815
+ 'row': [int(r) for r in l.rows] if l.rows is not None else None,
816
+ 'columns': [column(name, v) for name, v in cols.items()]})
817
+ # Every unit keeps its place, so data-rho="U.E" names units[U]; one not matched names no marks.
818
+ out_units = [{'layer': unit[0], 'panel': 1, 'marks': named.get(u, [])} for u, unit in enumerate(units)]
819
+ for name, (table, rows) in used.items(): sources.append(table.source(rows))
820
+ suptitle = getattr(fig, '_suptitle', None)
821
+ title = (suptitle.get_text() if suptitle is not None and suptitle.get_text() else None) or (plots[0]['title'] if plots else None)
822
+ return {'svg': svg, 'problems': list(dict.fromkeys(problems)), 'units': out_units,
823
+ 'figure': {'kind': 'matplotlib', 'title': title, 'plots': plots, 'layers': out_layers}, 'marks': out_marks, 'sources': sources}
824
+
825
+ def describe_figure(figure, into, namespace=None, prefer=(), **saving):
826
+ try:
827
+ from matplotlib.figure import Figure
828
+ except Exception: return None
829
+ if not isinstance(figure, Figure): return None
830
+ import matplotlib, numpy
831
+ started = time.monotonic()
832
+ result, problem = None, None
833
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()), warnings.catch_warnings():
834
+ warnings.simplefilter('ignore')
835
+ try: result = describe(figure, namespace or {}, saving, started + limits['maxSeconds'], list(prefer or ()))
836
+ except Late: problem = 'Describing the plot took longer than %d s; it stays a picture.' % limits['maxSeconds']
837
+ except Exception as error: problem = str(error) or type(error).__name__
838
+ if problem: return {'problem': problem}
839
+ w, h = figure.get_size_inches()
840
+ versions = {'Python': platform.python_version(), 'matplotlib': matplotlib.__version__, 'numpy': numpy.__version__}
841
+ if 'seaborn' in sys.modules: versions['seaborn'] = getattr(sys.modules['seaborn'], '__version__', '')
842
+ record = {'schema': schema, 'describer': 'matplotlib', 'size': {'width': num(w), 'height': num(h), 'units': 'in'}, 'versions': versions,
843
+ 'seconds': round(time.monotonic() - started, 2)}
844
+ record.update(result)
845
+ text = json.dumps(record, ensure_ascii=False, allow_nan=False, separators=(',', ':'))
846
+ size = len(text.encode('utf-8'))
847
+ if size > limits['maxBytes']: return {'problem': 'The figure record would take %.1f MB, more than %d MB.' % (size / 1048576, limits['maxBytes'] // 1048576)}
848
+ tmp = into + '.tmp'
849
+ with open(tmp, 'w', encoding='utf-8') as f: f.write(text)
850
+ os.replace(tmp, into)
851
+ f = result['figure']
852
+ bound = len({(u['layer'], m) for u in result['units'] for ms in u['marks'] for m in ms})
853
+ notes = [n for l in f['layers'] for n in l.get('notes', [])]
854
+ return {'describer': 'matplotlib', 'kind': f['kind'], 'title': f['title'], 'layers': len(f['layers']),
855
+ 'marks': sum(l['marks'] for l in f['layers']), 'bound': bound, 'geoms': [l['geom'] for l in f['layers']], 'file': os.path.basename(into),
856
+ **({'notes': notes[:4]} if notes else {})}