munchboka-edutools 0.1.13__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 +272 -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/factor_tree.py +549 -0
- munchboka_edutools/directives/ggb.py +209 -0
- munchboka_edutools/directives/ggb_icon.py +105 -0
- munchboka_edutools/directives/ggb_popup.py +165 -0
- munchboka_edutools/directives/horner.py +324 -0
- munchboka_edutools/directives/interactive_code.py +75 -0
- munchboka_edutools/directives/jeopardy.py +252 -0
- munchboka_edutools/directives/multi_plot.py +1126 -0
- munchboka_edutools/directives/pair_puzzle.py +191 -0
- munchboka_edutools/directives/parsons.py +109 -0
- munchboka_edutools/directives/plot.py +3105 -0
- munchboka_edutools/directives/poly_icon.py +111 -0
- munchboka_edutools/directives/polydiv.py +344 -0
- munchboka_edutools/directives/popup.py +245 -0
- munchboka_edutools/directives/quiz.py +291 -0
- munchboka_edutools/directives/signchart.py +516 -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 +274 -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 +112 -0
- munchboka_edutools/static/css/github-light.css +120 -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 +529 -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 +312 -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/geogebra-setup.js +6 -0
- munchboka_edutools/static/js/highlight-init.js +6 -0
- munchboka_edutools/static/js/interactiveCode/codeEditor.js +632 -0
- munchboka_edutools/static/js/interactiveCode/interactiveCodeSetup.js +348 -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 +523 -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 +422 -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.1.13.dist-info/METADATA +108 -0
- munchboka_edutools-0.1.13.dist-info/RECORD +149 -0
- munchboka_edutools-0.1.13.dist-info/WHEEL +4 -0
- munchboka_edutools-0.1.13.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1126 @@
|
|
|
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`` – 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
|
+
* ``vlines`` / ``hlines`` – vertical / horizontal reference lines.
|
|
187
|
+
* ``xlims`` / ``ylims`` – per-axis limits.
|
|
188
|
+
* ``lines`` – reference lines y = a*x + b or derived from basepoint.
|
|
189
|
+
* ``fn_labels`` / ``function-names`` – labels.
|
|
190
|
+
|
|
191
|
+
Meta / Control:
|
|
192
|
+
* ``nocache`` – force regeneration.
|
|
193
|
+
* ``debug`` – keep raw SVG + skip ID rewriting.
|
|
194
|
+
* ``name`` – explicit figure base name (influences cache filename).
|
|
195
|
+
|
|
196
|
+
Caption
|
|
197
|
+
-------
|
|
198
|
+
Any content after the parsed key/value block (or after inline key/value lines)
|
|
199
|
+
is treated as the caption and wrapped in a Sphinx ``<caption>`` node.
|
|
200
|
+
|
|
201
|
+
Implementation Notes
|
|
202
|
+
--------------------
|
|
203
|
+
* Expressions are compiled once per build variant and cached.
|
|
204
|
+
* Domain exclusions create widened gaps (± a few samples) for visual clarity.
|
|
205
|
+
* ID rewriting avoids collisions of gradients / clips when multiple multi-plot
|
|
206
|
+
images exist on the same page.
|
|
207
|
+
* Inline width styling is injected only once by a regex match on the first
|
|
208
|
+
``<svg>`` tag; subsequent styling merges if a style attribute already exists.
|
|
209
|
+
|
|
210
|
+
This directive is specifically tuned for the pedagogical needs of the material
|
|
211
|
+
in this repository; tweak or extend as needed. If you add new options, please
|
|
212
|
+
update this docstring to keep the self-documenting pattern intact.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
from __future__ import annotations
|
|
216
|
+
|
|
217
|
+
import os
|
|
218
|
+
import re
|
|
219
|
+
import uuid
|
|
220
|
+
import hashlib
|
|
221
|
+
import shutil
|
|
222
|
+
from typing import Callable, Dict, Any, Tuple, List
|
|
223
|
+
|
|
224
|
+
from docutils import nodes
|
|
225
|
+
from docutils.parsers.rst import directives
|
|
226
|
+
from sphinx.util.docutils import SphinxDirective
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Helpers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _hash_key(*parts) -> str:
|
|
234
|
+
h = hashlib.sha1()
|
|
235
|
+
for p in parts:
|
|
236
|
+
if p is None:
|
|
237
|
+
p = "__NONE__"
|
|
238
|
+
h.update(str(p).encode("utf-8"))
|
|
239
|
+
h.update(b"||")
|
|
240
|
+
return h.hexdigest()[:12]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _compile_function(expr: str) -> Callable:
|
|
244
|
+
import sympy, numpy as np # local import
|
|
245
|
+
|
|
246
|
+
expr = expr.strip()
|
|
247
|
+
x = sympy.symbols("x")
|
|
248
|
+
try:
|
|
249
|
+
sym = sympy.sympify(expr)
|
|
250
|
+
except Exception as e: # pragma: no cover - user error path
|
|
251
|
+
raise ValueError(f"Ugyldig funksjonsuttrykk '{expr}': {e}")
|
|
252
|
+
fn_np = sympy.lambdify(x, sym, modules=["numpy"])
|
|
253
|
+
|
|
254
|
+
def f(arr):
|
|
255
|
+
return fn_np(np.asarray(arr, dtype=float))
|
|
256
|
+
|
|
257
|
+
# simple vectorization test
|
|
258
|
+
_ = f([0.0, 1.0])
|
|
259
|
+
return f
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Directive
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _strip_root_svg_size(svg_text: str) -> str:
|
|
268
|
+
"""Remove width/height only on the root <svg> tag for responsiveness."""
|
|
269
|
+
|
|
270
|
+
def repl(m):
|
|
271
|
+
tag = m.group(0)
|
|
272
|
+
tag = re.sub(r'\swidth="[^"]+"', "", tag)
|
|
273
|
+
tag = re.sub(r'\sheight="[^"]+"', "", tag)
|
|
274
|
+
return tag
|
|
275
|
+
|
|
276
|
+
return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_bool(val, default: bool | None = None) -> bool | None:
|
|
280
|
+
if val is None:
|
|
281
|
+
return default
|
|
282
|
+
if isinstance(val, bool):
|
|
283
|
+
return val
|
|
284
|
+
s = str(val).strip().lower()
|
|
285
|
+
if s == "": # option present with no value => treat as True
|
|
286
|
+
return True
|
|
287
|
+
if s in {"true", "yes", "on", "1"}:
|
|
288
|
+
return True
|
|
289
|
+
if s in {"false", "no", "off", "0"}:
|
|
290
|
+
return False
|
|
291
|
+
return default
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _split_expr_list(val: str) -> List[str]:
|
|
295
|
+
if not isinstance(val, str):
|
|
296
|
+
return []
|
|
297
|
+
s = val.strip()
|
|
298
|
+
if not s:
|
|
299
|
+
return []
|
|
300
|
+
# allow [a,b,c] or a;b;c or a,b,c
|
|
301
|
+
if s.startswith("[") and s.endswith("]"):
|
|
302
|
+
s = s[1:-1]
|
|
303
|
+
s = s.replace(";", ",")
|
|
304
|
+
parts = [p.strip() for p in s.split(",")]
|
|
305
|
+
return [p for p in parts if p]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _split_top_level(val: str) -> List[str]:
|
|
309
|
+
"""Split by commas at top level only (ignores commas inside (), [], {})."""
|
|
310
|
+
if not isinstance(val, str):
|
|
311
|
+
return []
|
|
312
|
+
s = val.strip()
|
|
313
|
+
if not s:
|
|
314
|
+
return []
|
|
315
|
+
# Strip surrounding brackets if present
|
|
316
|
+
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
|
317
|
+
s = s[1:-1].strip()
|
|
318
|
+
out: List[str] = []
|
|
319
|
+
cur = []
|
|
320
|
+
depth = 0
|
|
321
|
+
pairs = {")": "(", "]": "[", "}": "{"}
|
|
322
|
+
stack: List[str] = []
|
|
323
|
+
i = 0
|
|
324
|
+
while i < len(s):
|
|
325
|
+
ch = s[i]
|
|
326
|
+
if ch in "([{":
|
|
327
|
+
depth += 1
|
|
328
|
+
stack.append(ch)
|
|
329
|
+
cur.append(ch)
|
|
330
|
+
elif ch in ")]}":
|
|
331
|
+
depth = max(0, depth - 1)
|
|
332
|
+
if stack:
|
|
333
|
+
stack.pop()
|
|
334
|
+
cur.append(ch)
|
|
335
|
+
elif ch == "," and depth == 0:
|
|
336
|
+
part = "".join(cur).strip()
|
|
337
|
+
if part:
|
|
338
|
+
out.append(part)
|
|
339
|
+
cur = []
|
|
340
|
+
else:
|
|
341
|
+
cur.append(ch)
|
|
342
|
+
i += 1
|
|
343
|
+
tail = "".join(cur).strip()
|
|
344
|
+
if tail:
|
|
345
|
+
out.append(tail)
|
|
346
|
+
return out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _safe_literal(val: str):
|
|
350
|
+
try:
|
|
351
|
+
import ast as _ast
|
|
352
|
+
|
|
353
|
+
return _ast.literal_eval(val)
|
|
354
|
+
except Exception:
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _parse_values_list_or_none(s: str):
|
|
359
|
+
"""Parse a scalar number or a tuple/list of numbers; return list[float] or None."""
|
|
360
|
+
if not isinstance(s, str):
|
|
361
|
+
return None
|
|
362
|
+
st = s.strip()
|
|
363
|
+
if not st or st.lower() == "none":
|
|
364
|
+
return None
|
|
365
|
+
lit = _safe_literal(st)
|
|
366
|
+
if isinstance(lit, (int, float)):
|
|
367
|
+
try:
|
|
368
|
+
return [float(lit)]
|
|
369
|
+
except Exception:
|
|
370
|
+
return None
|
|
371
|
+
if isinstance(lit, (list, tuple)):
|
|
372
|
+
out: List[float] = []
|
|
373
|
+
for v in lit:
|
|
374
|
+
try:
|
|
375
|
+
out.append(float(v))
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
return out if out else None
|
|
379
|
+
# fallback: split by commas
|
|
380
|
+
parts = [p.strip() for p in st.split(",") if p.strip()]
|
|
381
|
+
out2: List[float] = []
|
|
382
|
+
for p in parts:
|
|
383
|
+
try:
|
|
384
|
+
out2.append(float(p))
|
|
385
|
+
except Exception:
|
|
386
|
+
return None
|
|
387
|
+
return out2 if out2 else None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class MultiPlotDirective(SphinxDirective):
|
|
391
|
+
has_content = True
|
|
392
|
+
required_arguments = 0
|
|
393
|
+
option_spec = {
|
|
394
|
+
"functions": directives.unchanged_required, # list of expressions
|
|
395
|
+
"fn_labels": directives.unchanged, # optional list of labels
|
|
396
|
+
"function-names": directives.unchanged, # alias for fn_labels in examples
|
|
397
|
+
"domains": directives.unchanged, # per-function domain (a,b) or (a,b) \ {..}
|
|
398
|
+
"vlines": directives.unchanged, # per-function vline x or None
|
|
399
|
+
"hlines": directives.unchanged, # per-function hline y or None
|
|
400
|
+
"xlims": directives.unchanged, # per-function xlim tuple or None
|
|
401
|
+
"ylims": directives.unchanged, # per-function ylim tuple or None
|
|
402
|
+
"lines": directives.unchanged, # per-axis line spec: (a,b) or (a,(x,y)) or None
|
|
403
|
+
"points": directives.unchanged, # per-axis point lists: [(x,y),(x,y)] or None
|
|
404
|
+
"xmin": directives.unchanged,
|
|
405
|
+
"xmax": directives.unchanged,
|
|
406
|
+
"ymin": directives.unchanged,
|
|
407
|
+
"ymax": directives.unchanged,
|
|
408
|
+
"xstep": directives.unchanged,
|
|
409
|
+
"ystep": directives.unchanged,
|
|
410
|
+
"fontsize": directives.unchanged,
|
|
411
|
+
"lw": directives.unchanged,
|
|
412
|
+
"alpha": directives.unchanged,
|
|
413
|
+
"grid": directives.unchanged,
|
|
414
|
+
"ticks": directives.unchanged,
|
|
415
|
+
"rows": directives.unchanged,
|
|
416
|
+
"cols": directives.unchanged,
|
|
417
|
+
"align": lambda a: directives.choice(a, ["left", "center", "right"]),
|
|
418
|
+
"class": directives.class_option,
|
|
419
|
+
"name": directives.unchanged,
|
|
420
|
+
"nocache": directives.flag,
|
|
421
|
+
"alt": directives.unchanged,
|
|
422
|
+
"width": directives.length_or_percentage_or_unitless,
|
|
423
|
+
"debug": directives.flag,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
# -----------------------------
|
|
427
|
+
# Content key/value parsing
|
|
428
|
+
# -----------------------------
|
|
429
|
+
def _gather_kv_from_content(self) -> Tuple[Dict[str, str], int]:
|
|
430
|
+
kv: Dict[str, str] = {}
|
|
431
|
+
lines = list(self.content)
|
|
432
|
+
idx = 0
|
|
433
|
+
# YAML front matter style
|
|
434
|
+
if lines and lines[0].strip() == "---":
|
|
435
|
+
idx = 1
|
|
436
|
+
while idx < len(lines) and lines[idx].strip() != "---":
|
|
437
|
+
line = lines[idx].rstrip()
|
|
438
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
439
|
+
if m:
|
|
440
|
+
kv[m.group(1)] = m.group(2)
|
|
441
|
+
idx += 1
|
|
442
|
+
if idx < len(lines) and lines[idx].strip() == "---":
|
|
443
|
+
idx += 1
|
|
444
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
445
|
+
idx += 1
|
|
446
|
+
return kv, idx
|
|
447
|
+
# flat key: value lines until first non-matching or blank separation
|
|
448
|
+
caption_start = 0
|
|
449
|
+
for i, line in enumerate(lines):
|
|
450
|
+
if not line.strip():
|
|
451
|
+
caption_start = i + 1
|
|
452
|
+
continue
|
|
453
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
454
|
+
if m:
|
|
455
|
+
kv[m.group(1)] = m.group(2)
|
|
456
|
+
caption_start = i + 1
|
|
457
|
+
else:
|
|
458
|
+
break
|
|
459
|
+
return kv, caption_start
|
|
460
|
+
|
|
461
|
+
# -----------------------------
|
|
462
|
+
# Main run
|
|
463
|
+
# -----------------------------
|
|
464
|
+
def run(self): # noqa: C901 (complexity OK for directive)
|
|
465
|
+
env = self.state.document.settings.env
|
|
466
|
+
app = env.app
|
|
467
|
+
try:
|
|
468
|
+
import plotmath # type: ignore
|
|
469
|
+
except Exception as e: # pragma: no cover - dependency missing
|
|
470
|
+
err = nodes.error()
|
|
471
|
+
err += nodes.paragraph(text=f"Kunne ikke importere plotmath: {e}")
|
|
472
|
+
return [err]
|
|
473
|
+
|
|
474
|
+
kv, caption_idx = self._gather_kv_from_content()
|
|
475
|
+
merged: Dict[str, Any] = {**kv, **self.options}
|
|
476
|
+
if "functions" not in merged:
|
|
477
|
+
return [
|
|
478
|
+
self.state_machine.reporter.error(
|
|
479
|
+
"Directive 'multi-plot' krever 'functions:'", line=self.lineno
|
|
480
|
+
)
|
|
481
|
+
]
|
|
482
|
+
|
|
483
|
+
exprs = _split_expr_list(str(merged["functions"]))
|
|
484
|
+
if not exprs:
|
|
485
|
+
return [
|
|
486
|
+
self.state_machine.reporter.error(
|
|
487
|
+
"'functions' var tomt eller ugyldig.", line=self.lineno
|
|
488
|
+
)
|
|
489
|
+
]
|
|
490
|
+
# compile all
|
|
491
|
+
functions: List[Callable] = []
|
|
492
|
+
for e in exprs:
|
|
493
|
+
try:
|
|
494
|
+
functions.append(_compile_function(e))
|
|
495
|
+
except Exception as ex:
|
|
496
|
+
return [
|
|
497
|
+
self.state_machine.reporter.error(
|
|
498
|
+
f"Ugyldig funksjon '{e}': {ex}", line=self.lineno
|
|
499
|
+
)
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
def _f(name, default):
|
|
503
|
+
v = merged.get(name)
|
|
504
|
+
if v in (None, ""):
|
|
505
|
+
return default
|
|
506
|
+
try:
|
|
507
|
+
return float(v)
|
|
508
|
+
except Exception:
|
|
509
|
+
return default
|
|
510
|
+
|
|
511
|
+
xmin = _f("xmin", -6)
|
|
512
|
+
xmax = _f("xmax", 6)
|
|
513
|
+
ymin = _f("ymin", -6)
|
|
514
|
+
ymax = _f("ymax", 6)
|
|
515
|
+
xstep = _f("xstep", 1)
|
|
516
|
+
ystep = _f("ystep", 1)
|
|
517
|
+
fontsize = _f("fontsize", 20)
|
|
518
|
+
lw = _f("lw", 2.5)
|
|
519
|
+
alpha_raw = merged.get("alpha")
|
|
520
|
+
grid_flag = _parse_bool(merged.get("grid"), default=True)
|
|
521
|
+
ticks_flag = _parse_bool(merged.get("ticks"), default=True)
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
alpha = float(alpha_raw) if alpha_raw not in (None, "") else None
|
|
525
|
+
except Exception:
|
|
526
|
+
alpha = None
|
|
527
|
+
|
|
528
|
+
# Accept both fn_labels and function-names; function-names takes precedence if provided
|
|
529
|
+
labels_list: List[str] = _split_expr_list(
|
|
530
|
+
str(merged.get("function-names", merged.get("fn_labels", "")))
|
|
531
|
+
)
|
|
532
|
+
if labels_list and len(labels_list) == len(functions):
|
|
533
|
+
labels_arg: Any = labels_list
|
|
534
|
+
else:
|
|
535
|
+
labels_arg = True
|
|
536
|
+
|
|
537
|
+
# Per-function domains with optional exclusions, vlines, hlines, and axis limits
|
|
538
|
+
# Helper to parse domain with optional set-difference exclusions
|
|
539
|
+
def _parse_domain_with_exclusions(s: str):
|
|
540
|
+
if not isinstance(s, str):
|
|
541
|
+
return None, []
|
|
542
|
+
s = s.strip()
|
|
543
|
+
if not s or s.lower() == "none":
|
|
544
|
+
return None, []
|
|
545
|
+
num_re = r"[+-]?\d+(?:\.\d+)?"
|
|
546
|
+
dom_ex_pat = re.compile(
|
|
547
|
+
rf"\(\s*({num_re})\s*,\s*({num_re})\s*\)\s*(?:\\\s*\{{\s*([^}}]*)\s*\}})?"
|
|
548
|
+
)
|
|
549
|
+
m = dom_ex_pat.search(s)
|
|
550
|
+
if not m:
|
|
551
|
+
return None, []
|
|
552
|
+
try:
|
|
553
|
+
d0 = float(m.group(1))
|
|
554
|
+
d1 = float(m.group(2))
|
|
555
|
+
dom = (d0, d1)
|
|
556
|
+
except Exception:
|
|
557
|
+
dom = None
|
|
558
|
+
excludes: List[float] = []
|
|
559
|
+
excl_str = m.group(3) if m.lastindex and m.lastindex >= 3 else None
|
|
560
|
+
if excl_str:
|
|
561
|
+
for tok in [t.strip() for t in excl_str.split(",") if t.strip()]:
|
|
562
|
+
try:
|
|
563
|
+
excludes.append(float(tok))
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
return dom, excludes
|
|
567
|
+
|
|
568
|
+
def _parse_tuple_or_none(s: str):
|
|
569
|
+
if not isinstance(s, str):
|
|
570
|
+
return None
|
|
571
|
+
st = s.strip()
|
|
572
|
+
if not st or st.lower() == "none":
|
|
573
|
+
return None
|
|
574
|
+
lit = _safe_literal(st)
|
|
575
|
+
if isinstance(lit, (list, tuple)) and len(lit) == 2:
|
|
576
|
+
try:
|
|
577
|
+
return (float(lit[0]), float(lit[1]))
|
|
578
|
+
except Exception:
|
|
579
|
+
return None
|
|
580
|
+
m = re.match(r"\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)", st)
|
|
581
|
+
if m:
|
|
582
|
+
try:
|
|
583
|
+
return (float(m.group(1)), float(m.group(2)))
|
|
584
|
+
except Exception:
|
|
585
|
+
return None
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
def _parse_scalar_or_none(s: str):
|
|
589
|
+
if not isinstance(s, str):
|
|
590
|
+
return None
|
|
591
|
+
st = s.strip()
|
|
592
|
+
if not st or st.lower() == "none":
|
|
593
|
+
return None
|
|
594
|
+
try:
|
|
595
|
+
return float(st)
|
|
596
|
+
except Exception:
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
domains_raw = _split_top_level(str(merged.get("domains", "")))
|
|
600
|
+
vlines_raw = _split_top_level(str(merged.get("vlines", "")))
|
|
601
|
+
hlines_raw = _split_top_level(str(merged.get("hlines", "")))
|
|
602
|
+
xlims_raw = _split_top_level(str(merged.get("xlims", "")))
|
|
603
|
+
ylims_raw = _split_top_level(str(merged.get("ylims", "")))
|
|
604
|
+
lines_raw = _split_top_level(str(merged.get("lines", "")))
|
|
605
|
+
points_raw = _split_top_level(str(merged.get("points", "")))
|
|
606
|
+
|
|
607
|
+
# Normalize sizes to match number of functions
|
|
608
|
+
n = len(functions)
|
|
609
|
+
|
|
610
|
+
def _pad(lst, fill="None"):
|
|
611
|
+
return lst + [fill] * max(0, n - len(lst))
|
|
612
|
+
|
|
613
|
+
domains_raw = _pad(domains_raw)
|
|
614
|
+
vlines_raw = _pad(vlines_raw)
|
|
615
|
+
hlines_raw = _pad(hlines_raw)
|
|
616
|
+
xlims_raw = _pad(xlims_raw)
|
|
617
|
+
ylims_raw = _pad(ylims_raw)
|
|
618
|
+
lines_raw = _pad(lines_raw)
|
|
619
|
+
points_raw = _pad(points_raw)
|
|
620
|
+
|
|
621
|
+
dom_list: List[Tuple[float, float] | None] = []
|
|
622
|
+
excl_list: List[List[float]] = []
|
|
623
|
+
for s in domains_raw[:n]:
|
|
624
|
+
dom, ex = _parse_domain_with_exclusions(s)
|
|
625
|
+
dom_list.append(dom)
|
|
626
|
+
excl_list.append(ex)
|
|
627
|
+
|
|
628
|
+
vline_vals: List[List[float] | None] = [
|
|
629
|
+
_parse_values_list_or_none(s) for s in vlines_raw[:n]
|
|
630
|
+
]
|
|
631
|
+
hline_vals: List[List[float] | None] = [
|
|
632
|
+
_parse_values_list_or_none(s) for s in hlines_raw[:n]
|
|
633
|
+
]
|
|
634
|
+
xlim_vals: List[Tuple[float, float] | None] = [
|
|
635
|
+
_parse_tuple_or_none(s) for s in xlims_raw[:n]
|
|
636
|
+
]
|
|
637
|
+
ylim_vals: List[Tuple[float, float] | None] = [
|
|
638
|
+
_parse_tuple_or_none(s) for s in ylims_raw[:n]
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
# Parse per-axis line specs
|
|
642
|
+
def _parse_line_spec(s: str):
|
|
643
|
+
if not isinstance(s, str):
|
|
644
|
+
return None
|
|
645
|
+
st = s.strip()
|
|
646
|
+
if not st or st.lower() == "none":
|
|
647
|
+
return None
|
|
648
|
+
lit = _safe_literal(st)
|
|
649
|
+
a_val = None
|
|
650
|
+
b_val = None
|
|
651
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 2:
|
|
652
|
+
try:
|
|
653
|
+
a_val = float(lit[0])
|
|
654
|
+
except Exception:
|
|
655
|
+
a_val = None
|
|
656
|
+
second = lit[1]
|
|
657
|
+
if isinstance(second, (list, tuple)) and len(second) == 2:
|
|
658
|
+
try:
|
|
659
|
+
x0p = float(second[0])
|
|
660
|
+
y0p = float(second[1])
|
|
661
|
+
if a_val is not None:
|
|
662
|
+
b_val = y0p - a_val * x0p
|
|
663
|
+
except Exception:
|
|
664
|
+
b_val = None
|
|
665
|
+
else:
|
|
666
|
+
try:
|
|
667
|
+
b_val = float(second)
|
|
668
|
+
except Exception:
|
|
669
|
+
b_val = None
|
|
670
|
+
if a_val is not None and b_val is not None:
|
|
671
|
+
return (a_val, b_val)
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
line_specs: List[Tuple[float, float] | None] = [_parse_line_spec(s) for s in lines_raw[:n]]
|
|
675
|
+
|
|
676
|
+
# Parse per-axis points. Each entry can be:
|
|
677
|
+
# - "None" or empty => no points for that axis
|
|
678
|
+
# - a single tuple like (x,y)
|
|
679
|
+
# - a list/tuple of tuples: [(x1,y1),(x2,y2)] or ((x1,y1),(x2,y2))
|
|
680
|
+
# - a loose comma form: (x1,y1); (x2,y2)
|
|
681
|
+
def _parse_points_entry(s: str):
|
|
682
|
+
if not isinstance(s, str):
|
|
683
|
+
return None
|
|
684
|
+
st = s.strip()
|
|
685
|
+
if not st or st.lower() == "none":
|
|
686
|
+
return None
|
|
687
|
+
lit = _safe_literal(st)
|
|
688
|
+
points_list: List[Tuple[float, float]] = []
|
|
689
|
+
|
|
690
|
+
def _coerce_pair(obj):
|
|
691
|
+
try:
|
|
692
|
+
if (
|
|
693
|
+
isinstance(obj, (list, tuple))
|
|
694
|
+
and len(obj) == 2
|
|
695
|
+
and all(isinstance(v, (int, float)) for v in obj)
|
|
696
|
+
):
|
|
697
|
+
return (float(obj[0]), float(obj[1]))
|
|
698
|
+
except Exception:
|
|
699
|
+
return None
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
if isinstance(lit, (list, tuple)):
|
|
703
|
+
# Could be list of pairs or a single pair
|
|
704
|
+
if len(lit) == 2 and all(isinstance(v, (int, float)) for v in lit):
|
|
705
|
+
p = _coerce_pair(lit)
|
|
706
|
+
if p:
|
|
707
|
+
points_list.append(p)
|
|
708
|
+
else:
|
|
709
|
+
for item in lit:
|
|
710
|
+
p = _coerce_pair(item)
|
|
711
|
+
if p:
|
|
712
|
+
points_list.append(p)
|
|
713
|
+
return points_list or None
|
|
714
|
+
# Fallback: find all (x,y) pattern occurrences
|
|
715
|
+
import re as _re
|
|
716
|
+
|
|
717
|
+
matches = _re.findall(
|
|
718
|
+
r"\(\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*,\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s*\)",
|
|
719
|
+
st,
|
|
720
|
+
)
|
|
721
|
+
for a, b in matches:
|
|
722
|
+
try:
|
|
723
|
+
points_list.append((float(a), float(b)))
|
|
724
|
+
except Exception:
|
|
725
|
+
pass
|
|
726
|
+
return points_list or None
|
|
727
|
+
|
|
728
|
+
points_vals: List[List[Tuple[float, float]] | None] = [
|
|
729
|
+
_parse_points_entry(s) for s in points_raw[:n]
|
|
730
|
+
]
|
|
731
|
+
explicit_name = merged.get("name")
|
|
732
|
+
debug_mode = "debug" in merged
|
|
733
|
+
rows = int(float(merged.get("rows", 1)))
|
|
734
|
+
# If cols not provided, default to enough columns to fit all functions over the given rows
|
|
735
|
+
default_cols = max(1, (len(functions) + rows - 1) // max(1, rows))
|
|
736
|
+
cols = int(float(merged.get("cols", default_cols)))
|
|
737
|
+
|
|
738
|
+
# Include per-axis settings in the hash to prevent stale caches
|
|
739
|
+
content_hash = _hash_key(
|
|
740
|
+
"|".join(exprs),
|
|
741
|
+
"|".join(labels_list),
|
|
742
|
+
xmin,
|
|
743
|
+
xmax,
|
|
744
|
+
ymin,
|
|
745
|
+
ymax,
|
|
746
|
+
xstep,
|
|
747
|
+
ystep,
|
|
748
|
+
fontsize,
|
|
749
|
+
lw,
|
|
750
|
+
alpha,
|
|
751
|
+
rows,
|
|
752
|
+
cols,
|
|
753
|
+
int(grid_flag),
|
|
754
|
+
int(ticks_flag),
|
|
755
|
+
"|".join(["" if d is None else f"{d[0]},{d[1]}" for d in dom_list]),
|
|
756
|
+
"|".join(["|".join(map(str, exs)) if exs else "" for exs in excl_list]),
|
|
757
|
+
"|".join(["|".join(map(str, vs)) if vs else "" for vs in vline_vals]),
|
|
758
|
+
"|".join(["|".join(map(str, hs)) if hs else "" for hs in hline_vals]),
|
|
759
|
+
"|".join(["" if xl is None else f"{xl[0]},{xl[1]}" for xl in xlim_vals]),
|
|
760
|
+
"|".join(["" if yl is None else f"{yl[0]},{yl[1]}" for yl in ylim_vals]),
|
|
761
|
+
"|".join(["" if ls is None else f"{ls[0]},{ls[1]}" for ls in line_specs]),
|
|
762
|
+
"|".join(
|
|
763
|
+
[
|
|
764
|
+
"" if pv is None else ";".join([f"{p[0]},{p[1]}" for p in pv])
|
|
765
|
+
for pv in points_vals
|
|
766
|
+
]
|
|
767
|
+
),
|
|
768
|
+
)
|
|
769
|
+
base_name = explicit_name or f"multi_plot_{content_hash}"
|
|
770
|
+
|
|
771
|
+
rel_dir = os.path.join("_static", "multi_plot")
|
|
772
|
+
abs_dir = os.path.join(app.srcdir, rel_dir)
|
|
773
|
+
os.makedirs(abs_dir, exist_ok=True)
|
|
774
|
+
svg_name = f"{base_name}.svg"
|
|
775
|
+
abs_svg = os.path.join(abs_dir, svg_name)
|
|
776
|
+
|
|
777
|
+
regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
|
|
778
|
+
if regenerate:
|
|
779
|
+
try:
|
|
780
|
+
letters = [chr(i) for i in range(65, 65 + len(functions))]
|
|
781
|
+
# Create axes grid without auto-plotting functions
|
|
782
|
+
fig, axes = plotmath.multiplot(
|
|
783
|
+
functions=[],
|
|
784
|
+
fn_labels=False,
|
|
785
|
+
xmin=xmin,
|
|
786
|
+
xmax=xmax,
|
|
787
|
+
ymin=ymin,
|
|
788
|
+
ymax=ymax,
|
|
789
|
+
xstep=xstep,
|
|
790
|
+
ystep=ystep,
|
|
791
|
+
ticks=ticks_flag,
|
|
792
|
+
grid=grid_flag,
|
|
793
|
+
rows=rows,
|
|
794
|
+
cols=cols,
|
|
795
|
+
lw=lw,
|
|
796
|
+
alpha=alpha,
|
|
797
|
+
fontsize=fontsize,
|
|
798
|
+
figsize=(4.5 * cols, 3.5 * rows),
|
|
799
|
+
)
|
|
800
|
+
# Normalize axes to flat list
|
|
801
|
+
try:
|
|
802
|
+
import numpy as _np
|
|
803
|
+
|
|
804
|
+
axes_list = (
|
|
805
|
+
list(axes.flatten())
|
|
806
|
+
if hasattr(axes, "flatten")
|
|
807
|
+
else list(_np.array(axes).flatten())
|
|
808
|
+
)
|
|
809
|
+
except Exception:
|
|
810
|
+
axes_list = axes if isinstance(axes, (list, tuple)) else [axes]
|
|
811
|
+
|
|
812
|
+
# Ensure tick label font size matches provided fontsize (legend handled later)
|
|
813
|
+
try:
|
|
814
|
+
for _ax in axes_list:
|
|
815
|
+
try:
|
|
816
|
+
_ax.tick_params(labelsize=int(fontsize))
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
except Exception:
|
|
820
|
+
pass
|
|
821
|
+
|
|
822
|
+
# Manual plotting per-axis
|
|
823
|
+
import numpy as np
|
|
824
|
+
|
|
825
|
+
for idx, (expr, fn) in enumerate(zip(exprs, functions)):
|
|
826
|
+
if idx >= len(axes_list):
|
|
827
|
+
break
|
|
828
|
+
ax = axes_list[idx]
|
|
829
|
+
# Per-axis domain
|
|
830
|
+
dom = dom_list[idx]
|
|
831
|
+
x0, x1 = dom if dom is not None else (xmin, xmax)
|
|
832
|
+
N = int(2**12)
|
|
833
|
+
x = np.linspace(x0, x1, N)
|
|
834
|
+
y = fn(x)
|
|
835
|
+
# Ensure float array and blank out non-finite values
|
|
836
|
+
y = np.asarray(y, dtype=float)
|
|
837
|
+
y[~np.isfinite(y)] = np.nan
|
|
838
|
+
# Robust exclusions: widen window and clear neighbors
|
|
839
|
+
exs = [e for e in excl_list[idx] if x0 < e < x1]
|
|
840
|
+
if exs and N > 1:
|
|
841
|
+
dx = (x1 - x0) / (N - 1)
|
|
842
|
+
w = max(4 * dx, 1e-6 * (1.0 + max(abs(e) for e in exs)))
|
|
843
|
+
for e in exs:
|
|
844
|
+
try:
|
|
845
|
+
mask = np.abs(x - e) <= w
|
|
846
|
+
if mask.any():
|
|
847
|
+
y[mask] = np.nan
|
|
848
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
849
|
+
for k in (j - 2, j - 1, j, j + 1, j + 2):
|
|
850
|
+
if 0 <= k < y.size:
|
|
851
|
+
y[k] = np.nan
|
|
852
|
+
except Exception:
|
|
853
|
+
try:
|
|
854
|
+
j = int(np.argmin(np.abs(x - e)))
|
|
855
|
+
if 0 <= j < y.size:
|
|
856
|
+
y[j] = np.nan
|
|
857
|
+
except Exception:
|
|
858
|
+
pass
|
|
859
|
+
# Also break across steep jumps or extreme magnitudes
|
|
860
|
+
# Determine per-axis y-span preference: use provided ylim for this axis if any
|
|
861
|
+
if ylim_vals[idx] is not None:
|
|
862
|
+
y0_lim, y1_lim = ylim_vals[idx]
|
|
863
|
+
else:
|
|
864
|
+
y0_lim, y1_lim = ymin, ymax
|
|
865
|
+
try:
|
|
866
|
+
y_span = abs(float(y1_lim) - float(y0_lim))
|
|
867
|
+
except Exception:
|
|
868
|
+
y_span = np.nan
|
|
869
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
870
|
+
finite_y = y[np.isfinite(y)]
|
|
871
|
+
if finite_y.size > 0:
|
|
872
|
+
y_span = float(np.nanmax(finite_y) - np.nanmin(finite_y))
|
|
873
|
+
if not (isinstance(y_span, (int, float)) and y_span > 0):
|
|
874
|
+
y_span = 1.0
|
|
875
|
+
finite_pair = np.isfinite(y[:-1]) & np.isfinite(y[1:])
|
|
876
|
+
jump_factor = 0.5
|
|
877
|
+
big_jump = finite_pair & (np.abs(y[1:] - y[:-1]) > (jump_factor * y_span))
|
|
878
|
+
if big_jump.any():
|
|
879
|
+
idx_break = np.where(big_jump)[0]
|
|
880
|
+
for i_b in idx_break:
|
|
881
|
+
if 0 <= i_b + 1 < y.size:
|
|
882
|
+
y[i_b + 1] = np.nan
|
|
883
|
+
mag_factor = 50.0
|
|
884
|
+
too_big = np.isfinite(y) & (np.abs(y) > (mag_factor * y_span))
|
|
885
|
+
if too_big.any():
|
|
886
|
+
y[too_big] = np.nan
|
|
887
|
+
lbl = labels_list[idx] if (labels_list and idx < len(labels_list)) else None
|
|
888
|
+
if lbl:
|
|
889
|
+
ax.plot(x, y, lw=lw, alpha=alpha, label=f"${lbl}$")
|
|
890
|
+
ax.legend(fontsize=int(fontsize))
|
|
891
|
+
else:
|
|
892
|
+
ax.plot(x, y, lw=lw, alpha=alpha)
|
|
893
|
+
# Plot per-axis points if provided
|
|
894
|
+
try:
|
|
895
|
+
pv = points_vals[idx]
|
|
896
|
+
if pv:
|
|
897
|
+
xs = [p[0] for p in pv]
|
|
898
|
+
ys = [p[1] for p in pv]
|
|
899
|
+
ax.plot(
|
|
900
|
+
xs,
|
|
901
|
+
ys,
|
|
902
|
+
linestyle="none",
|
|
903
|
+
marker="o",
|
|
904
|
+
markersize=max(4, min(12, int(fontsize) // 2)),
|
|
905
|
+
color="black",
|
|
906
|
+
alpha=0.8,
|
|
907
|
+
)
|
|
908
|
+
except Exception:
|
|
909
|
+
pass
|
|
910
|
+
# Optional line y = a*x + b per axis
|
|
911
|
+
if line_specs[idx] is not None:
|
|
912
|
+
try:
|
|
913
|
+
a_l, b_l = line_specs[idx] # type: ignore[misc]
|
|
914
|
+
# Use provided xlim for this axis if any; else global
|
|
915
|
+
if xlim_vals[idx] is not None:
|
|
916
|
+
x_min_line, x_max_line = xlim_vals[idx]
|
|
917
|
+
else:
|
|
918
|
+
x_min_line, x_max_line = xmin, xmax
|
|
919
|
+
x_line = np.array([float(x_min_line), float(x_max_line)], dtype=float)
|
|
920
|
+
y_line = a_l * x_line + b_l
|
|
921
|
+
ax.plot(
|
|
922
|
+
x_line,
|
|
923
|
+
y_line,
|
|
924
|
+
linestyle="--",
|
|
925
|
+
color=plotmath.COLORS.get("red"),
|
|
926
|
+
lw=lw,
|
|
927
|
+
alpha=alpha,
|
|
928
|
+
)
|
|
929
|
+
except Exception:
|
|
930
|
+
pass
|
|
931
|
+
# vlines / hlines (support multiple values per axis)
|
|
932
|
+
for xv in vline_vals[idx] or []:
|
|
933
|
+
try:
|
|
934
|
+
ax.axvline(
|
|
935
|
+
x=float(xv),
|
|
936
|
+
color=plotmath.COLORS.get("red"),
|
|
937
|
+
linestyle="--",
|
|
938
|
+
lw=lw,
|
|
939
|
+
)
|
|
940
|
+
except Exception:
|
|
941
|
+
pass
|
|
942
|
+
for yh in hline_vals[idx] or []:
|
|
943
|
+
try:
|
|
944
|
+
ax.axhline(
|
|
945
|
+
y=float(yh),
|
|
946
|
+
color=plotmath.COLORS.get("red"),
|
|
947
|
+
linestyle="--",
|
|
948
|
+
lw=lw,
|
|
949
|
+
)
|
|
950
|
+
except Exception:
|
|
951
|
+
pass
|
|
952
|
+
# x/ylims
|
|
953
|
+
if xlim_vals[idx] is not None:
|
|
954
|
+
ax.set_xlim(*xlim_vals[idx])
|
|
955
|
+
if ylim_vals[idx] is not None:
|
|
956
|
+
ax.set_ylim(*ylim_vals[idx])
|
|
957
|
+
# Save via the single Figure object
|
|
958
|
+
fig.savefig(abs_svg, format="svg", bbox_inches="tight", transparent=True)
|
|
959
|
+
# Also save a PDF sidecar for debugging comparisons (optional)
|
|
960
|
+
# try:
|
|
961
|
+
# fig.savefig(
|
|
962
|
+
# os.path.join(abs_dir, f"{base_name}.pdf"),
|
|
963
|
+
# format="pdf",
|
|
964
|
+
# bbox_inches="tight",
|
|
965
|
+
# transparent=True,
|
|
966
|
+
# )
|
|
967
|
+
# except Exception:
|
|
968
|
+
# pass
|
|
969
|
+
import matplotlib
|
|
970
|
+
|
|
971
|
+
matplotlib.pyplot.close(fig)
|
|
972
|
+
except Exception as e:
|
|
973
|
+
return [
|
|
974
|
+
self.state_machine.reporter.error(
|
|
975
|
+
f"Feil under generering av graf: {e}", line=self.lineno
|
|
976
|
+
)
|
|
977
|
+
]
|
|
978
|
+
|
|
979
|
+
if not os.path.exists(abs_svg):
|
|
980
|
+
return [self.state_machine.reporter.error("multi-plot: SVG mangler.", line=self.lineno)]
|
|
981
|
+
|
|
982
|
+
env.note_dependency(abs_svg)
|
|
983
|
+
try: # copy to output _static
|
|
984
|
+
out_static = os.path.join(app.outdir, "_static", "multi_plot")
|
|
985
|
+
os.makedirs(out_static, exist_ok=True)
|
|
986
|
+
shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
|
|
987
|
+
except Exception:
|
|
988
|
+
pass
|
|
989
|
+
|
|
990
|
+
try:
|
|
991
|
+
raw_svg = open(abs_svg, "r", encoding="utf-8").read()
|
|
992
|
+
except Exception as e:
|
|
993
|
+
return [
|
|
994
|
+
self.state_machine.reporter.error(
|
|
995
|
+
f"graph inline: kunne ikke lese SVG: {e}", line=self.lineno
|
|
996
|
+
)
|
|
997
|
+
]
|
|
998
|
+
|
|
999
|
+
if not debug_mode and "viewBox" in raw_svg:
|
|
1000
|
+
raw_svg = _strip_root_svg_size(raw_svg)
|
|
1001
|
+
|
|
1002
|
+
def _rewrite_ids(txt: str, prefix: str) -> str:
|
|
1003
|
+
# Collect ids
|
|
1004
|
+
ids = re.findall(r'\bid="([^"]+)"', txt)
|
|
1005
|
+
if not ids:
|
|
1006
|
+
return txt
|
|
1007
|
+
# Skip font glyphs to avoid disrupting text rendering
|
|
1008
|
+
skip_prefixes = (
|
|
1009
|
+
"DejaVu",
|
|
1010
|
+
"CM",
|
|
1011
|
+
"STIX",
|
|
1012
|
+
"Nimbus",
|
|
1013
|
+
"Bitstream",
|
|
1014
|
+
"Arial",
|
|
1015
|
+
"Times",
|
|
1016
|
+
"Helvetica",
|
|
1017
|
+
)
|
|
1018
|
+
mapping = {}
|
|
1019
|
+
for i in ids:
|
|
1020
|
+
if i.startswith(skip_prefixes):
|
|
1021
|
+
continue
|
|
1022
|
+
mapping[i] = f"{prefix}{i}"
|
|
1023
|
+
if not mapping:
|
|
1024
|
+
return txt
|
|
1025
|
+
|
|
1026
|
+
# Replace id definitions
|
|
1027
|
+
def repl_id(m: re.Match) -> str:
|
|
1028
|
+
old = m.group(1)
|
|
1029
|
+
new = mapping.get(old, old)
|
|
1030
|
+
return f'id="{new}"'
|
|
1031
|
+
|
|
1032
|
+
txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
|
|
1033
|
+
|
|
1034
|
+
# Replace url(#id) everywhere (attributes and styles)
|
|
1035
|
+
def repl_url(m: re.Match) -> str:
|
|
1036
|
+
old = m.group(1).strip()
|
|
1037
|
+
new = mapping.get(old, old)
|
|
1038
|
+
return f"url(#{new})"
|
|
1039
|
+
|
|
1040
|
+
txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
|
|
1041
|
+
|
|
1042
|
+
# Replace href/xlink:href references supporting both quote styles
|
|
1043
|
+
def repl_href(m: re.Match) -> str:
|
|
1044
|
+
attr = m.group(1)
|
|
1045
|
+
quote = m.group(2)
|
|
1046
|
+
old = m.group(3).strip()
|
|
1047
|
+
new = mapping.get(old, old)
|
|
1048
|
+
return f"{attr}={quote}#{new}{quote}"
|
|
1049
|
+
|
|
1050
|
+
txt = re.sub(
|
|
1051
|
+
r'(xlink:href|href)\s*=\s*(["\"])#\s*([^"\"]+)\s*\2',
|
|
1052
|
+
repl_href,
|
|
1053
|
+
txt,
|
|
1054
|
+
)
|
|
1055
|
+
return txt
|
|
1056
|
+
|
|
1057
|
+
if not debug_mode:
|
|
1058
|
+
raw_svg = _rewrite_ids(raw_svg, f"mgr_{content_hash}_{uuid.uuid4().hex[:6]}_")
|
|
1059
|
+
|
|
1060
|
+
alt_default = f"Multiplot av {len(exprs)} funksjoner"
|
|
1061
|
+
alt = merged.get("alt", alt_default)
|
|
1062
|
+
|
|
1063
|
+
width_opt = merged.get("width")
|
|
1064
|
+
percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
|
|
1065
|
+
|
|
1066
|
+
def _augment(m):
|
|
1067
|
+
tag = m.group(0)
|
|
1068
|
+
if "class=" not in tag:
|
|
1069
|
+
tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
|
|
1070
|
+
else:
|
|
1071
|
+
tag = tag.replace('class="', 'class="graph-inline-svg ')
|
|
1072
|
+
if alt and "aria-label=" not in tag:
|
|
1073
|
+
tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
|
|
1074
|
+
if width_opt:
|
|
1075
|
+
if percent:
|
|
1076
|
+
wval = width_opt.strip()
|
|
1077
|
+
else:
|
|
1078
|
+
wval = width_opt.strip()
|
|
1079
|
+
if wval.isdigit():
|
|
1080
|
+
wval += "px"
|
|
1081
|
+
style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
|
|
1082
|
+
if "style=" in tag:
|
|
1083
|
+
tag = re.sub(
|
|
1084
|
+
r'style="([^"]*)"',
|
|
1085
|
+
lambda mm: f'style="{mm.group(1)}; {style_frag}"',
|
|
1086
|
+
tag,
|
|
1087
|
+
count=1,
|
|
1088
|
+
)
|
|
1089
|
+
else:
|
|
1090
|
+
tag = tag[:-1] + f' style="{style_frag}"' + ">"
|
|
1091
|
+
return tag
|
|
1092
|
+
|
|
1093
|
+
raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
|
|
1094
|
+
# Intentionally do not inject a <title> element to avoid hover tooltips; accessibility
|
|
1095
|
+
# remains via role="img" and aria-label attributes. Add manually later if truly needed.
|
|
1096
|
+
|
|
1097
|
+
figure = nodes.figure()
|
|
1098
|
+
figure.setdefault("classes", []).extend(
|
|
1099
|
+
["adaptive-figure", "multi-plot-figure", "no-click"]
|
|
1100
|
+
)
|
|
1101
|
+
raw_node = nodes.raw("", raw_svg, format="html")
|
|
1102
|
+
raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
|
|
1103
|
+
figure += raw_node
|
|
1104
|
+
|
|
1105
|
+
extra_classes = merged.get("class")
|
|
1106
|
+
if extra_classes:
|
|
1107
|
+
figure["classes"].extend(extra_classes)
|
|
1108
|
+
figure["align"] = merged.get("align", "center")
|
|
1109
|
+
|
|
1110
|
+
caption_lines = list(self.content)[caption_idx:]
|
|
1111
|
+
while caption_lines and not caption_lines[0].strip():
|
|
1112
|
+
caption_lines.pop(0)
|
|
1113
|
+
if caption_lines:
|
|
1114
|
+
caption = nodes.caption()
|
|
1115
|
+
caption += nodes.Text("\n".join(caption_lines))
|
|
1116
|
+
figure += caption
|
|
1117
|
+
|
|
1118
|
+
if explicit_name:
|
|
1119
|
+
self.add_name(figure)
|
|
1120
|
+
return [figure]
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def setup(app): # pragma: no cover
|
|
1124
|
+
app.add_directive("multi-plot", MultiPlotDirective)
|
|
1125
|
+
app.add_directive("multiplot", MultiPlotDirective) # Also register without hyphen
|
|
1126
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|