@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.
- package/dist/agent-connect.d.ts +36 -2
- package/dist/agent-connect.js +64 -11
- package/dist/answer-board.js +3 -3
- package/dist/bridge.js +6 -1
- package/dist/browser-access.d.ts +1 -0
- package/dist/browser-access.js +5 -3
- package/dist/capabilities.d.ts +21 -5
- package/dist/capabilities.js +6 -4
- package/dist/cli.js +76 -11
- package/dist/composition.d.ts +1 -1
- package/dist/composition.js +13 -3
- package/dist/connect-cli.d.ts +50 -0
- package/dist/connect-cli.js +128 -0
- package/dist/contract.generated.d.ts +1191 -278
- package/dist/contract.generated.js +1 -1
- package/dist/contracts/owners.generated.js +845 -47
- package/dist/conversation-presentation.d.ts +9 -2
- package/dist/conversation-presentation.js +3 -1
- package/dist/conversations.d.ts +154 -8
- package/dist/conversations.js +147 -24
- package/dist/discovery.js +2 -2
- package/dist/extension-contract.d.ts +19 -0
- package/dist/extension-contract.js +15 -6
- package/dist/extension-markets.d.ts +74 -0
- package/dist/extension-markets.js +166 -0
- package/dist/extensions.d.ts +29 -1
- package/dist/extensions.js +13 -4
- package/dist/external-work.d.ts +22 -0
- package/dist/external-work.js +91 -0
- package/dist/plugin-cli.js +75 -3
- package/dist/plugin-inventory.d.ts +56 -0
- package/dist/plugin-inventory.js +41 -0
- package/dist/plugin-inventory.json +1581 -102
- package/dist/plugin-presentation.d.ts +105 -2
- package/dist/plugin-presentation.js +59 -5
- package/dist/plugins.d.ts +14 -3
- package/dist/plugins.js +12 -6
- package/dist/project-notes.js +1 -1
- package/dist/protocol.d.ts +6 -0
- package/dist/protocol.js +30 -12
- package/dist/replay.js +4 -0
- package/dist/response-contracts.js +26 -11
- package/dist/running.d.ts +10 -0
- package/dist/running.js +45 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +71 -8
- package/dist/state.d.ts +8 -0
- package/dist/state.js +8 -0
- package/dist/studio-layout.js +3 -3
- package/dist/task-frame.d.ts +1 -1
- package/dist/timeline-events.d.ts +1 -0
- package/dist/timeline-events.js +8 -1
- package/dist/ui-context.d.ts +73 -0
- package/dist/ui-context.js +98 -0
- package/dist/ui-contract.d.ts +23 -0
- package/dist/web/.vite/manifest.json +133 -74
- package/dist/web/assets/answer-board-CeYS8k8y.js +1 -0
- package/dist/web/assets/choices-D8vGwsl2.js +1 -0
- package/dist/web/assets/{console-Trqc0swD.js → console-Dyuo1q4N.js} +4 -4
- package/dist/web/assets/{dist-pF902jHA.js → dist-CSiCyBml.js} +1 -1
- package/dist/web/assets/{dist-DpIHRk3B.js → dist-D9FLb9Hm.js} +1 -1
- package/dist/web/assets/{dist-eMx6b8N1.js → dist-FXbTBwML.js} +1 -1
- package/dist/web/assets/{dist-DZmszrjG.js → dist-l3DUhbw5.js} +1 -1
- package/dist/web/assets/{explore-in-BCHqjJ7B.js → explore-in-BfSFGRh2.js} +1 -1
- package/dist/web/assets/graph-BU2_o5uA.js +2 -0
- package/dist/web/assets/index-C16mXVvk.css +1 -0
- package/dist/web/assets/index-CWg9XYXx.js +952 -0
- package/dist/web/assets/markdown.reader-Ct2-e2Zt.png +0 -0
- package/dist/web/assets/{notes-meaning-DcVGUBBY.js → notes-meaning-T2m5zQfv.js} +1 -1
- package/dist/web/assets/overview-PzfLJCo-.js +6 -0
- package/dist/web/assets/preview-BguEdsza.js +1 -0
- package/dist/web/assets/previews-pWNik-2g.js +3 -0
- package/dist/web/assets/{review-DYP0aCXb.js → review-Dy_85lcH.js} +1 -1
- package/dist/web/assets/rho.editor-CCJeyHma.png +0 -0
- package/dist/web/assets/rho.flow-WkMj6fRS.png +0 -0
- package/dist/web/assets/rho.media-Cc6amodO.png +0 -0
- package/dist/web/assets/rho.notebook-6vsZKGDq.png +0 -0
- package/dist/web/assets/rho.object-BGoeXjte.png +0 -0
- package/dist/web/assets/rho.plots-yIiISCkb.png +0 -0
- package/dist/web/assets/rho.scatter-DwHxesCs.png +0 -0
- package/dist/web/assets/rho.servers-DKf_mnNq.png +0 -0
- package/dist/web/assets/rho.table-DWOGaWDa.png +0 -0
- package/dist/web/assets/rho.variables-CKcWczhQ.png +0 -0
- package/dist/web/assets/{skills-1ec0Z-jx.js → skills-Dyz7mBlc.js} +1 -1
- package/dist/web/assets/{skills-work-DNexV4OJ.js → skills-work-BEGqlCkx.js} +1 -1
- package/dist/web/assets/studio-process-D-ykbC4D.js +2 -0
- package/dist/web/assets/timeline-events-DKTho9fU.js +1 -0
- package/dist/web/assets/work-turns-DSabCogR.js +1 -0
- package/dist/web/assets/{workspace-COmCbGV3.js → workspace-DfjBL--r.js} +2 -2
- package/dist/web/composition.json +138 -83
- package/dist/web/index.html +3 -3
- package/dist/web/renderer.json +1 -1
- package/dist/web-assets.js +2 -2
- package/dist/work-graph.d.ts +11 -1
- package/dist/work-graph.js +16 -5
- package/dist/workspace.d.ts +2 -2
- package/dist/workspace.js +8 -7
- package/docs/FRONTEND.md +53 -11
- package/docs/HEADLESS.md +104 -11
- package/docs/THIRD-PARTY-PLUGINS.md +75 -3
- package/node_modules/@rho-workspace/plugin-agent/README.md +5 -0
- package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.d.ts +31 -0
- package/node_modules/@rho-workspace/plugin-agent/dist/acp-command.js +45 -10
- package/node_modules/@rho-workspace/plugin-agent/dist/acp-conversation.js +19 -3
- package/node_modules/@rho-workspace/plugin-agent/dist/external-session-contract.js +3 -2
- package/node_modules/@rho-workspace/plugin-agent/dist/external-session.d.ts +1 -1
- package/node_modules/@rho-workspace/plugin-agent/dist/index.d.ts +8 -0
- package/node_modules/@rho-workspace/plugin-agent/dist/index.js +3 -2
- package/node_modules/@rho-workspace/plugin-agent/dist/mcp-entry.d.ts +13 -0
- package/node_modules/@rho-workspace/plugin-agent/dist/mcp-entry.js +13 -0
- package/node_modules/@rho-workspace/plugin-agent/dist/mcp.js +16 -8
- package/node_modules/@rho-workspace/plugin-agent/dist/native-sessions.d.ts +1 -1
- package/node_modules/@rho-workspace/plugin-agent/package.json +1 -1
- package/node_modules/@rho-workspace/plugin-figures/LICENSE +661 -0
- package/node_modules/@rho-workspace/plugin-figures/README.md +191 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/index.d.ts +236 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/index.js +165 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/operations.d.ts +44 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/operations.js +39 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/presentation.d.ts +19 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/presentation.js +26 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/record.d.ts +188 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/record.js +143 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/ui-styles.d.ts +1 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/ui-styles.js +68 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/ui.d.ts +56 -0
- package/node_modules/@rho-workspace/plugin-figures/dist/ui.js +363 -0
- package/node_modules/@rho-workspace/plugin-figures/package.json +41 -0
- package/node_modules/@rho-workspace/plugin-figures/py/figures.py +856 -0
- package/node_modules/@rho-workspace/plugin-figures/r/figures.R +775 -0
- package/node_modules/@rho-workspace/plugin-kernel/README.md +27 -3
- package/node_modules/@rho-workspace/plugin-kernel/dist/contract.d.ts +12 -0
- package/node_modules/@rho-workspace/plugin-kernel/dist/contract.js +8 -1
- package/node_modules/@rho-workspace/plugin-kernel/dist/index.d.ts +61 -0
- package/node_modules/@rho-workspace/plugin-kernel/dist/index.js +125 -9
- package/node_modules/@rho-workspace/plugin-kernel/dist/session-scope.d.ts +14 -0
- package/node_modules/@rho-workspace/plugin-kernel/dist/session-scope.js +9 -0
- package/node_modules/@rho-workspace/plugin-kernel/dist/ui-base.d.ts +23 -5
- package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook-work.js +1 -1
- package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook.d.ts +6 -4
- package/node_modules/@rho-workspace/plugin-kernel/dist/ui-notebook.js +44 -17
- package/node_modules/@rho-workspace/plugin-kernel/dist/ui-styles.js +6 -0
- package/node_modules/@rho-workspace/plugin-kernel/package.json +1 -1
- package/node_modules/@rho-workspace/plugin-kernel/runners/runner.R +144 -4
- package/node_modules/@rho-workspace/plugin-kernel/runners/runner.py +106 -1
- package/node_modules/@rho-workspace/plugin-tables/README.md +5 -0
- package/node_modules/@rho-workspace/plugin-tables/dist/index.d.ts +1 -0
- package/node_modules/@rho-workspace/plugin-tables/dist/index.js +19 -3
- package/node_modules/@rho-workspace/plugin-tables/dist/ui.js +1 -1
- package/node_modules/@rho-workspace/plugin-tables/package.json +1 -1
- package/node_modules/jose/dist/webapi/jwe/general/decrypt.js +3 -2
- package/node_modules/jose/dist/webapi/jwks/remote.js +1 -1
- package/node_modules/jose/dist/webapi/jws/general/verify.js +2 -8
- package/node_modules/jose/dist/webapi/lib/jwe_decrypt.js +11 -8
- package/node_modules/jose/dist/webapi/lib/key_management.js +0 -1
- package/node_modules/jose/package.json +1 -1
- package/package.json +6 -4
- package/protocol/index.json +91 -55
- package/protocol/operations/agent.sessions.list.json +13 -5
- package/protocol/operations/agent.sessions.read.json +13 -5
- package/protocol/operations/capability.list.json +382 -6
- package/protocol/operations/composition.apply.json +10 -2
- package/protocol/operations/composition.delete.json +845 -0
- package/protocol/operations/composition.read.json +10 -2
- package/protocol/operations/composition.undo.json +10 -2
- package/protocol/operations/conversation.agents.add.json +346 -0
- package/protocol/operations/conversation.agents.remove.json +340 -0
- package/protocol/operations/conversation.decide.json +196 -0
- package/protocol/operations/conversation.permission.json +196 -0
- package/protocol/operations/conversation.read.json +196 -0
- package/protocol/operations/conversation.send.json +265 -0
- package/protocol/operations/conversation.settings.check.json +213 -495
- package/protocol/operations/conversation.settings.read.json +211 -490
- package/protocol/operations/conversation.settings.update.json +235 -554
- package/protocol/operations/conversation.start.json +267 -5
- package/protocol/operations/conversation.stop.json +196 -0
- package/protocol/operations/extension.describe.json +39 -0
- package/protocol/operations/extension.install.json +62 -0
- package/protocol/operations/extension.list.json +164 -1
- package/protocol/operations/extension.market.change.json +210 -0
- package/protocol/operations/figure.read.json +265 -0
- package/protocol/operations/figure.select.json +157 -0
- package/protocol/operations/kernel.interrupt.json +119 -0
- package/protocol/operations/kernel.read.json +119 -0
- package/protocol/operations/kernel.run.json +119 -0
- package/protocol/operations/observation.snapshot.json +416 -8
- package/protocol/operations/project.openFile.json +10 -2
- package/protocol/operations/session.grant.json +4 -0
- package/protocol/operations/shared.json +1 -0
- package/protocol/operations/view.close.json +10 -2
- package/protocol/operations/view.protection.set.json +10 -2
- package/vendor/composition.json +177 -112
- package/vendor/rho-workspace-plugin-agent-0.4.33.tgz +0 -0
- package/vendor/rho-workspace-plugin-figures-0.5.1.tgz +0 -0
- package/vendor/rho-workspace-plugin-kernel-0.5.13.tgz +0 -0
- package/vendor/rho-workspace-plugin-tables-0.5.2.tgz +0 -0
- package/web/theme.css +5 -0
- package/dist/web/assets/answer-board-CBkGbSLD.js +0 -1
- package/dist/web/assets/canvas-D8rDdfDz.js +0 -1
- package/dist/web/assets/choices-D-YfnFP6.js +0 -1
- package/dist/web/assets/graph-lENgfQNH.js +0 -2
- package/dist/web/assets/index-B3_OVy91.css +0 -1
- package/dist/web/assets/index-BpL_Kl_s.js +0 -892
- package/dist/web/assets/overview-CDnVKlOt.js +0 -6
- package/dist/web/assets/preview-BEC1E9MK.js +0 -1
- package/dist/web/assets/studio-process-EYQgikxq.js +0 -2
- package/dist/web/assets/timeline-events-Chj2Gbx7.js +0 -1
- package/dist/web/assets/work-turns-CT9RVcWY.js +0 -1
- package/vendor/rho-workspace-plugin-agent-0.4.30.tgz +0 -0
- package/vendor/rho-workspace-plugin-kernel-0.5.5.tgz +0 -0
- 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 {})}
|