qmlmathplot 0.4.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (57) hide show
  1. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/PKG-INFO +19 -8
  2. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/README.md +18 -7
  3. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/pyproject.toml +1 -1
  4. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/pyproject.toml.orig +1 -1
  5. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/curve.py +83 -40
  6. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/input.py +22 -0
  7. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/plot.py +6 -4
  8. qmlmathplot-0.4.1/src/qmlmathplot/qml/PlotView.qml +275 -0
  9. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotView.qml +0 -134
  10. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/__init__.py +0 -0
  11. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/__main__.py +0 -0
  12. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/animation.py +0 -0
  13. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/app.py +0 -0
  14. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/camera.py +0 -0
  15. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/canvas.py +0 -0
  16. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/export.py +0 -0
  17. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/figure.py +0 -0
  18. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/interval.py +0 -0
  19. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/model.py +0 -0
  20. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/mpl.py +0 -0
  21. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/pyplot.py +0 -0
  22. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/qml/PlotCurves.qml +0 -0
  23. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/qml/PlotFurniture.qml +0 -0
  24. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/qml/PlotInput.qml +0 -0
  25. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/qsb.py +0 -0
  26. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  27. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/README.md +0 -0
  28. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  29. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  30. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  31. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  32. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/default.mplstyle +0 -0
  33. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  34. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  35. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  36. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  37. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  38. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  39. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  40. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  41. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  42. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  43. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  44. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  45. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  46. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  47. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  48. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  49. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  50. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  51. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  52. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  53. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  54. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  55. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes.py +0 -0
  56. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/view.py +0 -0
  57. {qmlmathplot-0.4.0 → qmlmathplot-0.4.1}/src/qmlmathplot/widget.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qmlmathplot
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)
5
5
  Author: I-AM-A-NOOB
6
6
  Author-email: I-AM-A-NOOB <suzc08@outlook.com>
@@ -145,7 +145,7 @@ The model:
145
145
  |---|---|
146
146
  | `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
147
147
  | `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, `animationDuration`, `input` (a `ViewInput`), slots `set_viewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `zoom_at(factor, u, v, w, h)` / `pan_pixels(dx, dy)` / `animate_zoom(...)` / `animate_pan(dx, dy, ms)` / `stop_animation()` / `reset()` |
148
- | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
148
+ | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `keyPanRepeatInterval`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
149
149
  | `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
150
150
  | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
151
151
 
@@ -239,9 +239,16 @@ and `add_implicit` asks for the level-set artist explicitly — also for a funct
239
239
  which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
240
240
  first.
241
241
 
242
+ A sympy `Eq(lhs, rhs)` is always a level set: it is normalized to `lhs - rhs = 0`, and an
243
+ equation with only `x` still means the vertical lines where it holds (`Eq(x**2, 1)`), not
244
+ `y = x**2 - 1`. The canonical `expression` keeps the `Eq(...)` text and `expr` is the normalized
245
+ difference.
246
+
242
247
  ```python
248
+ x, y = sp.symbols("x y")
243
249
  plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
244
- plot.add_implicit("x*y - 1") # the same artist, asked for by name
250
+ plot.add_curve(sp.Eq(x**2 + y**2, 1)) # the same circle in equation form
251
+ plot.add_implicit(sp.Eq(x, sp.sin(y))) # asked for by name, equation form
245
252
  plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
246
253
  plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
247
254
  ```
@@ -306,9 +313,12 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
306
313
  ## Keyboard, touch and animation
307
314
 
308
315
  Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
309
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Every command is a property on
310
- the camera's `input` object, so a host can rebind it — the value is a `QKeySequence` string,
311
- and `|` separates alternatives (empty disables the command). `input.keyPanStep`,
316
+ per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Held keys advance continuously:
317
+ one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
318
+ there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
319
+ moves diagonally at the normal speed. Every command is a property on the camera's `input`
320
+ object, so a host can rebind it — the value is a `QKeySequence` string, and `|` separates
321
+ alternatives (empty disables the command). `input.keyPanStep`, `input.keyPanRepeatInterval`,
312
322
  `input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
313
323
 
314
324
  ```python
@@ -318,8 +328,9 @@ input.panLeftShortcut = "A" # rebind one arrow
318
328
  input.panRightShortcut = "D"
319
329
  input.zoomInShortcut = "Ctrl+="
320
330
  input.zoomOutShortcut = "Ctrl+-"
321
- input.keyPanStep = 0.2 # 20% of the viewport per arrow press
322
- camera.animationDuration = 220 # ms; 0 disables easing
331
+ input.keyPanStep = 0.2 # 20% of the viewport per step
332
+ input.keyPanRepeatInterval = 80 # ms per step; smaller = faster pan/zoom
333
+ camera.animationDuration = 220 # ms; tap easing
323
334
  ```
324
335
 
325
336
  Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
@@ -132,7 +132,7 @@ The model:
132
132
  |---|---|
133
133
  | `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
134
134
  | `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, `animationDuration`, `input` (a `ViewInput`), slots `set_viewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `zoom_at(factor, u, v, w, h)` / `pan_pixels(dx, dy)` / `animate_zoom(...)` / `animate_pan(dx, dy, ms)` / `stop_animation()` / `reset()` |
135
- | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
135
+ | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `keyPanRepeatInterval`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
136
136
  | `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
137
137
  | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
138
138
 
@@ -226,9 +226,16 @@ and `add_implicit` asks for the level-set artist explicitly — also for a funct
226
226
  which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
227
227
  first.
228
228
 
229
+ A sympy `Eq(lhs, rhs)` is always a level set: it is normalized to `lhs - rhs = 0`, and an
230
+ equation with only `x` still means the vertical lines where it holds (`Eq(x**2, 1)`), not
231
+ `y = x**2 - 1`. The canonical `expression` keeps the `Eq(...)` text and `expr` is the normalized
232
+ difference.
233
+
229
234
  ```python
235
+ x, y = sp.symbols("x y")
230
236
  plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
231
- plot.add_implicit("x*y - 1") # the same artist, asked for by name
237
+ plot.add_curve(sp.Eq(x**2 + y**2, 1)) # the same circle in equation form
238
+ plot.add_implicit(sp.Eq(x, sp.sin(y))) # asked for by name, equation form
232
239
  plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
233
240
  plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
234
241
  ```
@@ -293,9 +300,12 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
293
300
  ## Keyboard, touch and animation
294
301
 
295
302
  Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
296
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Every command is a property on
297
- the camera's `input` object, so a host can rebind it — the value is a `QKeySequence` string,
298
- and `|` separates alternatives (empty disables the command). `input.keyPanStep`,
303
+ per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Held keys advance continuously:
304
+ one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
305
+ there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
306
+ moves diagonally at the normal speed. Every command is a property on the camera's `input`
307
+ object, so a host can rebind it — the value is a `QKeySequence` string, and `|` separates
308
+ alternatives (empty disables the command). `input.keyPanStep`, `input.keyPanRepeatInterval`,
299
309
  `input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
300
310
 
301
311
  ```python
@@ -305,8 +315,9 @@ input.panLeftShortcut = "A" # rebind one arrow
305
315
  input.panRightShortcut = "D"
306
316
  input.zoomInShortcut = "Ctrl+="
307
317
  input.zoomOutShortcut = "Ctrl+-"
308
- input.keyPanStep = 0.2 # 20% of the viewport per arrow press
309
- camera.animationDuration = 220 # ms; 0 disables easing
318
+ input.keyPanStep = 0.2 # 20% of the viewport per step
319
+ input.keyPanRepeatInterval = 80 # ms per step; smaller = faster pan/zoom
320
+ camera.animationDuration = 220 # ms; tap easing
310
321
  ```
311
322
 
312
323
  Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -80,19 +80,36 @@ def _color(value: object) -> QColor:
80
80
  return QColor(value) if not isinstance(value, QColor) else QColor(value)
81
81
 
82
82
 
83
- def parsed(value: str | sp.Expr) -> tuple[str, sp.Expr]:
83
+ def _parse(value: str | sp.Basic) -> tuple[str, sp.Expr, bool]:
84
+ """Canonical text, normalized expression and whether the input was an equation.
85
+
86
+ ``Eq(lhs, rhs)`` becomes the level set ``lhs - rhs = 0`` and is flagged as implicit even
87
+ when only ``x`` is free (``Eq(x**2, 1)`` is two vertical lines, not ``y = x**2 - 1``).
88
+ """
89
+ if isinstance(value, sp.Basic):
90
+ text = str(value)
91
+ expr = value
92
+ else:
93
+ text = str(value)
94
+ expr = sp.sympify(value, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
95
+ if isinstance(expr, sp.Eq):
96
+ return text, expr.lhs - expr.rhs, True
97
+ return text, expr, False
98
+
99
+
100
+ def parsed(value: str | sp.Basic) -> tuple[str, sp.Expr]:
84
101
  """The canonical string and the parsed expression of a curve input.
85
102
 
86
103
  A string is parsed here, once; a sympy expression is used as it is (no ``Expr -> str ->
87
- Expr`` round trip). The canonical string is what identifies the expression.
104
+ Expr`` round trip). ``Eq(lhs, rhs)`` is normalized to ``lhs - rhs``. The canonical string
105
+ is what identifies the expression.
88
106
  """
89
- if isinstance(value, sp.Basic):
90
- return str(value), value
91
- return str(value), sp.sympify(value, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
107
+ text, expr, _ = _parse(value)
108
+ return text, expr
92
109
 
93
110
 
94
111
  def curve_for(
95
- expression: str | sp.Expr,
112
+ expression: str | sp.Basic,
96
113
  *,
97
114
  implicit: bool | None = None,
98
115
  color: object = None,
@@ -106,17 +123,25 @@ def curve_for(
106
123
  is an implicit curve (the level set ``F(x, y) = 0``), and anything else raises — a parameter
107
124
  must be substituted before it is plotted, and silently evaluating it as a pixel coordinate is
108
125
  exactly the trap this avoids. ``implicit=True`` picks the level-set artist regardless of the
109
- symbols (``add_implicit``).
126
+ symbols (``add_implicit``). A sympy ``Eq`` is always a level set (``lhs - rhs = 0``), even
127
+ when only ``x`` is free; it keeps the ordinary ``Curve`` class so a later assignment can
128
+ re-route it.
110
129
  """
111
- text, expr = parsed(expression)
130
+ text, expr, is_equation = _parse(expression)
112
131
  names = {symbol.name for symbol in expr.free_symbols}
113
132
  if names - {"x", "y"}:
114
133
  raise ValueError(
115
134
  f"{sorted(names - {'x', 'y'})} must be substituted first: only x (or x and y) may "
116
135
  f"be free, got {text!r}"
117
136
  )
118
- cls: type[Curve] = ImplicitCurve if (implicit or "y" in names) else Curve
119
- return cls(expr, color=color, line_width=line_width, label=label, parent=parent)
137
+ if implicit:
138
+ cls: type[Curve] = ImplicitCurve
139
+ elif is_equation:
140
+ cls = Curve
141
+ else:
142
+ cls = ImplicitCurve if "y" in names else Curve
143
+ source = expression if is_equation else expr
144
+ return cls(source, color=color, line_width=line_width, label=label, parent=parent)
120
145
 
121
146
 
122
147
  class Curve(QObject):
@@ -125,7 +150,8 @@ class Curve(QObject):
125
150
  The fragment shader follows the expression: ``y = f(x)`` while only ``x`` (or nothing) is
126
151
  free, the level set ``F(x, y) = 0`` as soon as ``y`` appears — so ``add_curve`` and assigning
127
152
  to ``expression`` both route themselves, and a text field can turn a function into a level
128
- set. ``ImplicitCurve`` forces the level-set template regardless of the symbols.
153
+ set. A sympy ``Eq(lhs, rhs)`` is always a level set (``lhs - rhs = 0``), even when only ``x``
154
+ is free. ``ImplicitCurve`` forces the level-set template regardless of the symbols.
129
155
  """
130
156
 
131
157
  #: Forced to the level-set template (see ``ImplicitCurve``).
@@ -145,7 +171,7 @@ class Curve(QObject):
145
171
 
146
172
  def __init__(
147
173
  self,
148
- expression: str | sp.Expr = "sin(x)",
174
+ expression: str | sp.Basic = "sin(x)",
149
175
  color: str | QColor = DEFAULT_COLOR,
150
176
  line_width: float = 1.5,
151
177
  label: str = "",
@@ -164,6 +190,7 @@ class Curve(QObject):
164
190
  self._fragment = QUrl()
165
191
  self._generation = 0
166
192
  self._pending_expr: sp.Expr | None = None
193
+ self._pending_implicit: bool | None = None
167
194
  # Not parented: a job may outlive the curve; the signal object must stay valid for the
168
195
  # queued connection (Qt disconnects the receiver when the curve is destroyed).
169
196
  self._bake_signals = _BakeSignals()
@@ -174,51 +201,50 @@ class Curve(QObject):
174
201
  def _get_expression(self) -> str:
175
202
  return self._expression
176
203
 
177
- def _set_expression(self, source: str | sp.Expr, *, synchronous: bool = False) -> None:
204
+ def _set_expression(self, source: str | sp.Basic, *, synchronous: bool = False) -> None:
178
205
  """Set the expression from a string (parsed here, once) or from a sympy expression.
179
206
 
180
207
  The canonical **string** is what identifies the expression — ``sp.sin(x)`` and
181
208
  ``"sin(x)"`` are the same, and re-setting either is free — while the parsed
182
209
  ``sp.Expr`` is cached for the bake and for the matplotlib hand-off, so neither ever
183
- stringifies and re-parses again.
210
+ stringifies and re-parses again. ``Eq(lhs, rhs)`` is normalized to ``lhs - rhs`` and
211
+ drawn as the level set, even when only ``x`` is free.
184
212
 
185
213
  Parsing and source generation are synchronous (microseconds); the qsb subprocess runs
186
214
  on the thread pool for later assignments. ``expr`` keeps the last successfully baked
187
215
  expression until the new shaders arrive, so a failed or pending edit never draws an
188
216
  incomplete curve.
189
217
  """
190
- if isinstance(source, str):
191
- canonical = source
192
- else:
193
- canonical = str(source)
218
+ canonical = source if isinstance(source, str) else str(source)
194
219
  if canonical == self._expression:
195
220
  return
196
221
  self._generation += 1
197
222
  generation = self._generation
198
223
  self._pending_expr = None
199
- if isinstance(source, sp.Basic): # already an expression: use it as it is
200
- expr: sp.Expr = source
201
- else:
202
- try:
203
- expr = sp.sympify(source, locals={"x": self._symbol, "y": sp.Symbol("y")})
204
- except Exception as exc:
205
- # Keep the last drawn expression: the shaders stay, `expression` is what was
206
- # typed and `error` says why.
207
- self._expression = str(canonical)
208
- self._error = f"{type(exc).__name__}: {exc}"
209
- self.expressionChanged.emit()
210
- self.errorChanged.emit()
211
- return
224
+ self._pending_implicit = None
225
+ try:
226
+ _, expr, is_equation = _parse(source)
227
+ except Exception as exc:
228
+ # Keep the last drawn expression: the shaders stay, `expression` is what was
229
+ # typed and `error` says why.
230
+ self._expression = str(canonical)
231
+ self._error = f"{type(exc).__name__}: {exc}"
232
+ self.expressionChanged.emit()
233
+ self.errorChanged.emit()
234
+ return
235
+ forced_implicit = type(self)._force_implicit or is_equation
212
236
  self._expression = str(canonical)
213
237
  self.expressionChanged.emit()
214
- self._start_bake(generation, expr, synchronous=synchronous)
238
+ self._start_bake(
239
+ generation, expr, synchronous=synchronous, force_implicit=forced_implicit
240
+ )
215
241
 
216
242
  # Annotate the value type (PySide6 idiom): Property is a descriptor, so without the
217
243
  # annotation a type checker only sees the Property object and flags every read and write.
218
244
  # Declared "QVariant" so an ``sp.Expr`` reaches the setter unconverted; the getter is always
219
245
  # the canonical string (QML, a project file and the text field all want one).
220
- expression: str | sp.Expr = Property("QVariant", _get_expression, _set_expression,
221
- notify=expressionChanged)
246
+ expression: str | sp.Basic = Property("QVariant", _get_expression, _set_expression,
247
+ notify=expressionChanged)
222
248
 
223
249
  def _get_expr(self) -> sp.Expr:
224
250
  return self._expr
@@ -302,7 +328,9 @@ class Curve(QObject):
302
328
  """
303
329
  return self._force_implicit or "y" in {symbol.name for symbol in self._expr.free_symbols}
304
330
 
305
- def _sources(self, expr: sp.Expr) -> tuple[str, str]:
331
+ def _sources(
332
+ self, expr: sp.Expr, *, force_implicit: bool | None = None
333
+ ) -> tuple[str, str]:
306
334
  """The shader sources for this expression; anything unrenderable raises ``ValueError``
307
335
  (which ``_start_bake`` turns into ``error``, keeping the last working shaders)."""
308
336
  names = {symbol.name for symbol in expr.free_symbols}
@@ -311,22 +339,33 @@ class Curve(QObject):
311
339
  f"only x (or x and y) may be free, got {sorted(names)}: substitute parameters "
312
340
  "first"
313
341
  )
314
- return implicit_shader_sources(expr) if (self._force_implicit or "y" in names) \
342
+ implicit = self._force_implicit if force_implicit is None else force_implicit
343
+ return implicit_shader_sources(expr) if (implicit or "y" in names) \
315
344
  else shader_sources(expr)
316
345
 
317
- def _start_bake(self, generation: int, expr: sp.Expr, *, synchronous: bool) -> None:
346
+ def _start_bake(
347
+ self,
348
+ generation: int,
349
+ expr: sp.Expr,
350
+ *,
351
+ synchronous: bool,
352
+ force_implicit: bool | None = None,
353
+ ) -> None:
318
354
  """Generate the GLSL now and bake it on the pool unless this curve is synchronous.
319
355
 
320
356
  Source generation is cheap and stays on the caller's thread; only the qsb subprocess
321
357
  (the ~126 ms cache miss) leaves it.
322
358
  """
323
359
  try:
324
- vertex_source, fragment_source = self._sources(expr)
360
+ vertex_source, fragment_source = self._sources(
361
+ expr, force_implicit=force_implicit
362
+ )
325
363
  except Exception as exc: # noqa: BLE001 - reported through error, previous shaders stay
326
364
  self._error = f"{type(exc).__name__}: {exc}"
327
365
  self.errorChanged.emit()
328
366
  return
329
367
  self._pending_expr = expr
368
+ self._pending_implicit = force_implicit
330
369
  if synchronous or not self.bake_async or QCoreApplication.instance() is None:
331
370
  self._run_bake(generation, vertex_source, fragment_source)
332
371
  return
@@ -349,7 +388,9 @@ class Curve(QObject):
349
388
  if generation != self._generation:
350
389
  return
351
390
  expr = self._pending_expr
391
+ implicit = self._pending_implicit
352
392
  self._pending_expr = None
393
+ self._pending_implicit = None
353
394
  if isinstance(result, Exception):
354
395
  self._error = f"{type(result).__name__}: {result}"
355
396
  self.errorChanged.emit()
@@ -358,6 +399,8 @@ class Curve(QObject):
358
399
  return
359
400
  vertex, fragment = result # type: ignore[misc]
360
401
  self._expr = expr
402
+ if implicit is not None:
403
+ self._force_implicit = bool(implicit)
361
404
  self._error = ""
362
405
  self._vertex = QUrl.fromLocalFile(str(vertex))
363
406
  self._fragment = QUrl.fromLocalFile(str(fragment))
@@ -475,7 +518,7 @@ class CurveListModel(QAbstractListModel):
475
518
  @Slot("QVariant", result=QObject)
476
519
  def add_curve(
477
520
  self,
478
- expression: str | sp.Expr,
521
+ expression: str | sp.Basic,
479
522
  color: object = None,
480
523
  line_width: object = None,
481
524
  label: str = "",
@@ -491,7 +534,7 @@ class CurveListModel(QAbstractListModel):
491
534
  @Slot("QVariant", result=QObject)
492
535
  def add_implicit(
493
536
  self,
494
- expression: str | sp.Expr,
537
+ expression: str | sp.Basic,
495
538
  color: object = None,
496
539
  line_width: object = None,
497
540
  label: str = "",
@@ -39,12 +39,14 @@ class ViewInput(QObject):
39
39
 
40
40
  keyboardEnabledChanged = Signal()
41
41
  keyPanStepChanged = Signal()
42
+ keyPanRepeatIntervalChanged = Signal()
42
43
  shortcutsChanged = Signal()
43
44
 
44
45
  def __init__(self, parent: QObject | None = None) -> None:
45
46
  super().__init__(parent)
46
47
  self._keyboard_enabled = True
47
48
  self._key_pan_step = 0.1
49
+ self._key_pan_repeat_interval = 100
48
50
  self._pan_left = "Left"
49
51
  self._pan_right = "Right"
50
52
  self._pan_up = "Up"
@@ -85,6 +87,26 @@ class ViewInput(QObject):
85
87
  float, _get_key_pan_step, _set_key_pan_step, notify=keyPanStepChanged
86
88
  )
87
89
 
90
+ def _get_key_pan_repeat_interval(self) -> int:
91
+ return self._key_pan_repeat_interval
92
+
93
+ def _set_key_pan_repeat_interval(self, value: int) -> None:
94
+ value = int(value)
95
+ if value <= 0:
96
+ raise ValueError(f"keyPanRepeatInterval must be positive, got {value!r}")
97
+ if value == self._key_pan_repeat_interval:
98
+ return
99
+ self._key_pan_repeat_interval = value
100
+ self.keyPanRepeatIntervalChanged.emit()
101
+
102
+ #: Milliseconds per held-key step (the frame timer integrates at dt / this value).
103
+ keyPanRepeatInterval: int = Property(
104
+ int,
105
+ _get_key_pan_repeat_interval,
106
+ _set_key_pan_repeat_interval,
107
+ notify=keyPanRepeatIntervalChanged,
108
+ )
109
+
88
110
  #: Key sequences (QKeySequence text) for the six view commands. ``|`` separates
89
111
  #: alternatives, so the default zoom-in is ``"+|="`` (Shift+= and the unshifted key).
90
112
  panLeftShortcut: str = _shortcut_property("_pan_left", shortcutsChanged)
@@ -152,7 +152,7 @@ class Plot(QObject):
152
152
  @Slot("QVariant", result=QObject)
153
153
  def add_curve(
154
154
  self,
155
- expression: "str | sp.Expr",
155
+ expression: "str | sp.Basic",
156
156
  color: object = None,
157
157
  line_width: object = None,
158
158
  label: str = "",
@@ -162,7 +162,8 @@ class Plot(QObject):
162
162
  The artist follows the expression's free symbols: ``x`` (or none) is a 1-D curve
163
163
  ``y = f(x)``, ``x`` **and** ``y`` is an implicit curve — the level set ``F(x, y) = 0``,
164
164
  so ``add_curve("x**2 + y**2 - 1")`` is a circle — and any other free symbol raises
165
- ``ValueError`` (substitute parameters first).
165
+ ``ValueError`` (substitute parameters first). A sympy ``Eq(lhs, rhs)`` is normalized to
166
+ ``lhs - rhs`` and treated as a level set even when only ``x`` is free.
166
167
  """
167
168
  return self._curves.add_curve(expression, color, line_width, label)
168
169
 
@@ -170,13 +171,14 @@ class Plot(QObject):
170
171
  @Slot("QVariant", result=QObject)
171
172
  def add_implicit(
172
173
  self,
173
- expression: str | sp.Expr,
174
+ expression: str | sp.Basic,
174
175
  color: object = None,
175
176
  line_width: object = None,
176
177
  label: str = "",
177
178
  ) -> Curve:
178
179
  """Add an implicit curve — the level set ``F(x, y) = 0`` — explicitly, whatever the
179
- expression's free symbols (``x`` alone gives vertical lines).
180
+ expression's free symbols (``x`` alone gives vertical lines; ``Eq(lhs, rhs)`` is
181
+ normalized to ``lhs - rhs``).
180
182
 
181
183
  Drawn per pixel like the other artists, with no sampling: ``|F|`` divided by ``|grad F|``
182
184
  in pixels is the distance to the level set. Smooth, single-sheet level sets only for now
@@ -0,0 +1,275 @@
1
+ // View layer composition: background, one shader pass per curve, screen-space furniture and
2
+ // pointer/keyboard input. The reusable pieces live in PlotCurves.qml, PlotFurniture.qml and
3
+ // PlotInput.qml; this file owns the model injection, the camera mapping and keyboard focus.
4
+ //
5
+ // Usage (the host creates the model and injects it; MVVM):
6
+ // PlotView { plot: myPlot }
7
+ // It also works standalone: the component brings its own empty Plot (`defaultExpression` adds
8
+ // a curve). The view owns no state: everything it draws is read from the Plot.
9
+ import QtQuick
10
+ import QmlMathPlot 1.0
11
+
12
+ Item {
13
+ id: root
14
+
15
+ implicitWidth: 800
16
+ implicitHeight: 600
17
+
18
+ // Keyboard navigation is opt-in by focus: a click (or a host focusing the QQuickWidget)
19
+ // makes this the active item, and the keys are handled only then, so a host's shortcuts
20
+ // keep working while the plot is idle. `focus: true` is the default *within* this scene,
21
+ // not a grab: the QQuickWidget is still ClickFocus and does not enter the Tab chain.
22
+ focus: true
23
+ Keys.onPressed: (event) => {
24
+ if (root._handleKeyPress(event))
25
+ event.accepted = true;
26
+ }
27
+ Keys.onReleased: (event) => {
28
+ if (root._handleKeyRelease(event))
29
+ event.accepted = true;
30
+ }
31
+ onActiveFocusChanged: {
32
+ if (!activeFocus)
33
+ root._clearHeld();
34
+ }
35
+
36
+ // The model (injected by the host). Without injection the component brings its own plot;
37
+ // it adds no curve by itself. A standalone user opts into one with `defaultExpression`
38
+ // (``PlotView { defaultExpression: "sin(x)" }``) — no hidden default, and a host that
39
+ // injects a model or adds its own curves never races this handler.
40
+ property Plot plot: defaultPlot
41
+ property string defaultExpression: ""
42
+ Plot {
43
+ id: defaultPlot
44
+ }
45
+
46
+ // Forwarders, so a host can drive the component without knowing where the knob lives:
47
+ // `view.camera.aspect = 1.0`, `view.curves.add_curve("tan(x)")`. These are plain
48
+ // properties rather than QML aliases simply because an alias cannot reach through two
49
+ // objects (the plot is an injected property, not an id).
50
+ property QtObject camera: root.plot.camera
51
+ property QtObject curves: root.plot.curves
52
+
53
+ // World <-> screen. The camera maps the centre to the middle of the item, so screen x grows
54
+ // right and screen y grows down (vUV is top-down in the shader too).
55
+ function mapToScreen(x, y) {
56
+ const camera = root.plot.camera;
57
+ return Qt.point((x - camera.centre.x) / camera.scale_x + width / 2,
58
+ (camera.centre.y - y) / camera.scale_y + height / 2);
59
+ }
60
+
61
+ function mapFromScreen(point) {
62
+ const camera = root.plot.camera;
63
+ return Qt.point(camera.centre.x + (point.x - width / 2) * camera.scale_x,
64
+ camera.centre.y - (point.y - height / 2) * camera.scale_y);
65
+ }
66
+
67
+ // Keyboard: arrow keys move the view (the camera moves, so the content follows), the
68
+ // zoom shortcuts anchor at the viewport centre. Every mapping is on camera.input, so a
69
+ // host can rebind it; an empty string disables a command.
70
+ //
71
+ // Held keys are tracked here rather than relying on the OS auto-repeat (which begins with
72
+ // a ~0.5 s delay and then repeats in bursts). One frame-rate Timer advances both pan and
73
+ // zoom by `dt / keyPanRepeatInterval` of a step, so movement is continuous and several
74
+ // keys combine (diagonal movement is normalised). The press itself still animates one
75
+ // step, so a tap keeps the one-step semantics.
76
+ property var _heldKeys: ({})
77
+ property real _lastTickMs: 0
78
+
79
+ Timer {
80
+ id: keyTimer
81
+ interval: 16
82
+ repeat: true
83
+ onTriggered: root._keyTick()
84
+ }
85
+
86
+ Connections {
87
+ target: root.plot.camera
88
+ function onPanEnabledChanged() {
89
+ if (!root.plot.camera.panEnabled) {
90
+ root._dropHeld("left");
91
+ root._dropHeld("right");
92
+ root._dropHeld("up");
93
+ root._dropHeld("down");
94
+ }
95
+ }
96
+ function onZoomEnabledChanged() {
97
+ if (!root.plot.camera.zoomEnabled) {
98
+ root._dropHeld("zoomIn");
99
+ root._dropHeld("zoomOut");
100
+ }
101
+ }
102
+ }
103
+ Connections {
104
+ target: root.plot.camera.input
105
+ function onKeyboardEnabledChanged() {
106
+ if (!root.plot.camera.input.keyboardEnabled)
107
+ root._clearHeld();
108
+ }
109
+ }
110
+
111
+ // One command name for this event, or "" when it is not bound.
112
+ function _commandFor(event, requireEnabled) {
113
+ const camera = root.plot.camera;
114
+ const input = camera.input;
115
+ if (!input.keyboardEnabled)
116
+ return "";
117
+ if (input.matches(event.key, event.modifiers, input.panLeftShortcut))
118
+ return (!requireEnabled || camera.panEnabled) ? "left" : "";
119
+ if (input.matches(event.key, event.modifiers, input.panRightShortcut))
120
+ return (!requireEnabled || camera.panEnabled) ? "right" : "";
121
+ if (input.matches(event.key, event.modifiers, input.panUpShortcut))
122
+ return (!requireEnabled || camera.panEnabled) ? "up" : "";
123
+ if (input.matches(event.key, event.modifiers, input.panDownShortcut))
124
+ return (!requireEnabled || camera.panEnabled) ? "down" : "";
125
+ if (camera.zoomEnabled) {
126
+ if (input.matches(event.key, event.modifiers, input.zoomInShortcut))
127
+ return "zoomIn";
128
+ if (input.matches(event.key, event.modifiers, input.zoomOutShortcut))
129
+ return "zoomOut";
130
+ }
131
+ return "";
132
+ }
133
+
134
+ function _isPanCommand(command) {
135
+ return command === "left" || command === "right"
136
+ || command === "up" || command === "down";
137
+ }
138
+
139
+ function _clearHeld() {
140
+ root._heldKeys = ({});
141
+ keyTimer.stop();
142
+ }
143
+
144
+ function _dropHeld(command) {
145
+ delete root._heldKeys[command];
146
+ if (Object.keys(root._heldKeys).length === 0)
147
+ keyTimer.stop();
148
+ }
149
+
150
+ function _ensureTimer() {
151
+ if (!keyTimer.running) {
152
+ root._lastTickMs = Date.now();
153
+ keyTimer.start();
154
+ }
155
+ }
156
+
157
+ // Frame-rate integration: a step per keyPanRepeatInterval, scaled by the real elapsed
158
+ // time (a delayed frame moves farther instead of slowing the pan down).
159
+ function _keyTick() {
160
+ const camera = root.plot.camera;
161
+ const input = camera.input;
162
+ if (!input.keyboardEnabled) {
163
+ root._clearHeld();
164
+ return;
165
+ }
166
+ const now = Date.now();
167
+ const dt = Math.min(100, Math.max(0, now - root._lastTickMs));
168
+ root._lastTickMs = now;
169
+ if (dt <= 0)
170
+ return;
171
+ const fraction = dt / Math.max(1, input.keyPanRepeatInterval);
172
+
173
+ let dx = 0;
174
+ let dy = 0;
175
+ if (root._heldKeys.left)
176
+ dx += 1;
177
+ if (root._heldKeys.right)
178
+ dx -= 1;
179
+ if (root._heldKeys.up)
180
+ dy += 1;
181
+ if (root._heldKeys.down)
182
+ dy -= 1;
183
+ if ((dx !== 0 || dy !== 0) && camera.panEnabled) {
184
+ if (dx !== 0 && dy !== 0) {
185
+ dx *= Math.SQRT1_2;
186
+ dy *= Math.SQRT1_2;
187
+ }
188
+ camera.pan_pixels(input.keyPanStep * root.width * dx * fraction,
189
+ input.keyPanStep * root.height * dy * fraction);
190
+ }
191
+
192
+ let zoom = 0;
193
+ if (root._heldKeys.zoomIn)
194
+ zoom += 1;
195
+ if (root._heldKeys.zoomOut)
196
+ zoom -= 1;
197
+ if (zoom !== 0 && camera.zoomEnabled) {
198
+ camera.zoom_by(120 * zoom * fraction, 0.5, 0.5, root.width, root.height);
199
+ }
200
+ }
201
+
202
+ function _handleKeyPress(event) {
203
+ const command = root._commandFor(event, true);
204
+ if (command === "")
205
+ return false;
206
+ if (event.isAutoRepeat) // the Timer owns repeats
207
+ return true;
208
+ const camera = root.plot.camera;
209
+ const input = camera.input;
210
+ root._heldKeys[command] = true;
211
+ root._ensureTimer();
212
+ if (root._isPanCommand(command)) {
213
+ // One eased step for a tap; the frame timer cancels it on the first held frame
214
+ // and takes over continuously.
215
+ const step = input.keyPanStep;
216
+ if (command === "left")
217
+ camera.animate_pan(step * root.width, 0, camera.animationDuration);
218
+ else if (command === "right")
219
+ camera.animate_pan(-step * root.width, 0, camera.animationDuration);
220
+ else if (command === "up")
221
+ camera.animate_pan(0, step * root.height, camera.animationDuration);
222
+ else
223
+ camera.animate_pan(0, -step * root.height, camera.animationDuration);
224
+ } else if (command === "zoomIn") {
225
+ camera.animate_zoom(120, 0.5, 0.5, root.width, root.height,
226
+ camera.animationDuration);
227
+ } else if (command === "zoomOut") {
228
+ camera.animate_zoom(-120, 0.5, 0.5, root.width, root.height,
229
+ camera.animationDuration);
230
+ }
231
+ return true;
232
+ }
233
+
234
+ function _handleKeyRelease(event) {
235
+ const command = root._commandFor(event, false);
236
+ if (command === "")
237
+ return false;
238
+ if (event.isAutoRepeat)
239
+ return true;
240
+ root._dropHeld(command);
241
+ if (Object.keys(root._heldKeys).length > 0)
242
+ root._ensureTimer();
243
+ return true;
244
+ }
245
+
246
+ // The first report resolves the home view for the real size; later ones keep the scales, so
247
+ // nothing zooms while a window or a splitter is dragged.
248
+ Component.onCompleted: {
249
+ if (defaultExpression !== "" && curves.rowCount() === 0)
250
+ add_curve(defaultExpression);
251
+ root.plot.camera.set_viewport(width, height);
252
+ }
253
+ onWidthChanged: root.plot.camera.set_viewport(width, height)
254
+ onHeightChanged: root.plot.camera.set_viewport(width, height)
255
+
256
+ Rectangle {
257
+ anchors.fill: parent
258
+ color: root.plot.background
259
+ }
260
+
261
+ PlotCurves {
262
+ anchors.fill: parent
263
+ view: root
264
+ }
265
+
266
+ PlotFurniture {
267
+ anchors.fill: parent
268
+ view: root
269
+ }
270
+
271
+ PlotInput {
272
+ anchors.fill: parent
273
+ view: root
274
+ }
275
+ }
@@ -1,134 +0,0 @@
1
- // View layer composition: background, one shader pass per curve, screen-space furniture and
2
- // pointer/keyboard input. The reusable pieces live in PlotCurves.qml, PlotFurniture.qml and
3
- // PlotInput.qml; this file owns the model injection, the camera mapping and keyboard focus.
4
- //
5
- // Usage (the host creates the model and injects it; MVVM):
6
- // PlotView { plot: myPlot }
7
- // It also works standalone: the component brings its own empty Plot (`defaultExpression` adds
8
- // a curve). The view owns no state: everything it draws is read from the Plot.
9
- import QtQuick
10
- import QmlMathPlot 1.0
11
-
12
- Item {
13
- id: root
14
-
15
- implicitWidth: 800
16
- implicitHeight: 600
17
-
18
- // Keyboard navigation is opt-in by focus: a click (or a host focusing the QQuickWidget)
19
- // makes this the active item, and the keys are handled only then, so a host's shortcuts
20
- // keep working while the plot is idle. `focus: true` is the default *within* this scene,
21
- // not a grab: the QQuickWidget is still ClickFocus and does not enter the Tab chain.
22
- focus: true
23
- Keys.onPressed: (event) => {
24
- if (root._handleKey(event))
25
- event.accepted = true;
26
- }
27
-
28
- // The model (injected by the host). Without injection the component brings its own plot;
29
- // it adds no curve by itself. A standalone user opts into one with `defaultExpression`
30
- // (``PlotView { defaultExpression: "sin(x)" }``) — no hidden default, and a host that
31
- // injects a model or adds its own curves never races this handler.
32
- property Plot plot: defaultPlot
33
- property string defaultExpression: ""
34
- Plot {
35
- id: defaultPlot
36
- }
37
-
38
- // Forwarders, so a host can drive the component without knowing where the knob lives:
39
- // `view.camera.aspect = 1.0`, `view.curves.add_curve("tan(x)")`. These are plain
40
- // properties rather than QML aliases simply because an alias cannot reach through two
41
- // objects (the plot is an injected property, not an id).
42
- property QtObject camera: root.plot.camera
43
- property QtObject curves: root.plot.curves
44
-
45
- // World <-> screen. The camera maps the centre to the middle of the item, so screen x grows
46
- // right and screen y grows down (vUV is top-down in the shader too).
47
- function mapToScreen(x, y) {
48
- const camera = root.plot.camera;
49
- return Qt.point((x - camera.centre.x) / camera.scale_x + width / 2,
50
- (camera.centre.y - y) / camera.scale_y + height / 2);
51
- }
52
-
53
- function mapFromScreen(point) {
54
- const camera = root.plot.camera;
55
- return Qt.point(camera.centre.x + (point.x - width / 2) * camera.scale_x,
56
- camera.centre.y - (point.y - height / 2) * camera.scale_y);
57
- }
58
-
59
- // Keyboard: arrow keys move the view (the camera moves, so the content follows), the
60
- // zoom shortcuts anchor at the viewport centre. Every mapping is on camera.input, so a
61
- // host can rebind it; an empty string disables a command.
62
- function _matches(event, shortcut) {
63
- return root.plot.camera.input.matches(event.key, event.modifiers, shortcut);
64
- }
65
-
66
- function _handleKey(event) {
67
- const camera = root.plot.camera;
68
- const input = camera.input;
69
- if (!input.keyboardEnabled)
70
- return false;
71
- const dx = input.keyPanStep * root.width;
72
- const dy = input.keyPanStep * root.height;
73
- const duration = camera.animationDuration;
74
- if (camera.panEnabled) {
75
- if (_matches(event, input.panLeftShortcut)) {
76
- camera.animate_pan(dx, 0, duration);
77
- return true;
78
- }
79
- if (_matches(event, input.panRightShortcut)) {
80
- camera.animate_pan(-dx, 0, duration);
81
- return true;
82
- }
83
- if (_matches(event, input.panUpShortcut)) {
84
- camera.animate_pan(0, dy, duration);
85
- return true;
86
- }
87
- if (_matches(event, input.panDownShortcut)) {
88
- camera.animate_pan(0, -dy, duration);
89
- return true;
90
- }
91
- }
92
- if (camera.zoomEnabled) {
93
- if (_matches(event, input.zoomInShortcut)) {
94
- camera.animate_zoom(120, 0.5, 0.5, root.width, root.height, duration);
95
- return true;
96
- }
97
- if (_matches(event, input.zoomOutShortcut)) {
98
- camera.animate_zoom(-120, 0.5, 0.5, root.width, root.height, duration);
99
- return true;
100
- }
101
- }
102
- return false;
103
- }
104
-
105
- // The first report resolves the home view for the real size; later ones keep the scales, so
106
- // nothing zooms while a window or a splitter is dragged.
107
- Component.onCompleted: {
108
- if (defaultExpression !== "" && curves.rowCount() === 0)
109
- add_curve(defaultExpression);
110
- root.plot.camera.set_viewport(width, height);
111
- }
112
- onWidthChanged: root.plot.camera.set_viewport(width, height)
113
- onHeightChanged: root.plot.camera.set_viewport(width, height)
114
-
115
- Rectangle {
116
- anchors.fill: parent
117
- color: root.plot.background
118
- }
119
-
120
- PlotCurves {
121
- anchors.fill: parent
122
- view: root
123
- }
124
-
125
- PlotFurniture {
126
- anchors.fill: parent
127
- view: root
128
- }
129
-
130
- PlotInput {
131
- anchors.fill: parent
132
- view: root
133
- }
134
- }