munchboka-edutools 0.2.3__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.
Potentially problematic release.
This version of munchboka-edutools might be problematic. Click here for more details.
- munchboka_edutools/__init__.py +184 -0
- munchboka_edutools/_plotmath_shim.py +126 -0
- munchboka_edutools/_version.py +2 -0
- munchboka_edutools/directives/__init__.py +1 -0
- munchboka_edutools/directives/admonitions.py +389 -0
- munchboka_edutools/directives/cas_popup.py +428 -0
- munchboka_edutools/directives/clear.py +103 -0
- munchboka_edutools/directives/dialogue.py +137 -0
- munchboka_edutools/directives/escape_room.py +296 -0
- munchboka_edutools/directives/escape_room2.py +318 -0
- munchboka_edutools/directives/factor_tree.py +552 -0
- munchboka_edutools/directives/flashcards.py +233 -0
- munchboka_edutools/directives/ggb.py +209 -0
- munchboka_edutools/directives/ggb_icon.py +105 -0
- munchboka_edutools/directives/ggb_popup.py +308 -0
- munchboka_edutools/directives/horner.py +326 -0
- munchboka_edutools/directives/interactive_code.py +75 -0
- munchboka_edutools/directives/jeopardy.py +252 -0
- munchboka_edutools/directives/jeopardy2.py +636 -0
- munchboka_edutools/directives/multi_plot.py +2524 -0
- munchboka_edutools/directives/multi_plot2.py +252 -0
- munchboka_edutools/directives/pair_puzzle.py +191 -0
- munchboka_edutools/directives/parsons.py +109 -0
- munchboka_edutools/directives/plot.py +3758 -0
- munchboka_edutools/directives/poly_icon.py +111 -0
- munchboka_edutools/directives/polydiv.py +346 -0
- munchboka_edutools/directives/popup.py +245 -0
- munchboka_edutools/directives/quiz.py +291 -0
- munchboka_edutools/directives/quiz2.py +453 -0
- munchboka_edutools/directives/signchart.py +519 -0
- munchboka_edutools/directives/signchart2.py +1545 -0
- munchboka_edutools/directives/timed_quiz.py +436 -0
- munchboka_edutools/directives/turtle.py +157 -0
- munchboka_edutools/static/css/admonitions.css +2012 -0
- munchboka_edutools/static/css/cas_popup.css +242 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_cm.css +112 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_default_cm.css +40 -0
- munchboka_edutools/static/css/code_mirror_themes/github_dark_high_contrast_cm.css +141 -0
- munchboka_edutools/static/css/code_mirror_themes/github_light_cm.css +120 -0
- munchboka_edutools/static/css/code_mirror_themes/github_light_default_cm.css +108 -0
- munchboka_edutools/static/css/code_mirror_themes/one_dark_cm.css +121 -0
- munchboka_edutools/static/css/dialogue.css +92 -0
- munchboka_edutools/static/css/escapeRoom/escape-room.css +223 -0
- munchboka_edutools/static/css/figures.css +321 -0
- munchboka_edutools/static/css/flashcards.css +219 -0
- munchboka_edutools/static/css/general_style.css +74 -0
- munchboka_edutools/static/css/github-dark-high-contrast.css +141 -0
- munchboka_edutools/static/css/github-dark.css +147 -0
- munchboka_edutools/static/css/github-light.css +155 -0
- munchboka_edutools/static/css/interactive_code/style.css +575 -0
- munchboka_edutools/static/css/interactive_code.css +582 -0
- munchboka_edutools/static/css/jeopardy.css +553 -0
- munchboka_edutools/static/css/pairPuzzle/style.css +177 -0
- munchboka_edutools/static/css/parsons/parsonsPuzzle.css +331 -0
- munchboka_edutools/static/css/popup.css +115 -0
- munchboka_edutools/static/css/quiz.css +377 -0
- munchboka_edutools/static/css/timedQuiz.css +375 -0
- munchboka_edutools/static/icons/ggb/mode_evaluate.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_extremum.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_intersect.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_nsolve.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_numeric.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_point.svg +1 -0
- munchboka_edutools/static/icons/ggb/mode_solve.svg +1 -0
- munchboka_edutools/static/icons/misc/windows-logo.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/academic.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/book.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/chat_bubble.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/check.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/cmd_line.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/file.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/fire.svg +4 -0
- munchboka_edutools/static/icons/outline/dark_mode/key.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/magnifying.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/pencil_square.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/play.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/question.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/square_check.svg +1 -0
- munchboka_edutools/static/icons/outline/dark_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/summary.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/undo.svg +3 -0
- munchboka_edutools/static/icons/outline/dark_mode/unlock.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/academic.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/book.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/chat_bubble.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/check.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/cmd_line.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/file.svg +1 -0
- munchboka_edutools/static/icons/outline/light_mode/fire.svg +4 -0
- munchboka_edutools/static/icons/outline/light_mode/key.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/magnifying.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/pencil_square.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/play.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/question.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/square_check.svg +1 -0
- munchboka_edutools/static/icons/outline/light_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/summary.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/undo.svg +3 -0
- munchboka_edutools/static/icons/outline/light_mode/unlock.svg +3 -0
- munchboka_edutools/static/icons/polyicons/cubicdown.svg +3 -0
- munchboka_edutools/static/icons/polyicons/cubicup.svg +3 -0
- munchboka_edutools/static/icons/polyicons/frown.svg +3 -0
- munchboka_edutools/static/icons/polyicons/smile.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/academic.svg +5 -0
- munchboka_edutools/static/icons/solid/dark_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/book.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/brain.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/file.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/fire.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/key.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/pencil_square.svg +4 -0
- munchboka_edutools/static/icons/solid/dark_mode/play.svg +3 -0
- munchboka_edutools/static/icons/solid/dark_mode/python.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/scroll.svg +1 -0
- munchboka_edutools/static/icons/solid/dark_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/academic.svg +5 -0
- munchboka_edutools/static/icons/solid/light_mode/backward.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/book.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/brain.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/file.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/fire.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/key.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/pencil_square.svg +4 -0
- munchboka_edutools/static/icons/solid/light_mode/play.svg +3 -0
- munchboka_edutools/static/icons/solid/light_mode/python.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/scroll.svg +1 -0
- munchboka_edutools/static/icons/solid/light_mode/stop.svg +3 -0
- munchboka_edutools/static/icons/stickers/edit.svg +1 -0
- munchboka_edutools/static/icons/stickers/pencil_square.svg +3 -0
- munchboka_edutools/static/js/casThemeManager.js +99 -0
- munchboka_edutools/static/js/escapeRoom/escape-room.js +219 -0
- munchboka_edutools/static/js/flashcards.js +199 -0
- munchboka_edutools/static/js/geogebra-setup.js +6 -0
- munchboka_edutools/static/js/highlight-init.js +6 -0
- munchboka_edutools/static/js/interactiveCode/codeEditor.js +648 -0
- munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +441 -0
- munchboka_edutools/static/js/interactiveCode/pythonRunner.js +336 -0
- munchboka_edutools/static/js/interactiveCode/turtleCode.js +203 -0
- munchboka_edutools/static/js/interactiveCode/workerManager.js +353 -0
- munchboka_edutools/static/js/jeopardy.js +560 -0
- munchboka_edutools/static/js/pairPuzzle/draggableItem.js +64 -0
- munchboka_edutools/static/js/pairPuzzle/dropZone.js +119 -0
- munchboka_edutools/static/js/pairPuzzle/game.js +160 -0
- munchboka_edutools/static/js/parsons/parsonsPuzzle.js +641 -0
- munchboka_edutools/static/js/popup.js +85 -0
- munchboka_edutools/static/js/quiz.js +566 -0
- munchboka_edutools/static/js/skulpt/skulpt.js +35721 -0
- munchboka_edutools/static/js/timedQuiz/multipleChoiceQuestion.js +184 -0
- munchboka_edutools/static/js/timedQuiz/timedMultipleChoiceQuiz.js +244 -0
- munchboka_edutools/static/js/timedQuiz/utils.js +6 -0
- munchboka_edutools/static/js/utils.js +3 -0
- munchboka_edutools-0.2.3.dist-info/METADATA +109 -0
- munchboka_edutools-0.2.3.dist-info/RECORD +157 -0
- munchboka_edutools-0.2.3.dist-info/WHEEL +4 -0
- munchboka_edutools-0.2.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2524 @@
|
|
|
1
|
+
"""multi-plot directive
|
|
2
|
+
=================================
|
|
3
|
+
|
|
4
|
+
Create a responsive grid (rows × cols) of mathematical function plots using
|
|
5
|
+
``plotmath.multiplot`` and a collection of numpy / matplotlib transformations.
|
|
6
|
+
The directive focuses on pedagogical clarity, robust domain / exclusion handling,
|
|
7
|
+
and clean SVG output (sanitized IDs, no fixed width/height, optional width styling).
|
|
8
|
+
|
|
9
|
+
Quick Example (MyST)
|
|
10
|
+
--------------------
|
|
11
|
+
:::{multi-plot}
|
|
12
|
+
functions: [x**2 - 2*x, -x + 2, x - 3]
|
|
13
|
+
rows: 1
|
|
14
|
+
cols: 3
|
|
15
|
+
width: 100%
|
|
16
|
+
alt: Tre grafer som sammenligner funksjoner
|
|
17
|
+
:::
|
|
18
|
+
|
|
19
|
+
Minimal Example
|
|
20
|
+
---------------
|
|
21
|
+
:::{multi-plot}
|
|
22
|
+
functions: [x**2, sin(x), exp(x)]
|
|
23
|
+
:::
|
|
24
|
+
|
|
25
|
+
Front‑Matter vs Inline Options
|
|
26
|
+
------------------------------
|
|
27
|
+
You can either:
|
|
28
|
+
|
|
29
|
+
1. Supply key/value pairs directly at the top (each ``key: value`` line) until a
|
|
30
|
+
blank or non-matching line is encountered.
|
|
31
|
+
2. Use a fenced *YAML-like* block delimited by ``---`` lines, then an empty line,
|
|
32
|
+
followed by an optional caption.
|
|
33
|
+
|
|
34
|
+
Example with front‑matter + caption:
|
|
35
|
+
:::{multi-plot}
|
|
36
|
+
---
|
|
37
|
+
functions: [x**2, -x, x**3]
|
|
38
|
+
rows: 1
|
|
39
|
+
cols: 3
|
|
40
|
+
width: 80%
|
|
41
|
+
alt: Tre forskjellige funksjoner
|
|
42
|
+
---
|
|
43
|
+
Sammenligning av tre funksjoner.
|
|
44
|
+
:::
|
|
45
|
+
|
|
46
|
+
Caching & Regeneration
|
|
47
|
+
----------------------
|
|
48
|
+
All parameters (functions, domains, lines, style flags, etc.) are hashed to form
|
|
49
|
+
an SVG cache filename under ``_static/multi_plot``. Use ``:nocache:`` (or
|
|
50
|
+
``nocache:`` in front‑matter) to force regeneration. The hash includes axis
|
|
51
|
+
limits, domain exclusions, per-axis lines, and labeling flags to minimize stale
|
|
52
|
+
outputs.
|
|
53
|
+
|
|
54
|
+
Safety & ID Rewriting
|
|
55
|
+
---------------------
|
|
56
|
+
All non-font glyph IDs within the generated SVG are rewritten with a unique
|
|
57
|
+
prefix to avoid collisions when embedding multiple multi-plot figures on the
|
|
58
|
+
same page. URL and href references (including gradients, clips, etc.) are
|
|
59
|
+
updated accordingly.
|
|
60
|
+
|
|
61
|
+
Accessibility
|
|
62
|
+
-------------
|
|
63
|
+
The root ``<svg>`` receives ``role="img"`` and ``aria-label="..."`` (from the
|
|
64
|
+
``alt`` option or a generated default) instead of a ``<title>`` tag to avert
|
|
65
|
+
hover tooltips while retaining screen reader context.
|
|
66
|
+
|
|
67
|
+
Width & Layout
|
|
68
|
+
--------------
|
|
69
|
+
``width`` may be a percentage (e.g. ``60%``, ``100%``) or an absolute size
|
|
70
|
+
(``400`` or ``400px``). Percent widths get ``display:block; margin:0 auto;`` for
|
|
71
|
+
centering. If you need left/right alignment, supply ``align: left`` or
|
|
72
|
+
``align: right`` (the directive's figure container alignment plus CSS handles the rest).
|
|
73
|
+
|
|
74
|
+
Function Expressions
|
|
75
|
+
--------------------
|
|
76
|
+
Provide a comma-separated or bracketed list (``[ ... ]``). Each expression is
|
|
77
|
+
sympified via SymPy, then vectorized with ``lambdify``. Example accepted forms:
|
|
78
|
+
* ``x**2 - 2*x``
|
|
79
|
+
* ``sin(x)``
|
|
80
|
+
* ``exp(x)``
|
|
81
|
+
|
|
82
|
+
Labels
|
|
83
|
+
------
|
|
84
|
+
Use either ``fn_labels`` or its alias ``function-names``. If the number of
|
|
85
|
+
labels matches the number of functions, they are shown (LaTeX math wrapped in
|
|
86
|
+
``$...$``). Otherwise, labels are auto-inferred / hidden depending on plotmath
|
|
87
|
+
defaults. Example:
|
|
88
|
+
``fn_labels: [f(x)=x^2, g(x)=-x, h(x)=x^3]``
|
|
89
|
+
|
|
90
|
+
Per-Function Domains & Exclusions
|
|
91
|
+
---------------------------------
|
|
92
|
+
``domains`` accepts top-level comma-separated domain specs with optional
|
|
93
|
+
exclusion points using a set difference notation ``(a,b) \ {x1, x2, ...}``.
|
|
94
|
+
Example:
|
|
95
|
+
``domains: [( -5,5 ) \ {0}, ( -2,2 ), (0, 6) \ {1,2}]``
|
|
96
|
+
|
|
97
|
+
Excluded x-values are widened internally (with a small numeric halo + neighbor
|
|
98
|
+
NaNs) to create visible breaks instead of spuriously connecting across
|
|
99
|
+
discontinuities.
|
|
100
|
+
|
|
101
|
+
Vertical & Horizontal Lines
|
|
102
|
+
---------------------------
|
|
103
|
+
``vlines`` / ``hlines`` accept lists of x or y locations per function panel. Each
|
|
104
|
+
panel’s list is expressed as a top-level comma-separated group. Example:
|
|
105
|
+
``vlines: [[-2,0,2], None, [1]]`` or ``vlines: [-2; None; 1]`` (semicolons treated as commas).
|
|
106
|
+
|
|
107
|
+
Axis-Specific Limits
|
|
108
|
+
--------------------
|
|
109
|
+
``xlims`` / ``ylims`` allow per-plot overrides: ``xlims: [(-3,3), None, (-1,5)]``.
|
|
110
|
+
Where omitted or ``None``, the global ``xmin/xmax`` or ``ymin/ymax`` apply.
|
|
111
|
+
|
|
112
|
+
Reference Line (y = a*x + b)
|
|
113
|
+
----------------------------
|
|
114
|
+
``lines`` takes per-axis slope/intercept specs. Each element may be a two-element
|
|
115
|
+
sequence ``[a, b]`` or ``(a, b)``, or an extended form ``[a, (x0,y0)]`` from which
|
|
116
|
+
``b`` is derived as ``y0 - a*x0``.
|
|
117
|
+
|
|
118
|
+
Alpha / Line Width / Font Size
|
|
119
|
+
------------------------------
|
|
120
|
+
* ``alpha`` – global transparency for function curves.
|
|
121
|
+
* ``lw`` – line width (float-like).
|
|
122
|
+
* ``fontsize`` – base font size applied to axes labels, tick labels, and legends.
|
|
123
|
+
|
|
124
|
+
Ticks & Grid
|
|
125
|
+
------------
|
|
126
|
+
* ``ticks`` – truthy/falsey values (``true``, ``false``, ``on``, ``off``...). Defaults to True.
|
|
127
|
+
* ``grid`` – same parsing as ``ticks``.
|
|
128
|
+
|
|
129
|
+
Rows & Cols Auto Layout
|
|
130
|
+
-----------------------
|
|
131
|
+
``rows`` and ``cols`` determine the subplot grid. If you omit ``cols`` we compute a
|
|
132
|
+
default that fits all functions given the specified rows.
|
|
133
|
+
|
|
134
|
+
Parsing Nuances
|
|
135
|
+
---------------
|
|
136
|
+
The directive tolerates:
|
|
137
|
+
* Bracketed lists ``[a, b, c]`` or raw ``a, b, c`` strings.
|
|
138
|
+
* Semicolons as separators (converted to commas).
|
|
139
|
+
* Per-item parentheses / brackets / braces inside domain or line specs.
|
|
140
|
+
|
|
141
|
+
Debug Mode
|
|
142
|
+
----------
|
|
143
|
+
``:debug:`` (or ``debug:``) disables ID rewriting and size stripping so you can
|
|
144
|
+
inspect the raw produced SVG. (A PDF sidecar snippet is present in code but
|
|
145
|
+
commented out.)
|
|
146
|
+
|
|
147
|
+
No Hover Tooltips
|
|
148
|
+
-----------------
|
|
149
|
+
We intentionally do not inject ``<title>`` elements; they created distracting
|
|
150
|
+
hover popups. Accessibility is preserved through ``aria-label``.
|
|
151
|
+
|
|
152
|
+
Error Handling
|
|
153
|
+
--------------
|
|
154
|
+
If an expression fails SymPy parsing or evaluation, the build emits a Sphinx
|
|
155
|
+
error node indicating which function failed. Numerical issues (NaNs, infs) are
|
|
156
|
+
neutralized into ``NaN`` gaps, avoiding Matplotlib from drawing misleading spikes.
|
|
157
|
+
|
|
158
|
+
Option Reference (Summary)
|
|
159
|
+
--------------------------
|
|
160
|
+
Required:
|
|
161
|
+
* ``functions`` – list of function expressions.
|
|
162
|
+
|
|
163
|
+
Styling & Layout:
|
|
164
|
+
* ``width`` – percentage or px (auto-centers if %).
|
|
165
|
+
* ``rows`` / ``cols`` – grid shape.
|
|
166
|
+
* ``align`` – left | center | right (figure alignment class).
|
|
167
|
+
* ``class`` – extra CSS classes appended to figure container.
|
|
168
|
+
* ``alt`` – accessible description (defaults to generic text).
|
|
169
|
+
|
|
170
|
+
Axes & Appearance:
|
|
171
|
+
* ``xmin``, ``xmax``, ``ymin``, ``ymax`` – global ranges.
|
|
172
|
+
* ``xstep``, ``ystep`` – tick spacing.
|
|
173
|
+
* ``fontsize`` – base font size.
|
|
174
|
+
* ``lw`` – line width.
|
|
175
|
+
* ``alpha`` – line alpha (float).
|
|
176
|
+
* ``grid`` – toggle grid.
|
|
177
|
+
* ``ticks`` – toggle ticks.
|
|
178
|
+
|
|
179
|
+
Per-Function / Per-Axis:
|
|
180
|
+
* ``domains`` – list of domain specs with optional exclusions ``(a,b) \ {e1,e2}``.
|
|
181
|
+
* ``points`` – (legacy) per-axis point lists. Each element can be ``None`` (or omitted) or a
|
|
182
|
+
single tuple ``(x,y)`` or a list/tuple of tuples ``[(x1,y1),(x2,y2)]``. Examples:
|
|
183
|
+
``points: [ (0,0), None, [(1,2),(2,3)] ]`` or using bracketless top-level splitting
|
|
184
|
+
``points: [(0,0), None, ((1,2),(2,3))]``. Points are drawn as filled blue circles
|
|
185
|
+
with black edges after the function curve so they appear on top.
|
|
186
|
+
* ``point`` – (new) single point with axis targeting. Format: ``point: (x, y), axis_spec``
|
|
187
|
+
where ``axis_spec`` is either an integer (1-indexed flattened row-major index) or
|
|
188
|
+
a tuple ``(row, col)`` (1-indexed). Supports expression evaluation and function calls.
|
|
189
|
+
Examples: ``point: (1, f(2)), 1`` (axis 1) or ``point: (1, h(2)), (2, 1)`` (row 2, col 1).
|
|
190
|
+
Multiple ``point:`` lines can be used to add points to different axes.
|
|
191
|
+
* ``vlines`` / ``hlines`` – (legacy) per-axis vertical / horizontal reference lines.
|
|
192
|
+
* ``hline`` – (new) single horizontal line with axis targeting. Format: ``hline: y, axis_spec``
|
|
193
|
+
or ``hline: y, x1, x2, axis_spec`` where y is the line height and optional x1, x2 define
|
|
194
|
+
the horizontal extent (defaults to global xmin/xmax). Supports expression evaluation.
|
|
195
|
+
Examples: ``hline: 2, 1`` (full width at y=2 on axis 1) or ``hline: f(3), -2, 2, (1, 2)``
|
|
196
|
+
(from x=-2 to x=2 at y=f(3) on row 1, col 2).
|
|
197
|
+
* ``vline`` – (new) single vertical line with axis targeting. Format: ``vline: x, axis_spec``
|
|
198
|
+
or ``vline: x, y1, y2, axis_spec`` where x is the line position and optional y1, y2 define
|
|
199
|
+
the vertical extent (defaults to global ymin/ymax). Supports expression evaluation.
|
|
200
|
+
Examples: ``vline: 1, 1`` (full height at x=1 on axis 1) or ``vline: sqrt(2), -3, 3, (2, 1)``
|
|
201
|
+
(from y=-3 to y=3 at x=sqrt(2) on row 2, col 1).
|
|
202
|
+
* ``lines`` – (legacy) per-axis reference lines y = a*x + b or derived from basepoint.
|
|
203
|
+
* ``line`` – (new) single line with axis targeting. Format: ``line: a, b, axis_spec`` for y = a*x + b
|
|
204
|
+
or ``line: a, (x0, y0), axis_spec`` for point-slope form y = y0 + a*(x - x0).
|
|
205
|
+
Supports expression evaluation and function calls.
|
|
206
|
+
Examples: ``line: 2, 1, 1`` (y = 2x + 1 on axis 1) or ``line: f(3), (1, 2), (2, 1)``
|
|
207
|
+
(slope f(3) through point (1, 2) on row 2, col 1).
|
|
208
|
+
* ``tangent`` – (new) tangent line with axis targeting. Format: ``tangent: x0, function_label, axis_spec``
|
|
209
|
+
where x0 is the point of tangency and function_label is one of the function labels (f, g, h, etc.).
|
|
210
|
+
Computes derivative automatically using SymPy.
|
|
211
|
+
Examples: ``tangent: 2, f, 1`` (tangent to f at x=2 on axis 1) or ``tangent: sqrt(3), g, (2, 1)``
|
|
212
|
+
(tangent to g at x=sqrt(3) on row 2, col 1).
|
|
213
|
+
* ``text`` – (new) text annotation with axis targeting. Format: ``text: x, y, "text"[, placement], axis_spec``
|
|
214
|
+
where placement is optional (default ``top-left``) and axis_spec is optional (applies to all if omitted).
|
|
215
|
+
Position tokens: ``top-left``, ``top-right``, ``bottom-left``, ``bottom-right``, ``top-center``, ``bottom-center``,
|
|
216
|
+
``center-left``, ``center-right``, ``center-center``. Long variants shift further from point: ``longtop-left``, etc.
|
|
217
|
+
Supports expression evaluation for x and y coordinates.
|
|
218
|
+
Examples: ``text: 1, 2, "Point A", 1`` or ``text: pi/2, sin(pi/2), "Peak", top-center, (1, 2)``.
|
|
219
|
+
* ``annotate`` – (new) arrow annotation with axis targeting. Format: ``annotate: (x_text, y_text), (x_target, y_target), "text"[, arc], axis_spec``
|
|
220
|
+
where arc is optional (default 0.3) and axis_spec is optional (applies to all if omitted).
|
|
221
|
+
Supports expression evaluation for all coordinates and arc curvature.
|
|
222
|
+
Examples: ``annotate: (1, 3), (2, 4), "Arrow", 1`` or ``annotate: (0, f(0)), (pi, f(pi)), "Peak", 0.5, (1, 1)``.
|
|
223
|
+
* ``xlims`` / ``ylims`` – (legacy) per-axis limits as tuples.
|
|
224
|
+
* ``xmin`` / ``xmax`` / ``ymin`` / ``ymax`` – (new) per-axis or global limits. Format: ``xmin: value, axis_spec``
|
|
225
|
+
or ``xmin: value`` (applies to all axes). Supports expression evaluation.
|
|
226
|
+
Examples: ``xmin: -10, 1`` (set xmin=-10 for axis 1) or ``ymax: 5`` (set ymax=5 for all axes).
|
|
227
|
+
* ``fn_labels`` / ``function-names`` – labels.
|
|
228
|
+
|
|
229
|
+
Meta / Control:
|
|
230
|
+
* ``nocache`` – force regeneration.
|
|
231
|
+
* ``debug`` – keep raw SVG + skip ID rewriting.
|
|
232
|
+
* ``name`` – explicit figure base name (influences cache filename).
|
|
233
|
+
|
|
234
|
+
Caption
|
|
235
|
+
-------
|
|
236
|
+
Any content after the parsed key/value block (or after inline key/value lines)
|
|
237
|
+
is treated as the caption and wrapped in a Sphinx ``<caption>`` node.
|
|
238
|
+
|
|
239
|
+
Implementation Notes
|
|
240
|
+
--------------------
|
|
241
|
+
* Expressions are compiled once per build variant and cached.
|
|
242
|
+
* Domain exclusions create widened gaps (± a few samples) for visual clarity.
|
|
243
|
+
* ID rewriting avoids collisions of gradients / clips when multiple multi-plot
|
|
244
|
+
images exist on the same page.
|
|
245
|
+
* Inline width styling is injected only once by a regex match on the first
|
|
246
|
+
``<svg>`` tag; subsequent styling merges if a style attribute already exists.
|
|
247
|
+
|
|
248
|
+
This directive is specifically tuned for the pedagogical needs of the material
|
|
249
|
+
in this repository; tweak or extend as needed. If you add new options, please
|
|
250
|
+
update this docstring to keep the self-documenting pattern intact.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
from __future__ import annotations
|
|
254
|
+
|
|
255
|
+
import os
|
|
256
|
+
import re
|
|
257
|
+
import uuid
|
|
258
|
+
import hashlib
|
|
259
|
+
import shutil
|
|
260
|
+
from typing import Callable, Dict, Any, Tuple, List
|
|
261
|
+
|
|
262
|
+
from docutils import nodes
|
|
263
|
+
from docutils.parsers.rst import directives
|
|
264
|
+
from sphinx.util.docutils import SphinxDirective
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Helpers
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _hash_key(*parts) -> str:
|
|
272
|
+
h = hashlib.sha1()
|
|
273
|
+
for p in parts:
|
|
274
|
+
if p is None:
|
|
275
|
+
p = "__NONE__"
|
|
276
|
+
h.update(str(p).encode("utf-8"))
|
|
277
|
+
h.update(b"||")
|
|
278
|
+
return h.hexdigest()[:12]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _compile_function(expr: str) -> Callable:
|
|
282
|
+
import sympy, numpy as np # local import
|
|
283
|
+
from scipy import special as sp_special
|
|
284
|
+
|
|
285
|
+
expr = expr.strip()
|
|
286
|
+
x = sympy.symbols("x")
|
|
287
|
+
try:
|
|
288
|
+
sym = sympy.sympify(expr)
|
|
289
|
+
except Exception as e: # pragma: no cover - user error path
|
|
290
|
+
raise ValueError(f"Ugyldig funksjonsuttrykk '{expr}': {e}")
|
|
291
|
+
|
|
292
|
+
# Map special functions like erf to scipy implementations for numpy arrays
|
|
293
|
+
fn_np = sympy.lambdify(x, sym, modules=[{"erf": sp_special.erf}, "numpy"])
|
|
294
|
+
|
|
295
|
+
def f(arr):
|
|
296
|
+
return fn_np(np.asarray(arr, dtype=float))
|
|
297
|
+
|
|
298
|
+
# simple vectorization test
|
|
299
|
+
_ = f([0.0, 1.0])
|
|
300
|
+
return f
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Directive
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _strip_root_svg_size(svg_text: str) -> str:
|
|
309
|
+
"""Remove width/height only on the root <svg> tag for responsiveness."""
|
|
310
|
+
|
|
311
|
+
def repl(m):
|
|
312
|
+
tag = m.group(0)
|
|
313
|
+
tag = re.sub(r'\swidth="[^"]+"', "", tag)
|
|
314
|
+
tag = re.sub(r'\sheight="[^"]+"', "", tag)
|
|
315
|
+
return tag
|
|
316
|
+
|
|
317
|
+
return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _parse_bool(val, default: bool | None = None) -> bool | None:
|
|
321
|
+
if val is None:
|
|
322
|
+
return default
|
|
323
|
+
if isinstance(val, bool):
|
|
324
|
+
return val
|
|
325
|
+
s = str(val).strip().lower()
|
|
326
|
+
if s == "": # option present with no value => treat as True
|
|
327
|
+
return True
|
|
328
|
+
if s in {"true", "yes", "on", "1"}:
|
|
329
|
+
return True
|
|
330
|
+
if s in {"false", "no", "off", "0"}:
|
|
331
|
+
return False
|
|
332
|
+
return default
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _split_expr_list(val: str) -> List[str]:
|
|
336
|
+
if not isinstance(val, str):
|
|
337
|
+
return []
|
|
338
|
+
s = val.strip()
|
|
339
|
+
if not s:
|
|
340
|
+
return []
|
|
341
|
+
# allow [a,b,c] or a;b;c or a,b,c
|
|
342
|
+
if s.startswith("[") and s.endswith("]"):
|
|
343
|
+
s = s[1:-1]
|
|
344
|
+
s = s.replace(";", ",")
|
|
345
|
+
parts = [p.strip() for p in s.split(",")]
|
|
346
|
+
return [p for p in parts if p]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _split_top_level(val: str) -> List[str]:
|
|
350
|
+
"""Split by commas at top level only (ignores commas inside (), [], {})."""
|
|
351
|
+
if not isinstance(val, str):
|
|
352
|
+
return []
|
|
353
|
+
s = val.strip()
|
|
354
|
+
if not s:
|
|
355
|
+
return []
|
|
356
|
+
# Strip surrounding brackets if present
|
|
357
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
358
|
+
s = s[1:-1].strip()
|
|
359
|
+
out: List[str] = []
|
|
360
|
+
cur = []
|
|
361
|
+
depth = 0
|
|
362
|
+
pairs = {")": "(", "]": "[", "}": "{"}
|
|
363
|
+
stack: List[str] = []
|
|
364
|
+
i = 0
|
|
365
|
+
while i < len(s):
|
|
366
|
+
ch = s[i]
|
|
367
|
+
if ch in "([{":
|
|
368
|
+
depth += 1
|
|
369
|
+
stack.append(ch)
|
|
370
|
+
cur.append(ch)
|
|
371
|
+
elif ch in ")]}":
|
|
372
|
+
depth = max(0, depth - 1)
|
|
373
|
+
if stack:
|
|
374
|
+
stack.pop()
|
|
375
|
+
cur.append(ch)
|
|
376
|
+
elif ch == "," and depth == 0:
|
|
377
|
+
part = "".join(cur).strip()
|
|
378
|
+
if part:
|
|
379
|
+
out.append(part)
|
|
380
|
+
cur = []
|
|
381
|
+
else:
|
|
382
|
+
cur.append(ch)
|
|
383
|
+
i += 1
|
|
384
|
+
tail = "".join(cur).strip()
|
|
385
|
+
if tail:
|
|
386
|
+
out.append(tail)
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _safe_literal(val: str):
|
|
391
|
+
try:
|
|
392
|
+
import ast as _ast
|
|
393
|
+
|
|
394
|
+
return _ast.literal_eval(val)
|
|
395
|
+
except Exception:
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _parse_values_list_or_none(s: str):
|
|
400
|
+
"""Parse a scalar number or a tuple/list of numbers; return list[float] or None."""
|
|
401
|
+
if not isinstance(s, str):
|
|
402
|
+
return None
|
|
403
|
+
st = s.strip()
|
|
404
|
+
if not st or st.lower() == "none":
|
|
405
|
+
return None
|
|
406
|
+
lit = _safe_literal(st)
|
|
407
|
+
if isinstance(lit, (int, float)):
|
|
408
|
+
try:
|
|
409
|
+
return [float(lit)]
|
|
410
|
+
except Exception:
|
|
411
|
+
return None
|
|
412
|
+
if isinstance(lit, (list, tuple)):
|
|
413
|
+
out: List[float] = []
|
|
414
|
+
for v in lit:
|
|
415
|
+
try:
|
|
416
|
+
out.append(float(v))
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
return out if out else None
|
|
420
|
+
# fallback: split by commas
|
|
421
|
+
parts = [p.strip() for p in st.split(",") if p.strip()]
|
|
422
|
+
out2: List[float] = []
|
|
423
|
+
for p in parts:
|
|
424
|
+
try:
|
|
425
|
+
out2.append(float(p))
|
|
426
|
+
except Exception:
|
|
427
|
+
return None
|
|
428
|
+
return out2 if out2 else None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class MultiPlotDirective(SphinxDirective):
|
|
432
|
+
has_content = True
|
|
433
|
+
required_arguments = 0
|
|
434
|
+
option_spec = {
|
|
435
|
+
"functions": directives.unchanged_required, # list of expressions
|
|
436
|
+
"fn_labels": directives.unchanged, # optional list of labels
|
|
437
|
+
"function-names": directives.unchanged, # alias for fn_labels in examples
|
|
438
|
+
"domains": directives.unchanged, # per-function domain (a,b) or (a,b) \ {..}
|
|
439
|
+
"vlines": directives.unchanged, # per-function vline x or None
|
|
440
|
+
"hlines": directives.unchanged, # per-function hline y or None
|
|
441
|
+
"xlims": directives.unchanged, # per-function xlim tuple or None
|
|
442
|
+
"ylims": directives.unchanged, # per-function ylim tuple or None
|
|
443
|
+
"lines": directives.unchanged, # per-axis line spec: (a,b) or (a,(x,y)) or None
|
|
444
|
+
"points": directives.unchanged, # per-axis point lists: [(x,y),(x,y)] or None
|
|
445
|
+
"point": directives.unchanged, # single point with axis target: (x,y), axis_spec
|
|
446
|
+
"hline": directives.unchanged, # single hline with axis target: y, x1, x2, axis_spec
|
|
447
|
+
"vline": directives.unchanged, # single vline with axis target: x, y1, y2, axis_spec
|
|
448
|
+
"line": directives.unchanged, # single line with axis target: a, b, axis_spec or a, (x0,y0), axis_spec
|
|
449
|
+
"tangent": directives.unchanged, # single tangent line: x0, function_label, axis_spec
|
|
450
|
+
"xmin": directives.unchanged,
|
|
451
|
+
"xmax": directives.unchanged,
|
|
452
|
+
"ymin": directives.unchanged,
|
|
453
|
+
"ymax": directives.unchanged,
|
|
454
|
+
"text": directives.unchanged, # text annotation: x, y, "text"[, placement], axis_spec
|
|
455
|
+
"annotate": directives.unchanged, # arrow annotation: (xytext), (xy), "text"[, arc], axis_spec
|
|
456
|
+
"xstep": directives.unchanged,
|
|
457
|
+
"ystep": directives.unchanged,
|
|
458
|
+
"fontsize": directives.unchanged,
|
|
459
|
+
"lw": directives.unchanged,
|
|
460
|
+
"alpha": directives.unchanged,
|
|
461
|
+
"grid": directives.unchanged,
|
|
462
|
+
"ticks": directives.unchanged,
|
|
463
|
+
"rows": directives.unchanged,
|
|
464
|
+
"cols": directives.unchanged,
|
|
465
|
+
"align": lambda a: directives.choice(a, ["left", "center", "right"]),
|
|
466
|
+
"class": directives.class_option,
|
|
467
|
+
"name": directives.unchanged,
|
|
468
|
+
"nocache": directives.flag,
|
|
469
|
+
"alt": directives.unchanged,
|
|
470
|
+
"width": directives.length_or_percentage_or_unitless,
|
|
471
|
+
"debug": directives.flag,
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# -----------------------------
|
|
475
|
+
# Content key/value parsing
|
|
476
|
+
# -----------------------------
|
|
477
|
+
def _gather_kv_from_content(self) -> Tuple[Dict[str, str], int]:
|
|
478
|
+
kv: Dict[str, str] = {}
|
|
479
|
+
lines = list(self.content)
|
|
480
|
+
idx = 0
|
|
481
|
+
# YAML front matter style
|
|
482
|
+
if lines and lines[0].strip() == "---":
|
|
483
|
+
idx = 1
|
|
484
|
+
while idx < len(lines) and lines[idx].strip() != "---":
|
|
485
|
+
line = lines[idx].rstrip()
|
|
486
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
487
|
+
if m:
|
|
488
|
+
kv[m.group(1)] = m.group(2)
|
|
489
|
+
idx += 1
|
|
490
|
+
if idx < len(lines) and lines[idx].strip() == "---":
|
|
491
|
+
idx += 1
|
|
492
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
493
|
+
idx += 1
|
|
494
|
+
return kv, idx
|
|
495
|
+
# flat key: value lines until first non-matching or blank separation
|
|
496
|
+
caption_start = 0
|
|
497
|
+
for i, line in enumerate(lines):
|
|
498
|
+
if not line.strip():
|
|
499
|
+
caption_start = i + 1
|
|
500
|
+
continue
|
|
501
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
502
|
+
if m:
|
|
503
|
+
kv[m.group(1)] = m.group(2)
|
|
504
|
+
caption_start = i + 1
|
|
505
|
+
else:
|
|
506
|
+
break
|
|
507
|
+
return kv, caption_start
|
|
508
|
+
|
|
509
|
+
# -----------------------------
|
|
510
|
+
# Main run
|
|
511
|
+
# -----------------------------
|
|
512
|
+
def run(self): # noqa: C901 (complexity OK for directive)
|
|
513
|
+
env = self.state.document.settings.env
|
|
514
|
+
app = env.app
|
|
515
|
+
try:
|
|
516
|
+
import plotmath # type: ignore
|
|
517
|
+
except Exception as e: # pragma: no cover - dependency missing
|
|
518
|
+
err = nodes.error()
|
|
519
|
+
err += nodes.paragraph(text=f"Kunne ikke importere plotmath: {e}")
|
|
520
|
+
return [err]
|
|
521
|
+
|
|
522
|
+
kv, caption_idx = self._gather_kv_from_content()
|
|
523
|
+
merged: Dict[str, Any] = {**kv, **self.options}
|
|
524
|
+
if "functions" not in merged:
|
|
525
|
+
return [
|
|
526
|
+
self.state_machine.reporter.error(
|
|
527
|
+
"Directive 'multi-plot' krever 'functions:'", line=self.lineno
|
|
528
|
+
)
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
exprs = _split_expr_list(str(merged["functions"]))
|
|
532
|
+
if not exprs:
|
|
533
|
+
return [
|
|
534
|
+
self.state_machine.reporter.error(
|
|
535
|
+
"'functions' var tomt eller ugyldig.", line=self.lineno
|
|
536
|
+
)
|
|
537
|
+
]
|
|
538
|
+
# compile all
|
|
539
|
+
functions: List[Callable] = []
|
|
540
|
+
for e in exprs:
|
|
541
|
+
try:
|
|
542
|
+
functions.append(_compile_function(e))
|
|
543
|
+
except Exception as ex:
|
|
544
|
+
return [
|
|
545
|
+
self.state_machine.reporter.error(
|
|
546
|
+
f"Ugyldig funksjon '{e}': {ex}", line=self.lineno
|
|
547
|
+
)
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
def _f(name, default):
|
|
551
|
+
v = merged.get(name)
|
|
552
|
+
if v in (None, ""):
|
|
553
|
+
return default
|
|
554
|
+
try:
|
|
555
|
+
return float(v)
|
|
556
|
+
except Exception:
|
|
557
|
+
return default
|
|
558
|
+
|
|
559
|
+
xmin = _f("xmin", -6)
|
|
560
|
+
xmax = _f("xmax", 6)
|
|
561
|
+
ymin = _f("ymin", -6)
|
|
562
|
+
ymax = _f("ymax", 6)
|
|
563
|
+
xstep = _f("xstep", 1)
|
|
564
|
+
ystep = _f("ystep", 1)
|
|
565
|
+
fontsize = _f("fontsize", 20)
|
|
566
|
+
lw = _f("lw", 2.5)
|
|
567
|
+
alpha_raw = merged.get("alpha")
|
|
568
|
+
grid_flag = _parse_bool(merged.get("grid"), default=True)
|
|
569
|
+
ticks_flag = _parse_bool(merged.get("ticks"), default=True)
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
alpha = float(alpha_raw) if alpha_raw not in (None, "") else None
|
|
573
|
+
except Exception:
|
|
574
|
+
alpha = None
|
|
575
|
+
|
|
576
|
+
# Accept both fn_labels and function-names; function-names takes precedence if provided
|
|
577
|
+
labels_list: List[str] = _split_expr_list(
|
|
578
|
+
str(merged.get("function-names", merged.get("fn_labels", "")))
|
|
579
|
+
)
|
|
580
|
+
if labels_list and len(labels_list) == len(functions):
|
|
581
|
+
labels_arg: Any = labels_list
|
|
582
|
+
else:
|
|
583
|
+
labels_arg = True
|
|
584
|
+
|
|
585
|
+
# Per-function domains with optional exclusions, vlines, hlines, and axis limits
|
|
586
|
+
# Helper to parse domain with optional set-difference exclusions
|
|
587
|
+
def _parse_domain_with_exclusions(s: str):
|
|
588
|
+
if not isinstance(s, str):
|
|
589
|
+
return None, []
|
|
590
|
+
s = s.strip()
|
|
591
|
+
if not s or s.lower() == "none":
|
|
592
|
+
return None, []
|
|
593
|
+
num_re = r"[+-]?\d+(?:\.\d+)?"
|
|
594
|
+
dom_ex_pat = re.compile(
|
|
595
|
+
rf"\(\s*({num_re})\s*,\s*({num_re})\s*\)\s*(?:\\\s*\{{\s*([^}}]*)\s*\}})?"
|
|
596
|
+
)
|
|
597
|
+
m = dom_ex_pat.search(s)
|
|
598
|
+
if not m:
|
|
599
|
+
return None, []
|
|
600
|
+
try:
|
|
601
|
+
d0 = float(m.group(1))
|
|
602
|
+
d1 = float(m.group(2))
|
|
603
|
+
dom = (d0, d1)
|
|
604
|
+
except Exception:
|
|
605
|
+
dom = None
|
|
606
|
+
excludes: List[float] = []
|
|
607
|
+
excl_str = m.group(3) if m.lastindex and m.lastindex >= 3 else None
|
|
608
|
+
if excl_str:
|
|
609
|
+
for tok in [t.strip() for t in excl_str.split(",") if t.strip()]:
|
|
610
|
+
try:
|
|
611
|
+
excludes.append(float(tok))
|
|
612
|
+
except Exception:
|
|
613
|
+
pass
|
|
614
|
+
return dom, excludes
|
|
615
|
+
|
|
616
|
+
def _parse_tuple_or_none(s: str):
|
|
617
|
+
if not isinstance(s, str):
|
|
618
|
+
return None
|
|
619
|
+
st = s.strip()
|
|
620
|
+
if not st or st.lower() == "none":
|
|
621
|
+
return None
|
|
622
|
+
lit = _safe_literal(st)
|
|
623
|
+
if isinstance(lit, (list, tuple)) and len(lit) == 2:
|
|
624
|
+
try:
|
|
625
|
+
return (float(lit[0]), float(lit[1]))
|
|
626
|
+
except Exception:
|
|
627
|
+
return None
|
|
628
|
+
m = re.match(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)", st)
|
|
629
|
+
if m:
|
|
630
|
+
try:
|
|
631
|
+
return (float(m.group(1)), float(m.group(2)))
|
|
632
|
+
except Exception:
|
|
633
|
+
return None
|
|
634
|
+
return None
|
|
635
|
+
|
|
636
|
+
def _parse_scalar_or_none(s: str):
|
|
637
|
+
if not isinstance(s, str):
|
|
638
|
+
return None
|
|
639
|
+
st = s.strip()
|
|
640
|
+
if not st or st.lower() == "none":
|
|
641
|
+
return None
|
|
642
|
+
try:
|
|
643
|
+
return float(st)
|
|
644
|
+
except Exception:
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
domains_raw = _split_top_level(str(merged.get("domains", "")))
|
|
648
|
+
vlines_raw = _split_top_level(str(merged.get("vlines", "")))
|
|
649
|
+
hlines_raw = _split_top_level(str(merged.get("hlines", "")))
|
|
650
|
+
xlims_raw = _split_top_level(str(merged.get("xlims", "")))
|
|
651
|
+
ylims_raw = _split_top_level(str(merged.get("ylims", "")))
|
|
652
|
+
lines_raw = _split_top_level(str(merged.get("lines", "")))
|
|
653
|
+
points_raw = _split_top_level(str(merged.get("points", "")))
|
|
654
|
+
|
|
655
|
+
# Normalize sizes to match number of functions
|
|
656
|
+
n = len(functions)
|
|
657
|
+
|
|
658
|
+
def _pad(lst, fill="None"):
|
|
659
|
+
return lst + [fill] * max(0, n - len(lst))
|
|
660
|
+
|
|
661
|
+
domains_raw = _pad(domains_raw)
|
|
662
|
+
vlines_raw = _pad(vlines_raw)
|
|
663
|
+
hlines_raw = _pad(hlines_raw)
|
|
664
|
+
xlims_raw = _pad(xlims_raw)
|
|
665
|
+
ylims_raw = _pad(ylims_raw)
|
|
666
|
+
lines_raw = _pad(lines_raw)
|
|
667
|
+
points_raw = _pad(points_raw)
|
|
668
|
+
|
|
669
|
+
dom_list: List[Tuple[float, float] | None] = []
|
|
670
|
+
excl_list: List[List[float]] = []
|
|
671
|
+
for s in domains_raw[:n]:
|
|
672
|
+
dom, ex = _parse_domain_with_exclusions(s)
|
|
673
|
+
dom_list.append(dom)
|
|
674
|
+
excl_list.append(ex)
|
|
675
|
+
|
|
676
|
+
vline_vals: List[List[float] | None] = [
|
|
677
|
+
_parse_values_list_or_none(s) for s in vlines_raw[:n]
|
|
678
|
+
]
|
|
679
|
+
hline_vals: List[List[float] | None] = [
|
|
680
|
+
_parse_values_list_or_none(s) for s in hlines_raw[:n]
|
|
681
|
+
]
|
|
682
|
+
xlim_vals: List[Tuple[float, float] | None] = [
|
|
683
|
+
_parse_tuple_or_none(s) for s in xlims_raw[:n]
|
|
684
|
+
]
|
|
685
|
+
ylim_vals: List[Tuple[float, float] | None] = [
|
|
686
|
+
_parse_tuple_or_none(s) for s in ylims_raw[:n]
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
# Parse per-axis line specs
|
|
690
|
+
def _parse_line_spec(s: str):
|
|
691
|
+
if not isinstance(s, str):
|
|
692
|
+
return None
|
|
693
|
+
st = s.strip()
|
|
694
|
+
if not st or st.lower() == "none":
|
|
695
|
+
return None
|
|
696
|
+
lit = _safe_literal(st)
|
|
697
|
+
a_val = None
|
|
698
|
+
b_val = None
|
|
699
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 2:
|
|
700
|
+
try:
|
|
701
|
+
a_val = float(lit[0])
|
|
702
|
+
except Exception:
|
|
703
|
+
a_val = None
|
|
704
|
+
second = lit[1]
|
|
705
|
+
if isinstance(second, (list, tuple)) and len(second) == 2:
|
|
706
|
+
try:
|
|
707
|
+
x0p = float(second[0])
|
|
708
|
+
y0p = float(second[1])
|
|
709
|
+
if a_val is not None:
|
|
710
|
+
b_val = y0p - a_val * x0p
|
|
711
|
+
except Exception:
|
|
712
|
+
b_val = None
|
|
713
|
+
else:
|
|
714
|
+
try:
|
|
715
|
+
b_val = float(second)
|
|
716
|
+
except Exception:
|
|
717
|
+
b_val = None
|
|
718
|
+
if a_val is not None and b_val is not None:
|
|
719
|
+
return (a_val, b_val)
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
line_specs: List[Tuple[float, float] | None] = [_parse_line_spec(s) for s in lines_raw[:n]]
|
|
723
|
+
|
|
724
|
+
# Parse per-axis points. Each entry can be:
|
|
725
|
+
# - "None" or empty => no points for that axis
|
|
726
|
+
# - a single tuple like (x,y)
|
|
727
|
+
# - a list/tuple of tuples: [(x1,y1),(x2,y2)] or ((x1,y1),(x2,y2))
|
|
728
|
+
# - a loose comma form: (x1,y1); (x2,y2)
|
|
729
|
+
def _parse_points_entry(s: str):
|
|
730
|
+
if not isinstance(s, str):
|
|
731
|
+
return None
|
|
732
|
+
st = s.strip()
|
|
733
|
+
if not st or st.lower() == "none":
|
|
734
|
+
return None
|
|
735
|
+
lit = _safe_literal(st)
|
|
736
|
+
points_list: List[Tuple[float, float]] = []
|
|
737
|
+
|
|
738
|
+
def _coerce_pair(obj):
|
|
739
|
+
try:
|
|
740
|
+
if (
|
|
741
|
+
isinstance(obj, (list, tuple))
|
|
742
|
+
and len(obj) == 2
|
|
743
|
+
and all(isinstance(v, (int, float)) for v in obj)
|
|
744
|
+
):
|
|
745
|
+
return (float(obj[0]), float(obj[1]))
|
|
746
|
+
except Exception:
|
|
747
|
+
return None
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
if isinstance(lit, (list, tuple)):
|
|
751
|
+
# Could be list of pairs or a single pair
|
|
752
|
+
if len(lit) == 2 and all(isinstance(v, (int, float)) for v in lit):
|
|
753
|
+
p = _coerce_pair(lit)
|
|
754
|
+
if p:
|
|
755
|
+
points_list.append(p)
|
|
756
|
+
else:
|
|
757
|
+
for item in lit:
|
|
758
|
+
p = _coerce_pair(item)
|
|
759
|
+
if p:
|
|
760
|
+
points_list.append(p)
|
|
761
|
+
return points_list or None
|
|
762
|
+
# Fallback: find all (x,y) pattern occurrences
|
|
763
|
+
import re as _re
|
|
764
|
+
|
|
765
|
+
matches = _re.findall(
|
|
766
|
+
r"\(\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*,\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*\)",
|
|
767
|
+
st,
|
|
768
|
+
)
|
|
769
|
+
for a, b in matches:
|
|
770
|
+
try:
|
|
771
|
+
points_list.append((float(a), float(b)))
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
return points_list or None
|
|
775
|
+
|
|
776
|
+
points_vals: List[List[Tuple[float, float]] | None] = [
|
|
777
|
+
_parse_points_entry(s) for s in points_raw[:n]
|
|
778
|
+
]
|
|
779
|
+
|
|
780
|
+
# Parse new-style point keyword with axis targeting
|
|
781
|
+
# Gather all point: entries from content
|
|
782
|
+
point_entries_raw = []
|
|
783
|
+
if "point" in merged:
|
|
784
|
+
# Single point from options
|
|
785
|
+
point_entries_raw.append(merged["point"])
|
|
786
|
+
# Also check content for multiple point: lines
|
|
787
|
+
for line_idx, line in enumerate(self.content):
|
|
788
|
+
m = re.match(r"^point\s*:\s*(.+)$", line.strip())
|
|
789
|
+
if m:
|
|
790
|
+
point_entries_raw.append(m.group(1))
|
|
791
|
+
|
|
792
|
+
explicit_name = merged.get("name")
|
|
793
|
+
debug_mode = "debug" in merged
|
|
794
|
+
rows = int(float(merged.get("rows", 1)))
|
|
795
|
+
# If cols not provided, default to enough columns to fit all functions over the given rows
|
|
796
|
+
default_cols = max(1, (len(functions) + rows - 1) // max(1, rows))
|
|
797
|
+
cols = int(float(merged.get("cols", default_cols)))
|
|
798
|
+
|
|
799
|
+
# Expression evaluator with function call support (similar to plot directive)
|
|
800
|
+
def _eval_expr_multiplot(val) -> float:
|
|
801
|
+
import sympy
|
|
802
|
+
|
|
803
|
+
if val is None:
|
|
804
|
+
raise ValueError("Empty value")
|
|
805
|
+
if isinstance(val, (int, float)):
|
|
806
|
+
return float(val)
|
|
807
|
+
s0 = str(val).strip()
|
|
808
|
+
if not s0:
|
|
809
|
+
raise ValueError("Blank numeric expression")
|
|
810
|
+
s = s0
|
|
811
|
+
|
|
812
|
+
# Replace function label calls with their values
|
|
813
|
+
# e.g., f(2) where 'f' is a function label
|
|
814
|
+
for _ in range(50): # iteration limit
|
|
815
|
+
m = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\(", s)
|
|
816
|
+
if not m:
|
|
817
|
+
break
|
|
818
|
+
lbl = m.group(1)
|
|
819
|
+
if lbl not in labels_list:
|
|
820
|
+
# Skip this label
|
|
821
|
+
start_next = m.start() + 1
|
|
822
|
+
n = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\(", s[start_next:])
|
|
823
|
+
if not n:
|
|
824
|
+
break
|
|
825
|
+
m = n
|
|
826
|
+
lbl = m.group(1)
|
|
827
|
+
if lbl not in labels_list:
|
|
828
|
+
break
|
|
829
|
+
|
|
830
|
+
# Find matching closing parenthesis
|
|
831
|
+
start = m.end() - 1 # position of '('
|
|
832
|
+
depth = 0
|
|
833
|
+
end = None
|
|
834
|
+
for i in range(start, len(s)):
|
|
835
|
+
if s[i] == "(":
|
|
836
|
+
depth += 1
|
|
837
|
+
elif s[i] == ")":
|
|
838
|
+
depth -= 1
|
|
839
|
+
if depth == 0:
|
|
840
|
+
end = i
|
|
841
|
+
break
|
|
842
|
+
if end is None:
|
|
843
|
+
break
|
|
844
|
+
|
|
845
|
+
arg_expr = s[start + 1 : end]
|
|
846
|
+
try:
|
|
847
|
+
arg_val = _eval_expr_multiplot(arg_expr)
|
|
848
|
+
idx = labels_list.index(lbl)
|
|
849
|
+
f = functions[idx]
|
|
850
|
+
import numpy as np
|
|
851
|
+
|
|
852
|
+
yv = float(f(np.array([arg_val]))[0])
|
|
853
|
+
s = s[: m.start()] + f"{yv}" + s[end + 1 :]
|
|
854
|
+
continue
|
|
855
|
+
except Exception:
|
|
856
|
+
break
|
|
857
|
+
|
|
858
|
+
# Evaluate remaining expression with sympy
|
|
859
|
+
allowed = {
|
|
860
|
+
k: getattr(sympy, k)
|
|
861
|
+
for k in [
|
|
862
|
+
"pi",
|
|
863
|
+
"E",
|
|
864
|
+
"exp",
|
|
865
|
+
"sqrt",
|
|
866
|
+
"log",
|
|
867
|
+
"sin",
|
|
868
|
+
"cos",
|
|
869
|
+
"tan",
|
|
870
|
+
"asin",
|
|
871
|
+
"acos",
|
|
872
|
+
"atan",
|
|
873
|
+
"Rational",
|
|
874
|
+
"erf",
|
|
875
|
+
]
|
|
876
|
+
if hasattr(sympy, k)
|
|
877
|
+
}
|
|
878
|
+
try:
|
|
879
|
+
expr = sympy.sympify(s, locals=allowed)
|
|
880
|
+
return float(expr.evalf())
|
|
881
|
+
except Exception:
|
|
882
|
+
raise ValueError(f"Could not evaluate: {s0}")
|
|
883
|
+
|
|
884
|
+
# Parse point entries after rows/cols are determined
|
|
885
|
+
# Format: (x, y), axis_spec where axis_spec is either:
|
|
886
|
+
# - integer (1-indexed flattened, row-major)
|
|
887
|
+
# - (row, col) tuple (1-indexed)
|
|
888
|
+
def _parse_point_with_axis(s: str, rows: int, cols: int):
|
|
889
|
+
"""Parse '(x, y), axis_spec' and return (x, y, flat_idx) or None."""
|
|
890
|
+
if not isinstance(s, str):
|
|
891
|
+
return None
|
|
892
|
+
s = s.strip()
|
|
893
|
+
if not s:
|
|
894
|
+
return None
|
|
895
|
+
|
|
896
|
+
# Split at top-level comma to separate (x,y) from axis_spec
|
|
897
|
+
parts = []
|
|
898
|
+
current = []
|
|
899
|
+
depth = 0
|
|
900
|
+
for ch in s:
|
|
901
|
+
if ch in "([{":
|
|
902
|
+
depth += 1
|
|
903
|
+
current.append(ch)
|
|
904
|
+
elif ch in ")]}":
|
|
905
|
+
depth -= 1
|
|
906
|
+
current.append(ch)
|
|
907
|
+
elif ch == "," and depth == 0:
|
|
908
|
+
parts.append("".join(current).strip())
|
|
909
|
+
current = []
|
|
910
|
+
else:
|
|
911
|
+
current.append(ch)
|
|
912
|
+
if current:
|
|
913
|
+
parts.append("".join(current).strip())
|
|
914
|
+
|
|
915
|
+
if len(parts) < 2:
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
# First part should be (x, y) - can contain expressions or function calls
|
|
919
|
+
coord_str = parts[0].strip()
|
|
920
|
+
|
|
921
|
+
# Extract x and y expressions from (x_expr, y_expr)
|
|
922
|
+
if not (coord_str.startswith("(") and coord_str.endswith(")")):
|
|
923
|
+
return None
|
|
924
|
+
|
|
925
|
+
inner = coord_str[1:-1].strip()
|
|
926
|
+
# Split on top-level comma
|
|
927
|
+
comma_parts = []
|
|
928
|
+
current_part = []
|
|
929
|
+
depth = 0
|
|
930
|
+
for ch in inner:
|
|
931
|
+
if ch in "([{":
|
|
932
|
+
depth += 1
|
|
933
|
+
current_part.append(ch)
|
|
934
|
+
elif ch in ")]}":
|
|
935
|
+
depth -= 1
|
|
936
|
+
current_part.append(ch)
|
|
937
|
+
elif ch == "," and depth == 0:
|
|
938
|
+
comma_parts.append("".join(current_part).strip())
|
|
939
|
+
current_part = []
|
|
940
|
+
else:
|
|
941
|
+
current_part.append(ch)
|
|
942
|
+
if current_part:
|
|
943
|
+
comma_parts.append("".join(current_part).strip())
|
|
944
|
+
|
|
945
|
+
if len(comma_parts) != 2:
|
|
946
|
+
return None
|
|
947
|
+
|
|
948
|
+
x_expr, y_expr = comma_parts[0], comma_parts[1]
|
|
949
|
+
|
|
950
|
+
# Evaluate expressions (supports function calls like f(2))
|
|
951
|
+
try:
|
|
952
|
+
x_val = _eval_expr_multiplot(x_expr)
|
|
953
|
+
y_val = _eval_expr_multiplot(y_expr)
|
|
954
|
+
except Exception:
|
|
955
|
+
return None
|
|
956
|
+
|
|
957
|
+
# Second part is axis specifier
|
|
958
|
+
axis_str = parts[1]
|
|
959
|
+
lit_axis = _safe_literal(axis_str)
|
|
960
|
+
|
|
961
|
+
flat_idx = None
|
|
962
|
+
if isinstance(lit_axis, int):
|
|
963
|
+
# Direct flattened index (1-indexed)
|
|
964
|
+
flat_idx = lit_axis - 1 # Convert to 0-indexed
|
|
965
|
+
elif isinstance(lit_axis, (list, tuple)) and len(lit_axis) == 2:
|
|
966
|
+
# (row, col) format (1-indexed)
|
|
967
|
+
try:
|
|
968
|
+
row = int(lit_axis[0]) - 1 # Convert to 0-indexed
|
|
969
|
+
col = int(lit_axis[1]) - 1
|
|
970
|
+
if 0 <= row < rows and 0 <= col < cols:
|
|
971
|
+
flat_idx = row * cols + col
|
|
972
|
+
except Exception:
|
|
973
|
+
return None
|
|
974
|
+
else:
|
|
975
|
+
# Try to parse as int or (row, col)
|
|
976
|
+
try:
|
|
977
|
+
flat_idx = int(axis_str) - 1
|
|
978
|
+
except Exception:
|
|
979
|
+
# Try tuple
|
|
980
|
+
m = re.match(r"\(\s*(\d+)\s*,\s*(\d+)\s*\)", axis_str)
|
|
981
|
+
if m:
|
|
982
|
+
try:
|
|
983
|
+
row = int(m.group(1)) - 1
|
|
984
|
+
col = int(m.group(2)) - 1
|
|
985
|
+
if 0 <= row < rows and 0 <= col < cols:
|
|
986
|
+
flat_idx = row * cols + col
|
|
987
|
+
except Exception:
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
if flat_idx is not None and 0 <= flat_idx < rows * cols:
|
|
991
|
+
return (x_val, y_val, flat_idx)
|
|
992
|
+
return None
|
|
993
|
+
|
|
994
|
+
# Process point entries and add to points_vals
|
|
995
|
+
for pt_entry in point_entries_raw:
|
|
996
|
+
parsed = _parse_point_with_axis(pt_entry, rows, cols)
|
|
997
|
+
if parsed:
|
|
998
|
+
x_val, y_val, flat_idx = parsed
|
|
999
|
+
# Add point to the appropriate axis
|
|
1000
|
+
if flat_idx < len(points_vals):
|
|
1001
|
+
if points_vals[flat_idx] is None:
|
|
1002
|
+
points_vals[flat_idx] = []
|
|
1003
|
+
points_vals[flat_idx].append((x_val, y_val))
|
|
1004
|
+
|
|
1005
|
+
# Parse new-style hline keyword with axis targeting
|
|
1006
|
+
# Gather all hline: entries from content
|
|
1007
|
+
hline_entries_raw = []
|
|
1008
|
+
if "hline" in merged:
|
|
1009
|
+
hline_entries_raw.append(merged["hline"])
|
|
1010
|
+
for line_idx, line in enumerate(self.content):
|
|
1011
|
+
m = re.match(r"^hline\s*:\s*(.+)$", line.strip())
|
|
1012
|
+
if m:
|
|
1013
|
+
hline_entries_raw.append(m.group(1))
|
|
1014
|
+
|
|
1015
|
+
# Helper to parse axis specifier (used by both point and hline)
|
|
1016
|
+
def _parse_axis_spec(axis_str: str, rows: int, cols: int):
|
|
1017
|
+
"""Parse axis specifier and return flat index (0-indexed) or None."""
|
|
1018
|
+
lit_axis = _safe_literal(axis_str)
|
|
1019
|
+
flat_idx = None
|
|
1020
|
+
|
|
1021
|
+
if isinstance(lit_axis, int):
|
|
1022
|
+
flat_idx = lit_axis - 1
|
|
1023
|
+
elif isinstance(lit_axis, (list, tuple)) and len(lit_axis) == 2:
|
|
1024
|
+
try:
|
|
1025
|
+
row = int(lit_axis[0]) - 1
|
|
1026
|
+
col = int(lit_axis[1]) - 1
|
|
1027
|
+
if 0 <= row < rows and 0 <= col < cols:
|
|
1028
|
+
flat_idx = row * cols + col
|
|
1029
|
+
except Exception:
|
|
1030
|
+
return None
|
|
1031
|
+
else:
|
|
1032
|
+
try:
|
|
1033
|
+
flat_idx = int(axis_str) - 1
|
|
1034
|
+
except Exception:
|
|
1035
|
+
m = re.match(r"\(\s*(\d+)\s*,\s*(\d+)\s*\)", axis_str)
|
|
1036
|
+
if m:
|
|
1037
|
+
try:
|
|
1038
|
+
row = int(m.group(1)) - 1
|
|
1039
|
+
col = int(m.group(2)) - 1
|
|
1040
|
+
if 0 <= row < rows and 0 <= col < cols:
|
|
1041
|
+
flat_idx = row * cols + col
|
|
1042
|
+
except Exception:
|
|
1043
|
+
return None
|
|
1044
|
+
|
|
1045
|
+
if flat_idx is not None and 0 <= flat_idx < rows * cols:
|
|
1046
|
+
return flat_idx
|
|
1047
|
+
return None
|
|
1048
|
+
|
|
1049
|
+
# Parse hline entries: y, x1, x2, axis_spec or y, axis_spec
|
|
1050
|
+
# Format: y [, x1, x2], axis_spec
|
|
1051
|
+
def _parse_hline_with_axis(
|
|
1052
|
+
s: str, rows: int, cols: int, xmin_global: float, xmax_global: float
|
|
1053
|
+
):
|
|
1054
|
+
"""Parse 'y, [x1, x2,] axis_spec' and return (y, x1, x2, flat_idx) or None."""
|
|
1055
|
+
if not isinstance(s, str):
|
|
1056
|
+
return None
|
|
1057
|
+
s = s.strip()
|
|
1058
|
+
if not s:
|
|
1059
|
+
return None
|
|
1060
|
+
|
|
1061
|
+
# Split at top-level commas
|
|
1062
|
+
parts = []
|
|
1063
|
+
current = []
|
|
1064
|
+
depth = 0
|
|
1065
|
+
for ch in s:
|
|
1066
|
+
if ch in "([{":
|
|
1067
|
+
depth += 1
|
|
1068
|
+
current.append(ch)
|
|
1069
|
+
elif ch in ")]}":
|
|
1070
|
+
depth -= 1
|
|
1071
|
+
current.append(ch)
|
|
1072
|
+
elif ch == "," and depth == 0:
|
|
1073
|
+
parts.append("".join(current).strip())
|
|
1074
|
+
current = []
|
|
1075
|
+
else:
|
|
1076
|
+
current.append(ch)
|
|
1077
|
+
if current:
|
|
1078
|
+
parts.append("".join(current).strip())
|
|
1079
|
+
|
|
1080
|
+
if len(parts) < 2:
|
|
1081
|
+
return None
|
|
1082
|
+
|
|
1083
|
+
# Last part is always axis specifier
|
|
1084
|
+
axis_str = parts[-1]
|
|
1085
|
+
flat_idx = _parse_axis_spec(axis_str, rows, cols)
|
|
1086
|
+
if flat_idx is None:
|
|
1087
|
+
return None
|
|
1088
|
+
|
|
1089
|
+
# Parse y value (first part, supports expressions)
|
|
1090
|
+
try:
|
|
1091
|
+
y_val = _eval_expr_multiplot(parts[0])
|
|
1092
|
+
except Exception:
|
|
1093
|
+
return None
|
|
1094
|
+
|
|
1095
|
+
# Parse optional x1, x2
|
|
1096
|
+
x1_val, x2_val = None, None
|
|
1097
|
+
if len(parts) == 4: # y, x1, x2, axis_spec
|
|
1098
|
+
try:
|
|
1099
|
+
x1_val = _eval_expr_multiplot(parts[1])
|
|
1100
|
+
x2_val = _eval_expr_multiplot(parts[2])
|
|
1101
|
+
except Exception:
|
|
1102
|
+
pass
|
|
1103
|
+
elif (
|
|
1104
|
+
len(parts) == 3
|
|
1105
|
+
): # y, x1, axis_spec (treat as y, axis_spec if x1 looks like axis spec)
|
|
1106
|
+
# Check if parts[1] could be axis spec
|
|
1107
|
+
test_idx = _parse_axis_spec(parts[1], rows, cols)
|
|
1108
|
+
if test_idx is not None:
|
|
1109
|
+
# parts[1] is actually the axis spec, parts[2] is extra
|
|
1110
|
+
return None
|
|
1111
|
+
# Otherwise parts[1] might be x1 - but we need x2 too for it to make sense
|
|
1112
|
+
# So treat as malformed for now
|
|
1113
|
+
pass
|
|
1114
|
+
|
|
1115
|
+
# Use global xmin/xmax if x1, x2 not specified
|
|
1116
|
+
if x1_val is None:
|
|
1117
|
+
x1_val = xmin_global
|
|
1118
|
+
if x2_val is None:
|
|
1119
|
+
x2_val = xmax_global
|
|
1120
|
+
|
|
1121
|
+
return (y_val, x1_val, x2_val, flat_idx)
|
|
1122
|
+
|
|
1123
|
+
# Convert hline_vals from list of lists to dict for easy axis-specific updates
|
|
1124
|
+
hline_dict: Dict[int, List[Tuple[float, float | None, float | None]]] = {}
|
|
1125
|
+
for idx in range(len(hline_vals)):
|
|
1126
|
+
if hline_vals[idx] is not None:
|
|
1127
|
+
# Legacy hlines are just y values - convert to (y, None, None)
|
|
1128
|
+
hline_dict[idx] = [(y, None, None) for y in hline_vals[idx]]
|
|
1129
|
+
else:
|
|
1130
|
+
hline_dict[idx] = []
|
|
1131
|
+
|
|
1132
|
+
# Process hline entries
|
|
1133
|
+
for hl_entry in hline_entries_raw:
|
|
1134
|
+
parsed = _parse_hline_with_axis(hl_entry, rows, cols, xmin, xmax)
|
|
1135
|
+
if parsed:
|
|
1136
|
+
y_val, x1_val, x2_val, flat_idx = parsed
|
|
1137
|
+
if flat_idx not in hline_dict:
|
|
1138
|
+
hline_dict[flat_idx] = []
|
|
1139
|
+
hline_dict[flat_idx].append((y_val, x1_val, x2_val))
|
|
1140
|
+
|
|
1141
|
+
# Convert back to list format for plotting code
|
|
1142
|
+
for idx in range(len(hline_vals)):
|
|
1143
|
+
if idx in hline_dict and hline_dict[idx]:
|
|
1144
|
+
# Keep only y values for backward compatibility with existing plotting code
|
|
1145
|
+
# But we need to handle x1, x2 differently - let's store tuples
|
|
1146
|
+
hline_vals[idx] = hline_dict[idx]
|
|
1147
|
+
elif idx in hline_dict:
|
|
1148
|
+
hline_vals[idx] = None
|
|
1149
|
+
|
|
1150
|
+
# Parse new-style vline keyword with axis targeting
|
|
1151
|
+
# Gather all vline: entries from content
|
|
1152
|
+
vline_entries_raw = []
|
|
1153
|
+
if "vline" in merged:
|
|
1154
|
+
vline_entries_raw.append(merged["vline"])
|
|
1155
|
+
for line_idx, line in enumerate(self.content):
|
|
1156
|
+
m = re.match(r"^vline\s*:\s*(.+)$", line.strip())
|
|
1157
|
+
if m:
|
|
1158
|
+
vline_entries_raw.append(m.group(1))
|
|
1159
|
+
|
|
1160
|
+
# Parse vline entries: x, y1, y2, axis_spec or x, axis_spec
|
|
1161
|
+
# Format: x [, y1, y2], axis_spec
|
|
1162
|
+
def _parse_vline_with_axis(
|
|
1163
|
+
s: str, rows: int, cols: int, ymin_global: float, ymax_global: float
|
|
1164
|
+
):
|
|
1165
|
+
"""Parse 'x, [y1, y2,] axis_spec' and return (x, y1, y2, flat_idx) or None."""
|
|
1166
|
+
if not isinstance(s, str):
|
|
1167
|
+
return None
|
|
1168
|
+
s = s.strip()
|
|
1169
|
+
if not s:
|
|
1170
|
+
return None
|
|
1171
|
+
|
|
1172
|
+
# Split at top-level commas
|
|
1173
|
+
parts = []
|
|
1174
|
+
current = []
|
|
1175
|
+
depth = 0
|
|
1176
|
+
for ch in s:
|
|
1177
|
+
if ch in "([{":
|
|
1178
|
+
depth += 1
|
|
1179
|
+
current.append(ch)
|
|
1180
|
+
elif ch in ")]}":
|
|
1181
|
+
depth -= 1
|
|
1182
|
+
current.append(ch)
|
|
1183
|
+
elif ch == "," and depth == 0:
|
|
1184
|
+
parts.append("".join(current).strip())
|
|
1185
|
+
current = []
|
|
1186
|
+
else:
|
|
1187
|
+
current.append(ch)
|
|
1188
|
+
if current:
|
|
1189
|
+
parts.append("".join(current).strip())
|
|
1190
|
+
|
|
1191
|
+
if len(parts) < 2:
|
|
1192
|
+
return None
|
|
1193
|
+
|
|
1194
|
+
# Last part is always axis specifier
|
|
1195
|
+
axis_str = parts[-1]
|
|
1196
|
+
flat_idx = _parse_axis_spec(axis_str, rows, cols)
|
|
1197
|
+
if flat_idx is None:
|
|
1198
|
+
return None
|
|
1199
|
+
|
|
1200
|
+
# Parse x value (first part, supports expressions)
|
|
1201
|
+
try:
|
|
1202
|
+
x_val = _eval_expr_multiplot(parts[0])
|
|
1203
|
+
except Exception:
|
|
1204
|
+
return None
|
|
1205
|
+
|
|
1206
|
+
# Parse optional y1, y2
|
|
1207
|
+
y1_val, y2_val = None, None
|
|
1208
|
+
if len(parts) == 4: # x, y1, y2, axis_spec
|
|
1209
|
+
try:
|
|
1210
|
+
y1_val = _eval_expr_multiplot(parts[1])
|
|
1211
|
+
y2_val = _eval_expr_multiplot(parts[2])
|
|
1212
|
+
except Exception:
|
|
1213
|
+
pass
|
|
1214
|
+
elif (
|
|
1215
|
+
len(parts) == 3
|
|
1216
|
+
): # x, y1, axis_spec (treat as x, axis_spec if y1 looks like axis spec)
|
|
1217
|
+
# Check if parts[1] could be axis spec
|
|
1218
|
+
test_idx = _parse_axis_spec(parts[1], rows, cols)
|
|
1219
|
+
if test_idx is not None:
|
|
1220
|
+
# parts[1] is actually the axis spec, parts[2] is extra
|
|
1221
|
+
return None
|
|
1222
|
+
# Otherwise parts[1] might be y1 - but we need y2 too for it to make sense
|
|
1223
|
+
# So treat as malformed for now
|
|
1224
|
+
pass
|
|
1225
|
+
|
|
1226
|
+
# Use global ymin/ymax if y1, y2 not specified
|
|
1227
|
+
if y1_val is None:
|
|
1228
|
+
y1_val = ymin_global
|
|
1229
|
+
if y2_val is None:
|
|
1230
|
+
y2_val = ymax_global
|
|
1231
|
+
|
|
1232
|
+
return (x_val, y1_val, y2_val, flat_idx)
|
|
1233
|
+
|
|
1234
|
+
# Convert vline_vals from list of lists to dict for easy axis-specific updates
|
|
1235
|
+
vline_dict: Dict[int, List[Tuple[float, float | None, float | None]]] = {}
|
|
1236
|
+
for idx in range(len(vline_vals)):
|
|
1237
|
+
if vline_vals[idx] is not None:
|
|
1238
|
+
# Legacy vlines are just x values - convert to (x, None, None)
|
|
1239
|
+
vline_dict[idx] = [(x, None, None) for x in vline_vals[idx]]
|
|
1240
|
+
else:
|
|
1241
|
+
vline_dict[idx] = []
|
|
1242
|
+
|
|
1243
|
+
# Process vline entries
|
|
1244
|
+
for vl_entry in vline_entries_raw:
|
|
1245
|
+
parsed = _parse_vline_with_axis(vl_entry, rows, cols, ymin, ymax)
|
|
1246
|
+
if parsed:
|
|
1247
|
+
x_val, y1_val, y2_val, flat_idx = parsed
|
|
1248
|
+
if flat_idx not in vline_dict:
|
|
1249
|
+
vline_dict[flat_idx] = []
|
|
1250
|
+
vline_dict[flat_idx].append((x_val, y1_val, y2_val))
|
|
1251
|
+
|
|
1252
|
+
# Convert back to list format for plotting code
|
|
1253
|
+
for idx in range(len(vline_vals)):
|
|
1254
|
+
if idx in vline_dict and vline_dict[idx]:
|
|
1255
|
+
vline_vals[idx] = vline_dict[idx]
|
|
1256
|
+
elif idx in vline_dict:
|
|
1257
|
+
vline_vals[idx] = None
|
|
1258
|
+
|
|
1259
|
+
# Parse new-style line keyword with axis targeting
|
|
1260
|
+
# Gather all line: entries from content
|
|
1261
|
+
line_entries_raw = []
|
|
1262
|
+
if "line" in merged:
|
|
1263
|
+
line_entries_raw.append(merged["line"])
|
|
1264
|
+
for line_idx, line in enumerate(self.content):
|
|
1265
|
+
m = re.match(r"^line\s*:\s*(.+)$", line.strip())
|
|
1266
|
+
if m:
|
|
1267
|
+
line_entries_raw.append(m.group(1))
|
|
1268
|
+
|
|
1269
|
+
# Parse line entries: a, b, axis_spec or a, (x0, y0), axis_spec
|
|
1270
|
+
# Format: a, b, axis_spec -> y = a*x + b
|
|
1271
|
+
# a, (x0, y0), axis_spec -> y = y0 + a*(x - x0)
|
|
1272
|
+
def _parse_line_with_axis(s: str, rows: int, cols: int):
|
|
1273
|
+
"""Parse 'a, b, axis_spec' or 'a, (x0, y0), axis_spec' and return (a, b, flat_idx) or None."""
|
|
1274
|
+
if not isinstance(s, str):
|
|
1275
|
+
return None
|
|
1276
|
+
s = s.strip()
|
|
1277
|
+
if not s:
|
|
1278
|
+
return None
|
|
1279
|
+
|
|
1280
|
+
# Split at top-level commas
|
|
1281
|
+
parts = []
|
|
1282
|
+
current = []
|
|
1283
|
+
depth = 0
|
|
1284
|
+
for ch in s:
|
|
1285
|
+
if ch in "([{":
|
|
1286
|
+
depth += 1
|
|
1287
|
+
current.append(ch)
|
|
1288
|
+
elif ch in ")]}":
|
|
1289
|
+
depth -= 1
|
|
1290
|
+
current.append(ch)
|
|
1291
|
+
elif ch == "," and depth == 0:
|
|
1292
|
+
parts.append("".join(current).strip())
|
|
1293
|
+
current = []
|
|
1294
|
+
else:
|
|
1295
|
+
current.append(ch)
|
|
1296
|
+
if current:
|
|
1297
|
+
parts.append("".join(current).strip())
|
|
1298
|
+
|
|
1299
|
+
if len(parts) < 3:
|
|
1300
|
+
return None
|
|
1301
|
+
|
|
1302
|
+
# Last part is always axis specifier
|
|
1303
|
+
axis_str = parts[-1]
|
|
1304
|
+
flat_idx = _parse_axis_spec(axis_str, rows, cols)
|
|
1305
|
+
if flat_idx is None:
|
|
1306
|
+
return None
|
|
1307
|
+
|
|
1308
|
+
# Parse slope a (first part, supports expressions)
|
|
1309
|
+
try:
|
|
1310
|
+
a_val = _eval_expr_multiplot(parts[0])
|
|
1311
|
+
except Exception:
|
|
1312
|
+
return None
|
|
1313
|
+
|
|
1314
|
+
# Parse second part: either b or (x0, y0)
|
|
1315
|
+
second_part = parts[1].strip()
|
|
1316
|
+
b_val = None
|
|
1317
|
+
|
|
1318
|
+
# Check if it's a tuple (x0, y0)
|
|
1319
|
+
if second_part.startswith("(") and second_part.endswith(")"):
|
|
1320
|
+
# Extract x0, y0 from tuple
|
|
1321
|
+
inner = second_part[1:-1].strip()
|
|
1322
|
+
# Split on top-level comma
|
|
1323
|
+
coord_parts = []
|
|
1324
|
+
coord_current = []
|
|
1325
|
+
coord_depth = 0
|
|
1326
|
+
for ch in inner:
|
|
1327
|
+
if ch in "([{":
|
|
1328
|
+
coord_depth += 1
|
|
1329
|
+
coord_current.append(ch)
|
|
1330
|
+
elif ch in ")]}":
|
|
1331
|
+
coord_depth -= 1
|
|
1332
|
+
coord_current.append(ch)
|
|
1333
|
+
elif ch == "," and coord_depth == 0:
|
|
1334
|
+
coord_parts.append("".join(coord_current).strip())
|
|
1335
|
+
coord_current = []
|
|
1336
|
+
else:
|
|
1337
|
+
coord_current.append(ch)
|
|
1338
|
+
if coord_current:
|
|
1339
|
+
coord_parts.append("".join(coord_current).strip())
|
|
1340
|
+
|
|
1341
|
+
if len(coord_parts) == 2:
|
|
1342
|
+
try:
|
|
1343
|
+
x0_val = _eval_expr_multiplot(coord_parts[0])
|
|
1344
|
+
y0_val = _eval_expr_multiplot(coord_parts[1])
|
|
1345
|
+
# Convert to y = a*x + b form: y = y0 + a*(x - x0) => y = a*x + (y0 - a*x0)
|
|
1346
|
+
b_val = y0_val - a_val * x0_val
|
|
1347
|
+
except Exception:
|
|
1348
|
+
return None
|
|
1349
|
+
else:
|
|
1350
|
+
return None
|
|
1351
|
+
else:
|
|
1352
|
+
# It's just b
|
|
1353
|
+
try:
|
|
1354
|
+
b_val = _eval_expr_multiplot(second_part)
|
|
1355
|
+
except Exception:
|
|
1356
|
+
return None
|
|
1357
|
+
|
|
1358
|
+
if b_val is not None:
|
|
1359
|
+
return (a_val, b_val, flat_idx)
|
|
1360
|
+
return None
|
|
1361
|
+
|
|
1362
|
+
# Convert line_specs from list to dict for easy axis-specific updates
|
|
1363
|
+
# Now each axis can have multiple lines, so we store lists of tuples
|
|
1364
|
+
line_dict: Dict[int, List[Tuple[float, float]]] = {}
|
|
1365
|
+
for idx in range(len(line_specs)):
|
|
1366
|
+
if line_specs[idx] is not None:
|
|
1367
|
+
# Legacy single-line format: convert to list
|
|
1368
|
+
line_dict[idx] = [line_specs[idx]]
|
|
1369
|
+
else:
|
|
1370
|
+
line_dict[idx] = []
|
|
1371
|
+
|
|
1372
|
+
# Process line entries
|
|
1373
|
+
for ln_entry in line_entries_raw:
|
|
1374
|
+
parsed = _parse_line_with_axis(ln_entry, rows, cols)
|
|
1375
|
+
if parsed:
|
|
1376
|
+
a_val, b_val, flat_idx = parsed
|
|
1377
|
+
if flat_idx not in line_dict:
|
|
1378
|
+
line_dict[flat_idx] = []
|
|
1379
|
+
line_dict[flat_idx].append((a_val, b_val))
|
|
1380
|
+
|
|
1381
|
+
# Convert back to list format for plotting code
|
|
1382
|
+
for idx in range(len(line_specs)):
|
|
1383
|
+
if idx in line_dict and line_dict[idx]:
|
|
1384
|
+
# Store list of lines
|
|
1385
|
+
line_specs[idx] = line_dict[idx]
|
|
1386
|
+
else:
|
|
1387
|
+
# No lines for this axis
|
|
1388
|
+
line_specs[idx] = None
|
|
1389
|
+
|
|
1390
|
+
# Process tangent keyword: tangent: x0, function_label, axis_spec
|
|
1391
|
+
tangent_entries_raw = []
|
|
1392
|
+
if "tangent" in merged:
|
|
1393
|
+
tangent_entries_raw.append(merged["tangent"])
|
|
1394
|
+
for line in self.content:
|
|
1395
|
+
m = re.match(r"^tangent\s*:\s*(.+)$", line.strip())
|
|
1396
|
+
if m:
|
|
1397
|
+
tangent_entries_raw.append(m.group(1))
|
|
1398
|
+
|
|
1399
|
+
def _parse_tangent_with_axis(s: str, rows: int, cols: int):
|
|
1400
|
+
"""
|
|
1401
|
+
Parse: x0, function_label, axis_spec
|
|
1402
|
+
Returns (x0_val, func_label, flat_idx) or None
|
|
1403
|
+
"""
|
|
1404
|
+
import sympy
|
|
1405
|
+
|
|
1406
|
+
# Split by commas at top level
|
|
1407
|
+
parts = _split_top_level(s)
|
|
1408
|
+
if len(parts) < 3:
|
|
1409
|
+
return None
|
|
1410
|
+
|
|
1411
|
+
# Last part is axis_spec
|
|
1412
|
+
axis_part = parts[-1].strip()
|
|
1413
|
+
flat_idx = _parse_axis_spec(axis_part, rows, cols)
|
|
1414
|
+
if flat_idx is None:
|
|
1415
|
+
return None
|
|
1416
|
+
|
|
1417
|
+
# Second-to-last is function label
|
|
1418
|
+
func_label = parts[-2].strip()
|
|
1419
|
+
|
|
1420
|
+
# Everything before second-to-last is x0 expression
|
|
1421
|
+
x0_str = ",".join(parts[:-2]).strip()
|
|
1422
|
+
|
|
1423
|
+
# Evaluate x0 using expression evaluator
|
|
1424
|
+
try:
|
|
1425
|
+
x0_val = _eval_expr_multiplot(x0_str)
|
|
1426
|
+
except Exception:
|
|
1427
|
+
return None
|
|
1428
|
+
|
|
1429
|
+
return (x0_val, func_label, flat_idx)
|
|
1430
|
+
|
|
1431
|
+
# Process tangent entries and add to line_specs
|
|
1432
|
+
for tg_entry in tangent_entries_raw:
|
|
1433
|
+
parsed = _parse_tangent_with_axis(tg_entry, rows, cols)
|
|
1434
|
+
if parsed:
|
|
1435
|
+
x0_val, func_label, flat_idx = parsed
|
|
1436
|
+
|
|
1437
|
+
# Find the function index matching the label
|
|
1438
|
+
func_idx = None
|
|
1439
|
+
if labels_list:
|
|
1440
|
+
for i, lbl in enumerate(labels_list):
|
|
1441
|
+
if lbl.strip() == func_label:
|
|
1442
|
+
func_idx = i
|
|
1443
|
+
break
|
|
1444
|
+
|
|
1445
|
+
if func_idx is not None and func_idx < len(functions):
|
|
1446
|
+
# Compute tangent line: y = f'(x0) * (x - x0) + f(x0)
|
|
1447
|
+
# Which is: y = f'(x0) * x + (f(x0) - x0 * f'(x0))
|
|
1448
|
+
import sympy
|
|
1449
|
+
|
|
1450
|
+
x = sympy.symbols("x")
|
|
1451
|
+
try:
|
|
1452
|
+
expr_str = exprs[func_idx] # Use expression string, not compiled function
|
|
1453
|
+
sym = sympy.sympify(expr_str)
|
|
1454
|
+
sym_deriv = sympy.diff(sym, x)
|
|
1455
|
+
|
|
1456
|
+
# Evaluate f(x0) and f'(x0)
|
|
1457
|
+
f_x0 = float(sym.subs(x, x0_val))
|
|
1458
|
+
fp_x0 = float(sym_deriv.subs(x, x0_val))
|
|
1459
|
+
|
|
1460
|
+
# Tangent line: y = fp_x0 * x + (f_x0 - x0_val * fp_x0)
|
|
1461
|
+
a_tangent = fp_x0
|
|
1462
|
+
b_tangent = f_x0 - x0_val * fp_x0
|
|
1463
|
+
|
|
1464
|
+
# Add to line_specs (append to existing list)
|
|
1465
|
+
if line_specs[flat_idx] is None:
|
|
1466
|
+
line_specs[flat_idx] = []
|
|
1467
|
+
if isinstance(line_specs[flat_idx], tuple):
|
|
1468
|
+
# Convert legacy single-line format to list
|
|
1469
|
+
line_specs[flat_idx] = [line_specs[flat_idx]]
|
|
1470
|
+
line_specs[flat_idx].append((a_tangent, b_tangent))
|
|
1471
|
+
except Exception:
|
|
1472
|
+
pass
|
|
1473
|
+
|
|
1474
|
+
# Process per-axis xmin/xmax/ymin/ymax keywords
|
|
1475
|
+
# These can be specified with or without axis targeting
|
|
1476
|
+
# Format: xmin: value, axis_spec OR xmin: value (applies to all)
|
|
1477
|
+
|
|
1478
|
+
def _parse_limit_with_axis(s: str, rows: int, cols: int):
|
|
1479
|
+
"""
|
|
1480
|
+
Parse: value, axis_spec OR just value (applies to all)
|
|
1481
|
+
Returns (value, flat_idx) or (value, None) for all axes
|
|
1482
|
+
"""
|
|
1483
|
+
parts = _split_top_level(s)
|
|
1484
|
+
if len(parts) == 0:
|
|
1485
|
+
return None
|
|
1486
|
+
|
|
1487
|
+
if len(parts) == 1:
|
|
1488
|
+
# Just a value, applies to all axes
|
|
1489
|
+
try:
|
|
1490
|
+
val = _eval_expr_multiplot(parts[0])
|
|
1491
|
+
return (val, None) # None means apply to all
|
|
1492
|
+
except Exception:
|
|
1493
|
+
return None
|
|
1494
|
+
|
|
1495
|
+
# Two or more parts: value, axis_spec
|
|
1496
|
+
axis_part = parts[-1].strip()
|
|
1497
|
+
flat_idx = _parse_axis_spec(axis_part, rows, cols)
|
|
1498
|
+
if flat_idx is None:
|
|
1499
|
+
return None
|
|
1500
|
+
|
|
1501
|
+
# Everything before last part is the value
|
|
1502
|
+
val_str = ",".join(parts[:-1]).strip()
|
|
1503
|
+
try:
|
|
1504
|
+
val = _eval_expr_multiplot(val_str)
|
|
1505
|
+
return (val, flat_idx)
|
|
1506
|
+
except Exception:
|
|
1507
|
+
return None
|
|
1508
|
+
|
|
1509
|
+
# Gather xmin entries
|
|
1510
|
+
xmin_entries_raw = []
|
|
1511
|
+
if "xmin" in merged and merged["xmin"]:
|
|
1512
|
+
xmin_entries_raw.append(merged["xmin"])
|
|
1513
|
+
for line in self.content:
|
|
1514
|
+
m = re.match(r"^xmin\s*:\s*(.+)$", line.strip())
|
|
1515
|
+
if m:
|
|
1516
|
+
xmin_entries_raw.append(m.group(1))
|
|
1517
|
+
|
|
1518
|
+
# Gather xmax entries
|
|
1519
|
+
xmax_entries_raw = []
|
|
1520
|
+
if "xmax" in merged and merged["xmax"]:
|
|
1521
|
+
xmax_entries_raw.append(merged["xmax"])
|
|
1522
|
+
for line in self.content:
|
|
1523
|
+
m = re.match(r"^xmax\s*:\s*(.+)$", line.strip())
|
|
1524
|
+
if m:
|
|
1525
|
+
xmax_entries_raw.append(m.group(1))
|
|
1526
|
+
|
|
1527
|
+
# Gather ymin entries
|
|
1528
|
+
ymin_entries_raw = []
|
|
1529
|
+
if "ymin" in merged and merged["ymin"]:
|
|
1530
|
+
ymin_entries_raw.append(merged["ymin"])
|
|
1531
|
+
for line in self.content:
|
|
1532
|
+
m = re.match(r"^ymin\s*:\s*(.+)$", line.strip())
|
|
1533
|
+
if m:
|
|
1534
|
+
ymin_entries_raw.append(m.group(1))
|
|
1535
|
+
|
|
1536
|
+
# Gather ymax entries
|
|
1537
|
+
ymax_entries_raw = []
|
|
1538
|
+
if "ymax" in merged and merged["ymax"]:
|
|
1539
|
+
ymax_entries_raw.append(merged["ymax"])
|
|
1540
|
+
for line in self.content:
|
|
1541
|
+
m = re.match(r"^ymax\s*:\s*(.+)$", line.strip())
|
|
1542
|
+
if m:
|
|
1543
|
+
ymax_entries_raw.append(m.group(1))
|
|
1544
|
+
|
|
1545
|
+
# Process xmin entries
|
|
1546
|
+
for xmin_entry in xmin_entries_raw:
|
|
1547
|
+
parsed = _parse_limit_with_axis(xmin_entry, rows, cols)
|
|
1548
|
+
if parsed:
|
|
1549
|
+
val, flat_idx = parsed
|
|
1550
|
+
if flat_idx is None:
|
|
1551
|
+
# Apply to all axes
|
|
1552
|
+
for idx in range(len(xlim_vals)):
|
|
1553
|
+
if xlim_vals[idx] is None:
|
|
1554
|
+
xlim_vals[idx] = (val, xmax)
|
|
1555
|
+
else:
|
|
1556
|
+
xlim_vals[idx] = (val, xlim_vals[idx][1])
|
|
1557
|
+
else:
|
|
1558
|
+
# Apply to specific axis
|
|
1559
|
+
if xlim_vals[flat_idx] is None:
|
|
1560
|
+
xlim_vals[flat_idx] = (val, xmax)
|
|
1561
|
+
else:
|
|
1562
|
+
xlim_vals[flat_idx] = (val, xlim_vals[flat_idx][1])
|
|
1563
|
+
|
|
1564
|
+
# Process xmax entries
|
|
1565
|
+
for xmax_entry in xmax_entries_raw:
|
|
1566
|
+
parsed = _parse_limit_with_axis(xmax_entry, rows, cols)
|
|
1567
|
+
if parsed:
|
|
1568
|
+
val, flat_idx = parsed
|
|
1569
|
+
if flat_idx is None:
|
|
1570
|
+
# Apply to all axes
|
|
1571
|
+
for idx in range(len(xlim_vals)):
|
|
1572
|
+
if xlim_vals[idx] is None:
|
|
1573
|
+
xlim_vals[idx] = (xmin, val)
|
|
1574
|
+
else:
|
|
1575
|
+
xlim_vals[idx] = (xlim_vals[idx][0], val)
|
|
1576
|
+
else:
|
|
1577
|
+
# Apply to specific axis
|
|
1578
|
+
if xlim_vals[flat_idx] is None:
|
|
1579
|
+
xlim_vals[flat_idx] = (xmin, val)
|
|
1580
|
+
else:
|
|
1581
|
+
xlim_vals[flat_idx] = (xlim_vals[flat_idx][0], val)
|
|
1582
|
+
|
|
1583
|
+
# Process ymin entries
|
|
1584
|
+
for ymin_entry in ymin_entries_raw:
|
|
1585
|
+
parsed = _parse_limit_with_axis(ymin_entry, rows, cols)
|
|
1586
|
+
if parsed:
|
|
1587
|
+
val, flat_idx = parsed
|
|
1588
|
+
if flat_idx is None:
|
|
1589
|
+
# Apply to all axes
|
|
1590
|
+
for idx in range(len(ylim_vals)):
|
|
1591
|
+
if ylim_vals[idx] is None:
|
|
1592
|
+
ylim_vals[idx] = (val, ymax)
|
|
1593
|
+
else:
|
|
1594
|
+
ylim_vals[idx] = (val, ylim_vals[idx][1])
|
|
1595
|
+
else:
|
|
1596
|
+
# Apply to specific axis
|
|
1597
|
+
if ylim_vals[flat_idx] is None:
|
|
1598
|
+
ylim_vals[flat_idx] = (val, ymax)
|
|
1599
|
+
else:
|
|
1600
|
+
ylim_vals[flat_idx] = (val, ylim_vals[flat_idx][1])
|
|
1601
|
+
|
|
1602
|
+
# Process ymax entries
|
|
1603
|
+
for ymax_entry in ymax_entries_raw:
|
|
1604
|
+
parsed = _parse_limit_with_axis(ymax_entry, rows, cols)
|
|
1605
|
+
if parsed:
|
|
1606
|
+
val, flat_idx = parsed
|
|
1607
|
+
if flat_idx is None:
|
|
1608
|
+
# Apply to all axes
|
|
1609
|
+
for idx in range(len(ylim_vals)):
|
|
1610
|
+
if ylim_vals[idx] is None:
|
|
1611
|
+
ylim_vals[idx] = (ymin, val)
|
|
1612
|
+
else:
|
|
1613
|
+
ylim_vals[idx] = (ylim_vals[idx][0], val)
|
|
1614
|
+
else:
|
|
1615
|
+
# Apply to specific axis
|
|
1616
|
+
if ylim_vals[flat_idx] is None:
|
|
1617
|
+
ylim_vals[flat_idx] = (ymin, val)
|
|
1618
|
+
else:
|
|
1619
|
+
ylim_vals[flat_idx] = (ylim_vals[flat_idx][0], val)
|
|
1620
|
+
|
|
1621
|
+
# Include per-axis settings in the hash to prevent stale caches
|
|
1622
|
+
|
|
1623
|
+
# ─────────────────────────────────────────────────────────────
|
|
1624
|
+
# Helper for parsing text positioning (copied from plot.py)
|
|
1625
|
+
# ─────────────────────────────────────────────────────────────
|
|
1626
|
+
def _parse_text_positioning(pos: str) -> Tuple[str, str]:
|
|
1627
|
+
"""Map positioning string to (va, ha). Default is (top, left)."""
|
|
1628
|
+
if not isinstance(pos, str):
|
|
1629
|
+
return ("top", "left")
|
|
1630
|
+
key = pos.strip().lower().replace("_", "-")
|
|
1631
|
+
mapping = {
|
|
1632
|
+
"top-left": ("bottom", "right"),
|
|
1633
|
+
"top-right": ("bottom", "left"),
|
|
1634
|
+
"bottom-left": ("top", "right"),
|
|
1635
|
+
"bottom-right": ("top", "left"),
|
|
1636
|
+
"top-center": ("bottom", "center"),
|
|
1637
|
+
"bottom-center": ("top", "center"),
|
|
1638
|
+
"center-left": ("center", "right"),
|
|
1639
|
+
"center-right": ("center", "left"),
|
|
1640
|
+
"longtop-left": ("longbottom", "left"),
|
|
1641
|
+
"longtop-longleft": ("longbottom", "longright"),
|
|
1642
|
+
"longbottom-right": ("longtop", "right"),
|
|
1643
|
+
"longbottom-left": ("longtop", "left"),
|
|
1644
|
+
"longtop-center": ("longbottom", "center"),
|
|
1645
|
+
"longbottom-center": ("longtop", "center"),
|
|
1646
|
+
"longtop-longright": ("longbottom", "longleft"),
|
|
1647
|
+
"longbottom-longright": ("longtop", "longleft"),
|
|
1648
|
+
"longtop-longleft": ("longbottom", "longright"),
|
|
1649
|
+
"longbottom-longleft": ("longtop", "longright"),
|
|
1650
|
+
"top-longleft": ("bottom", "longright"),
|
|
1651
|
+
"top-longright": ("bottom", "longleft"),
|
|
1652
|
+
"bottom-longleft": ("top", "longright"),
|
|
1653
|
+
"bottom-longright": ("top", "longleft"),
|
|
1654
|
+
"center-longleft": ("center", "longright"),
|
|
1655
|
+
"center-longright": ("center", "longleft"),
|
|
1656
|
+
"center-center": ("center", "center"),
|
|
1657
|
+
}
|
|
1658
|
+
return mapping.get(key, ("top", "left"))
|
|
1659
|
+
|
|
1660
|
+
# ─────────────────────────────────────────────────────────────
|
|
1661
|
+
# Helper for parsing text annotation with axis specifier
|
|
1662
|
+
# ─────────────────────────────────────────────────────────────
|
|
1663
|
+
def _parse_text_with_axis(
|
|
1664
|
+
entry: str, rows: int, cols: int
|
|
1665
|
+
) -> Tuple[float, float, str, str, int | None] | None:
|
|
1666
|
+
"""
|
|
1667
|
+
Parse: x, y, "text"[, placement], axis_spec
|
|
1668
|
+
Returns: (x_val, y_val, text_str, placement_str, flat_idx) or None
|
|
1669
|
+
If axis_spec is omitted, flat_idx is None (applies to all axes).
|
|
1670
|
+
"""
|
|
1671
|
+
parts = _split_top_level(entry)
|
|
1672
|
+
if len(parts) < 3:
|
|
1673
|
+
return None
|
|
1674
|
+
|
|
1675
|
+
# Parse x and y as expressions
|
|
1676
|
+
try:
|
|
1677
|
+
x_val = _eval_expr_multiplot(parts[0].strip())
|
|
1678
|
+
y_val = _eval_expr_multiplot(parts[1].strip())
|
|
1679
|
+
except Exception:
|
|
1680
|
+
return None
|
|
1681
|
+
|
|
1682
|
+
# Parse text string (may be quoted)
|
|
1683
|
+
text_raw = parts[2].strip()
|
|
1684
|
+
text_str = text_raw.strip('"').strip("'")
|
|
1685
|
+
|
|
1686
|
+
# Determine placement and axis_spec based on number of parts
|
|
1687
|
+
placement_str = "top-left" # default
|
|
1688
|
+
axis_spec_raw = None
|
|
1689
|
+
|
|
1690
|
+
if len(parts) == 3:
|
|
1691
|
+
# No placement or axis_spec
|
|
1692
|
+
flat_idx = None
|
|
1693
|
+
elif len(parts) == 4:
|
|
1694
|
+
# Either placement or axis_spec
|
|
1695
|
+
candidate = parts[3].strip()
|
|
1696
|
+
# Check if it's a valid placement token
|
|
1697
|
+
pos_keys = {
|
|
1698
|
+
"top-left",
|
|
1699
|
+
"top-right",
|
|
1700
|
+
"bottom-left",
|
|
1701
|
+
"bottom-right",
|
|
1702
|
+
"top-center",
|
|
1703
|
+
"bottom-center",
|
|
1704
|
+
"center-left",
|
|
1705
|
+
"center-right",
|
|
1706
|
+
"center-center",
|
|
1707
|
+
"longtop-left",
|
|
1708
|
+
"longtop-longleft",
|
|
1709
|
+
"longbottom-right",
|
|
1710
|
+
"longbottom-left",
|
|
1711
|
+
"longtop-center",
|
|
1712
|
+
"longbottom-center",
|
|
1713
|
+
"longtop-longright",
|
|
1714
|
+
"longbottom-longright",
|
|
1715
|
+
"top-longleft",
|
|
1716
|
+
"top-longright",
|
|
1717
|
+
"bottom-longleft",
|
|
1718
|
+
"bottom-longright",
|
|
1719
|
+
"center-longleft",
|
|
1720
|
+
"center-longright",
|
|
1721
|
+
}
|
|
1722
|
+
if candidate.lower().replace("_", "-") in pos_keys:
|
|
1723
|
+
placement_str = candidate
|
|
1724
|
+
flat_idx = None
|
|
1725
|
+
else:
|
|
1726
|
+
# Treat as axis_spec
|
|
1727
|
+
axis_spec_raw = candidate
|
|
1728
|
+
parsed_axis = _parse_axis_spec(axis_spec_raw, rows, cols)
|
|
1729
|
+
flat_idx = parsed_axis if parsed_axis is not None else None
|
|
1730
|
+
elif len(parts) == 5:
|
|
1731
|
+
# Both placement and axis_spec
|
|
1732
|
+
placement_str = parts[3].strip()
|
|
1733
|
+
axis_spec_raw = parts[4].strip()
|
|
1734
|
+
parsed_axis = _parse_axis_spec(axis_spec_raw, rows, cols)
|
|
1735
|
+
flat_idx = parsed_axis if parsed_axis is not None else None
|
|
1736
|
+
else:
|
|
1737
|
+
return None
|
|
1738
|
+
|
|
1739
|
+
return (x_val, y_val, text_str, placement_str, flat_idx)
|
|
1740
|
+
|
|
1741
|
+
# ─────────────────────────────────────────────────────────────
|
|
1742
|
+
# Parse text annotations
|
|
1743
|
+
# ─────────────────────────────────────────────────────────────
|
|
1744
|
+
# Format: text: x, y, "text"[, placement], axis_spec
|
|
1745
|
+
# Storage: per-axis dictionary of lists of (x, y, text, placement) tuples
|
|
1746
|
+
text_dict: Dict[int, List[Tuple[float, float, str, str]]] = {}
|
|
1747
|
+
|
|
1748
|
+
text_entries_raw = []
|
|
1749
|
+
# From option
|
|
1750
|
+
text_opt = self.options.get("text", "").strip()
|
|
1751
|
+
if text_opt:
|
|
1752
|
+
text_entries_raw.append(text_opt)
|
|
1753
|
+
# From content
|
|
1754
|
+
for line in self.content:
|
|
1755
|
+
m = re.match(r"^text\s*:\s*(.+)$", line.strip())
|
|
1756
|
+
if m:
|
|
1757
|
+
text_entries_raw.append(m.group(1))
|
|
1758
|
+
|
|
1759
|
+
for text_entry in text_entries_raw:
|
|
1760
|
+
parsed = _parse_text_with_axis(text_entry, rows, cols)
|
|
1761
|
+
if parsed:
|
|
1762
|
+
x_val, y_val, text_str, placement_str, flat_idx = parsed
|
|
1763
|
+
if flat_idx is None:
|
|
1764
|
+
# Apply to all axes
|
|
1765
|
+
for idx in range(rows * cols):
|
|
1766
|
+
if idx not in text_dict:
|
|
1767
|
+
text_dict[idx] = []
|
|
1768
|
+
text_dict[idx].append((x_val, y_val, text_str, placement_str))
|
|
1769
|
+
else:
|
|
1770
|
+
# Apply to specific axis
|
|
1771
|
+
if flat_idx not in text_dict:
|
|
1772
|
+
text_dict[flat_idx] = []
|
|
1773
|
+
text_dict[flat_idx].append((x_val, y_val, text_str, placement_str))
|
|
1774
|
+
|
|
1775
|
+
# ─────────────────────────────────────────────────────────────
|
|
1776
|
+
# Helper for parsing annotate with axis specifier
|
|
1777
|
+
# ─────────────────────────────────────────────────────────────
|
|
1778
|
+
def _parse_annotate_with_axis(
|
|
1779
|
+
entry: str, rows: int, cols: int
|
|
1780
|
+
) -> Tuple[Tuple[float, float], Tuple[float, float], str, float, int | None] | None:
|
|
1781
|
+
"""
|
|
1782
|
+
Parse: (x_text, y_text), (x_target, y_target), "text"[, arc], axis_spec
|
|
1783
|
+
Returns: ((x_text, y_text), (x_target, y_target), text_str, arc_val, flat_idx) or None
|
|
1784
|
+
If axis_spec is omitted, flat_idx is None (applies to all axes).
|
|
1785
|
+
"""
|
|
1786
|
+
s = entry.strip()
|
|
1787
|
+
|
|
1788
|
+
# Find first two balanced tuples
|
|
1789
|
+
def _grab_tuple(start_index: int) -> Tuple[int, int, str] | None:
|
|
1790
|
+
if start_index >= len(s) or s[start_index] != "(":
|
|
1791
|
+
return None
|
|
1792
|
+
depth = 0
|
|
1793
|
+
for j in range(start_index, len(s)):
|
|
1794
|
+
if s[j] == "(":
|
|
1795
|
+
depth += 1
|
|
1796
|
+
elif s[j] == ")":
|
|
1797
|
+
depth -= 1
|
|
1798
|
+
if depth == 0:
|
|
1799
|
+
inner = s[start_index + 1 : j]
|
|
1800
|
+
return (start_index, j, inner)
|
|
1801
|
+
return None
|
|
1802
|
+
|
|
1803
|
+
# Locate first '('
|
|
1804
|
+
i1 = s.find("(")
|
|
1805
|
+
if i1 == -1:
|
|
1806
|
+
return None
|
|
1807
|
+
t1 = _grab_tuple(i1)
|
|
1808
|
+
if not t1:
|
|
1809
|
+
return None
|
|
1810
|
+
i2_search = t1[1] + 1
|
|
1811
|
+
# Skip commas/space
|
|
1812
|
+
while i2_search < len(s) and s[i2_search] in " ,":
|
|
1813
|
+
i2_search += 1
|
|
1814
|
+
if i2_search >= len(s) or s[i2_search] != "(":
|
|
1815
|
+
return None
|
|
1816
|
+
t2 = _grab_tuple(i2_search)
|
|
1817
|
+
if not t2:
|
|
1818
|
+
return None
|
|
1819
|
+
rest = s[t2[1] + 1 :].strip()
|
|
1820
|
+
|
|
1821
|
+
# Split tuple inners on top-level comma
|
|
1822
|
+
def _split_pair(inner: str) -> Tuple[str, str] | None:
|
|
1823
|
+
depth = 0
|
|
1824
|
+
for k, ch in enumerate(inner):
|
|
1825
|
+
if ch == "(":
|
|
1826
|
+
depth += 1
|
|
1827
|
+
elif ch == ")":
|
|
1828
|
+
depth -= 1
|
|
1829
|
+
elif ch == "," and depth == 0:
|
|
1830
|
+
left = inner[:k].strip()
|
|
1831
|
+
right = inner[k + 1 :].strip()
|
|
1832
|
+
if left and right:
|
|
1833
|
+
return (left, right)
|
|
1834
|
+
return None
|
|
1835
|
+
|
|
1836
|
+
p1 = _split_pair(t1[2])
|
|
1837
|
+
p2 = _split_pair(t2[2])
|
|
1838
|
+
if not (p1 and p2):
|
|
1839
|
+
return None
|
|
1840
|
+
|
|
1841
|
+
# Extract quoted text
|
|
1842
|
+
import re
|
|
1843
|
+
|
|
1844
|
+
m_txt = re.search(
|
|
1845
|
+
r"\"([^\"\\]*(?:\\.[^\"\\]*)*)\"|\'([^\'\\]*(?:\\.[^\'\\]*)*)\'",
|
|
1846
|
+
rest,
|
|
1847
|
+
)
|
|
1848
|
+
if not m_txt:
|
|
1849
|
+
return None
|
|
1850
|
+
text_str = m_txt.group(1) if m_txt.group(1) is not None else m_txt.group(2)
|
|
1851
|
+
after = rest[m_txt.end() :].strip().lstrip(",").strip()
|
|
1852
|
+
|
|
1853
|
+
# Parse remaining parts: [arc,] axis_spec
|
|
1854
|
+
remaining_parts = _split_top_level(after) if after else []
|
|
1855
|
+
arc_val = 0.3 # default
|
|
1856
|
+
flat_idx = None
|
|
1857
|
+
|
|
1858
|
+
if len(remaining_parts) == 0:
|
|
1859
|
+
# No arc or axis_spec
|
|
1860
|
+
pass
|
|
1861
|
+
elif len(remaining_parts) == 1:
|
|
1862
|
+
# Could be arc or axis_spec
|
|
1863
|
+
candidate = remaining_parts[0]
|
|
1864
|
+
# Try to parse as axis_spec first
|
|
1865
|
+
parsed_axis = _parse_axis_spec(candidate, rows, cols)
|
|
1866
|
+
if parsed_axis is not None:
|
|
1867
|
+
flat_idx = parsed_axis
|
|
1868
|
+
else:
|
|
1869
|
+
# Try as arc value
|
|
1870
|
+
try:
|
|
1871
|
+
arc_val = _eval_expr_multiplot(candidate)
|
|
1872
|
+
except Exception:
|
|
1873
|
+
pass
|
|
1874
|
+
elif len(remaining_parts) >= 2:
|
|
1875
|
+
# First is arc, second is axis_spec
|
|
1876
|
+
try:
|
|
1877
|
+
arc_val = _eval_expr_multiplot(remaining_parts[0])
|
|
1878
|
+
except Exception:
|
|
1879
|
+
pass
|
|
1880
|
+
parsed_axis = _parse_axis_spec(remaining_parts[1], rows, cols)
|
|
1881
|
+
if parsed_axis is not None:
|
|
1882
|
+
flat_idx = parsed_axis
|
|
1883
|
+
|
|
1884
|
+
# Evaluate coordinates
|
|
1885
|
+
try:
|
|
1886
|
+
x_text = _eval_expr_multiplot(p1[0])
|
|
1887
|
+
y_text = _eval_expr_multiplot(p1[1])
|
|
1888
|
+
x_target = _eval_expr_multiplot(p2[0])
|
|
1889
|
+
y_target = _eval_expr_multiplot(p2[1])
|
|
1890
|
+
except Exception:
|
|
1891
|
+
return None
|
|
1892
|
+
|
|
1893
|
+
return ((x_text, y_text), (x_target, y_target), text_str, arc_val, flat_idx)
|
|
1894
|
+
|
|
1895
|
+
# ─────────────────────────────────────────────────────────────
|
|
1896
|
+
# Parse annotate annotations
|
|
1897
|
+
# ─────────────────────────────────────────────────────────────
|
|
1898
|
+
# Format: annotate: (x_text, y_text), (x_target, y_target), "text"[, arc], axis_spec
|
|
1899
|
+
# Storage: per-axis dictionary of lists of ((xytext), (xy), text, arc) tuples
|
|
1900
|
+
annotate_dict: Dict[
|
|
1901
|
+
int, List[Tuple[Tuple[float, float], Tuple[float, float], str, float]]
|
|
1902
|
+
] = {}
|
|
1903
|
+
|
|
1904
|
+
annotate_entries_raw = []
|
|
1905
|
+
# From option
|
|
1906
|
+
annotate_opt = self.options.get("annotate", "").strip()
|
|
1907
|
+
if annotate_opt:
|
|
1908
|
+
annotate_entries_raw.append(annotate_opt)
|
|
1909
|
+
# From content
|
|
1910
|
+
for line in self.content:
|
|
1911
|
+
m = re.match(r"^annotate\s*:\s*(.+)$", line.strip())
|
|
1912
|
+
if m:
|
|
1913
|
+
annotate_entries_raw.append(m.group(1))
|
|
1914
|
+
|
|
1915
|
+
for annotate_entry in annotate_entries_raw:
|
|
1916
|
+
parsed = _parse_annotate_with_axis(annotate_entry, rows, cols)
|
|
1917
|
+
if parsed:
|
|
1918
|
+
xytext, xy, text_str, arc_val, flat_idx = parsed
|
|
1919
|
+
if flat_idx is None:
|
|
1920
|
+
# Apply to all axes
|
|
1921
|
+
for idx in range(rows * cols):
|
|
1922
|
+
if idx not in annotate_dict:
|
|
1923
|
+
annotate_dict[idx] = []
|
|
1924
|
+
annotate_dict[idx].append((xytext, xy, text_str, arc_val))
|
|
1925
|
+
else:
|
|
1926
|
+
# Apply to specific axis
|
|
1927
|
+
if flat_idx not in annotate_dict:
|
|
1928
|
+
annotate_dict[flat_idx] = []
|
|
1929
|
+
annotate_dict[flat_idx].append((xytext, xy, text_str, arc_val))
|
|
1930
|
+
|
|
1931
|
+
# Include per-axis settings in the hash to prevent stale caches
|
|
1932
|
+
content_hash = _hash_key(
|
|
1933
|
+
"|".join(exprs),
|
|
1934
|
+
"|".join(labels_list),
|
|
1935
|
+
xmin,
|
|
1936
|
+
xmax,
|
|
1937
|
+
ymin,
|
|
1938
|
+
ymax,
|
|
1939
|
+
xstep,
|
|
1940
|
+
ystep,
|
|
1941
|
+
fontsize,
|
|
1942
|
+
lw,
|
|
1943
|
+
alpha,
|
|
1944
|
+
rows,
|
|
1945
|
+
cols,
|
|
1946
|
+
int(grid_flag),
|
|
1947
|
+
int(ticks_flag),
|
|
1948
|
+
"|".join(["" if d is None else f"{d[0]},{d[1]}" for d in dom_list]),
|
|
1949
|
+
"|".join(["|".join(map(str, exs)) if exs else "" for exs in excl_list]),
|
|
1950
|
+
"|".join(["|".join(map(str, vs)) if vs else "" for vs in vline_vals]),
|
|
1951
|
+
"|".join(["|".join(map(str, hs)) if hs else "" for hs in hline_vals]),
|
|
1952
|
+
"|".join(["" if xl is None else f"{xl[0]},{xl[1]}" for xl in xlim_vals]),
|
|
1953
|
+
"|".join(["" if yl is None else f"{yl[0]},{yl[1]}" for yl in ylim_vals]),
|
|
1954
|
+
"|".join(
|
|
1955
|
+
[
|
|
1956
|
+
(
|
|
1957
|
+
""
|
|
1958
|
+
if ls is None
|
|
1959
|
+
else (
|
|
1960
|
+
";".join([f"{a},{b}" for a, b in ls])
|
|
1961
|
+
if isinstance(ls, list)
|
|
1962
|
+
else f"{ls[0]},{ls[1]}"
|
|
1963
|
+
)
|
|
1964
|
+
)
|
|
1965
|
+
for ls in line_specs
|
|
1966
|
+
]
|
|
1967
|
+
),
|
|
1968
|
+
"|".join(
|
|
1969
|
+
[
|
|
1970
|
+
"" if pv is None else ";".join([f"{p[0]},{p[1]}" for p in pv])
|
|
1971
|
+
for pv in points_vals
|
|
1972
|
+
]
|
|
1973
|
+
),
|
|
1974
|
+
"|".join(
|
|
1975
|
+
[
|
|
1976
|
+
(
|
|
1977
|
+
""
|
|
1978
|
+
if idx not in text_dict
|
|
1979
|
+
else ";".join([f"{x},{y},{t},{p}" for x, y, t, p in text_dict[idx]])
|
|
1980
|
+
)
|
|
1981
|
+
for idx in range(rows * cols)
|
|
1982
|
+
]
|
|
1983
|
+
),
|
|
1984
|
+
"|".join(
|
|
1985
|
+
[
|
|
1986
|
+
(
|
|
1987
|
+
""
|
|
1988
|
+
if idx not in annotate_dict
|
|
1989
|
+
else ";".join(
|
|
1990
|
+
[
|
|
1991
|
+
f"{xt[0]},{xt[1]},{xy[0]},{xy[1]},{t},{a}"
|
|
1992
|
+
for xt, xy, t, a in annotate_dict[idx]
|
|
1993
|
+
]
|
|
1994
|
+
)
|
|
1995
|
+
)
|
|
1996
|
+
for idx in range(rows * cols)
|
|
1997
|
+
]
|
|
1998
|
+
),
|
|
1999
|
+
)
|
|
2000
|
+
base_name = explicit_name or f"multi_plot_{content_hash}"
|
|
2001
|
+
|
|
2002
|
+
rel_dir = os.path.join("_static", "multi_plot")
|
|
2003
|
+
abs_dir = os.path.join(app.srcdir, rel_dir)
|
|
2004
|
+
os.makedirs(abs_dir, exist_ok=True)
|
|
2005
|
+
svg_name = f"{base_name}.svg"
|
|
2006
|
+
abs_svg = os.path.join(abs_dir, svg_name)
|
|
2007
|
+
|
|
2008
|
+
regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
|
|
2009
|
+
if regenerate:
|
|
2010
|
+
try:
|
|
2011
|
+
letters = [chr(i) for i in range(65, 65 + len(functions))]
|
|
2012
|
+
# Create axes grid without auto-plotting functions
|
|
2013
|
+
fig, axes = plotmath.multiplot(
|
|
2014
|
+
functions=[],
|
|
2015
|
+
fn_labels=False,
|
|
2016
|
+
xmin=xmin,
|
|
2017
|
+
xmax=xmax,
|
|
2018
|
+
ymin=ymin,
|
|
2019
|
+
ymax=ymax,
|
|
2020
|
+
xstep=xstep,
|
|
2021
|
+
ystep=ystep,
|
|
2022
|
+
ticks=ticks_flag,
|
|
2023
|
+
grid=grid_flag,
|
|
2024
|
+
rows=rows,
|
|
2025
|
+
cols=cols,
|
|
2026
|
+
lw=lw,
|
|
2027
|
+
alpha=alpha,
|
|
2028
|
+
fontsize=fontsize,
|
|
2029
|
+
figsize=(4.5 * cols, 3.5 * rows),
|
|
2030
|
+
)
|
|
2031
|
+
# Normalize axes to flat list
|
|
2032
|
+
try:
|
|
2033
|
+
import numpy as _np
|
|
2034
|
+
|
|
2035
|
+
axes_list = (
|
|
2036
|
+
list(axes.flatten())
|
|
2037
|
+
if hasattr(axes, "flatten")
|
|
2038
|
+
else list(_np.array(axes).flatten())
|
|
2039
|
+
)
|
|
2040
|
+
except Exception:
|
|
2041
|
+
axes_list = axes if isinstance(axes, (list, tuple)) else [axes]
|
|
2042
|
+
|
|
2043
|
+
# Ensure tick label font size matches provided fontsize (legend handled later)
|
|
2044
|
+
try:
|
|
2045
|
+
for _ax in axes_list:
|
|
2046
|
+
try:
|
|
2047
|
+
_ax.tick_params(labelsize=int(fontsize))
|
|
2048
|
+
except Exception:
|
|
2049
|
+
pass
|
|
2050
|
+
except Exception:
|
|
2051
|
+
pass
|
|
2052
|
+
|
|
2053
|
+
# Manual plotting per-axis
|
|
2054
|
+
import numpy as np
|
|
2055
|
+
|
|
2056
|
+
for idx, (expr, fn) in enumerate(zip(exprs, functions)):
|
|
2057
|
+
if idx >= len(axes_list):
|
|
2058
|
+
break
|
|
2059
|
+
ax = axes_list[idx]
|
|
2060
|
+
# Per-axis domain
|
|
2061
|
+
dom = dom_list[idx]
|
|
2062
|
+
x0, x1 = dom if dom is not None else (xmin, xmax)
|
|
2063
|
+
N = int(2**12)
|
|
2064
|
+
x = np.linspace(x0, x1, N)
|
|
2065
|
+
y = fn(x)
|
|
2066
|
+
# Ensure float array and blank out non-finite values
|
|
2067
|
+
y = np.asarray(y, dtype=float)
|
|
2068
|
+
y[~np.isfinite(y)] = np.nan
|
|
2069
|
+
# Robust exclusions: widen window and clear neighbors
|
|
2070
|
+
exs = [e for e in excl_list[idx] if x0 < e < x1]
|
|
2071
|
+
if exs and N > 1:
|
|
2072
|
+
dx = (x1 - x0) / (N - 1)
|
|
2073
|
+
w = max(4 * dx, 1e-6 * (1.0 + max(abs(e) for e in exs)))
|
|
2074
|
+
for e in exs:
|
|
2075
|
+
try:
|
|
2076
|
+
mask = np.abs(x - e) <= w
|
|
2077
|
+
if mask.any():
|
|
2078
|
+
y[mask] = np.nan
|
|
2079
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
2080
|
+
for k in (j - 2, j - 1, j, j + 1, j + 2):
|
|
2081
|
+
if 0 <= k < y.size:
|
|
2082
|
+
y[k] = np.nan
|
|
2083
|
+
except Exception:
|
|
2084
|
+
try:
|
|
2085
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
2086
|
+
if 0 <= j < y.size:
|
|
2087
|
+
y[j] = np.nan
|
|
2088
|
+
except Exception:
|
|
2089
|
+
pass
|
|
2090
|
+
# Also break across steep jumps or extreme magnitudes
|
|
2091
|
+
# Determine per-axis y-span preference: use provided ylim for this axis if any
|
|
2092
|
+
if ylim_vals[idx] is not None:
|
|
2093
|
+
y0_lim, y1_lim = ylim_vals[idx]
|
|
2094
|
+
else:
|
|
2095
|
+
y0_lim, y1_lim = ymin, ymax
|
|
2096
|
+
try:
|
|
2097
|
+
y_span = abs(float(y1_lim) - float(y0_lim))
|
|
2098
|
+
except Exception:
|
|
2099
|
+
y_span = np.nan
|
|
2100
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
2101
|
+
finite_y = y[np.isfinite(y)]
|
|
2102
|
+
if finite_y.size > 0:
|
|
2103
|
+
y_span = float(np.nanmax(finite_y) - np.nanmin(finite_y))
|
|
2104
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
2105
|
+
y_span = 1.0
|
|
2106
|
+
finite_pair = np.isfinite(y[:-1]) & np.isfinite(y[1:])
|
|
2107
|
+
jump_factor = 0.5
|
|
2108
|
+
big_jump = finite_pair & (np.abs(y[1:] - y[:-1]) > (jump_factor * y_span))
|
|
2109
|
+
if big_jump.any():
|
|
2110
|
+
idx_break = np.where(big_jump)[0]
|
|
2111
|
+
for i_b in idx_break:
|
|
2112
|
+
if 0 <= i_b + 1 < y.size:
|
|
2113
|
+
y[i_b + 1] = np.nan
|
|
2114
|
+
mag_factor = 50.0
|
|
2115
|
+
too_big = np.isfinite(y) & (np.abs(y) > (mag_factor * y_span))
|
|
2116
|
+
if too_big.any():
|
|
2117
|
+
y[too_big] = np.nan
|
|
2118
|
+
lbl = labels_list[idx] if (labels_list and idx < len(labels_list)) else None
|
|
2119
|
+
if lbl:
|
|
2120
|
+
ax.plot(x, y, lw=lw, alpha=alpha, label=f"${lbl}$")
|
|
2121
|
+
ax.legend(fontsize=int(fontsize))
|
|
2122
|
+
else:
|
|
2123
|
+
ax.plot(x, y, lw=lw, alpha=alpha)
|
|
2124
|
+
# Plot per-axis points if provided
|
|
2125
|
+
try:
|
|
2126
|
+
pv = points_vals[idx]
|
|
2127
|
+
if pv:
|
|
2128
|
+
xs = [p[0] for p in pv]
|
|
2129
|
+
ys = [p[1] for p in pv]
|
|
2130
|
+
ax.plot(
|
|
2131
|
+
xs,
|
|
2132
|
+
ys,
|
|
2133
|
+
linestyle="none",
|
|
2134
|
+
marker="o",
|
|
2135
|
+
markersize=max(4, min(12, int(fontsize) // 2)),
|
|
2136
|
+
color="black",
|
|
2137
|
+
alpha=0.8,
|
|
2138
|
+
)
|
|
2139
|
+
except Exception:
|
|
2140
|
+
pass
|
|
2141
|
+
# Optional line(s) y = a*x + b per axis
|
|
2142
|
+
if line_specs[idx] is not None:
|
|
2143
|
+
# Handle both legacy format (single tuple) and new format (list of tuples)
|
|
2144
|
+
lines_to_plot = []
|
|
2145
|
+
if isinstance(line_specs[idx], tuple):
|
|
2146
|
+
lines_to_plot = [line_specs[idx]]
|
|
2147
|
+
elif isinstance(line_specs[idx], list):
|
|
2148
|
+
lines_to_plot = line_specs[idx]
|
|
2149
|
+
|
|
2150
|
+
for line_spec in lines_to_plot:
|
|
2151
|
+
try:
|
|
2152
|
+
a_l, b_l = line_spec # type: ignore[misc]
|
|
2153
|
+
# Use provided xlim for this axis if any; else global
|
|
2154
|
+
if xlim_vals[idx] is not None:
|
|
2155
|
+
x_min_line, x_max_line = xlim_vals[idx]
|
|
2156
|
+
else:
|
|
2157
|
+
x_min_line, x_max_line = xmin, xmax
|
|
2158
|
+
x_line = np.array(
|
|
2159
|
+
[float(x_min_line), float(x_max_line)], dtype=float
|
|
2160
|
+
)
|
|
2161
|
+
y_line = a_l * x_line + b_l
|
|
2162
|
+
ax.plot(
|
|
2163
|
+
x_line,
|
|
2164
|
+
y_line,
|
|
2165
|
+
linestyle="--",
|
|
2166
|
+
color=plotmath.COLORS.get("red"),
|
|
2167
|
+
lw=lw,
|
|
2168
|
+
alpha=alpha,
|
|
2169
|
+
)
|
|
2170
|
+
except Exception:
|
|
2171
|
+
pass
|
|
2172
|
+
# vlines / hlines (support multiple values per axis)
|
|
2173
|
+
for xv in vline_vals[idx] or []:
|
|
2174
|
+
try:
|
|
2175
|
+
# Support both old format (float) and new format (tuple)
|
|
2176
|
+
if isinstance(xv, tuple) and len(xv) == 3:
|
|
2177
|
+
x_val, y1_val, y2_val = xv
|
|
2178
|
+
# If y1, y2 specified, use plot line segment
|
|
2179
|
+
if y1_val is not None and y2_val is not None:
|
|
2180
|
+
import numpy as np
|
|
2181
|
+
|
|
2182
|
+
x_line = np.array([float(x_val), float(x_val)])
|
|
2183
|
+
y_line = np.array([float(y1_val), float(y2_val)])
|
|
2184
|
+
ax.plot(
|
|
2185
|
+
x_line,
|
|
2186
|
+
y_line,
|
|
2187
|
+
color=plotmath.COLORS.get("red"),
|
|
2188
|
+
linestyle="--",
|
|
2189
|
+
lw=lw,
|
|
2190
|
+
)
|
|
2191
|
+
else:
|
|
2192
|
+
# Full vertical line
|
|
2193
|
+
ax.axvline(
|
|
2194
|
+
x=float(x_val),
|
|
2195
|
+
color=plotmath.COLORS.get("red"),
|
|
2196
|
+
linestyle="--",
|
|
2197
|
+
lw=lw,
|
|
2198
|
+
)
|
|
2199
|
+
else:
|
|
2200
|
+
# Old format: just x value
|
|
2201
|
+
ax.axvline(
|
|
2202
|
+
x=float(xv),
|
|
2203
|
+
color=plotmath.COLORS.get("red"),
|
|
2204
|
+
linestyle="--",
|
|
2205
|
+
lw=lw,
|
|
2206
|
+
)
|
|
2207
|
+
except Exception:
|
|
2208
|
+
pass
|
|
2209
|
+
for yh in hline_vals[idx] or []:
|
|
2210
|
+
try:
|
|
2211
|
+
# Support both old format (float) and new format (tuple)
|
|
2212
|
+
if isinstance(yh, tuple) and len(yh) == 3:
|
|
2213
|
+
y_val, x1_val, x2_val = yh
|
|
2214
|
+
# If x1, x2 specified, use axhspan or plot line segment
|
|
2215
|
+
if x1_val is not None and x2_val is not None:
|
|
2216
|
+
import numpy as np
|
|
2217
|
+
|
|
2218
|
+
x_line = np.array([float(x1_val), float(x2_val)])
|
|
2219
|
+
y_line = np.array([float(y_val), float(y_val)])
|
|
2220
|
+
ax.plot(
|
|
2221
|
+
x_line,
|
|
2222
|
+
y_line,
|
|
2223
|
+
color=plotmath.COLORS.get("red"),
|
|
2224
|
+
linestyle="--",
|
|
2225
|
+
lw=lw,
|
|
2226
|
+
)
|
|
2227
|
+
else:
|
|
2228
|
+
# Full horizontal line
|
|
2229
|
+
ax.axhline(
|
|
2230
|
+
y=float(y_val),
|
|
2231
|
+
color=plotmath.COLORS.get("red"),
|
|
2232
|
+
linestyle="--",
|
|
2233
|
+
lw=lw,
|
|
2234
|
+
)
|
|
2235
|
+
else:
|
|
2236
|
+
# Old format: just y value
|
|
2237
|
+
ax.axhline(
|
|
2238
|
+
y=float(yh),
|
|
2239
|
+
color=plotmath.COLORS.get("red"),
|
|
2240
|
+
linestyle="--",
|
|
2241
|
+
lw=lw,
|
|
2242
|
+
)
|
|
2243
|
+
except Exception:
|
|
2244
|
+
pass
|
|
2245
|
+
|
|
2246
|
+
# Draw text annotations
|
|
2247
|
+
if idx in text_dict:
|
|
2248
|
+
# Get axes dimensions for offset calculation
|
|
2249
|
+
try:
|
|
2250
|
+
fig.canvas.draw() # ensure layout is realized
|
|
2251
|
+
_bbox_px = ax.get_window_extent()
|
|
2252
|
+
_ax_w_px, _ax_h_px = _bbox_px.width, _bbox_px.height
|
|
2253
|
+
if _ax_w_px <= 0 or _ax_h_px <= 0:
|
|
2254
|
+
_ax_w_px = _ax_h_px = None
|
|
2255
|
+
except Exception:
|
|
2256
|
+
_ax_w_px = _ax_h_px = None
|
|
2257
|
+
|
|
2258
|
+
# Get current axis limits for fallback offset calculation
|
|
2259
|
+
try:
|
|
2260
|
+
ax_xlim = ax.get_xlim()
|
|
2261
|
+
ax_ylim = ax.get_ylim()
|
|
2262
|
+
ax_dx = abs(ax_xlim[1] - ax_xlim[0])
|
|
2263
|
+
ax_dy = abs(ax_ylim[1] - ax_ylim[0])
|
|
2264
|
+
except Exception:
|
|
2265
|
+
ax_dx = ax_dy = 1.0
|
|
2266
|
+
|
|
2267
|
+
for x0, y0, text_str, pos_str in text_dict[idx]:
|
|
2268
|
+
va, ha = _parse_text_positioning(pos_str)
|
|
2269
|
+
|
|
2270
|
+
# Offset factors (matching plot.py)
|
|
2271
|
+
_fx_short = 0.015
|
|
2272
|
+
_fy_short = 0.015
|
|
2273
|
+
_fx_long = 0.03
|
|
2274
|
+
_fy_long = 0.03
|
|
2275
|
+
|
|
2276
|
+
# Resolve long* variants
|
|
2277
|
+
_use_fx = _fx_short
|
|
2278
|
+
_use_fy = _fy_short
|
|
2279
|
+
if va == "longbottom":
|
|
2280
|
+
va = "bottom"
|
|
2281
|
+
_use_fy = _fy_long
|
|
2282
|
+
elif va == "longtop":
|
|
2283
|
+
va = "top"
|
|
2284
|
+
_use_fy = _fy_long
|
|
2285
|
+
if ha == "longright":
|
|
2286
|
+
ha = "right"
|
|
2287
|
+
_use_fx = _fx_long
|
|
2288
|
+
elif ha == "longleft":
|
|
2289
|
+
ha = "left"
|
|
2290
|
+
_use_fx = _fx_long
|
|
2291
|
+
|
|
2292
|
+
# Calculate offset
|
|
2293
|
+
if _ax_w_px and _ax_h_px:
|
|
2294
|
+
# Pixel-based offsets
|
|
2295
|
+
dx_px = 0.0
|
|
2296
|
+
dy_px = 0.0
|
|
2297
|
+
if ha == "right":
|
|
2298
|
+
dx_px = -_ax_w_px * _use_fx
|
|
2299
|
+
elif ha == "left":
|
|
2300
|
+
dx_px = _ax_w_px * _use_fx
|
|
2301
|
+
if va == "bottom":
|
|
2302
|
+
dy_px = _ax_h_px * _use_fy
|
|
2303
|
+
elif va == "top":
|
|
2304
|
+
dy_px = -_ax_h_px * _use_fy
|
|
2305
|
+
x_disp, y_disp = ax.transData.transform((x0, y0))
|
|
2306
|
+
x1, y1 = ax.transData.inverted().transform(
|
|
2307
|
+
(x_disp + dx_px, y_disp + dy_px)
|
|
2308
|
+
)
|
|
2309
|
+
dx = x1 - x0
|
|
2310
|
+
dy = y1 - y0
|
|
2311
|
+
else:
|
|
2312
|
+
# Fallback: data-space offsets
|
|
2313
|
+
if va == "bottom":
|
|
2314
|
+
dy = (_fy_short if _use_fy == _fy_short else _fy_long) * ax_dy
|
|
2315
|
+
elif va == "top":
|
|
2316
|
+
dy = -(_fy_short if _use_fy == _fy_short else _fy_long) * ax_dy
|
|
2317
|
+
else:
|
|
2318
|
+
dy = 0.0
|
|
2319
|
+
if ha == "right":
|
|
2320
|
+
dx = -(_fx_short if _use_fx == _fx_short else _fx_long) * ax_dx
|
|
2321
|
+
elif ha == "left":
|
|
2322
|
+
dx = (_fx_short if _use_fx == _fx_short else _fx_long) * ax_dx
|
|
2323
|
+
else:
|
|
2324
|
+
dx = 0.0
|
|
2325
|
+
|
|
2326
|
+
# Draw text
|
|
2327
|
+
ax.text(
|
|
2328
|
+
x0 + dx,
|
|
2329
|
+
y0 + dy,
|
|
2330
|
+
text_str,
|
|
2331
|
+
fontsize=int(fontsize),
|
|
2332
|
+
ha=ha,
|
|
2333
|
+
va=va,
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
# Draw arrow annotations
|
|
2337
|
+
if idx in annotate_dict:
|
|
2338
|
+
import matplotlib.pyplot as plt
|
|
2339
|
+
|
|
2340
|
+
plt.sca(ax) # Set current axes
|
|
2341
|
+
for xytext, xy, text_str, arc_val in annotate_dict[idx]:
|
|
2342
|
+
plotmath.annotate(
|
|
2343
|
+
xy=xy,
|
|
2344
|
+
xytext=xytext,
|
|
2345
|
+
s=text_str,
|
|
2346
|
+
arc=arc_val,
|
|
2347
|
+
fontsize=int(fontsize),
|
|
2348
|
+
)
|
|
2349
|
+
|
|
2350
|
+
# x/ylims
|
|
2351
|
+
if xlim_vals[idx] is not None:
|
|
2352
|
+
ax.set_xlim(*xlim_vals[idx])
|
|
2353
|
+
if ylim_vals[idx] is not None:
|
|
2354
|
+
ax.set_ylim(*ylim_vals[idx])
|
|
2355
|
+
# Save via the single Figure object
|
|
2356
|
+
fig.savefig(abs_svg, format="svg", bbox_inches="tight", transparent=True)
|
|
2357
|
+
# Also save a PDF sidecar for debugging comparisons (optional)
|
|
2358
|
+
# try:
|
|
2359
|
+
# fig.savefig(
|
|
2360
|
+
# os.path.join(abs_dir, f"{base_name}.pdf"),
|
|
2361
|
+
# format="pdf",
|
|
2362
|
+
# bbox_inches="tight",
|
|
2363
|
+
# transparent=True,
|
|
2364
|
+
# )
|
|
2365
|
+
# except Exception:
|
|
2366
|
+
# pass
|
|
2367
|
+
import matplotlib
|
|
2368
|
+
|
|
2369
|
+
matplotlib.pyplot.close(fig)
|
|
2370
|
+
except Exception as e:
|
|
2371
|
+
return [
|
|
2372
|
+
self.state_machine.reporter.error(
|
|
2373
|
+
f"Feil under generering av graf: {e}", line=self.lineno
|
|
2374
|
+
)
|
|
2375
|
+
]
|
|
2376
|
+
|
|
2377
|
+
if not os.path.exists(abs_svg):
|
|
2378
|
+
return [self.state_machine.reporter.error("multi-plot: SVG mangler.", line=self.lineno)]
|
|
2379
|
+
|
|
2380
|
+
env.note_dependency(abs_svg)
|
|
2381
|
+
try: # copy to output _static
|
|
2382
|
+
out_static = os.path.join(app.outdir, "_static", "multi_plot")
|
|
2383
|
+
os.makedirs(out_static, exist_ok=True)
|
|
2384
|
+
shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
|
|
2385
|
+
except Exception:
|
|
2386
|
+
pass
|
|
2387
|
+
|
|
2388
|
+
try:
|
|
2389
|
+
raw_svg = open(abs_svg, "r", encoding="utf-8").read()
|
|
2390
|
+
except Exception as e:
|
|
2391
|
+
return [
|
|
2392
|
+
self.state_machine.reporter.error(
|
|
2393
|
+
f"graph inline: kunne ikke lese SVG: {e}", line=self.lineno
|
|
2394
|
+
)
|
|
2395
|
+
]
|
|
2396
|
+
|
|
2397
|
+
if not debug_mode and "viewBox" in raw_svg:
|
|
2398
|
+
raw_svg = _strip_root_svg_size(raw_svg)
|
|
2399
|
+
|
|
2400
|
+
def _rewrite_ids(txt: str, prefix: str) -> str:
|
|
2401
|
+
# Collect ids
|
|
2402
|
+
ids = re.findall(r'\bid="([^"]+)"', txt)
|
|
2403
|
+
if not ids:
|
|
2404
|
+
return txt
|
|
2405
|
+
# Skip font glyphs to avoid disrupting text rendering
|
|
2406
|
+
skip_prefixes = (
|
|
2407
|
+
"DejaVu",
|
|
2408
|
+
"CM",
|
|
2409
|
+
"STIX",
|
|
2410
|
+
"Nimbus",
|
|
2411
|
+
"Bitstream",
|
|
2412
|
+
"Arial",
|
|
2413
|
+
"Times",
|
|
2414
|
+
"Helvetica",
|
|
2415
|
+
)
|
|
2416
|
+
mapping = {}
|
|
2417
|
+
for i in ids:
|
|
2418
|
+
if i.startswith(skip_prefixes):
|
|
2419
|
+
continue
|
|
2420
|
+
mapping[i] = f"{prefix}{i}"
|
|
2421
|
+
if not mapping:
|
|
2422
|
+
return txt
|
|
2423
|
+
|
|
2424
|
+
# Replace id definitions
|
|
2425
|
+
def repl_id(m: re.Match) -> str:
|
|
2426
|
+
old = m.group(1)
|
|
2427
|
+
new = mapping.get(old, old)
|
|
2428
|
+
return f'id="{new}"'
|
|
2429
|
+
|
|
2430
|
+
txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
|
|
2431
|
+
|
|
2432
|
+
# Replace url(#id) everywhere (attributes and styles)
|
|
2433
|
+
def repl_url(m: re.Match) -> str:
|
|
2434
|
+
old = m.group(1).strip()
|
|
2435
|
+
new = mapping.get(old, old)
|
|
2436
|
+
return f"url(#{new})"
|
|
2437
|
+
|
|
2438
|
+
txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
|
|
2439
|
+
|
|
2440
|
+
# Replace href/xlink:href references supporting both quote styles
|
|
2441
|
+
def repl_href(m: re.Match) -> str:
|
|
2442
|
+
attr = m.group(1)
|
|
2443
|
+
quote = m.group(2)
|
|
2444
|
+
old = m.group(3).strip()
|
|
2445
|
+
new = mapping.get(old, old)
|
|
2446
|
+
return f"{attr}={quote}#{new}{quote}"
|
|
2447
|
+
|
|
2448
|
+
txt = re.sub(
|
|
2449
|
+
r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2',
|
|
2450
|
+
repl_href,
|
|
2451
|
+
txt,
|
|
2452
|
+
)
|
|
2453
|
+
return txt
|
|
2454
|
+
|
|
2455
|
+
if not debug_mode:
|
|
2456
|
+
raw_svg = _rewrite_ids(raw_svg, f"mgr_{content_hash}_{uuid.uuid4().hex[:6]}_")
|
|
2457
|
+
|
|
2458
|
+
alt_default = f"Multiplot av {len(exprs)} funksjoner"
|
|
2459
|
+
alt = merged.get("alt", alt_default)
|
|
2460
|
+
|
|
2461
|
+
width_opt = merged.get("width")
|
|
2462
|
+
percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
|
|
2463
|
+
|
|
2464
|
+
def _augment(m):
|
|
2465
|
+
tag = m.group(0)
|
|
2466
|
+
if "class=" not in tag:
|
|
2467
|
+
tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
|
|
2468
|
+
else:
|
|
2469
|
+
tag = tag.replace('class="', 'class="graph-inline-svg ')
|
|
2470
|
+
if alt and "aria-label=" not in tag:
|
|
2471
|
+
tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
|
|
2472
|
+
if width_opt:
|
|
2473
|
+
if percent:
|
|
2474
|
+
wval = width_opt.strip()
|
|
2475
|
+
else:
|
|
2476
|
+
wval = width_opt.strip()
|
|
2477
|
+
if wval.isdigit():
|
|
2478
|
+
wval += "px"
|
|
2479
|
+
style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
|
|
2480
|
+
if "style=" in tag:
|
|
2481
|
+
tag = re.sub(
|
|
2482
|
+
r'style="([^"]*)"',
|
|
2483
|
+
lambda mm: f'style="{mm.group(1)}; {style_frag}"',
|
|
2484
|
+
tag,
|
|
2485
|
+
count=1,
|
|
2486
|
+
)
|
|
2487
|
+
else:
|
|
2488
|
+
tag = tag[:-1] + f' style="{style_frag}"' + ">"
|
|
2489
|
+
return tag
|
|
2490
|
+
|
|
2491
|
+
raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
|
|
2492
|
+
# Intentionally do not inject a <title> element to avoid hover tooltips; accessibility
|
|
2493
|
+
# remains via role="img" and aria-label attributes. Add manually later if truly needed.
|
|
2494
|
+
|
|
2495
|
+
figure = nodes.figure()
|
|
2496
|
+
figure.setdefault("classes", []).extend(
|
|
2497
|
+
["adaptive-figure", "multi-plot-figure", "no-click"]
|
|
2498
|
+
)
|
|
2499
|
+
raw_node = nodes.raw("", raw_svg, format="html")
|
|
2500
|
+
raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
|
|
2501
|
+
figure += raw_node
|
|
2502
|
+
|
|
2503
|
+
extra_classes = merged.get("class")
|
|
2504
|
+
if extra_classes:
|
|
2505
|
+
figure["classes"].extend(extra_classes)
|
|
2506
|
+
figure["align"] = merged.get("align", "center")
|
|
2507
|
+
|
|
2508
|
+
caption_lines = list(self.content)[caption_idx:]
|
|
2509
|
+
while caption_lines and not caption_lines[0].strip():
|
|
2510
|
+
caption_lines.pop(0)
|
|
2511
|
+
if caption_lines:
|
|
2512
|
+
caption = nodes.caption()
|
|
2513
|
+
caption += nodes.Text("\n".join(caption_lines))
|
|
2514
|
+
figure += caption
|
|
2515
|
+
|
|
2516
|
+
if explicit_name:
|
|
2517
|
+
self.add_name(figure)
|
|
2518
|
+
return [figure]
|
|
2519
|
+
|
|
2520
|
+
|
|
2521
|
+
def setup(app): # pragma: no cover
|
|
2522
|
+
app.add_directive("multi-plot", MultiPlotDirective)
|
|
2523
|
+
app.add_directive("multiplot", MultiPlotDirective) # Also register without hyphen
|
|
2524
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|