ilmplot 0.1.0__py3-none-any.whl
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.
- ilmplot/__init__.py +47 -0
- ilmplot/bridge.py +1469 -0
- ilmplot/document.py +2064 -0
- ilmplot/mathtext.py +69 -0
- ilmplot/messages.py +40 -0
- ilmplot/render.py +2574 -0
- ilmplot/stats.py +103 -0
- ilmplot-0.1.0.dist-info/METADATA +127 -0
- ilmplot-0.1.0.dist-info/RECORD +12 -0
- ilmplot-0.1.0.dist-info/WHEEL +5 -0
- ilmplot-0.1.0.dist-info/licenses/LICENSE +201 -0
- ilmplot-0.1.0.dist-info/top_level.txt +1 -0
ilmplot/render.py
ADDED
|
@@ -0,0 +1,2574 @@
|
|
|
1
|
+
"""Render a :class:`PlotDocument` to deterministic SVG bytes.
|
|
2
|
+
|
|
3
|
+
Rendering goes through ``matplotlib.figure.Figure`` + ``backend_svg`` inside
|
|
4
|
+
``rc_context`` — pyplot and the global backend are never touched. The rc
|
|
5
|
+
context is seeded from ``matplotlib.rcParamsDefault`` (minus the backend)
|
|
6
|
+
so user rc files/styles cannot leak a dark background or foreign defaults
|
|
7
|
+
into exported plots. The SVG carries the *original* (unscaled) document
|
|
8
|
+
JSON in its metadata node, so the file remains editable no matter which
|
|
9
|
+
``style_scale`` produced the snapshot.
|
|
10
|
+
|
|
11
|
+
``style_scale`` multiplies every physical size (fonts, line/marker widths,
|
|
12
|
+
spine and tick lengths, paddings) without changing figure geometry or data.
|
|
13
|
+
ILM uses it to keep typography at true final-figure points when a panel is
|
|
14
|
+
placed larger or smaller than the document's native size.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import tempfile
|
|
26
|
+
import xml.etree.ElementTree as ET
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from .document import (
|
|
31
|
+
MAX_DIMENSION_MM, MAX_FILE_BYTES, PlotDocument, PlotDocumentError,
|
|
32
|
+
embed_metadata,
|
|
33
|
+
)
|
|
34
|
+
from matplotlib.ticker import Formatter
|
|
35
|
+
|
|
36
|
+
from .mathtext import safe_text
|
|
37
|
+
|
|
38
|
+
_HASHSALT = 'ilm-plot-editor-v1'
|
|
39
|
+
|
|
40
|
+
# Text ids: matplotlib lands set_gid() on a wrapping <g>; ILM text groups
|
|
41
|
+
# key individual <text> elements, so these get stable per-run element ids.
|
|
42
|
+
_TEXT_GROUPS = ('ilmplot-title', 'ilmplot-xlabel', 'ilmplot-ylabel')
|
|
43
|
+
|
|
44
|
+
_SVG_NS = 'http://www.w3.org/2000/svg'
|
|
45
|
+
_XLINK_NS = 'http://www.w3.org/1999/xlink'
|
|
46
|
+
|
|
47
|
+
# QtSvg parses small inline font sizes with integer rounding, so native
|
|
48
|
+
# text is emitted at a 128 px reference and compensated with a scale()
|
|
49
|
+
# transform — the same high-reference technique ILM uses for overrides.
|
|
50
|
+
_REF_FONT_PX = 128.0
|
|
51
|
+
|
|
52
|
+
_LEN_RE = re.compile(
|
|
53
|
+
r'([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)(%|[A-Za-z]+)?')
|
|
54
|
+
_FONT_SIZE_STYLE_RE = re.compile(
|
|
55
|
+
r'(?:^|;)\s*font-size\s*:\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)'
|
|
56
|
+
r'(px|pt)?')
|
|
57
|
+
_FONT_FAMILY_STYLE_RE = re.compile(r'font-family\s*:[^;]+;?')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _local(elem) -> str:
|
|
61
|
+
return elem.tag.rsplit('}', 1)[-1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _register_namespaces(svg: bytes) -> None:
|
|
65
|
+
try:
|
|
66
|
+
for _event, data in ET.iterparse(io.BytesIO(svg),
|
|
67
|
+
events=['start-ns']):
|
|
68
|
+
prefix, uri = data
|
|
69
|
+
try:
|
|
70
|
+
ET.register_namespace(prefix, uri)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
ET.register_namespace('', _SVG_NS)
|
|
76
|
+
ET.register_namespace('xlink', _XLINK_NS)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _flatten_positioned_runs(root):
|
|
80
|
+
"""Turn <text> elements that are pure lists of positioned <tspan>s into
|
|
81
|
+
<g> containers of <text> runs (MathText emits these; flattening keeps
|
|
82
|
+
per-run font sizes instead of normalizing superscripts).
|
|
83
|
+
|
|
84
|
+
Returns the set of converted child elements — these are math runs and
|
|
85
|
+
must keep their own Matplotlib math font families."""
|
|
86
|
+
runs = set()
|
|
87
|
+
for elem in list(root.iter()):
|
|
88
|
+
if _local(elem) != 'text' or (elem.text or '').strip():
|
|
89
|
+
continue
|
|
90
|
+
children = list(elem)
|
|
91
|
+
if not children or any(k in elem.attrib for k in
|
|
92
|
+
('rotate', 'textLength')):
|
|
93
|
+
continue
|
|
94
|
+
if any(_local(c) != 'tspan' or 'x' not in c.attrib
|
|
95
|
+
or 'y' not in c.attrib or 'transform' in c.attrib
|
|
96
|
+
or (c.tail or '').strip()
|
|
97
|
+
or any(_local(n) == 'textPath' for n in c.iter())
|
|
98
|
+
for c in children):
|
|
99
|
+
continue
|
|
100
|
+
namespace = elem.tag[:-len('text')]
|
|
101
|
+
parent_id = elem.get('id')
|
|
102
|
+
elem.tag = namespace + 'g'
|
|
103
|
+
for key in ('x', 'y', 'dx', 'dy'):
|
|
104
|
+
elem.attrib.pop(key, None)
|
|
105
|
+
for i, child in enumerate(children):
|
|
106
|
+
child.tag = namespace + 'text'
|
|
107
|
+
if parent_id and child.get('id') is None:
|
|
108
|
+
child.set('id', f'{parent_id}-run-{i}')
|
|
109
|
+
runs.add(child)
|
|
110
|
+
return runs
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _assign_text_ids(root) -> bool:
|
|
114
|
+
"""Give <text> descendants of the labelled groups stable element ids."""
|
|
115
|
+
changed = False
|
|
116
|
+
for gid in _TEXT_GROUPS:
|
|
117
|
+
for elem in root.iter():
|
|
118
|
+
if elem.get('id') == gid:
|
|
119
|
+
idx = 0
|
|
120
|
+
for node in elem.iter():
|
|
121
|
+
if _local(node) == 'text':
|
|
122
|
+
node.set('id', f'{gid}-text-{idx}')
|
|
123
|
+
idx += 1
|
|
124
|
+
changed = True
|
|
125
|
+
return changed
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _explicit_font_px(node):
|
|
129
|
+
"""Font size in px declared inline (style property or attribute)."""
|
|
130
|
+
style = node.get('style') or ''
|
|
131
|
+
m = _FONT_SIZE_STYLE_RE.search(style)
|
|
132
|
+
if m:
|
|
133
|
+
unit = m.group(2) or 'px'
|
|
134
|
+
if unit == 'px':
|
|
135
|
+
return float(m.group(1))
|
|
136
|
+
return float(m.group(1)) * 96.0 / 72.0
|
|
137
|
+
attr = node.get('font-size')
|
|
138
|
+
if attr:
|
|
139
|
+
m = _LEN_RE.fullmatch(attr.strip())
|
|
140
|
+
if m:
|
|
141
|
+
unit = m.group(2) or 'px'
|
|
142
|
+
if unit == 'px':
|
|
143
|
+
return float(m.group(1))
|
|
144
|
+
if unit == 'pt':
|
|
145
|
+
return float(m.group(1)) * 96.0 / 72.0
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _divide_font_px(node, comp: float) -> None:
|
|
150
|
+
style = node.get('style')
|
|
151
|
+
if style and _FONT_SIZE_STYLE_RE.search(style):
|
|
152
|
+
def repl(m):
|
|
153
|
+
unit = m.group(2) or 'px'
|
|
154
|
+
return (m.group(0).replace(m.group(1),
|
|
155
|
+
f'{float(m.group(1)) / comp:.12g}', 1))
|
|
156
|
+
node.set('style', _FONT_SIZE_STYLE_RE.sub(repl, style))
|
|
157
|
+
elif node.get('font-size'):
|
|
158
|
+
m = _LEN_RE.fullmatch(node.get('font-size').strip())
|
|
159
|
+
if m:
|
|
160
|
+
node.set('font-size',
|
|
161
|
+
f'{float(m.group(1)) / comp:.12g}{m.group(2) or ""}')
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _rescale_text_subtree(elem, comp: float) -> None:
|
|
165
|
+
"""Divide absolute lengths by *comp* (inverse of the scale transform)."""
|
|
166
|
+
def inv(m):
|
|
167
|
+
unit = m.group(2) or ''
|
|
168
|
+
if unit in ('em', 'ex'):
|
|
169
|
+
return m.group(0)
|
|
170
|
+
return f'{float(m.group(1)) / comp:.12g}{unit}'
|
|
171
|
+
for node in elem.iter():
|
|
172
|
+
if _local(node) not in ('text', 'tspan'):
|
|
173
|
+
continue
|
|
174
|
+
for key in ('x', 'y', 'dx', 'dy', 'textLength',
|
|
175
|
+
'letter-spacing', 'word-spacing', 'stroke-width'):
|
|
176
|
+
if key in node.attrib:
|
|
177
|
+
node.set(key, _LEN_RE.sub(inv, node.get(key)))
|
|
178
|
+
style = node.get('style')
|
|
179
|
+
if style:
|
|
180
|
+
node.set('style', re.sub(
|
|
181
|
+
r'((?:letter-spacing|word-spacing|stroke-width)\s*:\s*)'
|
|
182
|
+
r'([^;]+)',
|
|
183
|
+
lambda m: m.group(1) + _LEN_RE.sub(inv, m.group(2)),
|
|
184
|
+
style))
|
|
185
|
+
if node is not elem:
|
|
186
|
+
# Nested runs keep their size relative to the parent's 128 px.
|
|
187
|
+
_divide_font_px(node, comp)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _set_font_px_128(node) -> None:
|
|
191
|
+
"""Replace any font-size (px, pt or presentation attribute) with a
|
|
192
|
+
literal ``font-size:128px`` appended after the other properties."""
|
|
193
|
+
style = node.get('style') or ''
|
|
194
|
+
if _FONT_SIZE_STYLE_RE.search(style):
|
|
195
|
+
style = _FONT_SIZE_STYLE_RE.sub('', style).strip('; ')
|
|
196
|
+
node.attrib.pop('font-size', None)
|
|
197
|
+
node.set('style',
|
|
198
|
+
f'{style};font-size:128px' if style else 'font-size:128px')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _precise_text(root, font_family: str, math_runs) -> bool:
|
|
202
|
+
"""128 px reference fonts + a single explicit family for <text> roots.
|
|
203
|
+
|
|
204
|
+
Qt misparses Matplotlib's CSS fallback list ('Arial', 'DejaVu Sans',
|
|
205
|
+
sans-serif), so ordinary text elements get one presentation
|
|
206
|
+
``font-family`` attribute; flattened math runs (*math_runs*) keep
|
|
207
|
+
their own Matplotlib math families and styles.
|
|
208
|
+
"""
|
|
209
|
+
changed = False
|
|
210
|
+
for node in root.iter():
|
|
211
|
+
if _local(node) != 'text':
|
|
212
|
+
continue
|
|
213
|
+
px = _explicit_font_px(node)
|
|
214
|
+
if px is not None and px > 0:
|
|
215
|
+
comp = px / _REF_FONT_PX
|
|
216
|
+
if abs(comp - 1.0) > 1e-12:
|
|
217
|
+
_rescale_text_subtree(node, comp)
|
|
218
|
+
node.set('transform',
|
|
219
|
+
(node.get('transform', '') +
|
|
220
|
+
f' scale({comp:.12g})').strip())
|
|
221
|
+
_set_font_px_128(node)
|
|
222
|
+
changed = True
|
|
223
|
+
if node not in math_runs:
|
|
224
|
+
style = node.get('style')
|
|
225
|
+
family = None
|
|
226
|
+
if style and _FONT_FAMILY_STYLE_RE.search(style):
|
|
227
|
+
m = re.search(r'font-family\s*:\s*([^;]+)', style)
|
|
228
|
+
if m:
|
|
229
|
+
# First family of the node's own CSS list wins, so
|
|
230
|
+
# per-element style families survive normalization.
|
|
231
|
+
family = m.group(1).split(',')[0] \
|
|
232
|
+
.strip().strip('"\'') or None
|
|
233
|
+
node.set('style', _FONT_FAMILY_STYLE_RE.sub('', style)
|
|
234
|
+
.strip('; '))
|
|
235
|
+
family = family or font_family
|
|
236
|
+
if family:
|
|
237
|
+
node.set('font-family', family)
|
|
238
|
+
changed = True
|
|
239
|
+
return changed
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _prepare_svg(svg: bytes, font_family: str) -> bytes:
|
|
243
|
+
"""Post-process raw Matplotlib SVG for Qt: flatten positioned runs,
|
|
244
|
+
assign stable text ids, emit high-precision 128 px reference text and
|
|
245
|
+
a single font family. Returns *svg* unchanged on any parse problem."""
|
|
246
|
+
_register_namespaces(svg)
|
|
247
|
+
try:
|
|
248
|
+
root = ET.fromstring(svg)
|
|
249
|
+
except ET.ParseError:
|
|
250
|
+
return svg
|
|
251
|
+
math_runs = _flatten_positioned_runs(root)
|
|
252
|
+
changed = bool(math_runs)
|
|
253
|
+
changed |= _assign_text_ids(root)
|
|
254
|
+
changed |= _precise_text(root, font_family, math_runs)
|
|
255
|
+
if not changed:
|
|
256
|
+
return svg
|
|
257
|
+
try:
|
|
258
|
+
return ET.tostring(root, encoding='unicode').encode('utf-8')
|
|
259
|
+
except Exception:
|
|
260
|
+
return svg
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True)
|
|
264
|
+
class PlotRender:
|
|
265
|
+
svg: bytes
|
|
266
|
+
# normalized (left, top, right, bottom) of the axes rectangle — the
|
|
267
|
+
# "plot interior" ILM alignment marks refer to.
|
|
268
|
+
plot_area: tuple
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _canonical(document: PlotDocument) -> str:
|
|
272
|
+
return json.dumps(document.to_dict(), sort_keys=True,
|
|
273
|
+
separators=(',', ':'))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_render_cache: OrderedDict = OrderedDict()
|
|
277
|
+
_RENDER_CACHE_MAX = 64
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _deterministic_rc(document: PlotDocument) -> dict:
|
|
281
|
+
"""rc seed built from matplotlib defaults, not the caller's rcParams."""
|
|
282
|
+
import matplotlib
|
|
283
|
+
rc = {k: v for k, v in matplotlib.rcParamsDefault.items()
|
|
284
|
+
if not k.startswith('backend')}
|
|
285
|
+
rc.update({
|
|
286
|
+
'svg.fonttype': 'none',
|
|
287
|
+
'text.usetex': False,
|
|
288
|
+
'svg.hashsalt': _HASHSALT,
|
|
289
|
+
'font.family': 'sans-serif',
|
|
290
|
+
'font.sans-serif': [document.font_family, 'DejaVu Sans'],
|
|
291
|
+
'axes.unicode_minus': False,
|
|
292
|
+
'figure.dpi': 100,
|
|
293
|
+
})
|
|
294
|
+
return rc
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def render_document(document: PlotDocument, *,
|
|
298
|
+
style_scale: float = 1.0) -> PlotRender:
|
|
299
|
+
"""Render *document* to SVG bytes with embedded metadata.
|
|
300
|
+
|
|
301
|
+
``style_scale`` scales typography/line weights only; figure size, axes
|
|
302
|
+
rectangle and data are unchanged, so the SVG viewBox stays identical for
|
|
303
|
+
every scale.
|
|
304
|
+
"""
|
|
305
|
+
document.validate()
|
|
306
|
+
if not (isinstance(style_scale, (int, float))
|
|
307
|
+
and 0 < float(style_scale) < float('inf')):
|
|
308
|
+
raise PlotDocumentError(
|
|
309
|
+
f"style_scale must be a positive finite number, got {style_scale!r}")
|
|
310
|
+
key = (_canonical(document), round(float(style_scale), 6))
|
|
311
|
+
hit = _render_cache.get(key)
|
|
312
|
+
if hit is not None:
|
|
313
|
+
_render_cache.move_to_end(key)
|
|
314
|
+
return hit
|
|
315
|
+
|
|
316
|
+
import matplotlib
|
|
317
|
+
|
|
318
|
+
s = float(style_scale)
|
|
319
|
+
with matplotlib.rc_context(_deterministic_rc(document)):
|
|
320
|
+
fig = None
|
|
321
|
+
try:
|
|
322
|
+
fig, ax = _build_figure(document, s, document.width_mm,
|
|
323
|
+
document.height_mm)
|
|
324
|
+
_fit_figure(document, fig, ax)
|
|
325
|
+
pos = ax.get_position()
|
|
326
|
+
buf = io.BytesIO()
|
|
327
|
+
fig.savefig(buf, format='svg',
|
|
328
|
+
metadata={'Date': None,
|
|
329
|
+
'Creator': 'ILM Plot Editor'})
|
|
330
|
+
finally:
|
|
331
|
+
if fig is not None:
|
|
332
|
+
fig.clear()
|
|
333
|
+
|
|
334
|
+
svg = embed_metadata(
|
|
335
|
+
_prepare_svg(buf.getvalue(), document.font_family), document)
|
|
336
|
+
if len(svg) > MAX_FILE_BYTES:
|
|
337
|
+
raise PlotDocumentError(
|
|
338
|
+
f"rendered SVG exceeds {MAX_FILE_BYTES // (1024 * 1024)} MiB — "
|
|
339
|
+
f"reduce the number of data points")
|
|
340
|
+
render = PlotRender(
|
|
341
|
+
svg=svg, plot_area=(pos.x0, 1.0 - pos.y1, pos.x1, 1.0 - pos.y0))
|
|
342
|
+
_render_cache[key] = render
|
|
343
|
+
if len(_render_cache) > _RENDER_CACHE_MAX:
|
|
344
|
+
_render_cache.popitem(last=False)
|
|
345
|
+
return render
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@functools.lru_cache(maxsize=1)
|
|
349
|
+
def available_font_families():
|
|
350
|
+
"""Sorted unique family names matplotlib can resolve."""
|
|
351
|
+
from matplotlib import font_manager
|
|
352
|
+
return tuple(sorted({f.name for f in font_manager.fontManager.ttflist}))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def resolve_font_family(text, families):
|
|
356
|
+
"""Typed font input → ``(family | None, ok)``.
|
|
357
|
+
|
|
358
|
+
Empty means the default (``(None, True)``); a case-insensitive match
|
|
359
|
+
resolves to the canonical family name; anything else is ``(None,
|
|
360
|
+
False)`` so the caller can flag the field instead of applying.
|
|
361
|
+
"""
|
|
362
|
+
text = (text or '').strip()
|
|
363
|
+
if not text:
|
|
364
|
+
return None, True
|
|
365
|
+
lowered = text.lower()
|
|
366
|
+
for fam in families:
|
|
367
|
+
if fam.lower() == lowered:
|
|
368
|
+
return fam, True
|
|
369
|
+
return None, False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class _AffixFormatter(Formatter):
|
|
373
|
+
"""Wraps an axis formatter: prefix/suffix, optional fixed decimals.
|
|
374
|
+
|
|
375
|
+
A real ``Formatter`` subclass — otherwise ``set_major_formatter``
|
|
376
|
+
would wrap it in a ``FuncFormatter`` and the wrapped formatter would
|
|
377
|
+
never see ``set_locs`` (categorical ``FixedFormatter`` would get
|
|
378
|
+
``pos=None`` too, yielding empty labels). ``set_axis``/``set_locs``/
|
|
379
|
+
``format_ticks``/``get_offset`` delegate so offset text and tick
|
|
380
|
+
behaviour are unchanged.
|
|
381
|
+
"""
|
|
382
|
+
def __init__(self, wrapped, prefix='', suffix='', decimals=None,
|
|
383
|
+
numeric=True):
|
|
384
|
+
super().__init__()
|
|
385
|
+
self._wrapped = wrapped
|
|
386
|
+
self._prefix = prefix
|
|
387
|
+
self._suffix = suffix
|
|
388
|
+
self._decimals = decimals if numeric else None
|
|
389
|
+
|
|
390
|
+
def _text(self, x, pos):
|
|
391
|
+
if self._decimals is not None:
|
|
392
|
+
try:
|
|
393
|
+
return f'{float(x):.{self._decimals}f}'
|
|
394
|
+
except (TypeError, ValueError):
|
|
395
|
+
pass
|
|
396
|
+
return self._wrapped(x, pos)
|
|
397
|
+
|
|
398
|
+
def __call__(self, x, pos=None):
|
|
399
|
+
return f'{self._prefix}{self._text(x, pos)}{self._suffix}'
|
|
400
|
+
|
|
401
|
+
def set_axis(self, axis):
|
|
402
|
+
super().set_axis(axis)
|
|
403
|
+
if hasattr(self._wrapped, 'set_axis'):
|
|
404
|
+
self._wrapped.set_axis(axis)
|
|
405
|
+
|
|
406
|
+
def set_locs(self, locs):
|
|
407
|
+
self._wrapped.set_locs(locs)
|
|
408
|
+
|
|
409
|
+
def format_ticks(self, values):
|
|
410
|
+
self.set_locs(values)
|
|
411
|
+
if self._decimals is not None:
|
|
412
|
+
texts = [self._text(v, None) for v in values]
|
|
413
|
+
else:
|
|
414
|
+
texts = [self._wrapped(v, pos=i)
|
|
415
|
+
for i, v in enumerate(values)]
|
|
416
|
+
return [f'{self._prefix}{t}{self._suffix}' for t in texts]
|
|
417
|
+
|
|
418
|
+
def get_offset(self):
|
|
419
|
+
return self._wrapped.get_offset()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def clip_series_to_view(x, y, xlim, ylim, xlog=False, ylog=False):
|
|
423
|
+
"""Clip a polyline to a view box (Liang–Barsky, per segment).
|
|
424
|
+
|
|
425
|
+
QtSvg ignores ``clipPath``, so data outside the axes box must be
|
|
426
|
+
clipped in the data itself. Works in transformed space (log10 for
|
|
427
|
+
log axes; non-positive inputs count as outside and are dropped).
|
|
428
|
+
Returns ``(xs, ys, mask)`` — ``NaN`` breaks where the line leaves the
|
|
429
|
+
box, interpolated boundary points where it crosses, and ``mask[i]``
|
|
430
|
+
True only where ``(xs[i], ys[i])`` is an original in-view point (so
|
|
431
|
+
callers can feed it to ``set_markevery`` to keep boundary points
|
|
432
|
+
unmarked).
|
|
433
|
+
"""
|
|
434
|
+
def bound(v, log, extreme):
|
|
435
|
+
try:
|
|
436
|
+
v = float(v)
|
|
437
|
+
except (TypeError, ValueError):
|
|
438
|
+
return extreme
|
|
439
|
+
if log:
|
|
440
|
+
return math.log10(v) if v > 0 else extreme
|
|
441
|
+
return v
|
|
442
|
+
|
|
443
|
+
# Transform the limits into the same space as the points.
|
|
444
|
+
x0 = bound(min(xlim), xlog, -math.inf)
|
|
445
|
+
x1 = bound(max(xlim), xlog, math.inf)
|
|
446
|
+
y0 = bound(min(ylim), ylog, -math.inf)
|
|
447
|
+
y1 = bound(max(ylim), ylog, math.inf)
|
|
448
|
+
|
|
449
|
+
pts = []
|
|
450
|
+
for xv, yv in zip(x, y):
|
|
451
|
+
try:
|
|
452
|
+
xv, yv = float(xv), float(yv)
|
|
453
|
+
except (TypeError, ValueError):
|
|
454
|
+
pts.append(None)
|
|
455
|
+
continue
|
|
456
|
+
if xlog and xv <= 0 or ylog and yv <= 0:
|
|
457
|
+
pts.append(None) # untransformable → outside
|
|
458
|
+
continue
|
|
459
|
+
if xlog:
|
|
460
|
+
xv = math.log10(xv)
|
|
461
|
+
if ylog:
|
|
462
|
+
yv = math.log10(yv)
|
|
463
|
+
if math.isnan(xv) or math.isnan(yv):
|
|
464
|
+
pts.append(None)
|
|
465
|
+
continue
|
|
466
|
+
pts.append((xv, yv))
|
|
467
|
+
|
|
468
|
+
out_x, out_y, mask = [], [], []
|
|
469
|
+
connected = False
|
|
470
|
+
|
|
471
|
+
def emit(px, py, is_original):
|
|
472
|
+
out_x.append(10 ** px if xlog else px)
|
|
473
|
+
out_y.append(10 ** py if ylog else py)
|
|
474
|
+
mask.append(is_original)
|
|
475
|
+
|
|
476
|
+
def emit_break():
|
|
477
|
+
nonlocal connected
|
|
478
|
+
if connected:
|
|
479
|
+
out_x.append(float('nan'))
|
|
480
|
+
out_y.append(float('nan'))
|
|
481
|
+
mask.append(False)
|
|
482
|
+
connected = False
|
|
483
|
+
|
|
484
|
+
prev = None
|
|
485
|
+
for q in pts:
|
|
486
|
+
if q is None:
|
|
487
|
+
emit_break()
|
|
488
|
+
prev = None
|
|
489
|
+
continue
|
|
490
|
+
if prev is None:
|
|
491
|
+
prev = q
|
|
492
|
+
continue
|
|
493
|
+
p, prev = prev, q
|
|
494
|
+
# Liang–Barsky on segment p→q in transformed space.
|
|
495
|
+
t0, t1, ok = 0.0, 1.0, True
|
|
496
|
+
for pv, qv, lo, hi in ((p[0], q[0], x0, x1),
|
|
497
|
+
(p[1], q[1], y0, y1)):
|
|
498
|
+
d = qv - pv
|
|
499
|
+
for p_, q_ in ((-d, pv - lo), (d, hi - pv)):
|
|
500
|
+
if p_ == 0:
|
|
501
|
+
if q_ < 0:
|
|
502
|
+
ok = False
|
|
503
|
+
break
|
|
504
|
+
continue
|
|
505
|
+
t = q_ / p_
|
|
506
|
+
if p_ < 0:
|
|
507
|
+
if t > t1:
|
|
508
|
+
ok = False
|
|
509
|
+
break
|
|
510
|
+
t0 = max(t0, t)
|
|
511
|
+
else:
|
|
512
|
+
if t < t0:
|
|
513
|
+
ok = False
|
|
514
|
+
break
|
|
515
|
+
t1 = min(t1, t)
|
|
516
|
+
if not ok:
|
|
517
|
+
break
|
|
518
|
+
if ok and t0 <= t1:
|
|
519
|
+
in_p = x0 <= p[0] <= x1 and y0 <= p[1] <= y1
|
|
520
|
+
in_q = x0 <= q[0] <= x1 and y0 <= q[1] <= y1
|
|
521
|
+
if not connected:
|
|
522
|
+
emit_break()
|
|
523
|
+
if t0 > 0 or not (out_x and connected):
|
|
524
|
+
ax_ = p[0] + t0 * (q[0] - p[0])
|
|
525
|
+
ay_ = p[1] + t0 * (q[1] - p[1])
|
|
526
|
+
emit(ax_, ay_, t0 == 0 and in_p)
|
|
527
|
+
bx_ = p[0] + t1 * (q[0] - p[0])
|
|
528
|
+
by_ = p[1] + t1 * (q[1] - p[1])
|
|
529
|
+
emit(bx_, by_, t1 == 1 and in_q)
|
|
530
|
+
connected = True
|
|
531
|
+
if t1 < 1:
|
|
532
|
+
emit_break()
|
|
533
|
+
else:
|
|
534
|
+
emit_break()
|
|
535
|
+
return out_x, out_y, mask
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def clip_polygon_to_view(xs, ys, xlim, ylim, xlog=False, ylog=False):
|
|
539
|
+
"""Clip a closed polygon to a view box (Sutherland–Hodgman).
|
|
540
|
+
|
|
541
|
+
QtSvg ignores ``clipPath``, so filled areas (violin bodies, stacked
|
|
542
|
+
bars, ridgeline fills) are clipped in data space when the document
|
|
543
|
+
pins a limit. Works in transformed space (log10 on log axes;
|
|
544
|
+
non-positive vertices are dropped). Returns ``(xs, ys)`` — empty
|
|
545
|
+
when the polygon lies fully outside.
|
|
546
|
+
"""
|
|
547
|
+
def bound(v, log, extreme):
|
|
548
|
+
try:
|
|
549
|
+
v = float(v)
|
|
550
|
+
except (TypeError, ValueError):
|
|
551
|
+
return extreme
|
|
552
|
+
if log:
|
|
553
|
+
return math.log10(v) if v > 0 else extreme
|
|
554
|
+
return v
|
|
555
|
+
|
|
556
|
+
x0 = bound(min(xlim), xlog, -math.inf)
|
|
557
|
+
x1 = bound(max(xlim), xlog, math.inf)
|
|
558
|
+
y0 = bound(min(ylim), ylog, -math.inf)
|
|
559
|
+
y1 = bound(max(ylim), ylog, math.inf)
|
|
560
|
+
|
|
561
|
+
pts = []
|
|
562
|
+
for xv, yv in zip(xs, ys):
|
|
563
|
+
try:
|
|
564
|
+
xv, yv = float(xv), float(yv)
|
|
565
|
+
except (TypeError, ValueError):
|
|
566
|
+
continue
|
|
567
|
+
if xlog and xv <= 0 or ylog and yv <= 0:
|
|
568
|
+
continue # untransformable → outside
|
|
569
|
+
if xlog:
|
|
570
|
+
xv = math.log10(xv)
|
|
571
|
+
if ylog:
|
|
572
|
+
yv = math.log10(yv)
|
|
573
|
+
if math.isnan(xv) or math.isnan(yv):
|
|
574
|
+
continue
|
|
575
|
+
pts.append((xv, yv))
|
|
576
|
+
if len(pts) < 3:
|
|
577
|
+
return [], []
|
|
578
|
+
|
|
579
|
+
def clip_bound(points, axis, bound_v, is_lo):
|
|
580
|
+
"""Sequential Sutherland–Hodgman against one half-plane."""
|
|
581
|
+
if bound_v in (-math.inf, math.inf):
|
|
582
|
+
return points
|
|
583
|
+
if (lo if is_lo else hi) is None:
|
|
584
|
+
return points
|
|
585
|
+
bound_v = lo if is_lo else hi
|
|
586
|
+
if bound_v in (-math.inf, math.inf):
|
|
587
|
+
return points
|
|
588
|
+
out = []
|
|
589
|
+
|
|
590
|
+
def inside(p):
|
|
591
|
+
return p[axis] >= bound_v if is_lo else p[axis] <= bound_v
|
|
592
|
+
|
|
593
|
+
for i, p in enumerate(points):
|
|
594
|
+
q = points[i - 1]
|
|
595
|
+
ip, iq = inside(p), inside(q)
|
|
596
|
+
if ip:
|
|
597
|
+
if not iq:
|
|
598
|
+
dq = p[axis] - q[axis]
|
|
599
|
+
t = (bound_v - q[axis]) / dq if dq else 0.0
|
|
600
|
+
out.append((q[0] + t * (p[0] - q[0]),
|
|
601
|
+
q[1] + t * (p[1] - q[1])))
|
|
602
|
+
out.append(p)
|
|
603
|
+
elif iq:
|
|
604
|
+
dq = p[axis] - q[axis]
|
|
605
|
+
t = (bound_v - q[axis]) / dq if dq else 0.0
|
|
606
|
+
out.append((q[0] + t * (p[0] - q[0]),
|
|
607
|
+
q[1] + t * (p[1] - q[1])))
|
|
608
|
+
return out
|
|
609
|
+
|
|
610
|
+
for axis, lo, hi in ((0, x0, x1), (1, y0, y1)):
|
|
611
|
+
pts = clip_bound(pts, axis, lo, True)
|
|
612
|
+
pts = clip_bound(pts, axis, hi, False)
|
|
613
|
+
if not pts:
|
|
614
|
+
return [], []
|
|
615
|
+
|
|
616
|
+
out_x = [10 ** p[0] if xlog else p[0] for p in pts]
|
|
617
|
+
out_y = [10 ** p[1] if ylog else p[1] for p in pts]
|
|
618
|
+
return out_x, out_y
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _add_underline(ax, get_texts):
|
|
622
|
+
"""Add an ``Artist`` that underlines text artists (matplotlib has none).
|
|
623
|
+
|
|
624
|
+
``get_texts`` is a callable returning the current texts to underline —
|
|
625
|
+
for tick labels it resolves labels at draw time so the underline follows
|
|
626
|
+
``_fit_axes`` repositioning and only covers visible ticks. A line just
|
|
627
|
+
below each visible text's window-extent bottom edge is drawn in the
|
|
628
|
+
text's own colour at ≈ 0.06 × its fontsize.
|
|
629
|
+
"""
|
|
630
|
+
from matplotlib.artist import Artist
|
|
631
|
+
from matplotlib.path import Path
|
|
632
|
+
from matplotlib.transforms import IdentityTransform
|
|
633
|
+
|
|
634
|
+
class _Underline(Artist):
|
|
635
|
+
def draw(self, renderer):
|
|
636
|
+
gc = renderer.new_gc()
|
|
637
|
+
try:
|
|
638
|
+
for text in get_texts():
|
|
639
|
+
try:
|
|
640
|
+
if not text.get_visible() or not text.get_text():
|
|
641
|
+
continue
|
|
642
|
+
bb = text.get_window_extent(renderer=renderer)
|
|
643
|
+
except Exception:
|
|
644
|
+
continue
|
|
645
|
+
if bb.width <= 0:
|
|
646
|
+
continue
|
|
647
|
+
pad = max(0.04 * text.get_fontsize(), 0.5)
|
|
648
|
+
path = Path([(bb.x0, bb.y0 - pad),
|
|
649
|
+
(bb.x1, bb.y0 - pad)])
|
|
650
|
+
gc.set_foreground(text.get_color())
|
|
651
|
+
gc.set_linewidth(
|
|
652
|
+
max(0.06 * text.get_fontsize(), 0.3))
|
|
653
|
+
renderer.draw_path(gc, path, IdentityTransform())
|
|
654
|
+
finally:
|
|
655
|
+
gc.restore()
|
|
656
|
+
return super().draw(renderer)
|
|
657
|
+
|
|
658
|
+
u = _Underline()
|
|
659
|
+
u.axes = ax
|
|
660
|
+
u.set_figure(ax.figure)
|
|
661
|
+
u.set_zorder(10)
|
|
662
|
+
ax.add_artist(u)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _text_kwargs(ts, family, size_pt, s):
|
|
666
|
+
"""Font kwargs for a ``TextStyle`` override (may be None)."""
|
|
667
|
+
kw = {}
|
|
668
|
+
if ts is not None:
|
|
669
|
+
kw['fontfamily'] = ts.family or family
|
|
670
|
+
kw['fontweight'] = 'bold' if ts.bold else 'normal'
|
|
671
|
+
kw['fontstyle'] = 'italic' if ts.italic else 'normal'
|
|
672
|
+
kw['color'] = ts.color
|
|
673
|
+
if ts.size_pt is not None:
|
|
674
|
+
kw['fontsize'] = ts.size_pt * s
|
|
675
|
+
return kw
|
|
676
|
+
kw['fontsize'] = size_pt * s
|
|
677
|
+
return kw
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _view_limits(document, ax, default_xlim, default_ylim):
|
|
681
|
+
"""Final (xlim, ylim): document pins win, else the computed default."""
|
|
682
|
+
xlim = document.xlim if document.xlim is not None else default_xlim
|
|
683
|
+
ylim = document.ylim if document.ylim is not None else default_ylim
|
|
684
|
+
return xlim, ylim
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _clip_needed(document):
|
|
688
|
+
return document.xlim is not None or document.ylim is not None
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _n_text(opt, group):
|
|
692
|
+
return safe_text(opt.n_format.format(n=len(group.values)))
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _draw_violin(document, ax, s):
|
|
696
|
+
"""Violin bodies + inner box + jittered points (NCPlot port).
|
|
697
|
+
|
|
698
|
+
``opt.body`` swaps the density body for a plain box ('none') or a
|
|
699
|
+
mean column ('bar'); the 'violin' path is byte-identical to the
|
|
700
|
+
original.
|
|
701
|
+
"""
|
|
702
|
+
from matplotlib.patches import Polygon
|
|
703
|
+
from matplotlib.colors import to_rgb, to_rgba, rgb_to_hsv
|
|
704
|
+
from . import stats
|
|
705
|
+
opt = document.violin
|
|
706
|
+
if opt is None:
|
|
707
|
+
from .document import ViolinOptions
|
|
708
|
+
opt = ViolinOptions()
|
|
709
|
+
groups = document.groups
|
|
710
|
+
n = len(groups)
|
|
711
|
+
clip = _clip_needed(document)
|
|
712
|
+
y_axis = getattr(document.style, 'yaxis', None) \
|
|
713
|
+
if document.style is not None else None
|
|
714
|
+
xlog = False # violin x is always categorical positions
|
|
715
|
+
ylog = y_axis is not None and y_axis.scale == 'log'
|
|
716
|
+
body = opt.body
|
|
717
|
+
box_full = opt.box_width if opt.box_width is not None \
|
|
718
|
+
else (0.5 if body == 'none' else 0.16)
|
|
719
|
+
box_w = box_full / 2.0
|
|
720
|
+
extra = 0.45 if opt.show_points and opt.points_beside else 0.0
|
|
721
|
+
default_xlim = (1 - 0.7, n + 0.7 + extra)
|
|
722
|
+
all_vals = [v for g in groups for v in g.values]
|
|
723
|
+
pad = (max(all_vals) - min(all_vals)) * 0.05 or 1.0
|
|
724
|
+
bar_stats = None
|
|
725
|
+
if body == 'bar':
|
|
726
|
+
import numpy as np
|
|
727
|
+
bar_stats = []
|
|
728
|
+
for g in groups:
|
|
729
|
+
n_v = len(g.values)
|
|
730
|
+
mean = float(np.mean(g.values))
|
|
731
|
+
sd = float(np.std(g.values, ddof=1)) if n_v >= 2 else 0.0
|
|
732
|
+
if opt.bar_error == 'sem':
|
|
733
|
+
err = sd / math.sqrt(n_v) if n_v else 0.0
|
|
734
|
+
elif opt.bar_error == 'sd':
|
|
735
|
+
err = sd
|
|
736
|
+
else:
|
|
737
|
+
err = None
|
|
738
|
+
bar_stats.append((mean, err))
|
|
739
|
+
top = max([max(all_vals)]
|
|
740
|
+
+ [m + e for m, e in bar_stats if e is not None])
|
|
741
|
+
default_ylim = (0.0 if min(all_vals) >= 0
|
|
742
|
+
else min(all_vals) - pad, top + pad)
|
|
743
|
+
else:
|
|
744
|
+
default_ylim = (min(all_vals) - pad, max(all_vals) + pad)
|
|
745
|
+
xlim, ylim = _view_limits(document, ax, default_xlim, default_ylim)
|
|
746
|
+
ax.set_xlim(xlim)
|
|
747
|
+
ax.set_ylim(ylim)
|
|
748
|
+
gid = 'ilmplot-violin-'
|
|
749
|
+
tops = []
|
|
750
|
+
for i, (g, pos) in enumerate(zip(groups, range(1, n + 1))):
|
|
751
|
+
g_top = max(g.values) if g.values else 0.0
|
|
752
|
+
edge = g.color
|
|
753
|
+
fill_alpha = opt.fill_alpha
|
|
754
|
+
edge_w = opt.edge_width_pt
|
|
755
|
+
dot_edge = 'face'
|
|
756
|
+
dot_alpha = opt.point_alpha
|
|
757
|
+
dot_lw = opt.point_edge_width_pt
|
|
758
|
+
if opt.enhance_contrast:
|
|
759
|
+
hsv = rgb_to_hsv(to_rgb(g.color))
|
|
760
|
+
if hsv[2] > 0.8 and hsv[1] < 0.5:
|
|
761
|
+
edge = '#666666'
|
|
762
|
+
fill_alpha = 0.7
|
|
763
|
+
edge_w = 1.2
|
|
764
|
+
dot_edge = '#666666'
|
|
765
|
+
dot_alpha = 0.9
|
|
766
|
+
if opt.edge_color is not None:
|
|
767
|
+
edge = opt.edge_color
|
|
768
|
+
if opt.point_edge_color is not None:
|
|
769
|
+
dot_edge = opt.point_edge_color
|
|
770
|
+
if body == 'violin':
|
|
771
|
+
shape = stats.violin_shape(g.values, opt.bandwidth)
|
|
772
|
+
if shape is not None:
|
|
773
|
+
pts, half = shape
|
|
774
|
+
g_top = float(pts[-1])
|
|
775
|
+
xs = [pos + h for h in half] + \
|
|
776
|
+
[pos - h for h in half[::-1]]
|
|
777
|
+
ys = list(pts) + list(pts[::-1])
|
|
778
|
+
if clip:
|
|
779
|
+
xs, ys = clip_polygon_to_view(xs, ys, xlim, ylim,
|
|
780
|
+
xlog, ylog)
|
|
781
|
+
if xs:
|
|
782
|
+
# QtSvg draws a 0-width stroke as a 1-px hairline —
|
|
783
|
+
# remove the edge instead of passing width 0.
|
|
784
|
+
patch = Polygon(list(zip(xs, ys)), closed=True,
|
|
785
|
+
facecolor=g.color,
|
|
786
|
+
edgecolor='none' if edge_w == 0
|
|
787
|
+
else edge,
|
|
788
|
+
alpha=fill_alpha,
|
|
789
|
+
linewidth=edge_w * s)
|
|
790
|
+
patch.set_gid(gid + g.id)
|
|
791
|
+
ax.add_patch(patch)
|
|
792
|
+
elif body == 'bar':
|
|
793
|
+
mean, err = bar_stats[i]
|
|
794
|
+
if err is not None and err > 0:
|
|
795
|
+
g_top = max(g_top, mean + err)
|
|
796
|
+
hw = opt.bar_width / 2.0
|
|
797
|
+
base = min(ylim) if ylog else 0.0
|
|
798
|
+
rx = [pos - hw, pos + hw, pos + hw, pos - hw]
|
|
799
|
+
ry = [base, base, mean, mean]
|
|
800
|
+
if clip:
|
|
801
|
+
rx, ry = clip_polygon_to_view(rx, ry, xlim, ylim,
|
|
802
|
+
xlog, ylog)
|
|
803
|
+
if rx:
|
|
804
|
+
patch = Polygon(list(zip(rx, ry)), closed=True,
|
|
805
|
+
facecolor=to_rgba(g.color, fill_alpha),
|
|
806
|
+
edgecolor='none' if edge_w == 0
|
|
807
|
+
else edge,
|
|
808
|
+
linewidth=edge_w * s, zorder=2)
|
|
809
|
+
patch.set_gid(gid + g.id)
|
|
810
|
+
ax.add_patch(patch)
|
|
811
|
+
if err is not None and err > 0:
|
|
812
|
+
lo_e, hi_e = mean - err, mean + err
|
|
813
|
+
if ylog and lo_e <= 0:
|
|
814
|
+
lo_e = mean / 10.0
|
|
815
|
+
in_view = True
|
|
816
|
+
if clip:
|
|
817
|
+
if lo_e > max(ylim) or hi_e < min(ylim):
|
|
818
|
+
in_view = False
|
|
819
|
+
else:
|
|
820
|
+
lo_e = max(lo_e, min(ylim))
|
|
821
|
+
hi_e = min(hi_e, max(ylim))
|
|
822
|
+
if in_view and hi_e >= lo_e:
|
|
823
|
+
from matplotlib.collections import LineCollection
|
|
824
|
+
coll = LineCollection(
|
|
825
|
+
[((pos, lo_e), (pos, hi_e))],
|
|
826
|
+
colors='#333333', linewidths=1.2 * s, zorder=3)
|
|
827
|
+
coll.set_gid(gid + g.id)
|
|
828
|
+
ax.add_collection(coll)
|
|
829
|
+
box = None
|
|
830
|
+
if opt.show_box or (body == 'none' and opt.show_outliers
|
|
831
|
+
and not opt.show_points):
|
|
832
|
+
box = stats.box_stats(g.values)
|
|
833
|
+
if opt.show_box:
|
|
834
|
+
q1, med, q3, lo, hi = box
|
|
835
|
+
gid_prefix = gid + g.id
|
|
836
|
+
wl, = ax.plot([pos, pos], [lo, hi], color='#333333',
|
|
837
|
+
linewidth=1.2 * s, zorder=3)
|
|
838
|
+
wl.set_gid(gid_prefix)
|
|
839
|
+
rx, ry = ([pos - box_w, pos + box_w, pos + box_w,
|
|
840
|
+
pos - box_w], [q1, q1, q3, q3])
|
|
841
|
+
if clip:
|
|
842
|
+
rx, ry = clip_polygon_to_view(rx, ry, xlim, ylim,
|
|
843
|
+
xlog, ylog)
|
|
844
|
+
if rx:
|
|
845
|
+
rect = Polygon(
|
|
846
|
+
list(zip(rx, ry)), closed=True,
|
|
847
|
+
facecolor=(to_rgba(g.color, opt.fill_alpha)
|
|
848
|
+
if body == 'none' else 'white'),
|
|
849
|
+
edgecolor='#333333',
|
|
850
|
+
linewidth=1.2 * s, zorder=4)
|
|
851
|
+
rect.set_gid(gid_prefix)
|
|
852
|
+
ax.add_patch(rect)
|
|
853
|
+
ml, = ax.plot([pos - box_w, pos + box_w], [med, med],
|
|
854
|
+
color='#333333', linewidth=1.8 * s, zorder=5,
|
|
855
|
+
solid_capstyle='butt')
|
|
856
|
+
ml.set_gid(gid_prefix)
|
|
857
|
+
caps = []
|
|
858
|
+
if body == 'none':
|
|
859
|
+
for cv in (lo, hi):
|
|
860
|
+
cl, = ax.plot([pos - box_w / 2, pos + box_w / 2],
|
|
861
|
+
[cv, cv], color='#333333',
|
|
862
|
+
linewidth=1.2 * s, zorder=3,
|
|
863
|
+
solid_capstyle='butt')
|
|
864
|
+
cl.set_gid(gid_prefix)
|
|
865
|
+
caps.append((cl, cv))
|
|
866
|
+
if clip:
|
|
867
|
+
cx, cy, _m = clip_series_to_view(
|
|
868
|
+
[pos, pos], [lo, hi], xlim, ylim, xlog, ylog)
|
|
869
|
+
wl.set_data(cx, cy)
|
|
870
|
+
mx, my, _m = clip_series_to_view(
|
|
871
|
+
[pos - box_w, pos + box_w], [med, med],
|
|
872
|
+
xlim, ylim, xlog, ylog)
|
|
873
|
+
ml.set_data(mx, my)
|
|
874
|
+
for cl, cv in caps:
|
|
875
|
+
cx, cy, _m = clip_series_to_view(
|
|
876
|
+
[pos - box_w / 2, pos + box_w / 2], [cv, cv],
|
|
877
|
+
xlim, ylim, xlog, ylog)
|
|
878
|
+
cl.set_data(cx, cy)
|
|
879
|
+
if body == 'none' and opt.show_outliers and not opt.show_points:
|
|
880
|
+
_q1, _med, _q3, lo, hi = box
|
|
881
|
+
xs = [pos] * len(g.values)
|
|
882
|
+
ys = [v for v in g.values if v < lo or v > hi]
|
|
883
|
+
xs = xs[:len(ys)]
|
|
884
|
+
if clip:
|
|
885
|
+
pts_xy = [(x, y) for x, y in zip(xs, ys)
|
|
886
|
+
if min(xlim) <= x <= max(xlim)
|
|
887
|
+
and min(ylim) <= y <= max(ylim)]
|
|
888
|
+
xs = [p[0] for p in pts_xy]
|
|
889
|
+
ys = [p[1] for p in pts_xy]
|
|
890
|
+
if xs:
|
|
891
|
+
coll = ax.scatter(
|
|
892
|
+
xs, ys, s=(opt.point_size_pt * s) ** 2,
|
|
893
|
+
facecolors='none', edgecolors='#333333',
|
|
894
|
+
linewidths=1.0 * s, zorder=6)
|
|
895
|
+
coll.set_gid(gid + g.id)
|
|
896
|
+
if opt.show_points:
|
|
897
|
+
jit = stats.jitter(len(g.values), opt.points_beside,
|
|
898
|
+
42 + i)
|
|
899
|
+
xs = [pos + j for j in jit]
|
|
900
|
+
ys = list(g.values)
|
|
901
|
+
if clip:
|
|
902
|
+
pts_xy = [(x, y) for x, y in zip(xs, ys)
|
|
903
|
+
if min(xlim) <= x <= max(xlim)
|
|
904
|
+
and min(ylim) <= y <= max(ylim)]
|
|
905
|
+
xs = [p[0] for p in pts_xy]
|
|
906
|
+
ys = [p[1] for p in pts_xy]
|
|
907
|
+
coll = ax.scatter(
|
|
908
|
+
xs, ys, s=(opt.point_size_pt * s) ** 2,
|
|
909
|
+
color=g.color, alpha=dot_alpha,
|
|
910
|
+
edgecolors='none' if dot_lw == 0 else dot_edge,
|
|
911
|
+
linewidths=0.0 if dot_lw == 0 else dot_lw * s,
|
|
912
|
+
zorder=6)
|
|
913
|
+
coll.set_gid(gid + g.id)
|
|
914
|
+
tops.append(g_top)
|
|
915
|
+
if opt.show_n and opt.n_position in ('top', 'bottom'):
|
|
916
|
+
y_rng = max(ylim) - min(ylim)
|
|
917
|
+
for g, pos, g_top in zip(groups, range(1, n + 1), tops):
|
|
918
|
+
if opt.n_position == 'top':
|
|
919
|
+
y = g_top + 0.02 * y_rng
|
|
920
|
+
else:
|
|
921
|
+
y = min(ylim) + 0.02 * y_rng
|
|
922
|
+
if clip and not (min(xlim) <= pos <= max(xlim)
|
|
923
|
+
and min(ylim) <= y <= max(ylim)):
|
|
924
|
+
continue
|
|
925
|
+
t = ax.text(pos, y, _n_text(opt, g), ha='center',
|
|
926
|
+
va='bottom',
|
|
927
|
+
fontsize=(opt.n_size_pt if opt.n_size_pt
|
|
928
|
+
is not None else
|
|
929
|
+
document.font_size_pt) * s,
|
|
930
|
+
color=opt.n_color or '#333333', zorder=7)
|
|
931
|
+
t.set_gid(gid + g.id)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _draw_ridgeline(document, ax, s, lines):
|
|
935
|
+
"""Ridgeline (mountain-stacked) series — NCPlot's generate_plot."""
|
|
936
|
+
import types
|
|
937
|
+
from matplotlib.patches import Polygon
|
|
938
|
+
from .document import RidgeOptions
|
|
939
|
+
opt = document.ridgeline or RidgeOptions()
|
|
940
|
+
series = document.series
|
|
941
|
+
n = len(series)
|
|
942
|
+
max_range = 0.0
|
|
943
|
+
all_x = []
|
|
944
|
+
for s_ in series:
|
|
945
|
+
if s_.y:
|
|
946
|
+
max_range = max(max_range, max(s_.y) - min(s_.y))
|
|
947
|
+
all_x.extend(s_.x)
|
|
948
|
+
offset = opt.offset or (max_range * 1.05 if max_range > 0 else 1.0)
|
|
949
|
+
diffs = sorted(set(all_x))
|
|
950
|
+
steps = [b - a for a, b in zip(diffs, diffs[1:]) if b - a > 0]
|
|
951
|
+
min_step = min(steps) if steps else 1.0
|
|
952
|
+
half = min_step * 0.48
|
|
953
|
+
indices = list(range(n))
|
|
954
|
+
if opt.reverse:
|
|
955
|
+
indices.reverse()
|
|
956
|
+
clip = _clip_needed(document)
|
|
957
|
+
x_axis = getattr(document.style, 'xaxis', None) \
|
|
958
|
+
if document.style is not None else None
|
|
959
|
+
y_axis = getattr(document.style, 'yaxis', None) \
|
|
960
|
+
if document.style is not None else None
|
|
961
|
+
xlog = x_axis is not None and x_axis.scale == 'log'
|
|
962
|
+
ylog = y_axis is not None and y_axis.scale == 'log'
|
|
963
|
+
tick_ts = getattr(getattr(document.style, 'yaxis', None),
|
|
964
|
+
'ticks', None) if document.style else None
|
|
965
|
+
label_kw = _text_kwargs(tick_ts, document.font_family,
|
|
966
|
+
document.font_size_pt, s)
|
|
967
|
+
y_top = 0.0
|
|
968
|
+
x_min = min(all_x) if all_x else 0.0
|
|
969
|
+
x_max = max(all_x) if all_x else 1.0
|
|
970
|
+
for s_ in series:
|
|
971
|
+
if s_.y:
|
|
972
|
+
i = series.index(s_)
|
|
973
|
+
y_top = max(y_top, i * offset + max(s_.y) - min(s_.y))
|
|
974
|
+
pad = (x_max - x_min) * 0.04 if x_max > x_min else 0.5
|
|
975
|
+
view_xlim = document.xlim or (x_min - pad, x_max + pad)
|
|
976
|
+
view_ylim = document.ylim or (-offset * 0.15,
|
|
977
|
+
y_top + offset * 0.25)
|
|
978
|
+
ax.set_xlim(view_xlim)
|
|
979
|
+
ax.set_ylim(view_ylim)
|
|
980
|
+
for i in indices:
|
|
981
|
+
s_ = series[i]
|
|
982
|
+
ymin = min(s_.y)
|
|
983
|
+
baseline = i * offset
|
|
984
|
+
y_plot = [y - ymin + baseline for y in s_.y]
|
|
985
|
+
y_top = max(y_top, max(y_plot))
|
|
986
|
+
poly_x = list(s_.x) + list(s_.x[::-1])
|
|
987
|
+
poly_y = list(y_plot) + [baseline] * len(s_.x)
|
|
988
|
+
if clip:
|
|
989
|
+
px, py = clip_polygon_to_view(poly_x, poly_y,
|
|
990
|
+
view_xlim, view_ylim,
|
|
991
|
+
xlog, ylog)
|
|
992
|
+
else:
|
|
993
|
+
px, py = poly_x, poly_y
|
|
994
|
+
if px:
|
|
995
|
+
patch = Polygon(list(zip(px, py)), closed=True,
|
|
996
|
+
facecolor=s_.color, alpha=opt.fill_alpha,
|
|
997
|
+
edgecolor='none', zorder=2 + i)
|
|
998
|
+
patch.set_gid(f'ilmplot-series-{s_.id}')
|
|
999
|
+
ax.add_patch(patch)
|
|
1000
|
+
line, = ax.plot(s_.x, y_plot, color=s_.color,
|
|
1001
|
+
linewidth=max(s_.linewidth_pt * s, 1e-3),
|
|
1002
|
+
linestyle='None' if s_.linewidth_pt == 0 else
|
|
1003
|
+
(s_.linestyle if s_.linestyle else 'None'),
|
|
1004
|
+
marker=s_.marker or None,
|
|
1005
|
+
markersize=max(s_.markersize_pt * s, 0.0),
|
|
1006
|
+
markeredgewidth=1.0 * s, zorder=3 + i,
|
|
1007
|
+
label='_nolegend_')
|
|
1008
|
+
line.set_gid(f'ilmplot-series-{s_.id}')
|
|
1009
|
+
lines.append((line, types.SimpleNamespace(
|
|
1010
|
+
x=list(s_.x), y=list(y_plot), marker=s_.marker)))
|
|
1011
|
+
xmin, xmax = min(s_.x), max(s_.x)
|
|
1012
|
+
if opt.baseline_width_pt > 0:
|
|
1013
|
+
ax.plot([xmin - half, xmax + half], [baseline, baseline],
|
|
1014
|
+
color=opt.baseline_color,
|
|
1015
|
+
linewidth=opt.baseline_width_pt * s, zorder=1)
|
|
1016
|
+
if opt.labels:
|
|
1017
|
+
t = ax.text(xmin - half * 1.4,
|
|
1018
|
+
baseline + (max(y_plot) - baseline) * 0.5,
|
|
1019
|
+
safe_text(s_.label), ha='right', va='center',
|
|
1020
|
+
**label_kw)
|
|
1021
|
+
t.set_gid(f'ilmplot-series-label-{s_.id}')
|
|
1022
|
+
ax.set_yticks([])
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _yerr_bounds(item):
|
|
1026
|
+
"""Per-point (lower, upper) error extents from either
|
|
1027
|
+
representation, or None."""
|
|
1028
|
+
if item.yerr_minus is not None:
|
|
1029
|
+
return list(item.yerr_minus), list(item.yerr_plus)
|
|
1030
|
+
if item.yerr is not None:
|
|
1031
|
+
return list(item.yerr), list(item.yerr)
|
|
1032
|
+
return None
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _draw_series_yerr(document, ax, ser, s, gid):
|
|
1036
|
+
"""Capless vertical error bars for a line series, in its colour."""
|
|
1037
|
+
from matplotlib.collections import LineCollection
|
|
1038
|
+
lower, upper = _yerr_bounds(ser)
|
|
1039
|
+
xlim = document.xlim
|
|
1040
|
+
ylim = document.ylim
|
|
1041
|
+
y_axis = getattr(document.style, 'yaxis', None) \
|
|
1042
|
+
if document.style is not None else None
|
|
1043
|
+
ylog = y_axis is not None and y_axis.scale == 'log'
|
|
1044
|
+
segs = []
|
|
1045
|
+
for x, y, lo_e, hi_e in zip(ser.x, ser.y, lower, upper):
|
|
1046
|
+
if xlim is not None and not (min(xlim) <= x <= max(xlim)):
|
|
1047
|
+
continue
|
|
1048
|
+
lo, hi = y - lo_e, y + hi_e
|
|
1049
|
+
if ylog and lo <= 0:
|
|
1050
|
+
lo = y / 10.0
|
|
1051
|
+
if ylim is not None:
|
|
1052
|
+
if lo > max(ylim) or hi < min(ylim):
|
|
1053
|
+
continue
|
|
1054
|
+
lo, hi = max(lo, min(ylim)), min(hi, max(ylim))
|
|
1055
|
+
if lo > hi:
|
|
1056
|
+
continue
|
|
1057
|
+
segs.append(((x, lo), (x, hi)))
|
|
1058
|
+
if not segs:
|
|
1059
|
+
return
|
|
1060
|
+
lw = (ser.linewidth_pt if ser.linewidth_pt > 0 else 1.5) * s
|
|
1061
|
+
coll = LineCollection(segs, colors=ser.color,
|
|
1062
|
+
linewidths=max(lw, 1e-3), zorder=1.9)
|
|
1063
|
+
coll.set_gid(gid)
|
|
1064
|
+
ax.add_collection(coll)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _series_errband_poly(document, ax, ser):
|
|
1068
|
+
"""Shaded y±err band polygon for a line series, or ``None``.
|
|
1069
|
+
|
|
1070
|
+
Returns ``(poly, xs, ys)``; the caller clips and finalises it when
|
|
1071
|
+
the document pins limits. Same gid as the error-bar collection so
|
|
1072
|
+
hit-testing is unchanged.
|
|
1073
|
+
"""
|
|
1074
|
+
from matplotlib.colors import to_rgba
|
|
1075
|
+
from matplotlib.patches import Polygon
|
|
1076
|
+
bounds = _yerr_bounds(ser)
|
|
1077
|
+
if bounds is None:
|
|
1078
|
+
return None
|
|
1079
|
+
lower, upper = bounds
|
|
1080
|
+
y_axis = getattr(document.style, 'yaxis', None) \
|
|
1081
|
+
if document.style is not None else None
|
|
1082
|
+
ylog = y_axis is not None and y_axis.scale == 'log'
|
|
1083
|
+
lo_pts, hi_pts = [], []
|
|
1084
|
+
for x, y, lo_e, hi_e in zip(ser.x, ser.y, lower, upper):
|
|
1085
|
+
lo, hi = y - lo_e, y + hi_e
|
|
1086
|
+
if ylog and lo <= 0:
|
|
1087
|
+
lo = y / 10.0
|
|
1088
|
+
lo_pts.append((x, lo))
|
|
1089
|
+
hi_pts.append((x, hi))
|
|
1090
|
+
xs = [p[0] for p in lo_pts] + [p[0] for p in hi_pts][::-1]
|
|
1091
|
+
ys = [p[1] for p in lo_pts] + [p[1] for p in hi_pts][::-1]
|
|
1092
|
+
r, g, b, a = to_rgba(ser.color)
|
|
1093
|
+
poly = Polygon(list(zip(xs, ys)), closed=True,
|
|
1094
|
+
facecolor=(r, g, b, a * ser.error_alpha),
|
|
1095
|
+
edgecolor='none', linewidth=0, zorder=1.8)
|
|
1096
|
+
poly.set_gid(f'ilmplot-series-{ser.id}')
|
|
1097
|
+
ax.add_patch(poly)
|
|
1098
|
+
ax.update_datalim(poly.get_path().vertices)
|
|
1099
|
+
return poly, xs, ys
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _draw_stacked(document, ax, s):
|
|
1103
|
+
"""Stacked or grouped columns (percent or absolute) — NCPlot
|
|
1104
|
+
port. With ``opt.horizontal`` the bars run left-to-right: all
|
|
1105
|
+
geometry below stays in (category c, value v) terms and ``P``
|
|
1106
|
+
swaps the axes at emit time."""
|
|
1107
|
+
from matplotlib.patches import Polygon
|
|
1108
|
+
from .document import StackOptions
|
|
1109
|
+
opt = document.stacked or StackOptions()
|
|
1110
|
+
horizontal = opt.horizontal
|
|
1111
|
+
cats = document.categories
|
|
1112
|
+
n_bars = len(cats[0].values)
|
|
1113
|
+
clip = _clip_needed(document)
|
|
1114
|
+
x_axis = getattr(document.style, 'xaxis', None) \
|
|
1115
|
+
if document.style is not None else None
|
|
1116
|
+
y_axis = getattr(document.style, 'yaxis', None) \
|
|
1117
|
+
if document.style is not None else None
|
|
1118
|
+
# The value axis carries the log scale — y for columns, x for bars.
|
|
1119
|
+
if horizontal:
|
|
1120
|
+
xlog = x_axis is not None and x_axis.scale == 'log'
|
|
1121
|
+
ylog = False
|
|
1122
|
+
else:
|
|
1123
|
+
xlog = False # stacked x is always categorical positions
|
|
1124
|
+
ylog = y_axis is not None and y_axis.scale == 'log'
|
|
1125
|
+
|
|
1126
|
+
def P(c, v):
|
|
1127
|
+
return (v, c) if horizontal else (c, v)
|
|
1128
|
+
totals = [sum(c.values[i] for c in cats) for i in range(n_bars)]
|
|
1129
|
+
widths = opt.bar_width / 2.0
|
|
1130
|
+
if opt.percent:
|
|
1131
|
+
y_max = 100.0
|
|
1132
|
+
elif opt.grouped:
|
|
1133
|
+
uppers = [(_yerr_bounds(c) or (None, [0.0] * n_bars))[1]
|
|
1134
|
+
for c in cats]
|
|
1135
|
+
y_max = max(c.values[b] + uppers[i][b]
|
|
1136
|
+
for i, c in enumerate(cats)
|
|
1137
|
+
for b in range(n_bars)) * 1.02
|
|
1138
|
+
else:
|
|
1139
|
+
y_max = max(totals) * 1.02
|
|
1140
|
+
cat_lim = (-0.8, n_bars - 1 + 0.8)
|
|
1141
|
+
val_lim = (0.0, y_max)
|
|
1142
|
+
if horizontal:
|
|
1143
|
+
xlim = (document.xlim if document.xlim is not None
|
|
1144
|
+
else val_lim)
|
|
1145
|
+
ylim = (document.ylim if document.ylim is not None
|
|
1146
|
+
else cat_lim)
|
|
1147
|
+
ax.set_ylim(ylim)
|
|
1148
|
+
# Bars are bare Polygon patches, which never trigger
|
|
1149
|
+
# autoscaling, so the default range is set explicitly (a log
|
|
1150
|
+
# axis keeps matplotlib's own range: a 0 lower bound is
|
|
1151
|
+
# invalid there).
|
|
1152
|
+
if opt.percent or document.xlim is not None or not xlog:
|
|
1153
|
+
ax.set_xlim(xlim)
|
|
1154
|
+
cat_view, val_view = ylim, xlim
|
|
1155
|
+
else:
|
|
1156
|
+
xlim, ylim = _view_limits(document, ax, cat_lim, val_lim)
|
|
1157
|
+
ax.set_xlim(xlim)
|
|
1158
|
+
# Bars are bare Polygon patches, which never trigger y
|
|
1159
|
+
# autoscaling, so the default range is set explicitly (a log
|
|
1160
|
+
# axis keeps matplotlib's own range: a 0 lower bound is
|
|
1161
|
+
# invalid there).
|
|
1162
|
+
if opt.percent or document.ylim is not None or not ylog:
|
|
1163
|
+
ax.set_ylim(ylim)
|
|
1164
|
+
cat_view, val_view = xlim, ylim
|
|
1165
|
+
bottoms = [0.0] * n_bars
|
|
1166
|
+
value_fs = (opt.value_size_pt
|
|
1167
|
+
if opt.value_size_pt is not None
|
|
1168
|
+
else document.font_size_pt * 6.0 / 7.0)
|
|
1169
|
+
if opt.grouped:
|
|
1170
|
+
n_cats = len(cats)
|
|
1171
|
+
w = opt.bar_width / n_cats
|
|
1172
|
+
for i, c in enumerate(cats):
|
|
1173
|
+
gid = f'ilmplot-stack-{c.id}'
|
|
1174
|
+
err_segs = []
|
|
1175
|
+
bounds = _yerr_bounds(c)
|
|
1176
|
+
for b in range(n_bars):
|
|
1177
|
+
v = c.values[b]
|
|
1178
|
+
x = b - opt.bar_width / 2.0 + w * (i + 0.5)
|
|
1179
|
+
if bounds is not None and (bounds[0][b] > 0
|
|
1180
|
+
or bounds[1][b] > 0):
|
|
1181
|
+
lo, hi = v - bounds[0][b], v + bounds[1][b]
|
|
1182
|
+
in_x = not clip or (min(cat_view) <= x
|
|
1183
|
+
<= max(cat_view))
|
|
1184
|
+
if in_x and clip:
|
|
1185
|
+
if lo > max(val_view) or hi < min(val_view):
|
|
1186
|
+
in_x = False
|
|
1187
|
+
else:
|
|
1188
|
+
lo = max(lo, min(val_view))
|
|
1189
|
+
hi = min(hi, max(val_view))
|
|
1190
|
+
if in_x and hi >= lo:
|
|
1191
|
+
err_segs.append((P(x, lo), P(x, hi)))
|
|
1192
|
+
pts = [P(x - w / 2, 0.0), P(x + w / 2, 0.0),
|
|
1193
|
+
P(x + w / 2, v), P(x - w / 2, v)]
|
|
1194
|
+
xs = [p[0] for p in pts]
|
|
1195
|
+
ys = [p[1] for p in pts]
|
|
1196
|
+
if clip:
|
|
1197
|
+
xs, ys = clip_polygon_to_view(xs, ys, xlim, ylim,
|
|
1198
|
+
xlog, ylog)
|
|
1199
|
+
if xs:
|
|
1200
|
+
patch = Polygon(list(zip(xs, ys)), closed=True,
|
|
1201
|
+
facecolor=c.color,
|
|
1202
|
+
edgecolor='none'
|
|
1203
|
+
if opt.edge_width_pt == 0
|
|
1204
|
+
else opt.edge_color,
|
|
1205
|
+
linewidth=opt.edge_width_pt * s,
|
|
1206
|
+
zorder=2)
|
|
1207
|
+
patch.set_gid(gid)
|
|
1208
|
+
ax.add_patch(patch)
|
|
1209
|
+
if opt.show_values and v > 0:
|
|
1210
|
+
if clip and not (min(val_view) <= v
|
|
1211
|
+
<= max(val_view)):
|
|
1212
|
+
continue
|
|
1213
|
+
text = f'{v:.{opt.value_decimals}f}'
|
|
1214
|
+
# Labels sit past the bar end on the background:
|
|
1215
|
+
# the default white (meant for inside stacked
|
|
1216
|
+
# segments) would vanish, so it falls back to
|
|
1217
|
+
# black.
|
|
1218
|
+
tx, ty = P(x, v)
|
|
1219
|
+
t = ax.text(tx, ty, safe_text(text),
|
|
1220
|
+
ha='left' if horizontal else 'center',
|
|
1221
|
+
va='center' if horizontal else 'bottom',
|
|
1222
|
+
fontsize=value_fs * s,
|
|
1223
|
+
color='#000000'
|
|
1224
|
+
if opt.value_color == '#ffffff'
|
|
1225
|
+
else opt.value_color,
|
|
1226
|
+
fontweight='bold' if opt.value_bold
|
|
1227
|
+
else 'normal', zorder=3)
|
|
1228
|
+
t.set_gid(gid)
|
|
1229
|
+
if err_segs:
|
|
1230
|
+
from matplotlib.collections import LineCollection
|
|
1231
|
+
coll = LineCollection(err_segs, colors='#000000',
|
|
1232
|
+
linewidths=1.5 * s, zorder=3)
|
|
1233
|
+
coll.set_gid(gid)
|
|
1234
|
+
ax.add_collection(coll)
|
|
1235
|
+
return
|
|
1236
|
+
for c in cats:
|
|
1237
|
+
gid = f'ilmplot-stack-{c.id}'
|
|
1238
|
+
for b in range(n_bars):
|
|
1239
|
+
v = c.values[b]
|
|
1240
|
+
share = v / totals[b] * 100.0 if totals[b] else 0.0
|
|
1241
|
+
h = share if opt.percent else v
|
|
1242
|
+
y0, y1 = bottoms[b], bottoms[b] + h
|
|
1243
|
+
pts = [P(b - widths, y0), P(b + widths, y0),
|
|
1244
|
+
P(b + widths, y1), P(b - widths, y1)]
|
|
1245
|
+
xs = [p[0] for p in pts]
|
|
1246
|
+
ys = [p[1] for p in pts]
|
|
1247
|
+
if clip:
|
|
1248
|
+
xs, ys = clip_polygon_to_view(xs, ys, xlim, ylim,
|
|
1249
|
+
xlog, ylog)
|
|
1250
|
+
if xs:
|
|
1251
|
+
patch = Polygon(list(zip(xs, ys)), closed=True,
|
|
1252
|
+
facecolor=c.color,
|
|
1253
|
+
edgecolor='none'
|
|
1254
|
+
if opt.edge_width_pt == 0
|
|
1255
|
+
else opt.edge_color,
|
|
1256
|
+
linewidth=opt.edge_width_pt * s,
|
|
1257
|
+
zorder=2)
|
|
1258
|
+
patch.set_gid(gid)
|
|
1259
|
+
ax.add_patch(patch)
|
|
1260
|
+
bottoms[b] += h
|
|
1261
|
+
if opt.show_values and share >= opt.value_threshold \
|
|
1262
|
+
and h > 0:
|
|
1263
|
+
mid = (y0 + y1) / 2.0
|
|
1264
|
+
if clip and not (min(val_view) <= mid
|
|
1265
|
+
<= max(val_view)):
|
|
1266
|
+
continue
|
|
1267
|
+
text = (f'{share:.{opt.value_decimals}f}%'
|
|
1268
|
+
if opt.percent
|
|
1269
|
+
else f'{v:.{opt.value_decimals}f}')
|
|
1270
|
+
tx, ty = P(b, mid)
|
|
1271
|
+
t = ax.text(tx, ty, safe_text(text), ha='center',
|
|
1272
|
+
va='center', fontsize=value_fs * s,
|
|
1273
|
+
color=opt.value_color,
|
|
1274
|
+
fontweight='bold' if opt.value_bold
|
|
1275
|
+
else 'normal', zorder=3)
|
|
1276
|
+
t.set_gid(gid)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _hist_bins(document):
|
|
1280
|
+
"""Shared bin ``edges`` plus per-group counts/density.
|
|
1281
|
+
|
|
1282
|
+
Every group in a histogram shares one edge list so the outlines
|
|
1283
|
+
compare directly.
|
|
1284
|
+
"""
|
|
1285
|
+
import numpy as np
|
|
1286
|
+
from .document import HistOptions
|
|
1287
|
+
opt = document.histogram or HistOptions()
|
|
1288
|
+
pooled = np.asarray([v for g in document.groups
|
|
1289
|
+
for v in g.values], dtype=float)
|
|
1290
|
+
lo, hi = float(pooled.min()), float(pooled.max())
|
|
1291
|
+
if lo == hi:
|
|
1292
|
+
edges = np.asarray([lo - 0.5, hi + 0.5])
|
|
1293
|
+
elif opt.bin_width:
|
|
1294
|
+
w = float(opt.bin_width)
|
|
1295
|
+
start = math.floor(lo / w) * w
|
|
1296
|
+
stop = math.ceil(hi / w) * w
|
|
1297
|
+
if stop <= start:
|
|
1298
|
+
stop = start + w
|
|
1299
|
+
edges = np.arange(start, stop + w / 2.0, w)
|
|
1300
|
+
if len(edges) > 2000:
|
|
1301
|
+
edges = np.linspace(start, stop, 2000)
|
|
1302
|
+
elif opt.bins:
|
|
1303
|
+
edges = np.linspace(lo, hi, opt.bins + 1)
|
|
1304
|
+
else:
|
|
1305
|
+
edges = np.histogram_bin_edges(pooled, bins='auto')
|
|
1306
|
+
if len(edges) > 2000:
|
|
1307
|
+
edges = np.linspace(lo, hi, 2000)
|
|
1308
|
+
counts = [np.histogram(g.values, edges, density=opt.density)[0]
|
|
1309
|
+
for g in document.groups]
|
|
1310
|
+
return list(edges), counts
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _hist_label(opt, g):
|
|
1314
|
+
label = safe_text(g.label)
|
|
1315
|
+
if opt.show_n:
|
|
1316
|
+
label = f"{label} ({safe_text(opt.n_format.format(n=len(g.values)))})"
|
|
1317
|
+
return label
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _draw_histogram(document, ax, s):
|
|
1321
|
+
"""Overlaid group histograms (bars outline or step) + optional KDE."""
|
|
1322
|
+
import numpy as np
|
|
1323
|
+
from matplotlib.patches import Polygon
|
|
1324
|
+
from matplotlib.colors import to_rgba
|
|
1325
|
+
from . import stats
|
|
1326
|
+
from .document import HistOptions
|
|
1327
|
+
opt = document.histogram or HistOptions()
|
|
1328
|
+
clip = _clip_needed(document)
|
|
1329
|
+
x_axis = getattr(document.style, 'xaxis', None) \
|
|
1330
|
+
if document.style is not None else None
|
|
1331
|
+
y_axis = getattr(document.style, 'yaxis', None) \
|
|
1332
|
+
if document.style is not None else None
|
|
1333
|
+
xlog = x_axis is not None and x_axis.scale == 'log'
|
|
1334
|
+
ylog = y_axis is not None and y_axis.scale == 'log'
|
|
1335
|
+
edges, counts = _hist_bins(document)
|
|
1336
|
+
top = max((float(c.max()) for c in counts if len(c)), default=0.0)
|
|
1337
|
+
pad = (edges[-1] - edges[0]) * 0.02
|
|
1338
|
+
xlim, ylim = _view_limits(document, ax,
|
|
1339
|
+
(edges[0] - pad, edges[-1] + pad),
|
|
1340
|
+
(0.0, top * 1.05 or 1.0))
|
|
1341
|
+
ax.set_xlim(xlim)
|
|
1342
|
+
# Polygons don't autoscale: pin the default range (a log y axis
|
|
1343
|
+
# keeps matplotlib's own range unless the document pins one).
|
|
1344
|
+
if document.ylim is not None or not ylog:
|
|
1345
|
+
ax.set_ylim(ylim)
|
|
1346
|
+
for g, c in zip(document.groups, counts):
|
|
1347
|
+
gid = f'ilmplot-hist-{g.id}'
|
|
1348
|
+
xs = [e for e in edges for _ in (0, 1)]
|
|
1349
|
+
ys = [0.0] + [float(v) for v in c for _ in (0, 1)] + [0.0]
|
|
1350
|
+
if opt.style == 'step':
|
|
1351
|
+
if clip:
|
|
1352
|
+
xs_c, ys_c, _m = clip_series_to_view(
|
|
1353
|
+
xs, ys, xlim, ylim, xlog, ylog)
|
|
1354
|
+
else:
|
|
1355
|
+
xs_c, ys_c = xs, ys
|
|
1356
|
+
line, = ax.plot(xs_c, ys_c, color=g.color,
|
|
1357
|
+
linewidth=1.5 * s, zorder=2,
|
|
1358
|
+
label=_hist_label(opt, g))
|
|
1359
|
+
line.set_gid(gid)
|
|
1360
|
+
else:
|
|
1361
|
+
px, py = xs, ys
|
|
1362
|
+
if clip:
|
|
1363
|
+
px, py = clip_polygon_to_view(px, py, xlim, ylim,
|
|
1364
|
+
xlog, ylog)
|
|
1365
|
+
if px:
|
|
1366
|
+
patch = Polygon(
|
|
1367
|
+
list(zip(px, py)), closed=True,
|
|
1368
|
+
facecolor=to_rgba(g.color, opt.fill_alpha),
|
|
1369
|
+
edgecolor='none' if opt.edge_width_pt == 0
|
|
1370
|
+
else (opt.edge_color or g.color),
|
|
1371
|
+
linewidth=opt.edge_width_pt * s, zorder=2,
|
|
1372
|
+
label=_hist_label(opt, g))
|
|
1373
|
+
patch.set_gid(gid)
|
|
1374
|
+
ax.add_patch(patch)
|
|
1375
|
+
if opt.kde and len(g.values) >= 2:
|
|
1376
|
+
sd = float(np.std(g.values, ddof=1))
|
|
1377
|
+
if sd > 0:
|
|
1378
|
+
evaluate, bw = stats.gaussian_kde(g.values,
|
|
1379
|
+
opt.bandwidth)
|
|
1380
|
+
grid = np.linspace(edges[0], edges[-1], 200)
|
|
1381
|
+
scale = 1.0 if opt.density else \
|
|
1382
|
+
len(g.values) * float(np.mean(np.diff(edges)))
|
|
1383
|
+
y = evaluate(grid) * scale
|
|
1384
|
+
gx, gy = list(grid), list(y)
|
|
1385
|
+
if clip:
|
|
1386
|
+
gx, gy, _m = clip_series_to_view(
|
|
1387
|
+
gx, gy, xlim, ylim, xlog, ylog)
|
|
1388
|
+
line, = ax.plot(gx, gy, color=g.color,
|
|
1389
|
+
linewidth=1.5 * s, zorder=3,
|
|
1390
|
+
label='_nolegend_')
|
|
1391
|
+
line.set_gid(gid)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
_ANCHOR_XY = {
|
|
1395
|
+
'upper left': (0.02, 0.98, 'left', 'top'),
|
|
1396
|
+
'upper center': (0.5, 0.98, 'center', 'top'),
|
|
1397
|
+
'upper right': (0.98, 0.98, 'right', 'top'),
|
|
1398
|
+
'lower left': (0.02, 0.02, 'left', 'bottom'),
|
|
1399
|
+
'lower center': (0.5, 0.02, 'center', 'bottom'),
|
|
1400
|
+
'lower right': (0.98, 0.02, 'right', 'bottom'),
|
|
1401
|
+
'center left': (0.02, 0.5, 'left', 'center'),
|
|
1402
|
+
'center right': (0.98, 0.5, 'right', 'center'),
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def _data_y_range(document):
|
|
1407
|
+
"""(y_max, y_range) for bracket placement, per kind."""
|
|
1408
|
+
if document.kind == 'violin':
|
|
1409
|
+
vals = [v for g in document.groups for v in g.values]
|
|
1410
|
+
v_opt = document.violin
|
|
1411
|
+
if v_opt is not None and v_opt.body == 'bar':
|
|
1412
|
+
import numpy as np
|
|
1413
|
+
top = max(vals)
|
|
1414
|
+
for g in document.groups:
|
|
1415
|
+
mean = float(np.mean(g.values))
|
|
1416
|
+
n_v = len(g.values)
|
|
1417
|
+
sd = float(np.std(g.values, ddof=1)) if n_v >= 2 \
|
|
1418
|
+
else 0.0
|
|
1419
|
+
if v_opt.bar_error == 'sem':
|
|
1420
|
+
err = sd / math.sqrt(n_v) if n_v else 0.0
|
|
1421
|
+
elif v_opt.bar_error == 'sd':
|
|
1422
|
+
err = sd
|
|
1423
|
+
else:
|
|
1424
|
+
err = 0.0
|
|
1425
|
+
top = max(top, mean + err)
|
|
1426
|
+
rng = (top - min(0.0, min(vals))) or 1.0
|
|
1427
|
+
if v_opt.show_n and v_opt.n_position == 'top':
|
|
1428
|
+
top += 0.08 * rng
|
|
1429
|
+
return top, rng
|
|
1430
|
+
rng = (max(vals) - min(vals)) or 1.0
|
|
1431
|
+
top = max(vals)
|
|
1432
|
+
if v_opt is not None and v_opt.show_n \
|
|
1433
|
+
and v_opt.n_position == 'top':
|
|
1434
|
+
top += 0.08 * rng
|
|
1435
|
+
return top, rng
|
|
1436
|
+
if document.kind == 'histogram':
|
|
1437
|
+
_edges, counts = _hist_bins(document)
|
|
1438
|
+
top = max((float(c.max()) for c in counts if len(c)),
|
|
1439
|
+
default=0.0)
|
|
1440
|
+
return top, top or 1.0
|
|
1441
|
+
if document.kind == 'stacked_column':
|
|
1442
|
+
opt = document.stacked
|
|
1443
|
+
percent = opt.percent if opt is not None else True
|
|
1444
|
+
if percent:
|
|
1445
|
+
return 100.0, 100.0
|
|
1446
|
+
if opt is not None and opt.grouped:
|
|
1447
|
+
uppers = [
|
|
1448
|
+
(_yerr_bounds(c)
|
|
1449
|
+
or (None, [0.0] * len(c.values)))[1]
|
|
1450
|
+
for c in document.categories]
|
|
1451
|
+
top = max(v + uppers[j][i]
|
|
1452
|
+
for j, c in enumerate(document.categories)
|
|
1453
|
+
for i, v in enumerate(c.values))
|
|
1454
|
+
return top, top or 1.0
|
|
1455
|
+
n_bars = len(document.categories[0].values)
|
|
1456
|
+
totals = [sum(c.values[i] for c in document.categories)
|
|
1457
|
+
for i in range(n_bars)]
|
|
1458
|
+
top = max(totals)
|
|
1459
|
+
return top, top or 1.0
|
|
1460
|
+
if document.kind == 'ridgeline':
|
|
1461
|
+
offset = _ridge_offset(document)
|
|
1462
|
+
top = 0.0
|
|
1463
|
+
for i, s_ in enumerate(document.series):
|
|
1464
|
+
if s_.y:
|
|
1465
|
+
top = max(top, i * offset + max(s_.y) - min(s_.y))
|
|
1466
|
+
return top, top or 1.0
|
|
1467
|
+
ys = [y for s_ in document.series for y in s_.y]
|
|
1468
|
+
for b_ in document.bands:
|
|
1469
|
+
ys += list(b_.y1) + list(b_.y2)
|
|
1470
|
+
return max(ys), (max(ys) - min(ys)) or 1.0
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _ridge_offset(document):
|
|
1474
|
+
opt = document.ridgeline
|
|
1475
|
+
if opt is not None and opt.offset:
|
|
1476
|
+
return opt.offset
|
|
1477
|
+
max_range = 0.0
|
|
1478
|
+
for s_ in document.series:
|
|
1479
|
+
if s_.y:
|
|
1480
|
+
max_range = max(max_range, max(s_.y) - min(s_.y))
|
|
1481
|
+
return max_range * 1.05 if max_range > 0 else 1.0
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _draw_annotations(document, ax, s):
|
|
1485
|
+
for ann in document.annotations:
|
|
1486
|
+
if ann.x is not None and ann.y is not None:
|
|
1487
|
+
x, y, ha, va = ann.x, ann.y, 'center', 'center'
|
|
1488
|
+
else:
|
|
1489
|
+
x, y, ha, va = _ANCHOR_XY[ann.anchor]
|
|
1490
|
+
kw = _text_kwargs(ann.style, document.font_family,
|
|
1491
|
+
document.font_size_pt, s)
|
|
1492
|
+
if ann.box:
|
|
1493
|
+
kw['bbox'] = dict(boxstyle='round,pad=0.3',
|
|
1494
|
+
facecolor='white', edgecolor='none',
|
|
1495
|
+
alpha=0.8)
|
|
1496
|
+
t = ax.text(x, y, safe_text(ann.text), transform=ax.transAxes,
|
|
1497
|
+
ha=ha, va=va, zorder=10, **kw)
|
|
1498
|
+
t.set_gid(f'ilmplot-annotation-{ann.id}')
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _draw_brackets(document, ax, s):
|
|
1502
|
+
if not document.brackets:
|
|
1503
|
+
return
|
|
1504
|
+
ymax, yrange = _data_y_range(document)
|
|
1505
|
+
index_kind = document.kind in ('violin', 'stacked_column')
|
|
1506
|
+
for br in document.brackets:
|
|
1507
|
+
pa = br.a + 1 if document.kind == 'violin' else br.a
|
|
1508
|
+
pb = br.b + 1 if document.kind == 'violin' else br.b
|
|
1509
|
+
y = ymax + yrange * br.offset
|
|
1510
|
+
tick = yrange * 0.01
|
|
1511
|
+
color = br.style.color if br.style is not None else '#333333'
|
|
1512
|
+
kw = _text_kwargs(br.style, document.font_family, 8.0, s)
|
|
1513
|
+
if br.style is None:
|
|
1514
|
+
kw['color'] = '#333333'
|
|
1515
|
+
gid = f'ilmplot-bracket-{br.id}'
|
|
1516
|
+
for xs, ys in (((pa, pb), (y, y)),
|
|
1517
|
+
((pa, pa), (y - tick, y)),
|
|
1518
|
+
((pb, pb), (y - tick, y))):
|
|
1519
|
+
line, = ax.plot(xs, ys, color=color, linewidth=1.0 * s,
|
|
1520
|
+
zorder=10, clip_on=False)
|
|
1521
|
+
line.set_gid(gid)
|
|
1522
|
+
t = ax.text((pa + pb) / 2.0, y + yrange * 0.01,
|
|
1523
|
+
safe_text(br.text), ha='center', va='bottom',
|
|
1524
|
+
zorder=10, clip_on=False, **kw)
|
|
1525
|
+
t.set_gid(gid)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
def _build_figure(document: PlotDocument, s: float,
|
|
1529
|
+
width_mm: float, height_mm: float):
|
|
1530
|
+
"""Build (Figure, Axes) for *document* at typography scale *s*."""
|
|
1531
|
+
from matplotlib.figure import Figure
|
|
1532
|
+
from matplotlib.ticker import (AutoMinorLocator, LogLocator,
|
|
1533
|
+
MultipleLocator, NullFormatter)
|
|
1534
|
+
style = document.style
|
|
1535
|
+
x_axis = getattr(style, 'xaxis', None) if style is not None else None
|
|
1536
|
+
y_axis = getattr(style, 'yaxis', None) if style is not None else None
|
|
1537
|
+
kind = document.kind
|
|
1538
|
+
horizontal_bars = kind == 'stacked_column' \
|
|
1539
|
+
and document.stacked is not None and document.stacked.horizontal
|
|
1540
|
+
categorical_y = horizontal_bars
|
|
1541
|
+
categorical_x = (bool(document.x_tick_labels) or kind in (
|
|
1542
|
+
'violin', 'stacked_column')) and not horizontal_bars
|
|
1543
|
+
fig = Figure(figsize=(width_mm / 25.4, height_mm / 25.4))
|
|
1544
|
+
ax = fig.add_axes(list(document.axes_rect))
|
|
1545
|
+
lines = []
|
|
1546
|
+
span_polys = []
|
|
1547
|
+
band_polys = []
|
|
1548
|
+
err_polys = []
|
|
1549
|
+
if kind == 'violin':
|
|
1550
|
+
_draw_violin(document, ax, s)
|
|
1551
|
+
elif kind == 'ridgeline':
|
|
1552
|
+
_draw_ridgeline(document, ax, s, lines)
|
|
1553
|
+
elif kind == 'stacked_column':
|
|
1554
|
+
_draw_stacked(document, ax, s)
|
|
1555
|
+
elif kind == 'histogram':
|
|
1556
|
+
_draw_histogram(document, ax, s)
|
|
1557
|
+
else:
|
|
1558
|
+
for sp_ in document.spans:
|
|
1559
|
+
# Full-height/width strips sit below everything (zorder 0.9):
|
|
1560
|
+
# a unit rect through a blended axis transform.
|
|
1561
|
+
from matplotlib.patches import Polygon
|
|
1562
|
+
if sp_.axis == 'x':
|
|
1563
|
+
verts = [(sp_.lo, 0.0), (sp_.hi, 0.0),
|
|
1564
|
+
(sp_.hi, 1.0), (sp_.lo, 1.0)]
|
|
1565
|
+
trans = ax.get_xaxis_transform()
|
|
1566
|
+
ax.update_datalim([(sp_.lo, 0.0), (sp_.hi, 0.0)],
|
|
1567
|
+
updatey=False)
|
|
1568
|
+
else:
|
|
1569
|
+
verts = [(0.0, sp_.lo), (1.0, sp_.lo),
|
|
1570
|
+
(1.0, sp_.hi), (0.0, sp_.hi)]
|
|
1571
|
+
trans = ax.get_yaxis_transform()
|
|
1572
|
+
ax.update_datalim([(0.0, sp_.lo), (0.0, sp_.hi)],
|
|
1573
|
+
updatex=False)
|
|
1574
|
+
poly = Polygon(verts, closed=True, transform=trans,
|
|
1575
|
+
facecolor=sp_.color, edgecolor='none',
|
|
1576
|
+
linewidth=0, zorder=0.9)
|
|
1577
|
+
poly.set_gid(f'ilmplot-span-{sp_.id}')
|
|
1578
|
+
ax.add_patch(poly)
|
|
1579
|
+
span_polys.append((poly, sp_))
|
|
1580
|
+
# On a log axis non-positive band coordinates are excluded
|
|
1581
|
+
# from datalim (a baseline of 0 must not poison autoscale) and
|
|
1582
|
+
# clipped to the view once limits are final (below).
|
|
1583
|
+
xlog_d = x_axis is not None and x_axis.scale == 'log' \
|
|
1584
|
+
and not categorical_x
|
|
1585
|
+
ylog_d = y_axis is not None and y_axis.scale == 'log'
|
|
1586
|
+
for b_ in document.bands:
|
|
1587
|
+
from matplotlib.patches import Polygon
|
|
1588
|
+
xs = list(b_.x) + list(b_.x)[::-1]
|
|
1589
|
+
ys = list(b_.y1) + list(b_.y2)[::-1]
|
|
1590
|
+
poly = Polygon(list(zip(xs, ys)), closed=True,
|
|
1591
|
+
facecolor=b_.color, edgecolor='none',
|
|
1592
|
+
linewidth=0, zorder=1)
|
|
1593
|
+
poly.set_gid(f'ilmplot-band-{b_.id}')
|
|
1594
|
+
ax.add_patch(poly)
|
|
1595
|
+
ax.update_datalim([
|
|
1596
|
+
(x, y) for x, y in poly.get_path().vertices
|
|
1597
|
+
if (not xlog_d or x > 0) and (not ylog_d or y > 0)])
|
|
1598
|
+
band_polys.append((poly, b_))
|
|
1599
|
+
for s_ in document.series:
|
|
1600
|
+
# A 0-width stroke renders as a hairline under QtSvg — hide
|
|
1601
|
+
# the stroke instead (markers still draw).
|
|
1602
|
+
ls = s_.linestyle if s_.linestyle else 'None'
|
|
1603
|
+
line, = ax.plot(
|
|
1604
|
+
s_.x, s_.y,
|
|
1605
|
+
color=s_.color,
|
|
1606
|
+
linewidth=max(s_.linewidth_pt * s, 1e-3),
|
|
1607
|
+
linestyle='None' if s_.linewidth_pt == 0 else ls,
|
|
1608
|
+
marker=s_.marker or None,
|
|
1609
|
+
markersize=max(s_.markersize_pt * s, 0.0),
|
|
1610
|
+
markeredgewidth=1.0 * s,
|
|
1611
|
+
label=safe_text(s_.label) or '_nolegend_',
|
|
1612
|
+
)
|
|
1613
|
+
line.set_gid(f'ilmplot-series-{s_.id}')
|
|
1614
|
+
lines.append((line, s_))
|
|
1615
|
+
if _yerr_bounds(s_) is not None:
|
|
1616
|
+
if getattr(s_, 'error_style', 'bars') == 'band':
|
|
1617
|
+
got = _series_errband_poly(document, ax, s_)
|
|
1618
|
+
if got is not None:
|
|
1619
|
+
err_polys.append(got)
|
|
1620
|
+
else:
|
|
1621
|
+
_draw_series_yerr(document, ax, s_, s,
|
|
1622
|
+
f'ilmplot-series-{s_.id}')
|
|
1623
|
+
if kind == 'violin':
|
|
1624
|
+
v_opt = document.violin
|
|
1625
|
+
tick_n = v_opt is not None and v_opt.show_n \
|
|
1626
|
+
and v_opt.n_position == 'tick'
|
|
1627
|
+
ax.set_xticks(list(range(1, len(document.groups) + 1)),
|
|
1628
|
+
[safe_text(g.label) + '\n' + _n_text(v_opt, g)
|
|
1629
|
+
if tick_n else safe_text(g.label)
|
|
1630
|
+
for g in document.groups])
|
|
1631
|
+
elif kind == 'stacked_column':
|
|
1632
|
+
n_bars = len(document.categories[0].values)
|
|
1633
|
+
if document.x_tick_labels:
|
|
1634
|
+
labels = {p: safe_text(l)
|
|
1635
|
+
for p, l in document.x_tick_labels}
|
|
1636
|
+
else:
|
|
1637
|
+
labels = {}
|
|
1638
|
+
set_ticks = ax.set_yticks if horizontal_bars else ax.set_xticks
|
|
1639
|
+
set_ticks(list(range(n_bars)),
|
|
1640
|
+
[labels.get(float(i), str(i))
|
|
1641
|
+
for i in range(n_bars)])
|
|
1642
|
+
elif categorical_x:
|
|
1643
|
+
ax.set_xticks([p for p, _l in document.x_tick_labels],
|
|
1644
|
+
[safe_text(l) for _p, l in document.x_tick_labels])
|
|
1645
|
+
# Axis scales first so locators/formatters see the right transform.
|
|
1646
|
+
if x_axis is not None and x_axis.scale != 'linear' and not categorical_x:
|
|
1647
|
+
ax.set_xscale(x_axis.scale)
|
|
1648
|
+
if y_axis is not None and y_axis.scale != 'linear' \
|
|
1649
|
+
and not categorical_y:
|
|
1650
|
+
ax.set_yscale(y_axis.scale)
|
|
1651
|
+
if document.xlim is not None:
|
|
1652
|
+
ax.set_xlim(document.xlim)
|
|
1653
|
+
if document.ylim is not None:
|
|
1654
|
+
ax.set_ylim(document.ylim)
|
|
1655
|
+
# Tick locators (need final limits for the step density check).
|
|
1656
|
+
for axis, ast, axis_obj in (
|
|
1657
|
+
(ax.xaxis, x_axis, 'x'), (ax.yaxis, y_axis, 'y')):
|
|
1658
|
+
if ast is None:
|
|
1659
|
+
continue
|
|
1660
|
+
categorical = (categorical_x and axis_obj == 'x') \
|
|
1661
|
+
or (categorical_y and axis_obj == 'y')
|
|
1662
|
+
if ast.step is not None and not categorical \
|
|
1663
|
+
and ast.scale == 'linear':
|
|
1664
|
+
lo, hi = (ax.get_xlim() if axis_obj == 'x'
|
|
1665
|
+
else ax.get_ylim())
|
|
1666
|
+
if abs(hi - lo) / ast.step <= 1000:
|
|
1667
|
+
axis.set_major_locator(MultipleLocator(ast.step))
|
|
1668
|
+
if ast.minor:
|
|
1669
|
+
if ast.scale == 'log' and not categorical:
|
|
1670
|
+
axis.set_minor_locator(LogLocator(base=10.0, subs='auto'))
|
|
1671
|
+
elif not categorical:
|
|
1672
|
+
axis.set_minor_locator(AutoMinorLocator())
|
|
1673
|
+
axis.set_minor_formatter(NullFormatter())
|
|
1674
|
+
if ast.prefix or ast.suffix or ast.decimals is not None:
|
|
1675
|
+
axis.set_major_formatter(_AffixFormatter(
|
|
1676
|
+
axis.get_major_formatter(), ast.prefix, ast.suffix,
|
|
1677
|
+
ast.decimals, numeric=not categorical))
|
|
1678
|
+
# Reversed axes after limits are final.
|
|
1679
|
+
if x_axis is not None and x_axis.reversed \
|
|
1680
|
+
and not ax.xaxis_inverted():
|
|
1681
|
+
ax.invert_xaxis()
|
|
1682
|
+
if y_axis is not None and y_axis.reversed \
|
|
1683
|
+
and not ax.yaxis_inverted():
|
|
1684
|
+
ax.invert_yaxis()
|
|
1685
|
+
# QtSvg ignores clipPath: clip data to the axes box ourselves, but
|
|
1686
|
+
# only when the document pins a limit (unlimited docs keep the
|
|
1687
|
+
# autoscaled box that already contains every point → identical bytes).
|
|
1688
|
+
if document.xlim is not None or document.ylim is not None:
|
|
1689
|
+
ax.autoscale(False)
|
|
1690
|
+
xlog = ax.get_xscale() == 'log'
|
|
1691
|
+
ylog = ax.get_yscale() == 'log'
|
|
1692
|
+
for line, s_ in lines:
|
|
1693
|
+
xs, ys, mask = clip_series_to_view(
|
|
1694
|
+
s_.x, s_.y, ax.get_xlim(), ax.get_ylim(), xlog, ylog)
|
|
1695
|
+
line.set_data(xs, ys)
|
|
1696
|
+
if s_.marker:
|
|
1697
|
+
line.set_markevery(mask)
|
|
1698
|
+
for poly, b_ in band_polys:
|
|
1699
|
+
xs, ys = clip_polygon_to_view(
|
|
1700
|
+
list(b_.x) + list(b_.x)[::-1],
|
|
1701
|
+
list(b_.y1) + list(b_.y2)[::-1],
|
|
1702
|
+
ax.get_xlim(), ax.get_ylim(), xlog, ylog)
|
|
1703
|
+
if xs:
|
|
1704
|
+
poly.set_xy(list(zip(xs, ys)))
|
|
1705
|
+
else:
|
|
1706
|
+
poly.set_visible(False)
|
|
1707
|
+
for poly, xs0, ys0 in err_polys:
|
|
1708
|
+
xs, ys = clip_polygon_to_view(
|
|
1709
|
+
xs0, ys0, ax.get_xlim(), ax.get_ylim(), xlog, ylog)
|
|
1710
|
+
if xs:
|
|
1711
|
+
poly.set_xy(list(zip(xs, ys)))
|
|
1712
|
+
else:
|
|
1713
|
+
poly.set_visible(False)
|
|
1714
|
+
# Span strips draw through a blended axis transform, so their
|
|
1715
|
+
# data-axis extent is clipped to the final view limits by hand —
|
|
1716
|
+
# QtSvg ignores clipPath. ``get_xlim``/``get_ylim`` un-stale the
|
|
1717
|
+
# view (autoscale has already run when limits were pinned).
|
|
1718
|
+
if span_polys:
|
|
1719
|
+
ax.autoscale_view(tight=False)
|
|
1720
|
+
for poly, sp_ in span_polys:
|
|
1721
|
+
log = (ax.get_xscale() == 'log' if sp_.axis == 'x'
|
|
1722
|
+
else ax.get_yscale() == 'log')
|
|
1723
|
+
view = (ax.get_xlim() if sp_.axis == 'x'
|
|
1724
|
+
else ax.get_ylim())
|
|
1725
|
+
lo_v, hi_v = min(view), max(view)
|
|
1726
|
+
lo, hi = sp_.lo, sp_.hi
|
|
1727
|
+
if log and lo <= 0:
|
|
1728
|
+
lo = lo_v
|
|
1729
|
+
if hi < lo_v or lo > hi_v:
|
|
1730
|
+
poly.set_visible(False)
|
|
1731
|
+
continue
|
|
1732
|
+
lo, hi = max(lo, lo_v), min(hi, hi_v)
|
|
1733
|
+
if not lo < hi:
|
|
1734
|
+
poly.set_visible(False)
|
|
1735
|
+
continue
|
|
1736
|
+
if sp_.axis == 'x':
|
|
1737
|
+
poly.set_xy([(lo, 0.0), (hi, 0.0), (hi, 1.0),
|
|
1738
|
+
(lo, 1.0)])
|
|
1739
|
+
else:
|
|
1740
|
+
poly.set_xy([(0.0, lo), (1.0, lo), (1.0, hi),
|
|
1741
|
+
(0.0, hi)])
|
|
1742
|
+
# Bands on a log axis are clipped once limits are final even when
|
|
1743
|
+
# nothing is pinned (re-clipping a pinned band is idempotent).
|
|
1744
|
+
# Non-positive coordinates count as the view minimum, so a baseline
|
|
1745
|
+
# of 0 clamps to the bottom edge instead of being dropped.
|
|
1746
|
+
if band_polys:
|
|
1747
|
+
xlog_v = ax.get_xscale() == 'log'
|
|
1748
|
+
ylog_v = ax.get_yscale() == 'log'
|
|
1749
|
+
if xlog_v or ylog_v:
|
|
1750
|
+
ax.autoscale_view(tight=False)
|
|
1751
|
+
vx, vy = ax.get_xlim(), ax.get_ylim()
|
|
1752
|
+
for poly, b_ in band_polys:
|
|
1753
|
+
xs = [v if not xlog_v or v > 0 else min(vx)
|
|
1754
|
+
for v in list(b_.x) + list(b_.x)[::-1]]
|
|
1755
|
+
ys = [v if not ylog_v or v > 0 else min(vy)
|
|
1756
|
+
for v in list(b_.y1) + list(b_.y2)[::-1]]
|
|
1757
|
+
xs2, ys2 = clip_polygon_to_view(
|
|
1758
|
+
xs, ys, vx, vy, xlog_v, ylog_v)
|
|
1759
|
+
if xs2:
|
|
1760
|
+
poly.set_xy(list(zip(xs2, ys2)))
|
|
1761
|
+
else:
|
|
1762
|
+
poly.set_visible(False)
|
|
1763
|
+
underlines = []
|
|
1764
|
+
t_style = getattr(style, 'title', None) if style is not None else None
|
|
1765
|
+
if document.title:
|
|
1766
|
+
kw = _text_kwargs(t_style, document.font_family,
|
|
1767
|
+
document.title_size_pt, s)
|
|
1768
|
+
loc = t_style.align if t_style is not None else 'center'
|
|
1769
|
+
t = ax.set_title(safe_text(document.title), pad=6.0 * s,
|
|
1770
|
+
loc=loc, **kw)
|
|
1771
|
+
t.set_gid('ilmplot-title')
|
|
1772
|
+
if t_style is not None and t_style.underline:
|
|
1773
|
+
underlines.append(lambda t=t: [t])
|
|
1774
|
+
xl_style = getattr(style, 'xlabel', None) if style is not None else None
|
|
1775
|
+
yl_style = getattr(style, 'ylabel', None) if style is not None else None
|
|
1776
|
+
if document.xlabel:
|
|
1777
|
+
xl = ax.set_xlabel(
|
|
1778
|
+
safe_text(document.xlabel),
|
|
1779
|
+
**_text_kwargs(xl_style, document.font_family,
|
|
1780
|
+
document.font_size_pt, s))
|
|
1781
|
+
xl.set_gid('ilmplot-xlabel')
|
|
1782
|
+
if xl_style is not None and xl_style.underline:
|
|
1783
|
+
underlines.append(lambda t=xl: [t])
|
|
1784
|
+
if document.ylabel and kind != 'ridgeline':
|
|
1785
|
+
yl = ax.set_ylabel(
|
|
1786
|
+
safe_text(document.ylabel),
|
|
1787
|
+
**_text_kwargs(yl_style, document.font_family,
|
|
1788
|
+
document.font_size_pt, s))
|
|
1789
|
+
yl.set_gid('ilmplot-ylabel')
|
|
1790
|
+
if yl_style is not None and yl_style.underline:
|
|
1791
|
+
underlines.append(lambda t=yl: [t])
|
|
1792
|
+
ax.tick_params(labelsize=document.font_size_pt * s,
|
|
1793
|
+
length=3.5 * s, width=0.8 * s,
|
|
1794
|
+
pad=3.5 * s)
|
|
1795
|
+
for axis_name, ast in (('x', x_axis), ('y', y_axis)):
|
|
1796
|
+
if ast is None:
|
|
1797
|
+
continue
|
|
1798
|
+
ax.tick_params(axis=axis_name, direction=ast.direction,
|
|
1799
|
+
length=ast.length_pt * s,
|
|
1800
|
+
labelrotation=ast.rotation or 0)
|
|
1801
|
+
ts = ast.ticks
|
|
1802
|
+
tick_labels = (ax.get_xticklabels() if axis_name == 'x'
|
|
1803
|
+
else ax.get_yticklabels())
|
|
1804
|
+
if ts is not None:
|
|
1805
|
+
tick_kw = {}
|
|
1806
|
+
if ts.family is not None:
|
|
1807
|
+
tick_kw['labelfontfamily'] = ts.family
|
|
1808
|
+
if ts.size_pt is not None:
|
|
1809
|
+
tick_kw['labelsize'] = ts.size_pt * s
|
|
1810
|
+
tick_kw['labelcolor'] = ts.color
|
|
1811
|
+
ax.tick_params(axis=axis_name, **tick_kw)
|
|
1812
|
+
# weight/style are not tick_params keys: set them on the
|
|
1813
|
+
# (persistent, post-locator) tick label Text objects.
|
|
1814
|
+
for label in tick_labels:
|
|
1815
|
+
label.set_fontweight('bold' if ts.bold else 'normal')
|
|
1816
|
+
label.set_fontstyle('italic' if ts.italic else 'normal')
|
|
1817
|
+
if axis_name == 'x' and ast.rotation:
|
|
1818
|
+
ha = 'right' if ast.rotation > 0 else 'left'
|
|
1819
|
+
for label in tick_labels:
|
|
1820
|
+
label.set(ha=ha, rotation_mode='anchor')
|
|
1821
|
+
if ts is not None and ts.underline:
|
|
1822
|
+
labels = (ax.get_xticklabels if axis_name == 'x'
|
|
1823
|
+
else ax.get_yticklabels)
|
|
1824
|
+
underlines.append(
|
|
1825
|
+
lambda labels=labels:
|
|
1826
|
+
[l for l in labels() if l.get_visible()])
|
|
1827
|
+
frame = getattr(style, 'frame', None) if style is not None else None
|
|
1828
|
+
# Ridgeline always drops left/top/right and its y axis, like NCPlot.
|
|
1829
|
+
forced = kind == 'ridgeline'
|
|
1830
|
+
hide = {
|
|
1831
|
+
'top': forced or (frame is not None and frame.hide_top),
|
|
1832
|
+
'right': forced or (frame is not None and frame.hide_right),
|
|
1833
|
+
'left': forced or (frame is not None and frame.hide_left),
|
|
1834
|
+
'bottom': frame is not None and frame.hide_bottom,
|
|
1835
|
+
}
|
|
1836
|
+
for name, spine in ax.spines.items():
|
|
1837
|
+
if hide.get(name):
|
|
1838
|
+
spine.set_visible(False)
|
|
1839
|
+
continue
|
|
1840
|
+
if frame is not None:
|
|
1841
|
+
spine.set_color(frame.color)
|
|
1842
|
+
if frame.linewidth_pt == 0:
|
|
1843
|
+
spine.set_visible(False)
|
|
1844
|
+
else:
|
|
1845
|
+
spine.set_linewidth(frame.linewidth_pt * s)
|
|
1846
|
+
else:
|
|
1847
|
+
spine.set_linewidth(0.8 * s)
|
|
1848
|
+
# A hidden left/bottom spine also hides that axis's ticks/labels.
|
|
1849
|
+
if hide['left']:
|
|
1850
|
+
ax.tick_params(axis='y', which='both',
|
|
1851
|
+
left=False, labelleft=False)
|
|
1852
|
+
if hide['bottom']:
|
|
1853
|
+
ax.tick_params(axis='x', which='both',
|
|
1854
|
+
bottom=False, labelbottom=False)
|
|
1855
|
+
ax.xaxis.labelpad = 4.0 * s
|
|
1856
|
+
ax.yaxis.labelpad = 4.0 * s
|
|
1857
|
+
# Tick offset / scientific-notation text is not covered by
|
|
1858
|
+
# tick_params labelsize — set it explicitly.
|
|
1859
|
+
for axis in (ax.xaxis, ax.yaxis):
|
|
1860
|
+
try:
|
|
1861
|
+
axis.get_offset_text().set_size(
|
|
1862
|
+
document.font_size_pt * s)
|
|
1863
|
+
except Exception:
|
|
1864
|
+
pass
|
|
1865
|
+
if document.grid:
|
|
1866
|
+
grid = getattr(style, 'grid', None) if style is not None else None
|
|
1867
|
+
if grid is None:
|
|
1868
|
+
ax.grid(True, color='#b0b0b0', linewidth=0.8 * s, alpha=1.0)
|
|
1869
|
+
else:
|
|
1870
|
+
if grid.which == 'both':
|
|
1871
|
+
for axis_name, ast in (('x', x_axis), ('y', y_axis)):
|
|
1872
|
+
if grid.axis in ('both', axis_name) and (
|
|
1873
|
+
ast is None or not ast.minor):
|
|
1874
|
+
axis = ax.xaxis if axis_name == 'x' else ax.yaxis
|
|
1875
|
+
categorical = (categorical_x
|
|
1876
|
+
and axis_name == 'x') or \
|
|
1877
|
+
(categorical_y and axis_name == 'y')
|
|
1878
|
+
if categorical:
|
|
1879
|
+
continue
|
|
1880
|
+
if ast is not None and ast.scale == 'log':
|
|
1881
|
+
axis.set_minor_locator(
|
|
1882
|
+
LogLocator(base=10.0, subs='auto'))
|
|
1883
|
+
else:
|
|
1884
|
+
axis.set_minor_locator(AutoMinorLocator())
|
|
1885
|
+
axis.set_minor_formatter(NullFormatter())
|
|
1886
|
+
if grid.linewidth_pt > 0:
|
|
1887
|
+
ax.grid(True, which=grid.which, axis=grid.axis,
|
|
1888
|
+
color=grid.color, linestyle=grid.linestyle,
|
|
1889
|
+
linewidth=grid.linewidth_pt * s,
|
|
1890
|
+
alpha=grid.alpha)
|
|
1891
|
+
if document.legend:
|
|
1892
|
+
from matplotlib.patches import Patch
|
|
1893
|
+
if kind == 'stacked_column':
|
|
1894
|
+
handles = [Patch(facecolor=c.color,
|
|
1895
|
+
label=safe_text(c.label) or '_nolegend_')
|
|
1896
|
+
for c in document.categories]
|
|
1897
|
+
patch_handles = True
|
|
1898
|
+
elif kind == 'histogram':
|
|
1899
|
+
h_opt = document.histogram
|
|
1900
|
+
if h_opt is not None and h_opt.show_n:
|
|
1901
|
+
h_labels = [_hist_label(h_opt, g)
|
|
1902
|
+
for g in document.groups]
|
|
1903
|
+
else:
|
|
1904
|
+
h_labels = [safe_text(g.label)
|
|
1905
|
+
for g in document.groups]
|
|
1906
|
+
if h_opt is not None and h_opt.style == 'step':
|
|
1907
|
+
from matplotlib.lines import Line2D
|
|
1908
|
+
handles = [Line2D([], [], color=g.color, linewidth=1.5,
|
|
1909
|
+
label=l or '_nolegend_')
|
|
1910
|
+
for g, l in zip(document.groups, h_labels)]
|
|
1911
|
+
else:
|
|
1912
|
+
from matplotlib.colors import to_rgba
|
|
1913
|
+
alpha = h_opt.fill_alpha if h_opt is not None else 0.5
|
|
1914
|
+
handles = [
|
|
1915
|
+
Patch(facecolor=to_rgba(g.color, alpha),
|
|
1916
|
+
label=l or '_nolegend_')
|
|
1917
|
+
for g, l in zip(document.groups, h_labels)]
|
|
1918
|
+
patch_handles = True
|
|
1919
|
+
else:
|
|
1920
|
+
patch_handles = False
|
|
1921
|
+
handles = [l for l in ax.get_lines()
|
|
1922
|
+
if l.get_label()
|
|
1923
|
+
and not l.get_label().startswith('_')]
|
|
1924
|
+
handles += [Patch(facecolor=b_.color,
|
|
1925
|
+
label=safe_text(b_.label))
|
|
1926
|
+
for b_ in document.bands if b_.label]
|
|
1927
|
+
handles += [Patch(facecolor=sp_.color,
|
|
1928
|
+
label=safe_text(sp_.label))
|
|
1929
|
+
for sp_ in document.spans if sp_.label]
|
|
1930
|
+
if handles:
|
|
1931
|
+
leg_style = getattr(style, 'legend', None) \
|
|
1932
|
+
if style is not None else None
|
|
1933
|
+
loc = document.legend_location
|
|
1934
|
+
leg_kw = {'loc': loc,
|
|
1935
|
+
'fontsize': document.font_size_pt * s}
|
|
1936
|
+
if loc == 'outside right':
|
|
1937
|
+
leg_kw['loc'] = 'center left'
|
|
1938
|
+
leg_kw['bbox_to_anchor'] = (1.02, 0.5)
|
|
1939
|
+
if leg_style is not None:
|
|
1940
|
+
leg_kw['ncols'] = leg_style.ncols
|
|
1941
|
+
leg_kw['frameon'] = leg_style.frame
|
|
1942
|
+
if leg_style.frame_color != '#cccccc':
|
|
1943
|
+
leg_kw['edgecolor'] = leg_style.frame_color
|
|
1944
|
+
if leg_style.text is not None:
|
|
1945
|
+
from matplotlib.font_manager import FontProperties
|
|
1946
|
+
ts = leg_style.text
|
|
1947
|
+
# ``prop`` supersedes ``fontsize`` — drop it.
|
|
1948
|
+
del leg_kw['fontsize']
|
|
1949
|
+
leg_kw['prop'] = FontProperties(
|
|
1950
|
+
family=ts.family or document.font_family,
|
|
1951
|
+
size=(ts.size_pt * s if ts.size_pt is not None
|
|
1952
|
+
else document.font_size_pt * s),
|
|
1953
|
+
weight='bold' if ts.bold else 'normal',
|
|
1954
|
+
style='italic' if ts.italic else 'normal')
|
|
1955
|
+
leg_kw['labelcolor'] = ts.color
|
|
1956
|
+
leg = ax.legend(handles=handles, **leg_kw)
|
|
1957
|
+
if leg is not None:
|
|
1958
|
+
frame = leg.get_frame()
|
|
1959
|
+
frame.set_linewidth(frame.get_linewidth() * s)
|
|
1960
|
+
if leg_style is not None and leg_style.text is not None \
|
|
1961
|
+
and leg_style.text.underline:
|
|
1962
|
+
underlines.append(
|
|
1963
|
+
lambda leg=leg: [t for t in leg.get_texts()
|
|
1964
|
+
if t.get_visible()])
|
|
1965
|
+
_draw_annotations(document, ax, s)
|
|
1966
|
+
_draw_brackets(document, ax, s)
|
|
1967
|
+
# Brackets sit above the data; auto-scaled y limits don't reach them.
|
|
1968
|
+
# Extend the top so the bracket line plus ~10% (its text) fits —
|
|
1969
|
+
# except percent-stacked bars, where 0–100 stays fixed like NCPlot.
|
|
1970
|
+
if document.brackets and document.ylim is None:
|
|
1971
|
+
percent = (kind == 'stacked_column'
|
|
1972
|
+
and (document.stacked is None
|
|
1973
|
+
or document.stacked.percent))
|
|
1974
|
+
if not percent:
|
|
1975
|
+
ymax, yrange = _data_y_range(document)
|
|
1976
|
+
top = ymax + yrange * (
|
|
1977
|
+
max(b.offset for b in document.brackets) + 0.10)
|
|
1978
|
+
lo, hi = ax.get_ylim()
|
|
1979
|
+
if top > hi:
|
|
1980
|
+
ax.set_ylim(lo, top)
|
|
1981
|
+
if underlines:
|
|
1982
|
+
_add_underline(ax, lambda: [t for get in underlines
|
|
1983
|
+
for t in get()])
|
|
1984
|
+
return fig, ax
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
_regions_cache: OrderedDict = OrderedDict()
|
|
1988
|
+
_REGIONS_CACHE_MAX = 32
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
def element_regions(document: PlotDocument) -> dict:
|
|
1992
|
+
"""Bounding boxes of every drawable element, in figure fractions.
|
|
1993
|
+
|
|
1994
|
+
Coordinates are ``(x0, y0, x1, y1)`` with top-left origin (``x/W``,
|
|
1995
|
+
``1 - y/H``) so the editor can map them onto the SVG item's rect.
|
|
1996
|
+
Cached by the canonical document like ``render_document``.
|
|
1997
|
+
"""
|
|
1998
|
+
document.validate()
|
|
1999
|
+
key = ('regions', _canonical(document))
|
|
2000
|
+
hit = _regions_cache.get(key)
|
|
2001
|
+
if hit is not None:
|
|
2002
|
+
_regions_cache.move_to_end(key)
|
|
2003
|
+
return hit
|
|
2004
|
+
|
|
2005
|
+
import matplotlib
|
|
2006
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
2007
|
+
|
|
2008
|
+
regions = {}
|
|
2009
|
+
with matplotlib.rc_context(_deterministic_rc(document)):
|
|
2010
|
+
fig = None
|
|
2011
|
+
try:
|
|
2012
|
+
fig, ax = _build_figure(document, 1.0, document.width_mm,
|
|
2013
|
+
document.height_mm)
|
|
2014
|
+
_fit_figure(document, fig, ax)
|
|
2015
|
+
canvas = FigureCanvasAgg(fig)
|
|
2016
|
+
canvas.draw()
|
|
2017
|
+
renderer = canvas.get_renderer()
|
|
2018
|
+
W, H = canvas.get_width_height()
|
|
2019
|
+
|
|
2020
|
+
def frac(bb):
|
|
2021
|
+
# Hit-testing happens inside the canvas — clamp to it.
|
|
2022
|
+
return (max(0.0, min(1.0, bb.x0 / W)),
|
|
2023
|
+
max(0.0, min(1.0, 1.0 - bb.y1 / H)),
|
|
2024
|
+
max(0.0, min(1.0, bb.x1 / W)),
|
|
2025
|
+
max(0.0, min(1.0, 1.0 - bb.y0 / H)))
|
|
2026
|
+
|
|
2027
|
+
# loc='left'/'right' titles live on _left_title/_right_title.
|
|
2028
|
+
t_style = getattr(document.style, 'title', None) \
|
|
2029
|
+
if document.style is not None else None
|
|
2030
|
+
title_artist = {None: ax.title, 'center': ax.title,
|
|
2031
|
+
'left': ax._left_title,
|
|
2032
|
+
'right': ax._right_title}[
|
|
2033
|
+
getattr(t_style, 'align', None)]
|
|
2034
|
+
for name, artist in (('title', title_artist),
|
|
2035
|
+
('xlabel', ax.xaxis.label),
|
|
2036
|
+
('ylabel', ax.yaxis.label)):
|
|
2037
|
+
if artist.get_visible() and artist.get_text():
|
|
2038
|
+
regions[name] = frac(
|
|
2039
|
+
artist.get_window_extent(renderer))
|
|
2040
|
+
for name, labels in (('xticks', ax.get_xticklabels()),
|
|
2041
|
+
('yticks', ax.get_yticklabels())):
|
|
2042
|
+
boxes = [l.get_window_extent(renderer) for l in labels
|
|
2043
|
+
if l.get_visible() and l.get_text()]
|
|
2044
|
+
if boxes:
|
|
2045
|
+
x0 = min(b.x0 for b in boxes)
|
|
2046
|
+
y0 = min(b.y0 for b in boxes)
|
|
2047
|
+
x1 = max(b.x1 for b in boxes)
|
|
2048
|
+
y1 = max(b.y1 for b in boxes)
|
|
2049
|
+
|
|
2050
|
+
class _B:
|
|
2051
|
+
pass
|
|
2052
|
+
bb = _B()
|
|
2053
|
+
bb.x0, bb.y0, bb.x1, bb.y1 = x0, y0, x1, y1
|
|
2054
|
+
regions[name] = frac(bb)
|
|
2055
|
+
leg = ax.get_legend()
|
|
2056
|
+
if leg is not None:
|
|
2057
|
+
regions['legend'] = frac(leg.get_window_extent(renderer))
|
|
2058
|
+
rect = ax.get_position()
|
|
2059
|
+
regions['frame'] = (rect.x0, 1.0 - rect.y1,
|
|
2060
|
+
rect.x1, 1.0 - rect.y0)
|
|
2061
|
+
if document.grid:
|
|
2062
|
+
regions['grid'] = regions['frame']
|
|
2063
|
+
series_regions = {}
|
|
2064
|
+
for line in ax.get_lines():
|
|
2065
|
+
gid = line.get_gid() or ''
|
|
2066
|
+
if not gid.startswith('ilmplot-series-'):
|
|
2067
|
+
continue
|
|
2068
|
+
sid = gid[len('ilmplot-series-'):]
|
|
2069
|
+
try:
|
|
2070
|
+
pts = ax.transData.transform(
|
|
2071
|
+
list(zip(line.get_xdata(), line.get_ydata())))
|
|
2072
|
+
except Exception:
|
|
2073
|
+
continue
|
|
2074
|
+
n = len(pts)
|
|
2075
|
+
if not n:
|
|
2076
|
+
continue
|
|
2077
|
+
step = max(1, -(-n // 2000))
|
|
2078
|
+
sampled = pts[::step]
|
|
2079
|
+
fps = [(float(p[0]) / W, 1.0 - float(p[1]) / H)
|
|
2080
|
+
for p in sampled]
|
|
2081
|
+
xs = [p[0] for p in fps]
|
|
2082
|
+
ys = [p[1] for p in fps]
|
|
2083
|
+
series_regions[sid] = {
|
|
2084
|
+
'bbox': (min(xs), min(ys), max(xs), max(ys)),
|
|
2085
|
+
'points': fps,
|
|
2086
|
+
}
|
|
2087
|
+
regions['series'] = series_regions
|
|
2088
|
+
# Kind-specific element groups keyed by id.
|
|
2089
|
+
for prefix, key in (('ilmplot-violin-', 'violins'),
|
|
2090
|
+
('ilmplot-stack-', 'stacks'),
|
|
2091
|
+
('ilmplot-hist-', 'hists'),
|
|
2092
|
+
('ilmplot-annotation-', 'annotations'),
|
|
2093
|
+
('ilmplot-bracket-', 'brackets'),
|
|
2094
|
+
('ilmplot-band-', 'bands'),
|
|
2095
|
+
('ilmplot-span-', 'spans')):
|
|
2096
|
+
grouped = {}
|
|
2097
|
+
points = {}
|
|
2098
|
+
for artist in ax.findobj():
|
|
2099
|
+
gid = artist.get_gid() or ''
|
|
2100
|
+
if not gid.startswith(prefix):
|
|
2101
|
+
continue
|
|
2102
|
+
aid = gid[len(prefix):]
|
|
2103
|
+
if not artist.get_visible():
|
|
2104
|
+
continue
|
|
2105
|
+
try:
|
|
2106
|
+
bb = artist.get_window_extent(renderer)
|
|
2107
|
+
except Exception:
|
|
2108
|
+
continue
|
|
2109
|
+
if bb.width <= 0 and bb.height <= 0:
|
|
2110
|
+
continue
|
|
2111
|
+
if aid in grouped:
|
|
2112
|
+
prev = grouped[aid]
|
|
2113
|
+
bb = type(bb).from_extents(
|
|
2114
|
+
min(prev.x0, bb.x0), min(prev.y0, bb.y0),
|
|
2115
|
+
max(prev.x1, bb.x1), max(prev.y1, bb.y1))
|
|
2116
|
+
grouped[aid] = bb
|
|
2117
|
+
if key in ('bands', 'spans'):
|
|
2118
|
+
# The region polygon for point-in-polygon picks.
|
|
2119
|
+
try:
|
|
2120
|
+
tp = artist.get_transform().transform(
|
|
2121
|
+
artist.get_path().vertices)
|
|
2122
|
+
points[aid] = [
|
|
2123
|
+
(float(q[0]) / W,
|
|
2124
|
+
1.0 - float(q[1]) / H)
|
|
2125
|
+
for q in tp]
|
|
2126
|
+
except Exception:
|
|
2127
|
+
pass
|
|
2128
|
+
if grouped:
|
|
2129
|
+
regions[key] = {
|
|
2130
|
+
aid: {'bbox': frac(bb),
|
|
2131
|
+
'points': points.get(aid)}
|
|
2132
|
+
for aid, bb in grouped.items()}
|
|
2133
|
+
finally:
|
|
2134
|
+
if fig is not None:
|
|
2135
|
+
fig.clear()
|
|
2136
|
+
_regions_cache[key] = regions
|
|
2137
|
+
if len(_regions_cache) > _REGIONS_CACHE_MAX:
|
|
2138
|
+
_regions_cache.popitem(last=False)
|
|
2139
|
+
return regions
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def _atomic_write(path, write):
|
|
2143
|
+
"""Temp-sibling write, fsync, ``os.replace`` (same pattern as
|
|
2144
|
+
``export._atomic_write`` — duplicated to avoid an import cycle)."""
|
|
2145
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
2146
|
+
os.makedirs(parent, exist_ok=True)
|
|
2147
|
+
fd, tmp = tempfile.mkstemp(dir=parent,
|
|
2148
|
+
prefix='.' + os.path.basename(path),
|
|
2149
|
+
suffix='.tmp')
|
|
2150
|
+
try:
|
|
2151
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
2152
|
+
write(fh)
|
|
2153
|
+
fh.flush()
|
|
2154
|
+
os.fsync(fh.fileno())
|
|
2155
|
+
os.replace(tmp, path)
|
|
2156
|
+
except BaseException:
|
|
2157
|
+
try:
|
|
2158
|
+
os.unlink(tmp)
|
|
2159
|
+
except OSError:
|
|
2160
|
+
pass
|
|
2161
|
+
raise
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
|
+
def export_document(document: PlotDocument, path: str, fmt: str,
|
|
2165
|
+
dpi: int = 300, *, transparent: bool = False):
|
|
2166
|
+
"""Export *document* via the native figure to pdf/svg/png/tiff/jpeg.
|
|
2167
|
+
|
|
2168
|
+
Same figure and rc context as ``render_document`` (style scale 1);
|
|
2169
|
+
writes atomically. ``transparent`` applies only to png/svg: it drops
|
|
2170
|
+
the figure and axes background patches; other formats stay opaque.
|
|
2171
|
+
"""
|
|
2172
|
+
document.validate()
|
|
2173
|
+
if fmt not in ('pdf', 'svg', 'png', 'tiff', 'jpeg'):
|
|
2174
|
+
raise PlotDocumentError(f"unsupported export format {fmt!r}")
|
|
2175
|
+
import matplotlib
|
|
2176
|
+
with matplotlib.rc_context(_deterministic_rc(document)):
|
|
2177
|
+
fig = None
|
|
2178
|
+
try:
|
|
2179
|
+
fig, ax = _build_figure(document, 1.0, document.width_mm,
|
|
2180
|
+
document.height_mm)
|
|
2181
|
+
_fit_figure(document, fig, ax)
|
|
2182
|
+
|
|
2183
|
+
def _write(fh):
|
|
2184
|
+
if fmt == 'svg':
|
|
2185
|
+
fig.savefig(fh, format=fmt, metadata={'Date': None},
|
|
2186
|
+
transparent=transparent)
|
|
2187
|
+
elif fmt == 'pdf':
|
|
2188
|
+
fig.savefig(fh, format=fmt, metadata={'Date': None})
|
|
2189
|
+
elif fmt == 'png':
|
|
2190
|
+
fig.savefig(fh, format=fmt, dpi=dpi,
|
|
2191
|
+
transparent=transparent)
|
|
2192
|
+
else:
|
|
2193
|
+
fig.savefig(fh, format=fmt, dpi=dpi)
|
|
2194
|
+
|
|
2195
|
+
_atomic_write(path, _write)
|
|
2196
|
+
finally:
|
|
2197
|
+
if fig is not None:
|
|
2198
|
+
fig.clear()
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
FIT_PAD_MM = 0.5
|
|
2202
|
+
MIN_AXES_FRACTION = 0.2
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _needs_fit(document) -> bool:
|
|
2206
|
+
"""True when out-of-axes content requires ``_fit_axes`` margins.
|
|
2207
|
+
|
|
2208
|
+
Plain line documents (the historical path) keep their fixed
|
|
2209
|
+
``axes_rect`` so their rendered bytes stay identical.
|
|
2210
|
+
"""
|
|
2211
|
+
return (document.kind != 'line'
|
|
2212
|
+
or document.legend_location == 'outside right'
|
|
2213
|
+
or bool(document.annotations)
|
|
2214
|
+
or bool(document.brackets))
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
_MATH_TOKEN = re.compile(r'\$[^$]*\$|\S+')
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
def _text_length_px(artist, renderer, text):
|
|
2221
|
+
"""Unrotated length of *text* drawn with *artist*'s font (px)."""
|
|
2222
|
+
if not text:
|
|
2223
|
+
return 0.0
|
|
2224
|
+
prev = artist.get_text()
|
|
2225
|
+
try:
|
|
2226
|
+
artist.set_text(text)
|
|
2227
|
+
bb = artist.get_window_extent(renderer)
|
|
2228
|
+
finally:
|
|
2229
|
+
artist.set_text(prev)
|
|
2230
|
+
rot = artist.get_rotation() % 180.0
|
|
2231
|
+
return bb.height if abs(rot - 90.0) < 1.0 else bb.width
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def _wrap_text(artist, renderer, limit_px):
|
|
2235
|
+
"""Greedy word-wrap *artist* to *limit_px*; True when text changed.
|
|
2236
|
+
|
|
2237
|
+
The unwrapped source is cached on ``artist._ilm_unwrapped`` so
|
|
2238
|
+
repeated passes re-wrap from the original. Existing newlines are
|
|
2239
|
+
hard breaks; ``$...$`` math segments stay atomic and a single token
|
|
2240
|
+
longer than the limit keeps its own line (never split mid-word).
|
|
2241
|
+
"""
|
|
2242
|
+
if limit_px <= 0:
|
|
2243
|
+
return False
|
|
2244
|
+
text = getattr(artist, '_ilm_unwrapped', None)
|
|
2245
|
+
if text is None:
|
|
2246
|
+
text = artist.get_text()
|
|
2247
|
+
artist._ilm_unwrapped = text
|
|
2248
|
+
if not text:
|
|
2249
|
+
return False
|
|
2250
|
+
lines = []
|
|
2251
|
+
for para in text.split('\n'):
|
|
2252
|
+
tokens = _MATH_TOKEN.findall(para)
|
|
2253
|
+
if not tokens:
|
|
2254
|
+
lines.append('')
|
|
2255
|
+
continue
|
|
2256
|
+
cur = ''
|
|
2257
|
+
for tok in tokens:
|
|
2258
|
+
cand = tok if not cur else cur + ' ' + tok
|
|
2259
|
+
if not cur \
|
|
2260
|
+
or _text_length_px(artist, renderer, cand) \
|
|
2261
|
+
<= limit_px:
|
|
2262
|
+
cur = cand
|
|
2263
|
+
else:
|
|
2264
|
+
lines.append(cur)
|
|
2265
|
+
cur = tok
|
|
2266
|
+
lines.append(cur)
|
|
2267
|
+
new = '\n'.join(lines)
|
|
2268
|
+
if new == artist.get_text():
|
|
2269
|
+
return False
|
|
2270
|
+
artist.set_text(new)
|
|
2271
|
+
return True
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _title_limits(fig, ax, renderer):
|
|
2275
|
+
"""(horizontal limit, vertical limit) in px for title wrapping."""
|
|
2276
|
+
W = fig.get_figwidth() * fig.dpi
|
|
2277
|
+
H = fig.get_figheight() * fig.dpi
|
|
2278
|
+
pos = ax.get_window_extent(renderer)
|
|
2279
|
+
return (max(pos.width, 0.5 * W), max(pos.height, 0.5 * H))
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
def _wrap_axis_titles(fig, ax, renderer):
|
|
2283
|
+
"""Wrap title(s) and axis labels to their axis extent; True if any
|
|
2284
|
+
text changed."""
|
|
2285
|
+
lim_x, lim_y = _title_limits(fig, ax, renderer)
|
|
2286
|
+
changed = False
|
|
2287
|
+
for artist in (ax.title, ax._left_title, ax._right_title,
|
|
2288
|
+
ax.xaxis.label):
|
|
2289
|
+
changed = _wrap_text(artist, renderer, lim_x) or changed
|
|
2290
|
+
changed = _wrap_text(ax.yaxis.label, renderer, lim_y) or changed
|
|
2291
|
+
return changed
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
def _titles_overflow(fig, ax) -> bool:
|
|
2295
|
+
"""True when any title/axis label's unwrapped length exceeds its
|
|
2296
|
+
wrap limit. No text is modified."""
|
|
2297
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
2298
|
+
FigureCanvasAgg(fig)
|
|
2299
|
+
renderer = fig.canvas.get_renderer()
|
|
2300
|
+
lim_x, lim_y = _title_limits(fig, ax, renderer)
|
|
2301
|
+
for artist in (ax.title, ax._left_title, ax._right_title,
|
|
2302
|
+
ax.xaxis.label, ax.yaxis.label):
|
|
2303
|
+
limit = lim_y if artist is ax.yaxis.label else lim_x
|
|
2304
|
+
text = getattr(artist, '_ilm_unwrapped', None)
|
|
2305
|
+
if text is None:
|
|
2306
|
+
text = artist.get_text()
|
|
2307
|
+
if _text_length_px(artist, renderer, text) > limit:
|
|
2308
|
+
return True
|
|
2309
|
+
return False
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
def _fit_figure(document, fig, ax) -> None:
|
|
2313
|
+
"""Fit the axes rectangle when the document needs it."""
|
|
2314
|
+
if _needs_fit(document) or _titles_overflow(fig, ax):
|
|
2315
|
+
_fit_axes(fig, ax, FIT_PAD_MM / 25.4 * fig.dpi)
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
def _fit_axes(fig, ax, pad_px: float, fixed_y=None) -> None:
|
|
2319
|
+
"""Reposition *ax* so nothing outside the axes box is clipped.
|
|
2320
|
+
|
|
2321
|
+
Iterates a margin-feedback loop: each pass shrinks the axes rect by the
|
|
2322
|
+
measured overflow of tick labels, axis labels, title, offset text and
|
|
2323
|
+
legend plus *pad_px*, until every edge moves under 0.05 px (or 4
|
|
2324
|
+
passes). Typography is never scaled down — when the text does not fit,
|
|
2325
|
+
the axes clamps to ``MIN_AXES_FRACTION`` of the figure instead.
|
|
2326
|
+
|
|
2327
|
+
``fixed_y=(y0_px, y1_px)`` (bottom-origin px) pins the axes' vertical
|
|
2328
|
+
extent — used to share an axes frame across a row of cells — and only
|
|
2329
|
+
the horizontal fit is solved.
|
|
2330
|
+
"""
|
|
2331
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
2332
|
+
FigureCanvasAgg(fig)
|
|
2333
|
+
renderer = fig.canvas.get_renderer()
|
|
2334
|
+
W = fig.get_figwidth() * fig.dpi
|
|
2335
|
+
H = fig.get_figheight() * fig.dpi
|
|
2336
|
+
for _ in range(6):
|
|
2337
|
+
wrapped = _wrap_axis_titles(fig, ax, renderer)
|
|
2338
|
+
pos = ax.get_window_extent(renderer)
|
|
2339
|
+
# Measure the out-of-axes extras first: only the artists that can
|
|
2340
|
+
# legitimately stick out (legend, brackets, annotations). Other
|
|
2341
|
+
# clip_on=False artists (stack value labels, legend packer
|
|
2342
|
+
# internals) report stale/degenerate extents mid-iteration and
|
|
2343
|
+
# would feed a bogus, frozen overflow back into the loop.
|
|
2344
|
+
from matplotlib.transforms import Bbox
|
|
2345
|
+
extras = []
|
|
2346
|
+
leg = ax.get_legend()
|
|
2347
|
+
if leg is not None and leg.get_visible():
|
|
2348
|
+
try:
|
|
2349
|
+
extras.append(leg.get_window_extent(renderer))
|
|
2350
|
+
except Exception:
|
|
2351
|
+
pass
|
|
2352
|
+
for artist in ax.findobj():
|
|
2353
|
+
gid = artist.get_gid() or ''
|
|
2354
|
+
if not gid.startswith(('ilmplot-bracket-',
|
|
2355
|
+
'ilmplot-annotation-')):
|
|
2356
|
+
continue
|
|
2357
|
+
try:
|
|
2358
|
+
bb = artist.get_window_extent(renderer)
|
|
2359
|
+
except Exception:
|
|
2360
|
+
continue
|
|
2361
|
+
if math.isfinite(bb.x0) and math.isfinite(bb.y0) \
|
|
2362
|
+
and math.isfinite(bb.x1) and math.isfinite(bb.y1) \
|
|
2363
|
+
and (bb.width > 0 or bb.height > 0):
|
|
2364
|
+
extras.append(bb)
|
|
2365
|
+
# Wrapped titles/labels can exceed what get_tightbbox reports;
|
|
2366
|
+
# feed their measured extents so the margins account for them.
|
|
2367
|
+
for artist in (ax.title, ax._left_title, ax._right_title,
|
|
2368
|
+
ax.xaxis.label, ax.yaxis.label):
|
|
2369
|
+
if getattr(artist, '_ilm_unwrapped', None) is None \
|
|
2370
|
+
or artist.get_text() == artist._ilm_unwrapped:
|
|
2371
|
+
continue
|
|
2372
|
+
try:
|
|
2373
|
+
bb = artist.get_window_extent(renderer)
|
|
2374
|
+
except Exception:
|
|
2375
|
+
continue
|
|
2376
|
+
if math.isfinite(bb.x0) and math.isfinite(bb.y0) \
|
|
2377
|
+
and math.isfinite(bb.x1) and math.isfinite(bb.y1):
|
|
2378
|
+
extras.append(bb)
|
|
2379
|
+
tight = ax.get_tightbbox(renderer)
|
|
2380
|
+
if extras:
|
|
2381
|
+
union = Bbox.union(extras)
|
|
2382
|
+
tight = union if tight is None \
|
|
2383
|
+
else Bbox.union([tight, union])
|
|
2384
|
+
left = max(pos.x0 - tight.x0, 0.0)
|
|
2385
|
+
right = max(tight.x1 - pos.x1, 0.0)
|
|
2386
|
+
bottom = max(pos.y0 - tight.y0, 0.0)
|
|
2387
|
+
top = max(tight.y1 - pos.y1, 0.0)
|
|
2388
|
+
x0, x1 = left + pad_px, W - right - pad_px
|
|
2389
|
+
if x1 - x0 < MIN_AXES_FRACTION * W:
|
|
2390
|
+
spare = (1.0 - MIN_AXES_FRACTION) * W
|
|
2391
|
+
denom = left + pad_px + right + pad_px
|
|
2392
|
+
x0 = spare * ((left + pad_px) / denom) if denom > 0 else spare * 0.5
|
|
2393
|
+
x1 = x0 + MIN_AXES_FRACTION * W
|
|
2394
|
+
if fixed_y is not None:
|
|
2395
|
+
y0, y1 = fixed_y
|
|
2396
|
+
else:
|
|
2397
|
+
y0, y1 = bottom + pad_px, H - top - pad_px
|
|
2398
|
+
if y1 - y0 < MIN_AXES_FRACTION * H:
|
|
2399
|
+
spare = (1.0 - MIN_AXES_FRACTION) * H
|
|
2400
|
+
denom = bottom + pad_px + top + pad_px
|
|
2401
|
+
y0 = spare * ((bottom + pad_px) / denom) if denom > 0 else spare * 0.5
|
|
2402
|
+
y1 = y0 + MIN_AXES_FRACTION * H
|
|
2403
|
+
if not wrapped and max(abs(x0 - pos.x0), abs(x1 - pos.x1),
|
|
2404
|
+
abs(y0 - pos.y0), abs(y1 - pos.y1)) \
|
|
2405
|
+
< 0.05:
|
|
2406
|
+
break
|
|
2407
|
+
ax.set_position([x0 / W, y0 / H, (x1 - x0) / W, (y1 - y0) / H])
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
def _fixed_y_px(fig, v_span_mm):
|
|
2411
|
+
"""(top_mm, bottom_mm) from the figure top -> bottom-origin px span."""
|
|
2412
|
+
H = fig.get_figheight() * fig.dpi
|
|
2413
|
+
return (H - v_span_mm[1] / 25.4 * fig.dpi,
|
|
2414
|
+
H - v_span_mm[0] / 25.4 * fig.dpi)
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
def _quantized_dims(width_mm, height_mm):
|
|
2418
|
+
w, h = float(width_mm), float(height_mm)
|
|
2419
|
+
for name, v in (('width_mm', w), ('height_mm', h)):
|
|
2420
|
+
if not math.isfinite(v) or v <= 0 or v > MAX_DIMENSION_MM:
|
|
2421
|
+
raise PlotDocumentError(
|
|
2422
|
+
f"{name}: expected 0 < value <= {MAX_DIMENSION_MM} mm, "
|
|
2423
|
+
f"got {v!r}")
|
|
2424
|
+
return round(w, 2), round(h, 2)
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def _quantized_v_span(v_span_mm, h):
|
|
2428
|
+
if v_span_mm is None:
|
|
2429
|
+
return None
|
|
2430
|
+
top, bottom = float(v_span_mm[0]), float(v_span_mm[1])
|
|
2431
|
+
if not (math.isfinite(top) and math.isfinite(bottom)
|
|
2432
|
+
and 0.0 <= top < bottom <= h):
|
|
2433
|
+
raise PlotDocumentError(
|
|
2434
|
+
f"v_span_mm: expected 0 <= top < bottom <= {h} mm, "
|
|
2435
|
+
f"got {v_span_mm!r}")
|
|
2436
|
+
return round(top, 2), round(bottom, 2)
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
_measure_cache: OrderedDict = OrderedDict()
|
|
2440
|
+
_MEASURE_CACHE_MAX = 64
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
def fit_plot_area(document: PlotDocument, width_mm: float,
|
|
2444
|
+
height_mm: float, *,
|
|
2445
|
+
pad_mm: float = FIT_PAD_MM) -> tuple:
|
|
2446
|
+
"""Measure-only :func:`render_document_fitted` plot_area (no savefig)."""
|
|
2447
|
+
document.validate()
|
|
2448
|
+
w, h = _quantized_dims(width_mm, height_mm)
|
|
2449
|
+
key = ('measure', _canonical(document), w, h, float(pad_mm))
|
|
2450
|
+
hit = _measure_cache.get(key)
|
|
2451
|
+
if hit is not None:
|
|
2452
|
+
_measure_cache.move_to_end(key)
|
|
2453
|
+
return hit
|
|
2454
|
+
|
|
2455
|
+
import matplotlib
|
|
2456
|
+
with matplotlib.rc_context(_deterministic_rc(document)):
|
|
2457
|
+
fig = None
|
|
2458
|
+
try:
|
|
2459
|
+
fig, ax = _build_figure(document, 1.0, w, h)
|
|
2460
|
+
_fit_axes(fig, ax, float(pad_mm) / 25.4 * fig.dpi)
|
|
2461
|
+
pos = ax.get_position()
|
|
2462
|
+
finally:
|
|
2463
|
+
if fig is not None:
|
|
2464
|
+
fig.clear()
|
|
2465
|
+
area = (pos.x0, 1.0 - pos.y1, pos.x1, 1.0 - pos.y0)
|
|
2466
|
+
_measure_cache[key] = area
|
|
2467
|
+
if len(_measure_cache) > _MEASURE_CACHE_MAX:
|
|
2468
|
+
_measure_cache.popitem(last=False)
|
|
2469
|
+
return area
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
def render_document_fitted(document: PlotDocument, width_mm: float,
|
|
2473
|
+
height_mm: float, *,
|
|
2474
|
+
pad_mm: float = FIT_PAD_MM,
|
|
2475
|
+
v_span_mm=None) -> PlotRender:
|
|
2476
|
+
"""Render *document* at exactly ``width_mm`` × ``height_mm``.
|
|
2477
|
+
|
|
2478
|
+
Unlike :func:`render_document` the figure is sized to the target box
|
|
2479
|
+
(style scale 1.0, so point sizes are true) and the axes rectangle is
|
|
2480
|
+
solved by :func:`_fit_axes` so no text is clipped. ``v_span_mm`` may
|
|
2481
|
+
pin the axes' vertical extent to ``(top_mm, bottom_mm)`` measured from
|
|
2482
|
+
the figure's top edge (row-aligned axes frames). The embedded metadata
|
|
2483
|
+
carries the *original* document, not the fitted geometry.
|
|
2484
|
+
"""
|
|
2485
|
+
document.validate()
|
|
2486
|
+
w, h = _quantized_dims(width_mm, height_mm)
|
|
2487
|
+
span = _quantized_v_span(v_span_mm, h)
|
|
2488
|
+
key = ('fitted', _canonical(document), w, h, float(pad_mm), span)
|
|
2489
|
+
hit = _render_cache.get(key)
|
|
2490
|
+
if hit is not None:
|
|
2491
|
+
_render_cache.move_to_end(key)
|
|
2492
|
+
return hit
|
|
2493
|
+
|
|
2494
|
+
import matplotlib
|
|
2495
|
+
with matplotlib.rc_context(_deterministic_rc(document)):
|
|
2496
|
+
fig = None
|
|
2497
|
+
try:
|
|
2498
|
+
fig, ax = _build_figure(document, 1.0, w, h)
|
|
2499
|
+
_fit_axes(fig, ax, float(pad_mm) / 25.4 * fig.dpi,
|
|
2500
|
+
_fixed_y_px(fig, span) if span is not None else None)
|
|
2501
|
+
pos = ax.get_position()
|
|
2502
|
+
buf = io.BytesIO()
|
|
2503
|
+
fig.savefig(buf, format='svg',
|
|
2504
|
+
metadata={'Date': None,
|
|
2505
|
+
'Creator': 'ILM Plot Editor'})
|
|
2506
|
+
finally:
|
|
2507
|
+
if fig is not None:
|
|
2508
|
+
fig.clear()
|
|
2509
|
+
|
|
2510
|
+
svg = embed_metadata(
|
|
2511
|
+
_prepare_svg(buf.getvalue(), document.font_family), document)
|
|
2512
|
+
if len(svg) > MAX_FILE_BYTES:
|
|
2513
|
+
raise PlotDocumentError(
|
|
2514
|
+
f"rendered SVG exceeds {MAX_FILE_BYTES // (1024 * 1024)} MiB — "
|
|
2515
|
+
f"reduce the number of data points")
|
|
2516
|
+
render = PlotRender(
|
|
2517
|
+
svg=svg, plot_area=(pos.x0, 1.0 - pos.y1, pos.x1, 1.0 - pos.y0))
|
|
2518
|
+
_render_cache[key] = render
|
|
2519
|
+
if len(_render_cache) > _RENDER_CACHE_MAX:
|
|
2520
|
+
_render_cache.popitem(last=False)
|
|
2521
|
+
return render
|
|
2522
|
+
|
|
2523
|
+
|
|
2524
|
+
def save_document(document: PlotDocument, path: str) -> None:
|
|
2525
|
+
"""Atomically write the self-contained ``*.ilmplot.svg`` for *document*.
|
|
2526
|
+
|
|
2527
|
+
Stdlib-only (temp sibling + fsync + ``os.replace``) so the core package
|
|
2528
|
+
does not depend on the ILM figpack module. The destination is never
|
|
2529
|
+
touched when rendering fails.
|
|
2530
|
+
"""
|
|
2531
|
+
data = render_document(document).svg
|
|
2532
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
2533
|
+
os.makedirs(parent, exist_ok=True)
|
|
2534
|
+
fd, tmp = tempfile.mkstemp(dir=parent,
|
|
2535
|
+
prefix='.' + os.path.basename(path),
|
|
2536
|
+
suffix='.tmp')
|
|
2537
|
+
try:
|
|
2538
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
2539
|
+
fh.write(data)
|
|
2540
|
+
fh.flush()
|
|
2541
|
+
os.fsync(fh.fileno())
|
|
2542
|
+
os.replace(tmp, path)
|
|
2543
|
+
except BaseException:
|
|
2544
|
+
try:
|
|
2545
|
+
os.unlink(tmp)
|
|
2546
|
+
except OSError:
|
|
2547
|
+
pass
|
|
2548
|
+
raise
|
|
2549
|
+
|
|
2550
|
+
|
|
2551
|
+
# ── file-backed document recognition (bounded cache) ─────────────────────
|
|
2552
|
+
|
|
2553
|
+
_file_doc_cache: OrderedDict = OrderedDict()
|
|
2554
|
+
_FILE_DOC_CACHE_MAX = 64
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
def load_rendered_document(path: str) -> PlotDocument:
|
|
2558
|
+
"""Load a document from *path*, cached on (path, size, mtime_ns).
|
|
2559
|
+
|
|
2560
|
+
Returns a clone — the cached instance is private so callers cannot
|
|
2561
|
+
mutate shared state.
|
|
2562
|
+
"""
|
|
2563
|
+
from .document import load_document
|
|
2564
|
+
st = os.stat(path)
|
|
2565
|
+
key = (os.path.abspath(path), st.st_size, st.st_mtime_ns)
|
|
2566
|
+
hit = _file_doc_cache.get(key)
|
|
2567
|
+
if hit is not None:
|
|
2568
|
+
_file_doc_cache.move_to_end(key)
|
|
2569
|
+
return hit.clone()
|
|
2570
|
+
doc = load_document(path)
|
|
2571
|
+
_file_doc_cache[key] = doc
|
|
2572
|
+
if len(_file_doc_cache) > _FILE_DOC_CACHE_MAX:
|
|
2573
|
+
_file_doc_cache.popitem(last=False)
|
|
2574
|
+
return doc.clone()
|