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,1545 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sign Chart 2 Directive for Munchboka Edutools
|
|
3
|
+
==============================================
|
|
4
|
+
|
|
5
|
+
An improved sign chart directive with embedded plotting logic that supports:
|
|
6
|
+
- Polynomial functions
|
|
7
|
+
- Rational functions
|
|
8
|
+
- Transcendental functions (sin, cos, exp, log, etc.)
|
|
9
|
+
- Custom domains for numerical zero finding
|
|
10
|
+
- Flexible styling options
|
|
11
|
+
|
|
12
|
+
Usage in MyST Markdown:
|
|
13
|
+
```{signchart-2}
|
|
14
|
+
---
|
|
15
|
+
function: x**2 - 4, f(x)
|
|
16
|
+
factors: true
|
|
17
|
+
domain: (-5, 5)
|
|
18
|
+
width: 100%
|
|
19
|
+
---
|
|
20
|
+
Optional caption text
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Features:
|
|
24
|
+
- Automatic factorization and zero finding
|
|
25
|
+
- Support for singularities (poles/discontinuities)
|
|
26
|
+
- Numerical zero finding for transcendental functions
|
|
27
|
+
- Configurable colors, labels, and figure sizes
|
|
28
|
+
- SVG output with theme-aware styling
|
|
29
|
+
- Caching for faster builds
|
|
30
|
+
|
|
31
|
+
Author: René Aasen
|
|
32
|
+
Date: January 2026
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import hashlib
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import uuid
|
|
42
|
+
import platform
|
|
43
|
+
import warnings
|
|
44
|
+
from typing import Any, Dict, List, Tuple
|
|
45
|
+
|
|
46
|
+
import numpy as np
|
|
47
|
+
import matplotlib.pyplot as plt
|
|
48
|
+
import sympy as sp
|
|
49
|
+
|
|
50
|
+
from docutils import nodes
|
|
51
|
+
from docutils.parsers.rst import directives
|
|
52
|
+
from sphinx.util.docutils import SphinxDirective
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ------------------------------------
|
|
56
|
+
# LaTeX Configuration
|
|
57
|
+
# ------------------------------------
|
|
58
|
+
|
|
59
|
+
# Check if LaTeX is available
|
|
60
|
+
if platform.system() == "Windows":
|
|
61
|
+
latex_available = shutil.which("latex.exe") is not None
|
|
62
|
+
else:
|
|
63
|
+
latex_available = shutil.which("latex") is not None
|
|
64
|
+
|
|
65
|
+
if latex_available:
|
|
66
|
+
try:
|
|
67
|
+
plt.rc("text", usetex=True)
|
|
68
|
+
except (FileNotFoundError, RuntimeError):
|
|
69
|
+
plt.rc("text", usetex=False)
|
|
70
|
+
else:
|
|
71
|
+
plt.rc("text", usetex=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ------------------------------------
|
|
75
|
+
# Core Plotting Functions
|
|
76
|
+
# ------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_zeros_and_singularities(f, x, domain=None):
|
|
80
|
+
"""Find zeros and singularities (poles/discontinuities) of arbitrary functions.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
f: sympy expression
|
|
84
|
+
x: variable symbol
|
|
85
|
+
domain: optional tuple (x_min, x_max) to search for numerical zeros
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
dict with 'zeros' and 'singularities' lists containing symbolic/numeric values
|
|
89
|
+
"""
|
|
90
|
+
zeros = []
|
|
91
|
+
singularities = []
|
|
92
|
+
|
|
93
|
+
# Detect singularities FIRST (for rational/transcendental functions)
|
|
94
|
+
try:
|
|
95
|
+
# Check denominator for rational functions
|
|
96
|
+
numer, denom = f.as_numer_denom()
|
|
97
|
+
if denom != 1:
|
|
98
|
+
denom_zeros = sp.solve(denom, x, domain=sp.S.Reals)
|
|
99
|
+
for z in denom_zeros:
|
|
100
|
+
if z.is_real or (hasattr(z, "is_real") and z.is_real is not False):
|
|
101
|
+
singularities.append(z)
|
|
102
|
+
except:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Try symbolic solving for zeros
|
|
106
|
+
try:
|
|
107
|
+
symbolic_zeros = sp.solve(f, x, domain=sp.S.Reals)
|
|
108
|
+
if symbolic_zeros:
|
|
109
|
+
for z in symbolic_zeros:
|
|
110
|
+
if z.is_real or (hasattr(z, "is_real") and z.is_real is not False):
|
|
111
|
+
# Make sure this isn't a singularity
|
|
112
|
+
is_sing = False
|
|
113
|
+
for sing in singularities:
|
|
114
|
+
try:
|
|
115
|
+
if abs(float((z - sing).evalf())) < 1e-10:
|
|
116
|
+
is_sing = True
|
|
117
|
+
break
|
|
118
|
+
except:
|
|
119
|
+
pass
|
|
120
|
+
if not is_sing:
|
|
121
|
+
zeros.append(z)
|
|
122
|
+
except (NotImplementedError, ValueError):
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
# For transcendental/complex functions, find numerical zeros if domain provided
|
|
126
|
+
if domain:
|
|
127
|
+
try:
|
|
128
|
+
# Sample points to find sign changes
|
|
129
|
+
x_min, x_max = domain
|
|
130
|
+
test_points = np.linspace(float(x_min), float(x_max), 1000)
|
|
131
|
+
f_lamb = sp.lambdify(x, f, modules=["numpy"])
|
|
132
|
+
|
|
133
|
+
# Suppress warnings for evaluation at singularities
|
|
134
|
+
with warnings.catch_warnings():
|
|
135
|
+
warnings.simplefilter("ignore")
|
|
136
|
+
y_vals = []
|
|
137
|
+
valid_x = []
|
|
138
|
+
for xp in test_points:
|
|
139
|
+
try:
|
|
140
|
+
yp = float(f_lamb(xp))
|
|
141
|
+
# Exclude points near singularities
|
|
142
|
+
near_sing = False
|
|
143
|
+
for sing in singularities:
|
|
144
|
+
try:
|
|
145
|
+
if abs(xp - float(sing.evalf())) < 1e-4:
|
|
146
|
+
near_sing = True
|
|
147
|
+
break
|
|
148
|
+
except:
|
|
149
|
+
pass
|
|
150
|
+
if not near_sing and np.isfinite(yp) and abs(yp) < 1e10:
|
|
151
|
+
y_vals.append(yp)
|
|
152
|
+
valid_x.append(xp)
|
|
153
|
+
except:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
y_vals = np.array(y_vals)
|
|
157
|
+
valid_x = np.array(valid_x)
|
|
158
|
+
|
|
159
|
+
# Find sign changes (potential zeros) and near-zero values
|
|
160
|
+
numerical_zeros = []
|
|
161
|
+
if len(y_vals) > 1:
|
|
162
|
+
# Check for sign changes
|
|
163
|
+
signs = np.sign(y_vals)
|
|
164
|
+
sign_changes = np.where(np.diff(signs) != 0)[0]
|
|
165
|
+
|
|
166
|
+
for idx in sign_changes:
|
|
167
|
+
# Skip if both values are zero (avoid duplicates)
|
|
168
|
+
if abs(y_vals[idx]) < 1e-10 and abs(y_vals[idx + 1]) < 1e-10:
|
|
169
|
+
continue
|
|
170
|
+
# Refine zero location
|
|
171
|
+
try:
|
|
172
|
+
from scipy.optimize import brentq
|
|
173
|
+
|
|
174
|
+
x_zero = brentq(f_lamb, valid_x[idx], valid_x[idx + 1], xtol=1e-10)
|
|
175
|
+
numerical_zeros.append(sp.Float(x_zero))
|
|
176
|
+
except:
|
|
177
|
+
# Use midpoint if brentq fails
|
|
178
|
+
x_zero = (valid_x[idx] + valid_x[idx + 1]) / 2
|
|
179
|
+
numerical_zeros.append(sp.Float(x_zero))
|
|
180
|
+
|
|
181
|
+
# Also check for near-zero values at sample points
|
|
182
|
+
near_zero_idx = np.where(np.abs(y_vals) < 1e-10)[0]
|
|
183
|
+
for idx in near_zero_idx:
|
|
184
|
+
x_val = sp.Float(valid_x[idx])
|
|
185
|
+
# Verify it's actually a zero by checking nearby points
|
|
186
|
+
# (avoid singularities that happen to evaluate near zero)
|
|
187
|
+
try:
|
|
188
|
+
y_val = float(f_lamb(float(x_val)))
|
|
189
|
+
eps = 1e-6
|
|
190
|
+
y_left = float(f_lamb(float(x_val) - eps))
|
|
191
|
+
y_right = float(f_lamb(float(x_val) + eps))
|
|
192
|
+
# Only add if function is continuous and actually crosses zero
|
|
193
|
+
if np.isfinite(y_left) and np.isfinite(y_right) and abs(y_val) < 1e-8:
|
|
194
|
+
# Avoid duplicates
|
|
195
|
+
if not any(abs(float(z - x_val)) < 1e-8 for z in numerical_zeros):
|
|
196
|
+
numerical_zeros.append(x_val)
|
|
197
|
+
except:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
# Merge symbolic and numerical zeros, removing duplicates and singularities
|
|
201
|
+
all_zeros = list(zeros) # Start with symbolic zeros
|
|
202
|
+
for nz in numerical_zeros:
|
|
203
|
+
# Check if this is actually a singularity
|
|
204
|
+
is_singularity = False
|
|
205
|
+
for sing in singularities:
|
|
206
|
+
try:
|
|
207
|
+
if abs(float(sing.evalf() - nz.evalf())) < 1e-6:
|
|
208
|
+
is_singularity = True
|
|
209
|
+
break
|
|
210
|
+
except:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
if is_singularity:
|
|
214
|
+
continue # Skip singularities
|
|
215
|
+
|
|
216
|
+
# Check if this zero is already in the list (symbolic or numeric)
|
|
217
|
+
is_duplicate = False
|
|
218
|
+
for z in all_zeros:
|
|
219
|
+
try:
|
|
220
|
+
if abs(float(z.evalf() - nz.evalf())) < 1e-8:
|
|
221
|
+
is_duplicate = True
|
|
222
|
+
break
|
|
223
|
+
except:
|
|
224
|
+
pass
|
|
225
|
+
if not is_duplicate:
|
|
226
|
+
all_zeros.append(nz)
|
|
227
|
+
|
|
228
|
+
zeros = all_zeros
|
|
229
|
+
except:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
return {"zeros": zeros, "singularities": singularities}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_factors(polynomial, x):
|
|
236
|
+
"""Get factors for polynomial functions (legacy support)."""
|
|
237
|
+
polynomial = sp.expand(polynomial)
|
|
238
|
+
factor_list = sp.factor_list(polynomial)
|
|
239
|
+
leading_coeff = factor_list[0]
|
|
240
|
+
|
|
241
|
+
linear_factors = (
|
|
242
|
+
[{"expression": leading_coeff, "exponent": 1, "root": -np.inf}]
|
|
243
|
+
if leading_coeff != 1
|
|
244
|
+
else []
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
for linear_factor, exponent in factor_list[1]:
|
|
248
|
+
exponent = int(exponent)
|
|
249
|
+
roots = sp.solve(linear_factor, x)
|
|
250
|
+
|
|
251
|
+
# Handle factors that may have multiple real roots (e.g., quadratics like x^2 - 2)
|
|
252
|
+
if not roots:
|
|
253
|
+
linear_factors.append(
|
|
254
|
+
{
|
|
255
|
+
"expression": linear_factor,
|
|
256
|
+
"exponent": exponent,
|
|
257
|
+
"root": -np.inf,
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
# For each root of the factor, create a separate entry with the correct exponent
|
|
262
|
+
for root_value in roots:
|
|
263
|
+
# Only include real roots
|
|
264
|
+
if root_value.is_real:
|
|
265
|
+
linear_factors.append(
|
|
266
|
+
{
|
|
267
|
+
"expression": sp.simplify(x - root_value),
|
|
268
|
+
"exponent": exponent, # Use the actual exponent from factorization
|
|
269
|
+
"root": root_value,
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return linear_factors
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def get_transcendental_factors(f, x, zeros, singularities):
|
|
277
|
+
"""Extract factors from transcendental or composite functions.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
f: sympy expression
|
|
281
|
+
x: variable symbol
|
|
282
|
+
zeros: list of zeros found numerically/symbolically
|
|
283
|
+
singularities: list of singularities (poles)
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
list of factor dictionaries (one per unique expression+exponent, with all roots)
|
|
287
|
+
"""
|
|
288
|
+
factors = []
|
|
289
|
+
|
|
290
|
+
# First check if it's a rational function with transcendental parts
|
|
291
|
+
try:
|
|
292
|
+
numer, denom = f.as_numer_denom()
|
|
293
|
+
if denom != 1 and not (denom.is_polynomial() and numer.is_polynomial()):
|
|
294
|
+
# Transcendental rational function - process numerator and denominator separately
|
|
295
|
+
# Process numerator
|
|
296
|
+
numer_factors = _extract_factors_from_expression(numer, x, zeros, [])
|
|
297
|
+
factors.extend(numer_factors)
|
|
298
|
+
|
|
299
|
+
# Process denominator
|
|
300
|
+
denom_factors = _extract_factors_from_expression(denom, x, [], singularities)
|
|
301
|
+
factors.extend(denom_factors)
|
|
302
|
+
|
|
303
|
+
return factors
|
|
304
|
+
except:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
# Not a rational function, process as a single expression
|
|
308
|
+
return _extract_factors_from_expression(f, x, zeros, singularities)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _extract_factors_from_expression(expr, x, zeros, singularities):
|
|
312
|
+
"""Helper function to extract factors from a single expression (not a rational function).
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
expr: sympy expression (numerator or denominator)
|
|
316
|
+
x: variable symbol
|
|
317
|
+
zeros: zeros to associate with this expression
|
|
318
|
+
singularities: singularities to associate with this expression
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
list of factor dictionaries
|
|
322
|
+
"""
|
|
323
|
+
factors = []
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
# Try to factor the expression
|
|
327
|
+
factored = sp.factor(expr)
|
|
328
|
+
|
|
329
|
+
# Check if it's a power expression (e.g., cos(x)**3)
|
|
330
|
+
if factored.is_Pow:
|
|
331
|
+
base = factored.base
|
|
332
|
+
exponent = int(factored.exp) if factored.exp.is_integer else 1
|
|
333
|
+
|
|
334
|
+
# Find zeros of the base
|
|
335
|
+
base_zeros = []
|
|
336
|
+
try:
|
|
337
|
+
base_zeros_sym = sp.solve(base, x, domain=sp.S.Reals)
|
|
338
|
+
for z in base_zeros_sym:
|
|
339
|
+
if z.is_real:
|
|
340
|
+
# Match with known zeros
|
|
341
|
+
for known_zero in zeros:
|
|
342
|
+
try:
|
|
343
|
+
if abs(float(z.evalf()) - float(known_zero.evalf())) < 1e-8:
|
|
344
|
+
base_zeros.append(known_zero)
|
|
345
|
+
break
|
|
346
|
+
except:
|
|
347
|
+
pass
|
|
348
|
+
except:
|
|
349
|
+
# If symbolic solve fails, use all known zeros
|
|
350
|
+
base_zeros = zeros
|
|
351
|
+
|
|
352
|
+
# Create single factor with all zeros
|
|
353
|
+
if base_zeros:
|
|
354
|
+
factors.append(
|
|
355
|
+
{
|
|
356
|
+
"expression": base,
|
|
357
|
+
"exponent": exponent,
|
|
358
|
+
"roots": base_zeros, # Changed: store all roots together
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# If it's a product, try to extract individual factors
|
|
363
|
+
elif factored.is_Mul:
|
|
364
|
+
# Group factors by (expression, exponent) to avoid duplicates
|
|
365
|
+
factor_dict = {}
|
|
366
|
+
|
|
367
|
+
for arg in factored.args:
|
|
368
|
+
# Check if this arg is a power
|
|
369
|
+
if arg.is_Pow:
|
|
370
|
+
base = arg.base
|
|
371
|
+
exponent = int(arg.exp) if arg.exp.is_integer else 1
|
|
372
|
+
expr_to_use = base
|
|
373
|
+
else:
|
|
374
|
+
expr_to_use = arg
|
|
375
|
+
exponent = 1
|
|
376
|
+
|
|
377
|
+
# Create a key for grouping
|
|
378
|
+
key = (str(expr_to_use), exponent)
|
|
379
|
+
|
|
380
|
+
if key not in factor_dict:
|
|
381
|
+
factor_dict[key] = {
|
|
382
|
+
"expression": expr_to_use,
|
|
383
|
+
"exponent": exponent,
|
|
384
|
+
"zeros": [],
|
|
385
|
+
"singularities": [],
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# Find zeros of this factor
|
|
389
|
+
try:
|
|
390
|
+
arg_zeros = sp.solve(expr_to_use, x, domain=sp.S.Reals)
|
|
391
|
+
for z in arg_zeros:
|
|
392
|
+
if z.is_real:
|
|
393
|
+
# Check if this zero is in our list
|
|
394
|
+
for known_zero in zeros:
|
|
395
|
+
try:
|
|
396
|
+
if abs(float(z.evalf()) - float(known_zero.evalf())) < 1e-8:
|
|
397
|
+
if known_zero not in factor_dict[key]["zeros"]:
|
|
398
|
+
factor_dict[key]["zeros"].append(known_zero)
|
|
399
|
+
break
|
|
400
|
+
except:
|
|
401
|
+
pass
|
|
402
|
+
except:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
# Check for singularities in denominator
|
|
406
|
+
try:
|
|
407
|
+
numer, denom = expr_to_use.as_numer_denom()
|
|
408
|
+
if denom != 1:
|
|
409
|
+
denom_zeros = sp.solve(denom, x, domain=sp.S.Reals)
|
|
410
|
+
for z in denom_zeros:
|
|
411
|
+
if z.is_real:
|
|
412
|
+
for known_sing in singularities:
|
|
413
|
+
try:
|
|
414
|
+
if abs(float(z.evalf()) - float(known_sing.evalf())) < 1e-8:
|
|
415
|
+
if known_sing not in factor_dict[key]["singularities"]:
|
|
416
|
+
factor_dict[key]["singularities"].append(known_sing)
|
|
417
|
+
break
|
|
418
|
+
except:
|
|
419
|
+
pass
|
|
420
|
+
except:
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
# Convert grouped factors to list
|
|
424
|
+
# Include ALL factors, even those without zeros/singularities
|
|
425
|
+
for factor_info in factor_dict.values():
|
|
426
|
+
all_roots = factor_info["zeros"] + factor_info["singularities"]
|
|
427
|
+
# Always add the factor, even if it has no roots
|
|
428
|
+
factors.append(
|
|
429
|
+
{
|
|
430
|
+
"expression": factor_info["expression"],
|
|
431
|
+
"exponent": factor_info["exponent"],
|
|
432
|
+
"roots": all_roots if all_roots else [],
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
# Not a product or power, show as single factor
|
|
437
|
+
all_roots = zeros + singularities
|
|
438
|
+
factors.append(
|
|
439
|
+
{
|
|
440
|
+
"expression": factored,
|
|
441
|
+
"exponent": 1,
|
|
442
|
+
"roots": all_roots if all_roots else [],
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
except:
|
|
446
|
+
# Fallback: just use the original expression
|
|
447
|
+
all_roots = zeros + singularities
|
|
448
|
+
factors.append(
|
|
449
|
+
{
|
|
450
|
+
"expression": expr,
|
|
451
|
+
"exponent": 1,
|
|
452
|
+
"roots": all_roots if all_roots else [],
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return factors
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def sort_factors(factors):
|
|
460
|
+
def get_numeric_root(factor):
|
|
461
|
+
# Handle both old format ("root") and new format ("roots")
|
|
462
|
+
if "roots" in factor and factor["roots"]:
|
|
463
|
+
# For new format, use the first root for sorting
|
|
464
|
+
root = factor["roots"][0]
|
|
465
|
+
elif "root" in factor:
|
|
466
|
+
root = factor.get("root")
|
|
467
|
+
else:
|
|
468
|
+
# No roots - sort to end
|
|
469
|
+
return np.inf
|
|
470
|
+
|
|
471
|
+
if root == -np.inf or root is None:
|
|
472
|
+
return -np.inf
|
|
473
|
+
try:
|
|
474
|
+
# Try to convert symbolic roots to float for comparison
|
|
475
|
+
return float(root.evalf())
|
|
476
|
+
except (AttributeError, TypeError):
|
|
477
|
+
try:
|
|
478
|
+
return float(root)
|
|
479
|
+
except:
|
|
480
|
+
return -np.inf
|
|
481
|
+
|
|
482
|
+
factors = sorted(factors, key=get_numeric_root)
|
|
483
|
+
return factors
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def draw_factors(
|
|
487
|
+
f,
|
|
488
|
+
factors,
|
|
489
|
+
roots,
|
|
490
|
+
root_positions,
|
|
491
|
+
ax,
|
|
492
|
+
color_pos,
|
|
493
|
+
color_neg,
|
|
494
|
+
x,
|
|
495
|
+
dy=-1,
|
|
496
|
+
dx=0.02,
|
|
497
|
+
):
|
|
498
|
+
x_min = -0.05
|
|
499
|
+
x_max = 1.05
|
|
500
|
+
# Draw horizontal sign lines for each factor
|
|
501
|
+
for i, factor in enumerate(factors):
|
|
502
|
+
expression = factor.get("expression")
|
|
503
|
+
exponent = factor.get("exponent")
|
|
504
|
+
|
|
505
|
+
# Handle both old format (single "root") and new format (multiple "roots")
|
|
506
|
+
factor_roots = (
|
|
507
|
+
factor.get("roots", [factor.get("root")])
|
|
508
|
+
if factor.get("root") is not None or factor.get("roots")
|
|
509
|
+
else []
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Use LaTeX rendering for proper mathematical notation
|
|
513
|
+
try:
|
|
514
|
+
expression_latex = sp.latex(expression)
|
|
515
|
+
except:
|
|
516
|
+
# Fallback to string representation if latex fails
|
|
517
|
+
expression_latex = str(expression)
|
|
518
|
+
|
|
519
|
+
if exponent > 1:
|
|
520
|
+
s = f"$({expression_latex})^{{{exponent}}}$"
|
|
521
|
+
else:
|
|
522
|
+
s = f"${expression_latex}$"
|
|
523
|
+
|
|
524
|
+
plt.text(
|
|
525
|
+
x=-0.1,
|
|
526
|
+
y=(i + 1) * dy,
|
|
527
|
+
s=s,
|
|
528
|
+
fontsize=16,
|
|
529
|
+
ha="right",
|
|
530
|
+
va="center",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# If no real roots (constant factor)
|
|
534
|
+
if -np.inf in factor_roots or not factor_roots:
|
|
535
|
+
y_value = sp.sympify(expression).evalf(subs={x: 0})
|
|
536
|
+
if y_value > 0:
|
|
537
|
+
ax.plot(
|
|
538
|
+
[x_min, x_max],
|
|
539
|
+
[(i + 1) * dy, (i + 1) * dy],
|
|
540
|
+
color=color_pos,
|
|
541
|
+
linestyle="-",
|
|
542
|
+
lw=2,
|
|
543
|
+
)
|
|
544
|
+
else:
|
|
545
|
+
ax.plot(
|
|
546
|
+
[x_min, x_max],
|
|
547
|
+
[(i + 1) * dy, (i + 1) * dy],
|
|
548
|
+
color=color_neg,
|
|
549
|
+
linestyle="--",
|
|
550
|
+
lw=2,
|
|
551
|
+
)
|
|
552
|
+
else:
|
|
553
|
+
# Sort roots for drawing
|
|
554
|
+
sorted_roots = sorted(
|
|
555
|
+
[r for r in factor_roots if r != -np.inf],
|
|
556
|
+
key=lambda r: float(r.evalf()),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# Determine if even exponent (doesn't change sign) or odd (changes sign)
|
|
560
|
+
is_even_exponent = exponent % 2 == 0
|
|
561
|
+
|
|
562
|
+
# Create the full expression with exponent for evaluation
|
|
563
|
+
expr = expression
|
|
564
|
+
if exponent > 1:
|
|
565
|
+
full_expr = expr**exponent
|
|
566
|
+
else:
|
|
567
|
+
full_expr = expr
|
|
568
|
+
|
|
569
|
+
# Draw segments between roots
|
|
570
|
+
# We need to evaluate signs in each interval
|
|
571
|
+
all_positions = [x_min] + [root_positions[r] for r in sorted_roots] + [x_max]
|
|
572
|
+
|
|
573
|
+
for j in range(len(all_positions) - 1):
|
|
574
|
+
left_pos = all_positions[j]
|
|
575
|
+
right_pos = all_positions[j + 1]
|
|
576
|
+
|
|
577
|
+
# Add gap around roots (except at boundaries)
|
|
578
|
+
if j > 0: # Not the leftmost segment
|
|
579
|
+
left_pos += dx
|
|
580
|
+
if j < len(all_positions) - 2: # Not the rightmost segment
|
|
581
|
+
right_pos -= dx
|
|
582
|
+
|
|
583
|
+
# Map position back to actual x value for evaluation
|
|
584
|
+
# Position is normalized 0-1, map to actual domain
|
|
585
|
+
if j < len(sorted_roots):
|
|
586
|
+
if j == 0:
|
|
587
|
+
# Before first root
|
|
588
|
+
test_x = float(sorted_roots[0].evalf()) - 1
|
|
589
|
+
else:
|
|
590
|
+
# Between roots
|
|
591
|
+
test_x = (
|
|
592
|
+
float(sorted_roots[j - 1].evalf()) + float(sorted_roots[j].evalf())
|
|
593
|
+
) / 2
|
|
594
|
+
else:
|
|
595
|
+
# After last root
|
|
596
|
+
test_x = float(sorted_roots[-1].evalf()) + 1
|
|
597
|
+
|
|
598
|
+
# Evaluate sign at test point
|
|
599
|
+
try:
|
|
600
|
+
y_val = sp.sympify(full_expr).evalf(subs={x: test_x})
|
|
601
|
+
is_positive = y_val > 0
|
|
602
|
+
color = color_pos if is_positive else color_neg
|
|
603
|
+
linestyle = "-" if is_positive else "--"
|
|
604
|
+
except:
|
|
605
|
+
# Fallback
|
|
606
|
+
color = color_pos
|
|
607
|
+
linestyle = "-"
|
|
608
|
+
|
|
609
|
+
# Draw segment
|
|
610
|
+
ax.plot(
|
|
611
|
+
[left_pos, right_pos],
|
|
612
|
+
[(i + 1) * dy, (i + 1) * dy],
|
|
613
|
+
color=color,
|
|
614
|
+
linestyle=linestyle,
|
|
615
|
+
lw=2,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Draw markers at roots
|
|
619
|
+
for root in sorted_roots:
|
|
620
|
+
root_pos = root_positions[root]
|
|
621
|
+
|
|
622
|
+
# Check if it's a zero or singularity
|
|
623
|
+
try:
|
|
624
|
+
f_at_root = str(f.subs(x, root))
|
|
625
|
+
is_singularity = f_at_root == "zoo" or "zoo" in f_at_root
|
|
626
|
+
except:
|
|
627
|
+
is_singularity = False
|
|
628
|
+
|
|
629
|
+
if is_singularity:
|
|
630
|
+
plt.text(
|
|
631
|
+
x=root_pos + 0.005,
|
|
632
|
+
y=(i + 1) * dy,
|
|
633
|
+
s=f"$\\times$",
|
|
634
|
+
fontsize=24,
|
|
635
|
+
ha="center",
|
|
636
|
+
va="center",
|
|
637
|
+
)
|
|
638
|
+
else:
|
|
639
|
+
plt.text(
|
|
640
|
+
x=root_pos,
|
|
641
|
+
y=(i + 1) * dy,
|
|
642
|
+
s=f"$0$",
|
|
643
|
+
fontsize=20,
|
|
644
|
+
ha="center",
|
|
645
|
+
va="center",
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def draw_function(
|
|
650
|
+
factors,
|
|
651
|
+
roots,
|
|
652
|
+
root_positions,
|
|
653
|
+
ax,
|
|
654
|
+
color_pos,
|
|
655
|
+
color_neg,
|
|
656
|
+
x,
|
|
657
|
+
f,
|
|
658
|
+
fn_name=None,
|
|
659
|
+
include_factors=True,
|
|
660
|
+
dy=-1,
|
|
661
|
+
dx=0.02,
|
|
662
|
+
):
|
|
663
|
+
|
|
664
|
+
x_min = -0.05
|
|
665
|
+
x_max = 1.05
|
|
666
|
+
|
|
667
|
+
if include_factors:
|
|
668
|
+
y = (len(factors) + 1) * dy
|
|
669
|
+
else:
|
|
670
|
+
y = dy
|
|
671
|
+
plt.text(
|
|
672
|
+
x=-0.1,
|
|
673
|
+
y=y,
|
|
674
|
+
s=f"${fn_name}$" if fn_name else f"$f({str(x)})$",
|
|
675
|
+
fontsize=16,
|
|
676
|
+
ha="right",
|
|
677
|
+
va="center",
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
# Case 1: the polynomial has no roots.
|
|
681
|
+
if len(roots) == 0:
|
|
682
|
+
x0 = 0
|
|
683
|
+
y0 = sp.sympify(f).evalf(subs={x: x0})
|
|
684
|
+
|
|
685
|
+
if y0 > 0:
|
|
686
|
+
ax.plot(
|
|
687
|
+
[x_min, x_max],
|
|
688
|
+
[y, y],
|
|
689
|
+
color=color_pos,
|
|
690
|
+
linestyle="-",
|
|
691
|
+
lw=2,
|
|
692
|
+
)
|
|
693
|
+
else:
|
|
694
|
+
ax.plot(
|
|
695
|
+
[x_min, x_max],
|
|
696
|
+
[y, y],
|
|
697
|
+
color=color_neg,
|
|
698
|
+
linestyle="--",
|
|
699
|
+
lw=2,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
intervals = []
|
|
705
|
+
interval_positions = []
|
|
706
|
+
|
|
707
|
+
# Intervals before first root
|
|
708
|
+
intervals.append((roots[0] - 1, roots[0] - 0.1))
|
|
709
|
+
interval_positions.append((x_min, root_positions[roots[0]] - dx))
|
|
710
|
+
|
|
711
|
+
# Intervals between roots
|
|
712
|
+
for i in range(len(roots) - 1):
|
|
713
|
+
intervals.append((roots[i] + 0.1, roots[i + 1] - 0.1))
|
|
714
|
+
interval_positions.append(
|
|
715
|
+
(root_positions[roots[i]] + dx, root_positions[roots[i + 1]] - dx)
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Interval after last root
|
|
719
|
+
intervals.append((roots[-1] + 0.1, roots[-1] + 1))
|
|
720
|
+
interval_positions.append((root_positions[roots[-1]] + dx, x_max))
|
|
721
|
+
|
|
722
|
+
for i, (x0_interval, pos_interval) in enumerate(zip(intervals, interval_positions)):
|
|
723
|
+
x0 = (x0_interval[0] + x0_interval[1]) / 2
|
|
724
|
+
y0 = sp.sympify(f).evalf(subs={x: x0})
|
|
725
|
+
|
|
726
|
+
if y0 > 0:
|
|
727
|
+
ax.plot(
|
|
728
|
+
[pos_interval[0], pos_interval[1]],
|
|
729
|
+
[y, y],
|
|
730
|
+
color=color_pos,
|
|
731
|
+
linestyle="-",
|
|
732
|
+
lw=2,
|
|
733
|
+
)
|
|
734
|
+
else:
|
|
735
|
+
ax.plot(
|
|
736
|
+
[pos_interval[0], pos_interval[1]],
|
|
737
|
+
[y, y],
|
|
738
|
+
color=color_neg,
|
|
739
|
+
linestyle="--",
|
|
740
|
+
lw=2,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# Plot zeros at root positions
|
|
744
|
+
for root in roots:
|
|
745
|
+
root_pos = root_positions[root]
|
|
746
|
+
if str(f.subs(x, root)) != "zoo":
|
|
747
|
+
plt.text(
|
|
748
|
+
x=root_pos,
|
|
749
|
+
y=y,
|
|
750
|
+
s=f"$0$",
|
|
751
|
+
fontsize=20,
|
|
752
|
+
ha="center",
|
|
753
|
+
va="center",
|
|
754
|
+
)
|
|
755
|
+
else:
|
|
756
|
+
plt.text(
|
|
757
|
+
x=root_pos + 0.005,
|
|
758
|
+
y=y,
|
|
759
|
+
s=f"$\\times$",
|
|
760
|
+
fontsize=24,
|
|
761
|
+
ha="center",
|
|
762
|
+
va="center",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def draw_vertical_lines(
|
|
767
|
+
roots,
|
|
768
|
+
root_positions,
|
|
769
|
+
factors,
|
|
770
|
+
ax,
|
|
771
|
+
include_factors=True,
|
|
772
|
+
dy=-1,
|
|
773
|
+
):
|
|
774
|
+
# Draw vertical lines to separate regions
|
|
775
|
+
offset_dy = 0.2
|
|
776
|
+
|
|
777
|
+
if include_factors:
|
|
778
|
+
# Collect y positions of zeros from factors
|
|
779
|
+
y_zeros_dict = {}
|
|
780
|
+
for i, factor in enumerate(factors):
|
|
781
|
+
# Handle both old format ("root") and new format ("roots")
|
|
782
|
+
factor_roots = (
|
|
783
|
+
factor.get("roots", [factor.get("root")])
|
|
784
|
+
if factor.get("root") is not None or factor.get("roots")
|
|
785
|
+
else []
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
for root in factor_roots:
|
|
789
|
+
if root != -np.inf:
|
|
790
|
+
y_zero = (i + 1) * dy
|
|
791
|
+
if root in y_zeros_dict:
|
|
792
|
+
y_zeros_dict[root].append(y_zero)
|
|
793
|
+
else:
|
|
794
|
+
y_zeros_dict[root] = [y_zero]
|
|
795
|
+
# Add y position of zero from function
|
|
796
|
+
y_function = (len(factors) + 1) * dy
|
|
797
|
+
else:
|
|
798
|
+
y_zeros_dict = {}
|
|
799
|
+
y_function = dy
|
|
800
|
+
|
|
801
|
+
y_min = -0.4
|
|
802
|
+
y_max = y_function + 0.5
|
|
803
|
+
|
|
804
|
+
for root in roots:
|
|
805
|
+
root_pos = root_positions[root]
|
|
806
|
+
# Collect y positions where zeros are placed at this root
|
|
807
|
+
zero_y_positions = []
|
|
808
|
+
# From factors
|
|
809
|
+
if root in y_zeros_dict:
|
|
810
|
+
zero_y_positions.extend(y_zeros_dict[root])
|
|
811
|
+
# From function
|
|
812
|
+
zero_y_positions.append(y_function)
|
|
813
|
+
# Now adjust zero_y_positions to include offset_dy
|
|
814
|
+
y_positions = [y_min]
|
|
815
|
+
for y_zero in zero_y_positions:
|
|
816
|
+
y_positions.extend([y_zero - offset_dy, y_zero + offset_dy])
|
|
817
|
+
y_positions.append(y_max)
|
|
818
|
+
y_positions = sorted(y_positions)
|
|
819
|
+
|
|
820
|
+
# Now plot segments between pairs
|
|
821
|
+
for i in range(1, len(y_positions) - 1):
|
|
822
|
+
y_start = y_positions[i]
|
|
823
|
+
y_end = y_positions[i + 1]
|
|
824
|
+
# Skip the segments around the zeros
|
|
825
|
+
if (i % 2) == 0:
|
|
826
|
+
if y_end - y_start > 0:
|
|
827
|
+
ax.plot(
|
|
828
|
+
[root_pos, root_pos],
|
|
829
|
+
[y_start, y_end],
|
|
830
|
+
color="black",
|
|
831
|
+
linestyle="-",
|
|
832
|
+
lw=1,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def make_axis(x):
|
|
837
|
+
fig, ax = plt.subplots()
|
|
838
|
+
|
|
839
|
+
# Remove y-axis spines
|
|
840
|
+
ax.spines["left"].set_color("none") # Remove the left y-axis
|
|
841
|
+
ax.spines["right"].set_color("none") # Remove the right y-axis
|
|
842
|
+
|
|
843
|
+
# Move the x-axis to y=0
|
|
844
|
+
ax.spines["bottom"].set_position("zero") # Position the bottom x-axis at y=0
|
|
845
|
+
ax.spines["top"].set_color("none") # Remove the top x-axis
|
|
846
|
+
|
|
847
|
+
# Move x-axis ticks and labels to the top
|
|
848
|
+
ax.xaxis.set_ticks_position("top") # Move ticks to the top
|
|
849
|
+
ax.xaxis.set_label_position("top") # Move labels to the top
|
|
850
|
+
ax.tick_params(
|
|
851
|
+
axis="x",
|
|
852
|
+
which="both", # Hide bottom ticks and labels
|
|
853
|
+
bottom=False,
|
|
854
|
+
labelbottom=False,
|
|
855
|
+
length=10,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Attach arrow to the right end of the x-axis
|
|
859
|
+
ax.plot(1, 0, ">k", transform=ax.get_yaxis_transform(), clip_on=False)
|
|
860
|
+
|
|
861
|
+
# Label the x-axis
|
|
862
|
+
ax.set_xlabel(f"${str(x)}$", fontsize=16, loc="right")
|
|
863
|
+
|
|
864
|
+
# Remove tick labels on y-axis
|
|
865
|
+
plt.yticks([])
|
|
866
|
+
|
|
867
|
+
# Set x-limits
|
|
868
|
+
ax.set_xlim(-0.05, 1.05)
|
|
869
|
+
|
|
870
|
+
return fig, ax
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def plot(
|
|
874
|
+
f,
|
|
875
|
+
x=None,
|
|
876
|
+
fn_name=None,
|
|
877
|
+
color=True,
|
|
878
|
+
include_factors=True,
|
|
879
|
+
generic_labels=False,
|
|
880
|
+
small_figsize=False,
|
|
881
|
+
figsize=None,
|
|
882
|
+
domain=None,
|
|
883
|
+
):
|
|
884
|
+
"""Draws a sign chart for a function f (polynomial, rational, or transcendental).
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
f (sp.Expr, str):
|
|
888
|
+
Function expression. May be a sympy.Expr or str. Supports polynomials,
|
|
889
|
+
rational functions, and transcendental functions (sin, cos, exp, log, etc.).
|
|
890
|
+
x (sp.symbols, str, optional):
|
|
891
|
+
Variable in the function
|
|
892
|
+
fn_name (str, optional):
|
|
893
|
+
Name of the function. Defaults `None`.
|
|
894
|
+
color (bool, optional):
|
|
895
|
+
Enables coloring of sign chart. Default: `True`.
|
|
896
|
+
include_factors (bool, optional):
|
|
897
|
+
Includes all linear factors of f(x) for polynomials. For non-polynomial
|
|
898
|
+
functions, this shows the function name only. Default: `True`.
|
|
899
|
+
generic_label (bool, optional):
|
|
900
|
+
Uses generic labels for roots: x_1, x_2, ..., x_N. Default: `False`.
|
|
901
|
+
small_figsize (bool, optional):
|
|
902
|
+
Enables rescaling of the figure for a smaller figure size. Default: `False`.
|
|
903
|
+
domain (tuple, optional):
|
|
904
|
+
Domain (x_min, x_max) for searching zeros numerically in transcendental functions.
|
|
905
|
+
If None, uses a default range or symbolic solving only. Example: (-10, 10)
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
fig (plt.figure)
|
|
909
|
+
matplotlib figure.
|
|
910
|
+
ax (plt.Axis)
|
|
911
|
+
matplotlib axis.
|
|
912
|
+
"""
|
|
913
|
+
if isinstance(f, str):
|
|
914
|
+
f = sp.sympify(f)
|
|
915
|
+
|
|
916
|
+
original_variable = list(f.free_symbols)[0]
|
|
917
|
+
x = sp.symbols(str(original_variable), real=True)
|
|
918
|
+
f = f.subs(original_variable, x)
|
|
919
|
+
|
|
920
|
+
if color:
|
|
921
|
+
color_pos = "red"
|
|
922
|
+
color_neg = "blue"
|
|
923
|
+
else:
|
|
924
|
+
color_pos = color_neg = "black"
|
|
925
|
+
|
|
926
|
+
# Determine function type and extract zeros/singularities
|
|
927
|
+
is_polynomial = f.is_polynomial()
|
|
928
|
+
is_rational = False
|
|
929
|
+
|
|
930
|
+
if is_polynomial:
|
|
931
|
+
# Use existing polynomial factorization
|
|
932
|
+
factors = get_factors(polynomial=f, x=x)
|
|
933
|
+
factors = sort_factors(factors)
|
|
934
|
+
else:
|
|
935
|
+
# Check if it's a rational function
|
|
936
|
+
try:
|
|
937
|
+
numer, denom = f.as_numer_denom()
|
|
938
|
+
if denom != 1 and denom.is_polynomial() and numer.is_polynomial():
|
|
939
|
+
is_rational = True
|
|
940
|
+
# Handle as rational function
|
|
941
|
+
p_factors = get_factors(polynomial=numer, x=x) if numer != 1 else []
|
|
942
|
+
q_factors = get_factors(polynomial=denom, x=x) if denom != 1 else []
|
|
943
|
+
factors = p_factors + q_factors
|
|
944
|
+
factors = sort_factors(factors)
|
|
945
|
+
else:
|
|
946
|
+
# General transcendental or composite function
|
|
947
|
+
is_rational = False
|
|
948
|
+
# Use general zero finding
|
|
949
|
+
result = get_zeros_and_singularities(f, x, domain=domain)
|
|
950
|
+
zeros = result["zeros"]
|
|
951
|
+
singularities = result["singularities"]
|
|
952
|
+
|
|
953
|
+
# Extract factors from transcendental function
|
|
954
|
+
factors = get_transcendental_factors(f, x, zeros, singularities)
|
|
955
|
+
factors = sort_factors(factors)
|
|
956
|
+
# Now we can show factors for transcendental functions too!
|
|
957
|
+
except:
|
|
958
|
+
# Fallback: general function
|
|
959
|
+
result = get_zeros_and_singularities(f, x, domain=domain)
|
|
960
|
+
zeros = result["zeros"]
|
|
961
|
+
singularities = result["singularities"]
|
|
962
|
+
|
|
963
|
+
# Extract factors
|
|
964
|
+
factors = get_transcendental_factors(f, x, zeros, singularities)
|
|
965
|
+
factors = sort_factors(factors)
|
|
966
|
+
|
|
967
|
+
# Create figure
|
|
968
|
+
fig, ax = make_axis(x)
|
|
969
|
+
|
|
970
|
+
# Extract roots - handle both old format (single "root") and new format (multiple "roots")
|
|
971
|
+
roots = []
|
|
972
|
+
for factor in factors:
|
|
973
|
+
if "roots" in factor and factor["roots"]:
|
|
974
|
+
# New format: multiple roots per factor
|
|
975
|
+
for r in factor["roots"]:
|
|
976
|
+
if r != -np.inf and r not in roots:
|
|
977
|
+
roots.append(r)
|
|
978
|
+
elif "root" in factor and factor["root"] != -np.inf:
|
|
979
|
+
# Old format: single root per factor
|
|
980
|
+
if factor["root"] not in roots:
|
|
981
|
+
roots.append(factor["root"])
|
|
982
|
+
|
|
983
|
+
# Sort roots
|
|
984
|
+
roots = sorted(roots, key=lambda r: float(r.evalf()))
|
|
985
|
+
|
|
986
|
+
# Map roots to positions
|
|
987
|
+
num_roots = len(roots)
|
|
988
|
+
x_min = -0.05
|
|
989
|
+
x_max = 1.05
|
|
990
|
+
positions = np.linspace(0, 1, num_roots + 2)[1:-1] # Exclude 0 and 1
|
|
991
|
+
root_positions = dict(zip(roots, positions))
|
|
992
|
+
|
|
993
|
+
# Set tick marks for roots of the polynomial
|
|
994
|
+
plt.xticks(
|
|
995
|
+
ticks=positions,
|
|
996
|
+
labels=[
|
|
997
|
+
f"${sp.latex(root)}$" if not generic_labels else f"$x_{i + 1}$"
|
|
998
|
+
for i, root in enumerate(roots)
|
|
999
|
+
],
|
|
1000
|
+
fontsize=16,
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
# Draw factors
|
|
1004
|
+
if include_factors:
|
|
1005
|
+
draw_factors(f, factors, roots, root_positions, ax, color_pos, color_neg, x)
|
|
1006
|
+
|
|
1007
|
+
# Draw sign lines for function
|
|
1008
|
+
draw_function(
|
|
1009
|
+
factors,
|
|
1010
|
+
roots,
|
|
1011
|
+
root_positions,
|
|
1012
|
+
ax,
|
|
1013
|
+
color_pos,
|
|
1014
|
+
color_neg,
|
|
1015
|
+
x,
|
|
1016
|
+
f,
|
|
1017
|
+
fn_name,
|
|
1018
|
+
include_factors,
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
# Remove tick labels on y-axis
|
|
1022
|
+
plt.yticks([])
|
|
1023
|
+
|
|
1024
|
+
plt.xlim(x_min, x_max)
|
|
1025
|
+
|
|
1026
|
+
if include_factors:
|
|
1027
|
+
if figsize:
|
|
1028
|
+
fig.set_size_inches(figsize)
|
|
1029
|
+
else:
|
|
1030
|
+
fig.set_size_inches(8, 2 + int(0.7 * len(factors)))
|
|
1031
|
+
|
|
1032
|
+
elif small_figsize:
|
|
1033
|
+
fig.set_size_inches(4, 1.5)
|
|
1034
|
+
else:
|
|
1035
|
+
fig.set_size_inches(8, 2)
|
|
1036
|
+
|
|
1037
|
+
draw_vertical_lines(roots, root_positions, factors, ax, include_factors)
|
|
1038
|
+
|
|
1039
|
+
plt.tight_layout()
|
|
1040
|
+
|
|
1041
|
+
return fig, ax
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
# ------------------------------------
|
|
1045
|
+
# Utilities
|
|
1046
|
+
# ------------------------------------
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _hash_key(*parts) -> str:
|
|
1050
|
+
"""
|
|
1051
|
+
Generate a short hash key from multiple parts.
|
|
1052
|
+
|
|
1053
|
+
Used for creating unique filenames based on function content.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
*parts: Variable number of parts to hash
|
|
1057
|
+
|
|
1058
|
+
Returns:
|
|
1059
|
+
str: 12-character hex hash
|
|
1060
|
+
"""
|
|
1061
|
+
h = hashlib.sha1()
|
|
1062
|
+
for p in parts:
|
|
1063
|
+
if p is None:
|
|
1064
|
+
p = "__NONE__"
|
|
1065
|
+
h.update(str(p).encode("utf-8"))
|
|
1066
|
+
h.update(b"||")
|
|
1067
|
+
return h.hexdigest()[:12]
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _safe_literal(val: str):
|
|
1071
|
+
"""
|
|
1072
|
+
Safely evaluate a string as a Python literal.
|
|
1073
|
+
|
|
1074
|
+
Args:
|
|
1075
|
+
val: String to evaluate
|
|
1076
|
+
|
|
1077
|
+
Returns:
|
|
1078
|
+
Evaluated value or None if evaluation fails
|
|
1079
|
+
"""
|
|
1080
|
+
import ast
|
|
1081
|
+
|
|
1082
|
+
try:
|
|
1083
|
+
return ast.literal_eval(val)
|
|
1084
|
+
except Exception:
|
|
1085
|
+
return None
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _parse_bool(val, default: bool | None = None) -> bool | None:
|
|
1089
|
+
"""
|
|
1090
|
+
Parse a value as a boolean.
|
|
1091
|
+
|
|
1092
|
+
Args:
|
|
1093
|
+
val: Value to parse (bool, str, or None)
|
|
1094
|
+
default: Default value if parsing fails
|
|
1095
|
+
|
|
1096
|
+
Returns:
|
|
1097
|
+
bool | None: Parsed boolean value or default
|
|
1098
|
+
"""
|
|
1099
|
+
if val is None:
|
|
1100
|
+
return default
|
|
1101
|
+
if isinstance(val, bool):
|
|
1102
|
+
return val
|
|
1103
|
+
s = str(val).strip().lower()
|
|
1104
|
+
if s == "":
|
|
1105
|
+
return True
|
|
1106
|
+
if s in {"true", "yes", "on", "1"}:
|
|
1107
|
+
return True
|
|
1108
|
+
if s in {"false", "no", "off", "0"}:
|
|
1109
|
+
return False
|
|
1110
|
+
return default
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _parse_tuple(val, element_type=float):
|
|
1114
|
+
"""
|
|
1115
|
+
Parse a value as a tuple.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
val: Value to parse (string like "(1, 2)" or tuple)
|
|
1119
|
+
element_type: Type to convert elements to (default: float)
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
tuple or None if parsing fails
|
|
1123
|
+
"""
|
|
1124
|
+
if val is None:
|
|
1125
|
+
return None
|
|
1126
|
+
if isinstance(val, (list, tuple)):
|
|
1127
|
+
try:
|
|
1128
|
+
return tuple(element_type(x) for x in val)
|
|
1129
|
+
except:
|
|
1130
|
+
return None
|
|
1131
|
+
# Try to parse as literal
|
|
1132
|
+
lit = _safe_literal(str(val))
|
|
1133
|
+
if isinstance(lit, (list, tuple)):
|
|
1134
|
+
try:
|
|
1135
|
+
return tuple(element_type(x) for x in lit)
|
|
1136
|
+
except:
|
|
1137
|
+
return None
|
|
1138
|
+
return None
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _strip_root_svg_size(svg_text: str) -> str:
|
|
1142
|
+
"""
|
|
1143
|
+
Remove width/height attributes from the root <svg> tag.
|
|
1144
|
+
|
|
1145
|
+
This allows CSS to control the SVG size.
|
|
1146
|
+
|
|
1147
|
+
Args:
|
|
1148
|
+
svg_text: Raw SVG content
|
|
1149
|
+
|
|
1150
|
+
Returns:
|
|
1151
|
+
str: SVG with width/height removed from root tag
|
|
1152
|
+
"""
|
|
1153
|
+
|
|
1154
|
+
def repl(m):
|
|
1155
|
+
tag = m.group(0)
|
|
1156
|
+
tag = re.sub(r'\swidth="[^"]+"', "", tag)
|
|
1157
|
+
tag = re.sub(r'\sheight="[^"]+"', "", tag)
|
|
1158
|
+
return tag
|
|
1159
|
+
|
|
1160
|
+
return re.sub(r"<svg\b[^>]*>", repl, svg_text, count=1)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _rewrite_ids(txt: str, prefix: str) -> str:
|
|
1164
|
+
"""
|
|
1165
|
+
Rewrite all id attributes in SVG to avoid conflicts.
|
|
1166
|
+
|
|
1167
|
+
When multiple SVGs are on the same page, id conflicts can cause
|
|
1168
|
+
rendering issues. This function prefixes all ids with a unique prefix.
|
|
1169
|
+
|
|
1170
|
+
Args:
|
|
1171
|
+
txt: SVG content
|
|
1172
|
+
prefix: Prefix to add to all ids
|
|
1173
|
+
|
|
1174
|
+
Returns:
|
|
1175
|
+
str: SVG with rewritten ids
|
|
1176
|
+
"""
|
|
1177
|
+
ids = re.findall(r'\bid="([^"]+)"', txt)
|
|
1178
|
+
if not ids:
|
|
1179
|
+
return txt
|
|
1180
|
+
skip_prefixes = (
|
|
1181
|
+
"DejaVu",
|
|
1182
|
+
"CM",
|
|
1183
|
+
"STIX",
|
|
1184
|
+
"Nimbus",
|
|
1185
|
+
"Bitstream",
|
|
1186
|
+
"Arial",
|
|
1187
|
+
"Times",
|
|
1188
|
+
"Helvetica",
|
|
1189
|
+
)
|
|
1190
|
+
mapping = {}
|
|
1191
|
+
for i in ids:
|
|
1192
|
+
if i.startswith(skip_prefixes):
|
|
1193
|
+
continue
|
|
1194
|
+
mapping[i] = f"{prefix}{i}"
|
|
1195
|
+
if not mapping:
|
|
1196
|
+
return txt
|
|
1197
|
+
|
|
1198
|
+
def repl_id(m: re.Match) -> str:
|
|
1199
|
+
old = m.group(1)
|
|
1200
|
+
new = mapping.get(old, old)
|
|
1201
|
+
return f'id="{new}"'
|
|
1202
|
+
|
|
1203
|
+
txt = re.sub(r'\bid="([^"]+)"', repl_id, txt)
|
|
1204
|
+
|
|
1205
|
+
def repl_url(m: re.Match) -> str:
|
|
1206
|
+
old = m.group(1).strip()
|
|
1207
|
+
new = mapping.get(old, old)
|
|
1208
|
+
return f"url(#{new})"
|
|
1209
|
+
|
|
1210
|
+
txt = re.sub(r"url\(#\s*([^\)\s]+)\s*\)", repl_url, txt)
|
|
1211
|
+
|
|
1212
|
+
def repl_href(m: re.Match) -> str:
|
|
1213
|
+
attr = m.group(1)
|
|
1214
|
+
quote = m.group(2)
|
|
1215
|
+
old = m.group(3).strip()
|
|
1216
|
+
new = mapping.get(old, old)
|
|
1217
|
+
return f"{attr}={quote}#{new}{quote}"
|
|
1218
|
+
|
|
1219
|
+
txt = re.sub(r'(xlink:href|href)\s*=\s*(["\'])#\s*([^"\']+)\s*\2', repl_href, txt)
|
|
1220
|
+
return txt
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
# ------------------------------------
|
|
1224
|
+
# Sphinx Directive
|
|
1225
|
+
# ------------------------------------
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
class SignChart2Directive(SphinxDirective):
|
|
1229
|
+
"""
|
|
1230
|
+
Sphinx directive for generating improved sign charts with embedded plotting.
|
|
1231
|
+
|
|
1232
|
+
This directive supports polynomial, rational, and transcendental functions with
|
|
1233
|
+
extensive customization options.
|
|
1234
|
+
|
|
1235
|
+
Options:
|
|
1236
|
+
function (required): The function expression and optional label
|
|
1237
|
+
Format: "expression, label" or ("expression", "label")
|
|
1238
|
+
Example: "sin(x)*cos(x), f(x)"
|
|
1239
|
+
factors (optional): Whether to show factored form (default: true)
|
|
1240
|
+
domain (optional): Domain tuple (xmin, xmax) for numerical zero finding
|
|
1241
|
+
Example: "(-10, 10)" or (-10, 10)
|
|
1242
|
+
color (optional): Enable colored sign chart (default: true)
|
|
1243
|
+
generic_labels (optional): Use x₁, x₂ labels instead of actual values (default: false)
|
|
1244
|
+
small_figsize (optional): Use compact figure size (default: false)
|
|
1245
|
+
figsize (optional): Custom figure size as tuple (width, height)
|
|
1246
|
+
Example: "(10, 4)" or (10, 4)
|
|
1247
|
+
width (optional): Width of the chart (e.g., "100%", "500px", "500")
|
|
1248
|
+
align (optional): Alignment ("left", "center", "right")
|
|
1249
|
+
class (optional): Additional CSS classes
|
|
1250
|
+
name (optional): Reference name for the figure
|
|
1251
|
+
nocache (optional): Force regeneration of the chart
|
|
1252
|
+
debug (optional): Keep original SVG dimensions and ids
|
|
1253
|
+
alt (optional): Alt text for accessibility (default: "Fortegnsskjema")
|
|
1254
|
+
|
|
1255
|
+
Example:
|
|
1256
|
+
```{signchart-2}
|
|
1257
|
+
---
|
|
1258
|
+
function: sin(x)*cos(x), f(x)
|
|
1259
|
+
domain: (-3.14, 3.14)
|
|
1260
|
+
factors: true
|
|
1261
|
+
width: 100%
|
|
1262
|
+
---
|
|
1263
|
+
Sign chart for f(x) = sin(x)·cos(x)
|
|
1264
|
+
```
|
|
1265
|
+
"""
|
|
1266
|
+
|
|
1267
|
+
has_content = True
|
|
1268
|
+
required_arguments = 0
|
|
1269
|
+
option_spec = {
|
|
1270
|
+
# presentation / misc
|
|
1271
|
+
"width": directives.length_or_percentage_or_unitless,
|
|
1272
|
+
"align": lambda a: directives.choice(a, ["left", "center", "right"]),
|
|
1273
|
+
"class": directives.class_option,
|
|
1274
|
+
"name": directives.unchanged,
|
|
1275
|
+
"nocache": directives.flag,
|
|
1276
|
+
"debug": directives.flag,
|
|
1277
|
+
"alt": directives.unchanged,
|
|
1278
|
+
# specific options
|
|
1279
|
+
"function": directives.unchanged_required,
|
|
1280
|
+
"factors": directives.unchanged,
|
|
1281
|
+
"domain": directives.unchanged,
|
|
1282
|
+
"color": directives.unchanged,
|
|
1283
|
+
"generic_labels": directives.unchanged,
|
|
1284
|
+
"small_figsize": directives.unchanged,
|
|
1285
|
+
"figsize": directives.unchanged,
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
def _parse_kv_block(self) -> Tuple[Dict[str, Any], int]:
|
|
1289
|
+
"""
|
|
1290
|
+
Parse YAML-style key-value block from directive content.
|
|
1291
|
+
|
|
1292
|
+
Supports two formats:
|
|
1293
|
+
1. YAML front-matter style with --- delimiters
|
|
1294
|
+
2. Simple key: value pairs at the start
|
|
1295
|
+
|
|
1296
|
+
Returns:
|
|
1297
|
+
tuple: (dict of parsed options, index where caption starts)
|
|
1298
|
+
"""
|
|
1299
|
+
lines = list(self.content)
|
|
1300
|
+
scalars: Dict[str, Any] = {}
|
|
1301
|
+
idx = 0
|
|
1302
|
+
if lines and lines[0].strip() == "---":
|
|
1303
|
+
idx = 1
|
|
1304
|
+
while idx < len(lines) and lines[idx].strip() != "---":
|
|
1305
|
+
line = lines[idx].rstrip()
|
|
1306
|
+
if not line.strip():
|
|
1307
|
+
idx += 1
|
|
1308
|
+
continue
|
|
1309
|
+
m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
|
|
1310
|
+
if m:
|
|
1311
|
+
scalars[m.group(1)] = m.group(2)
|
|
1312
|
+
idx += 1
|
|
1313
|
+
if idx < len(lines) and lines[idx].strip() == "---":
|
|
1314
|
+
idx += 1
|
|
1315
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
1316
|
+
idx += 1
|
|
1317
|
+
return scalars, idx
|
|
1318
|
+
|
|
1319
|
+
caption_start = 0
|
|
1320
|
+
for i, line in enumerate(lines):
|
|
1321
|
+
if not line.strip():
|
|
1322
|
+
caption_start = i + 1
|
|
1323
|
+
continue
|
|
1324
|
+
m = re.match(r"^([A-Za-z_][\w]*)\s*:\s*(.*)$", line)
|
|
1325
|
+
if m:
|
|
1326
|
+
scalars[m.group(1)] = m.group(2)
|
|
1327
|
+
caption_start = i + 1
|
|
1328
|
+
else:
|
|
1329
|
+
break
|
|
1330
|
+
return scalars, caption_start
|
|
1331
|
+
|
|
1332
|
+
def run(self): # noqa: C901
|
|
1333
|
+
"""
|
|
1334
|
+
Generate the sign chart.
|
|
1335
|
+
|
|
1336
|
+
Returns:
|
|
1337
|
+
list: List of docutils nodes (figure containing SVG)
|
|
1338
|
+
"""
|
|
1339
|
+
env = self.state.document.settings.env
|
|
1340
|
+
app = env.app
|
|
1341
|
+
|
|
1342
|
+
scalars, caption_idx = self._parse_kv_block()
|
|
1343
|
+
merged: Dict[str, Any] = {**scalars, **self.options}
|
|
1344
|
+
|
|
1345
|
+
func_raw = merged.get("function")
|
|
1346
|
+
if not func_raw:
|
|
1347
|
+
return [
|
|
1348
|
+
self.state_machine.reporter.error(
|
|
1349
|
+
"Directive 'signchart-2' requires 'function:' option",
|
|
1350
|
+
line=self.lineno,
|
|
1351
|
+
)
|
|
1352
|
+
]
|
|
1353
|
+
|
|
1354
|
+
# Parse function as either (expr, label) literal or "expr, label"
|
|
1355
|
+
f_expr = None
|
|
1356
|
+
f_name = None
|
|
1357
|
+
lit = _safe_literal(str(func_raw))
|
|
1358
|
+
if isinstance(lit, (list, tuple)) and len(lit) >= 1:
|
|
1359
|
+
f_expr = str(lit[0]).strip()
|
|
1360
|
+
if len(lit) > 1:
|
|
1361
|
+
f_name = str(lit[1]).strip() or None
|
|
1362
|
+
else:
|
|
1363
|
+
s = str(func_raw)
|
|
1364
|
+
if "," in s:
|
|
1365
|
+
expr, label = s.split(",", 1)
|
|
1366
|
+
f_expr = expr.strip()
|
|
1367
|
+
label = label.strip()
|
|
1368
|
+
f_name = label or None
|
|
1369
|
+
else:
|
|
1370
|
+
f_expr = s.strip()
|
|
1371
|
+
f_name = None
|
|
1372
|
+
|
|
1373
|
+
# Parse options
|
|
1374
|
+
include_factors = _parse_bool(merged.get("factors"), default=True)
|
|
1375
|
+
use_color = _parse_bool(merged.get("color"), default=True)
|
|
1376
|
+
generic_labels = _parse_bool(merged.get("generic_labels"), default=False)
|
|
1377
|
+
small_figsize = _parse_bool(merged.get("small_figsize"), default=False)
|
|
1378
|
+
explicit_name = merged.get("name")
|
|
1379
|
+
debug_mode = "debug" in merged
|
|
1380
|
+
|
|
1381
|
+
# Parse domain
|
|
1382
|
+
domain_val = merged.get("domain")
|
|
1383
|
+
custom_domain = _parse_tuple(domain_val, float) if domain_val else None
|
|
1384
|
+
|
|
1385
|
+
# Parse figsize
|
|
1386
|
+
figsize_val = merged.get("figsize")
|
|
1387
|
+
custom_figsize = _parse_tuple(figsize_val, float) if figsize_val else None
|
|
1388
|
+
|
|
1389
|
+
# Hash includes all plot parameters
|
|
1390
|
+
content_hash = _hash_key(
|
|
1391
|
+
f_expr,
|
|
1392
|
+
f_name or "",
|
|
1393
|
+
int(bool(include_factors)),
|
|
1394
|
+
int(bool(use_color)),
|
|
1395
|
+
int(bool(generic_labels)),
|
|
1396
|
+
int(bool(small_figsize)),
|
|
1397
|
+
str(custom_domain) if custom_domain else "",
|
|
1398
|
+
str(custom_figsize) if custom_figsize else "",
|
|
1399
|
+
)
|
|
1400
|
+
base_name = explicit_name or f"signchart2_{content_hash}"
|
|
1401
|
+
|
|
1402
|
+
rel_dir = os.path.join("_static", "signchart")
|
|
1403
|
+
abs_dir = os.path.join(app.srcdir, rel_dir)
|
|
1404
|
+
os.makedirs(abs_dir, exist_ok=True)
|
|
1405
|
+
svg_name = f"{base_name}.svg"
|
|
1406
|
+
abs_svg = os.path.join(abs_dir, svg_name)
|
|
1407
|
+
|
|
1408
|
+
regenerate = ("nocache" in merged) or not os.path.exists(abs_svg)
|
|
1409
|
+
if regenerate:
|
|
1410
|
+
try:
|
|
1411
|
+
# Render using embedded plot function
|
|
1412
|
+
plot_kwargs = {
|
|
1413
|
+
"f": f_expr,
|
|
1414
|
+
"fn_name": f_name or None,
|
|
1415
|
+
"include_factors": bool(include_factors),
|
|
1416
|
+
"color": bool(use_color),
|
|
1417
|
+
"generic_labels": bool(generic_labels),
|
|
1418
|
+
"small_figsize": bool(small_figsize),
|
|
1419
|
+
}
|
|
1420
|
+
if custom_domain is not None:
|
|
1421
|
+
plot_kwargs["domain"] = custom_domain
|
|
1422
|
+
if custom_figsize is not None:
|
|
1423
|
+
plot_kwargs["figsize"] = custom_figsize
|
|
1424
|
+
|
|
1425
|
+
fig, ax = plot(**plot_kwargs)
|
|
1426
|
+
fig.savefig(abs_svg, format="svg", bbox_inches="tight")
|
|
1427
|
+
plt.close(fig)
|
|
1428
|
+
except Exception as e:
|
|
1429
|
+
return [
|
|
1430
|
+
self.state_machine.reporter.error(
|
|
1431
|
+
f"Error generating sign chart: {e}",
|
|
1432
|
+
line=self.lineno,
|
|
1433
|
+
)
|
|
1434
|
+
]
|
|
1435
|
+
|
|
1436
|
+
if not os.path.exists(abs_svg):
|
|
1437
|
+
return [
|
|
1438
|
+
self.state_machine.reporter.error(
|
|
1439
|
+
"signchart-2: SVG file missing.", line=self.lineno
|
|
1440
|
+
)
|
|
1441
|
+
]
|
|
1442
|
+
|
|
1443
|
+
env.note_dependency(abs_svg)
|
|
1444
|
+
# copy into build _static
|
|
1445
|
+
try:
|
|
1446
|
+
out_static = os.path.join(app.outdir, "_static", "signchart")
|
|
1447
|
+
os.makedirs(out_static, exist_ok=True)
|
|
1448
|
+
shutil.copy2(abs_svg, os.path.join(out_static, svg_name))
|
|
1449
|
+
except Exception:
|
|
1450
|
+
pass
|
|
1451
|
+
|
|
1452
|
+
try:
|
|
1453
|
+
raw_svg = open(abs_svg, "r", encoding="utf-8").read()
|
|
1454
|
+
except Exception as e:
|
|
1455
|
+
return [
|
|
1456
|
+
self.state_machine.reporter.error(
|
|
1457
|
+
f"signchart-2 inline: could not read SVG: {e}", line=self.lineno
|
|
1458
|
+
)
|
|
1459
|
+
]
|
|
1460
|
+
|
|
1461
|
+
if not debug_mode and "viewBox" in raw_svg:
|
|
1462
|
+
raw_svg = _strip_root_svg_size(raw_svg)
|
|
1463
|
+
|
|
1464
|
+
if not debug_mode:
|
|
1465
|
+
raw_svg = _rewrite_ids(raw_svg, f"sgc2_{content_hash}_{uuid.uuid4().hex[:6]}_")
|
|
1466
|
+
|
|
1467
|
+
alt_default = "Fortegnsskjema"
|
|
1468
|
+
alt = merged.get("alt", alt_default)
|
|
1469
|
+
|
|
1470
|
+
width_opt = merged.get("width")
|
|
1471
|
+
percent = isinstance(width_opt, str) and width_opt.strip().endswith("%")
|
|
1472
|
+
|
|
1473
|
+
def _augment(m):
|
|
1474
|
+
"""Add classes, aria-label, and width styling to root SVG tag."""
|
|
1475
|
+
tag = m.group(0)
|
|
1476
|
+
if "class=" not in tag:
|
|
1477
|
+
tag = tag[:-1] + ' class="graph-inline-svg"' + ">"
|
|
1478
|
+
else:
|
|
1479
|
+
tag = tag.replace('class="', 'class="graph-inline-svg ')
|
|
1480
|
+
if alt and "aria-label=" not in tag:
|
|
1481
|
+
tag = tag[:-1] + f' role="img" aria-label="{alt}"' + ">"
|
|
1482
|
+
if width_opt:
|
|
1483
|
+
if percent:
|
|
1484
|
+
wval = width_opt.strip()
|
|
1485
|
+
else:
|
|
1486
|
+
wval = width_opt.strip()
|
|
1487
|
+
if wval.isdigit():
|
|
1488
|
+
wval += "px"
|
|
1489
|
+
style_frag = f"width:{wval}; height:auto; display:block; margin:0 auto;"
|
|
1490
|
+
if "style=" in tag:
|
|
1491
|
+
tag = re.sub(
|
|
1492
|
+
r'style="([^"]*)"',
|
|
1493
|
+
lambda mm: f'style="{mm.group(1)}; {style_frag}"',
|
|
1494
|
+
tag,
|
|
1495
|
+
count=1,
|
|
1496
|
+
)
|
|
1497
|
+
else:
|
|
1498
|
+
tag = tag[:-1] + f' style="{style_frag}"' + ">"
|
|
1499
|
+
return tag
|
|
1500
|
+
|
|
1501
|
+
raw_svg = re.sub(r"<svg\b[^>]*>", _augment, raw_svg, count=1)
|
|
1502
|
+
|
|
1503
|
+
figure = nodes.figure()
|
|
1504
|
+
figure.setdefault("classes", []).extend(["adaptive-figure", "signchart-figure", "no-click"])
|
|
1505
|
+
raw_node = nodes.raw("", raw_svg, format="html")
|
|
1506
|
+
raw_node.setdefault("classes", []).extend(["graph-image", "no-click", "no-scaled-link"])
|
|
1507
|
+
figure += raw_node
|
|
1508
|
+
|
|
1509
|
+
extra_classes = merged.get("class")
|
|
1510
|
+
if extra_classes:
|
|
1511
|
+
figure["classes"].extend(extra_classes)
|
|
1512
|
+
figure["align"] = merged.get("align", "center")
|
|
1513
|
+
|
|
1514
|
+
caption_lines = list(self.content)[caption_idx:]
|
|
1515
|
+
while caption_lines and not caption_lines[0].strip():
|
|
1516
|
+
caption_lines.pop(0)
|
|
1517
|
+
if caption_lines:
|
|
1518
|
+
caption = nodes.caption()
|
|
1519
|
+
caption_text = "\n".join(caption_lines)
|
|
1520
|
+
# Parse as inline text to support math while avoiding extra paragraph nodes
|
|
1521
|
+
parsed_nodes, messages = self.state.inline_text(caption_text, self.lineno)
|
|
1522
|
+
caption.extend(parsed_nodes)
|
|
1523
|
+
figure += caption
|
|
1524
|
+
|
|
1525
|
+
if explicit_name := merged.get("name"):
|
|
1526
|
+
self.add_name(figure)
|
|
1527
|
+
return [figure]
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def setup(app):
|
|
1531
|
+
"""
|
|
1532
|
+
Setup function to register the directive with Sphinx.
|
|
1533
|
+
|
|
1534
|
+
This function is called automatically by Sphinx when the extension is loaded.
|
|
1535
|
+
It registers both 'signchart-2' and 'signchart2' directives for compatibility.
|
|
1536
|
+
|
|
1537
|
+
Args:
|
|
1538
|
+
app: The Sphinx application instance
|
|
1539
|
+
|
|
1540
|
+
Returns:
|
|
1541
|
+
dict: Extension metadata including version and parallel processing flags
|
|
1542
|
+
"""
|
|
1543
|
+
app.add_directive("signchart-2", SignChart2Directive)
|
|
1544
|
+
app.add_directive("signchart2", SignChart2Directive)
|
|
1545
|
+
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|