glplot 0.1.0__tar.gz → 0.1.2__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 (76) hide show
  1. glplot-0.1.2/.coverage +0 -0
  2. glplot-0.1.2/.gitignore +1 -0
  3. glplot-0.1.2/.vscode/settings.json +11 -0
  4. glplot-0.1.2/LICENSE +21 -0
  5. {glplot-0.1.0 → glplot-0.1.2}/PKG-INFO +25 -2
  6. glplot-0.1.2/examples/ex_aspect_ratio_fix.py +57 -0
  7. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_density.py +9 -2
  8. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_mpl_bridge.py +1 -1
  9. glplot-0.1.2/examples/ex_read_density.py +33 -0
  10. {glplot-0.1.0 → glplot-0.1.2}/glplot/__init__.py +1 -1
  11. glplot-0.1.2/glplot/controllers.py +145 -0
  12. {glplot-0.1.0 → glplot-0.1.2}/glplot/core/context.py +1 -0
  13. {glplot-0.1.0 → glplot-0.1.2}/glplot/core/legacy.py +22 -4
  14. {glplot-0.1.0 → glplot-0.1.2}/glplot/engine.py +197 -96
  15. {glplot-0.1.0 → glplot-0.1.2}/glplot/managers/effects.py +14 -0
  16. {glplot-0.1.0 → glplot-0.1.2}/glplot/managers/hud.py +101 -38
  17. {glplot-0.1.0 → glplot-0.1.2}/glplot/managers/renderer_manager.py +12 -5
  18. {glplot-0.1.0 → glplot-0.1.2}/glplot/options.py +7 -1
  19. {glplot-0.1.0 → glplot-0.1.2}/glplot/policy.py +15 -3
  20. {glplot-0.1.0 → glplot-0.1.2}/glplot/pyplot.py +19 -4
  21. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/axis.py +51 -7
  22. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/density.py +65 -2
  23. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/exact.py +4 -1
  24. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/interaction.py +2 -1
  25. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/line_family.py +4 -1
  26. {glplot-0.1.0 → glplot-0.1.2}/glplot/utils/export.py +1 -0
  27. {glplot-0.1.0 → glplot-0.1.2}/glplot/utils/mpl_bridge.py +3 -4
  28. {glplot-0.1.0 → glplot-0.1.2}/glplot/utils/shaders.py +253 -16
  29. {glplot-0.1.0 → glplot-0.1.2}/imgui.ini +10 -5
  30. glplot-0.1.2/pyproject.toml +65 -0
  31. glplot-0.1.2/scratch/check_import.py +8 -0
  32. glplot-0.1.2/scratch/check_runtime_math.py +40 -0
  33. glplot-0.1.2/scratch/test_asymmetric_projection.py +75 -0
  34. glplot-0.1.2/scratch/test_autoscale_all.py +35 -0
  35. {glplot-0.1.0 → glplot-0.1.2}/tests/test_backend.py +25 -6
  36. glplot-0.1.2/tests/test_camera_anisotropy.py +85 -0
  37. glplot-0.1.0/LICENSE +0 -0
  38. glplot-0.1.0/glplot/controllers.py +0 -69
  39. glplot-0.1.0/old/backend.py +0 -1615
  40. glplot-0.1.0/pyproject.toml +0 -36
  41. {glplot-0.1.0 → glplot-0.1.2}/README.md +0 -0
  42. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_density_gain.py +0 -0
  43. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_expert_performance.py +0 -0
  44. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_full_showcase.py +0 -0
  45. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_scatter.py +0 -0
  46. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_simple.py +0 -0
  47. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_v2_layers.py +0 -0
  48. {glplot-0.1.0 → glplot-0.1.2}/examples/ex_viewports.py +0 -0
  49. {glplot-0.1.0 → glplot-0.1.2}/examples/verify_polyline_cmap.py +0 -0
  50. {glplot-0.1.0 → glplot-0.1.2}/glplot/backend.py +0 -0
  51. {glplot-0.1.0 → glplot-0.1.2}/glplot/core/__init__.py +0 -0
  52. {glplot-0.1.0 → glplot-0.1.2}/glplot/core/layers.py +0 -0
  53. {glplot-0.1.0 → glplot-0.1.2}/glplot/managers/__init__.py +0 -0
  54. {glplot-0.1.0 → glplot-0.1.2}/glplot/managers/axis.py +0 -0
  55. {glplot-0.1.0 → glplot-0.1.2}/glplot/managers/hud_state.py +0 -0
  56. {glplot-0.1.0 → glplot-0.1.2}/glplot/managers/picking.py +0 -0
  57. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/__init__.py +0 -0
  58. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/base.py +0 -0
  59. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/patch.py +0 -0
  60. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/polyline.py +0 -0
  61. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/scatter.py +0 -0
  62. {glplot-0.1.0 → glplot-0.1.2}/glplot/renderers/text.py +0 -0
  63. {glplot-0.1.0 → glplot-0.1.2}/glplot/scratch/__init__.py +0 -0
  64. {glplot-0.1.0 → glplot-0.1.2}/glplot/utils/__init__.py +0 -0
  65. {glplot-0.1.0 → glplot-0.1.2}/glplot/utils/gl_utils.py +0 -0
  66. {glplot-0.1.0 → glplot-0.1.2}/scratch/check_gl_limits.py +0 -0
  67. {glplot-0.1.0 → glplot-0.1.2}/scratch/check_imgui.py +0 -0
  68. {glplot-0.1.0 → glplot-0.1.2}/scratch/diag_view.py +0 -0
  69. {glplot-0.1.0 → glplot-0.1.2}/scratch/smoke_test.py +0 -0
  70. {glplot-0.1.0 → glplot-0.1.2}/scratch/test_modular_export.py +0 -0
  71. {glplot-0.1.0 → glplot-0.1.2}/tests/test_pyplot.py +0 -0
  72. {glplot-0.1.0 → glplot-0.1.2}/tests/verify_blending_extension.py +0 -0
  73. {glplot-0.1.0 → glplot-0.1.2}/tests/verify_density.py +0 -0
  74. {glplot-0.1.0 → glplot-0.1.2}/tests/verify_headless.py +0 -0
  75. {glplot-0.1.0 → glplot-0.1.2}/tests/verify_phase4.py +0 -0
  76. {glplot-0.1.0 → glplot-0.1.2}/tests/verify_phase5_density.py +0 -0
glplot-0.1.2/.coverage ADDED
Binary file
@@ -0,0 +1 @@
1
+ .DS_Store
@@ -0,0 +1,11 @@
1
+ {
2
+ "python.testing.unittestArgs": [
3
+ "-v",
4
+ "-s",
5
+ "./tests",
6
+ "-p",
7
+ "test*.py"
8
+ ],
9
+ "python.testing.pytestEnabled": false,
10
+ "python.testing.unittestEnabled": true
11
+ }
glplot-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juan Manuel Lombardi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,9 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glplot
3
- Version: 0.1.0
4
- Summary: High-performance OpenGL plotting library, similar to Matplotlib
3
+ Version: 0.1.2
4
+ Summary: High-performance OpenGL plotting library for Python
5
+ Project-URL: Homepage, https://github.com/AkarisDimitry/GLPlot
6
+ Project-URL: Repository, https://github.com/AkarisDimitry/GLPlot
7
+ Project-URL: Issues, https://github.com/AkarisDimitry/GLPlot/issues
5
8
  Author-email: Juan Manuel Lombardi <lombardi@fhi-berlin.mpg.de>
9
+ License: MIT
6
10
  License-File: LICENSE
11
+ Keywords: gpu,opengl,plotting,scientific plotting,visualization
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Visualization
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
7
24
  Requires-Python: >=3.9
8
25
  Requires-Dist: glfw
9
26
  Requires-Dist: imgui
@@ -12,6 +29,12 @@ Requires-Dist: matplotlib
12
29
  Requires-Dist: numpy
13
30
  Requires-Dist: pyopengl
14
31
  Requires-Dist: scipy
32
+ Provides-Extra: dev
33
+ Requires-Dist: build; extra == 'dev'
34
+ Requires-Dist: pytest; extra == 'dev'
35
+ Requires-Dist: pytest-cov; extra == 'dev'
36
+ Requires-Dist: pytest-mock; extra == 'dev'
37
+ Requires-Dist: twine; extra == 'dev'
15
38
  Provides-Extra: test
16
39
  Requires-Dist: pytest; extra == 'test'
17
40
  Requires-Dist: pytest-cov; extra == 'test'
@@ -0,0 +1,57 @@
1
+ import os
2
+ import sys
3
+ # Force local glplot import
4
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
5
+
6
+ import glplot.pyplot as gplt
7
+ import numpy as np
8
+
9
+ def test_aspect_ratio():
10
+ print("Testing aspect ratio fix...")
11
+
12
+ # 1. Create data with extreme ranges
13
+ x = np.linspace(-2, 2, 1000)
14
+ y = 500 * np.sin(x * 10)
15
+
16
+ gplt.figure("Aspect Ratio Test", width=800, height=600)
17
+ gplt.plot(x, y, color='blue', label="Sinusoidal")
18
+
19
+ # 2. Set extreme limits
20
+ gplt.xlim(-2, 2)
21
+ gplt.ylim(-1000, 1000)
22
+
23
+ # 3. Verify limits via API
24
+ lx = gplt.xlim()
25
+ ly = gplt.ylim()
26
+
27
+ print(f"Current X limits: {lx}")
28
+ print(f"Current Y limits: {ly}")
29
+
30
+ expected_x = (-2.0, 2.0)
31
+ expected_y = (-1000.0, 1000.0)
32
+
33
+ # Allow small floating point epsilon
34
+ assert abs(lx[0] - expected_x[0]) < 1e-5
35
+ assert abs(lx[1] - expected_x[1]) < 1e-5
36
+ assert abs(ly[0] - expected_y[0]) < 1e-5
37
+ assert abs(ly[1] - expected_y[1]) < 1e-5
38
+
39
+ print("Success: API returns correct decoupled limits.")
40
+
41
+ # 4. Test Zero Span (Horizontal Line)
42
+ gplt.cla()
43
+ gplt.plot([-10, 10], [5, 5], color='red', label="Horizontal")
44
+ gplt.autoscale()
45
+
46
+ lx_zero = gplt.xlim()
47
+ ly_zero = gplt.ylim()
48
+ print(f"Autoscale on horizontal line: X={lx_zero}, Y={ly_zero}")
49
+
50
+ # Should have a sane Y range despite zero span in data
51
+ assert ly_zero[1] > ly_zero[0]
52
+
53
+ print("Verification complete.")
54
+ # gplt.show() # Uncomment to see visually if running locally
55
+
56
+ if __name__ == "__main__":
57
+ test_aspect_ratio()
@@ -1,11 +1,17 @@
1
1
  import numpy as np
2
+ import os
3
+ import sys
4
+ # Force local glplot import
5
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
6
+
2
7
  import glplot.pyplot as gplt
8
+ factor = 1000
3
9
 
4
10
  # Generate 10 million lines y = a*x + b
5
11
  N = 1_000_000
6
12
  print(f"Generating {N} lines. This tests memory throughput and GPU geometry performance.")
7
13
  a = np.random.randn(N) * 2.0
8
- b = np.random.randn(N) * 0.5
14
+ b = np.random.randn(N) * 0.5 * factor
9
15
 
10
16
  # Assign a random color with transparency
11
17
  colors = np.random.rand(N, 4)
@@ -27,7 +33,8 @@ gplt.plot_lines(a, b, x_range=(-5, 5), colors=colors)
27
33
 
28
34
  gplt.text(-4.5, 4.0, "10,000,000 Lines", fontsize=32, color="white")
29
35
  gplt.text(-4.5, 3.5, "Optimized: 0.5x Resolution Scale", fontsize=20, color="gray")
30
-
36
+ gplt.xlim(-5, 5)
37
+ gplt.ylim(-factor, factor)
31
38
  print("\nControls:")
32
39
  print(" [ D ] : Toggle Density/Exact view.")
33
40
  print(" [ UP/DOWN ] : Adjust density gain.")
@@ -54,7 +54,7 @@ def run_bridge_example():
54
54
  ax2.plot(x_mark, np.cos(x_mark), 'ro--', label="MPL Overlay")
55
55
 
56
56
  # Transfer the same view to the second subplot as a background
57
- plot.to_matplotlib(ax=ax2, scale=1.0, alpha=0.5) # Semi-transparent background
57
+ plot.to_matplotlib(ax=ax2, scale=1.0, )#alpha=0.5) # Semi-transparent background
58
58
  ax2.set_title("With Matplotlib Overlays")
59
59
  ax2.legend()
60
60
 
@@ -0,0 +1,33 @@
1
+ import numpy as np
2
+ import glplot.pyplot as gplt
3
+
4
+ def demo_read_density():
5
+ n = 500000
6
+ a = np.random.randn(n) * 0.15
7
+ b = np.random.randn(n) * 0.15
8
+
9
+ # Enable density mode
10
+ gplt.figure("Read Density Example", density=True, hud=True)
11
+ gplt.lines(a, b, x_range=(-1, 1), color='cyan')
12
+
13
+ # We retrieve the GPULinePlot engine instance
14
+ plot = gplt.gcf()
15
+
16
+ # Force a single frame draw so densities are accumulated on the GPU before readback.
17
+ plot._draw_exact_view()
18
+
19
+ # Retrieve the densities as a 2D numpy array
20
+ densities = plot.get_density_array()
21
+
22
+ print("\n--- Density Array Extracted via API ---")
23
+ print(f"Array shape: {densities.shape} (height, width)")
24
+ print(f"Data type: {densities.dtype}")
25
+ print(f"Min density: {densities.min()}")
26
+ print(f"Max density: {densities.max()}")
27
+ print(f"Mean density: {densities.mean()}")
28
+ print("----------------------------------------\n")
29
+
30
+ gplt.show()
31
+
32
+ if __name__ == "__main__":
33
+ demo_read_density()
@@ -1,6 +1,6 @@
1
1
  from .engine import GPULinePlot
2
2
  from .options import EngineOptions, RenderMode, BlendMode
3
3
 
4
- __version__ = "0.1.0"
4
+ __version__ = "0.1.1"
5
5
 
6
6
  __all__ = ["GPULinePlot", "EngineOptions", "RenderMode", "BlendMode"]
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+ from typing import Tuple, Optional, TYPE_CHECKING
3
+ import numpy as np
4
+ from .utils.gl_utils import ortho
5
+
6
+ if TYPE_CHECKING:
7
+ from .core.legacy import CameraState
8
+ from .options import EngineOptions
9
+
10
+ class CameraController:
11
+ def __init__(self, camera: CameraState, options: EngineOptions):
12
+ self.camera = camera
13
+ self.options = options
14
+
15
+ def world_window(self, width: int, height: int, padding: float = 1.0) -> Tuple[float, float, float, float]:
16
+ """
17
+ Returns the world-space bounds (l, r, b, t) of the view,
18
+ potentially expanded by padding for cache utility.
19
+ """
20
+ half_w = padding / max(self.camera.zoom_x, 1e-12)
21
+ half_h = padding / max(self.camera.zoom_y, 1e-12)
22
+
23
+ l = self.camera.cx - half_w
24
+ r = self.camera.cx + half_w
25
+ b = self.camera.cy - half_h
26
+ t = self.camera.cy + half_h
27
+ return l, r, b, t
28
+
29
+ def mvp(self, width: int, height: int, window: Optional[Tuple[float, float, float, float]] = None) -> np.ndarray:
30
+ l, r, b, t = window if window is not None else self.world_window(width, height)
31
+
32
+ # Apply inset margins for axes and labels visibility
33
+ margin_l = 60.0
34
+ margin_r = 20.0
35
+ margin_b = 40.0
36
+ margin_t = 20.0
37
+
38
+ rl, tb = (r - l), (t - b)
39
+
40
+ # Calculate NDC scale and translation for margins
41
+ w_px = max(width, 1e-12)
42
+ h_px = max(height, 1e-12)
43
+ sx = (width - margin_l - margin_r) / w_px
44
+ tx = (margin_l - margin_r) / w_px
45
+ sy = (height - margin_t - margin_b) / h_px
46
+ ty = (margin_b - margin_t) / h_px
47
+
48
+ # Standard ortho values
49
+ m00 = 2.0 / max(rl, 1e-12)
50
+ m03 = -(r + l) / max(rl, 1e-12)
51
+ m11 = 2.0 / max(tb, 1e-12)
52
+ m13 = -(t + b) / max(tb, 1e-12)
53
+
54
+ # Apply transformation
55
+ r00 = sx * m00
56
+ r03 = sx * m03 + tx
57
+ r11 = sy * m11
58
+ r13 = sy * m13 + ty
59
+
60
+ return np.array([
61
+ [r00, 0.0, 0.0, r03],
62
+ [0.0, r11, 0.0, r13],
63
+ [0.0, 0.0, -1.0, 0.0],
64
+ [0.0, 0.0, 0.0, 1.0]
65
+ ], dtype=np.float32)
66
+
67
+ def screen_to_world(self, sx: float, sy: float, width: int, height: int) -> Tuple[float, float]:
68
+ l, r, b, t = self.world_window(width, height)
69
+
70
+ # Apply inset margins for axes and labels visibility
71
+ margin_l = 60.0
72
+ margin_r = 20.0
73
+ margin_b = 40.0
74
+ margin_t = 20.0
75
+
76
+ w_px = max(width, 1e-12)
77
+ h_px = max(height, 1e-12)
78
+
79
+ # Scale and translation in NDC space
80
+ scale_x = (width - margin_l - margin_r) / w_px
81
+ trans_x = (margin_l - margin_r) / w_px
82
+ scale_y = (height - margin_t - margin_b) / h_px
83
+ trans_y = (margin_b - margin_t) / h_px
84
+
85
+ # Screen to NDC inset
86
+ ndc_x_inset = (sx / w_px) * 2.0 - 1.0
87
+ ndc_y_inset = ((height - sy) / h_px) * 2.0 - 1.0
88
+
89
+ # NDC inset to standard NDC
90
+ ndc_x = (ndc_x_inset - trans_x) / max(scale_x, 1e-12)
91
+ ndc_y = (ndc_y_inset - trans_y) / max(scale_y, 1e-12)
92
+
93
+ # Standard NDC to world
94
+ x = l + (ndc_x + 1.0) * 0.5 * (r - l)
95
+ y = b + (ndc_y + 1.0) * 0.5 * (t - b)
96
+ return x, y
97
+
98
+ def apply_zoom_at_cursor(self, factor: float, mx: float, my: float, width: int, height: int) -> None:
99
+ """Isotropic zoom centered at cursor position."""
100
+ wx0, wy0 = self.screen_to_world(mx, my, width, height)
101
+
102
+ self.camera.zoom_x = float(np.clip(self.camera.zoom_x * factor, self.camera.zoom_min, self.camera.zoom_max))
103
+ self.camera.zoom_y = float(np.clip(self.camera.zoom_y * factor, self.camera.zoom_min, self.camera.zoom_max))
104
+
105
+ wx1, wy1 = self.screen_to_world(mx, my, width, height)
106
+ self.camera.cx += (wx0 - wx1)
107
+ self.camera.cy += (wy0 - wy1)
108
+
109
+ def fit_bounds(
110
+ self,
111
+ xmin: float, xmax: float,
112
+ ymin: float, ymax: float,
113
+ width: int, height: int,
114
+ axes: str = "both"
115
+ ) -> None:
116
+ """
117
+ Calculates independent zoom_x and zoom_y to fit the requested bounds.
118
+ Handles zero-span degenerate cases by providing a sane default unit span.
119
+ """
120
+ # Handle degenerate spans
121
+ if abs(xmax - xmin) < 1e-9:
122
+ xmin -= 0.5
123
+ xmax += 0.5
124
+ if abs(ymax - ymin) < 1e-9:
125
+ ymin -= 0.5
126
+ ymax += 0.5
127
+
128
+ cx = 0.5 * (xmin + xmax)
129
+ cy = 0.5 * (ymin + ymax)
130
+ span_x = xmax - xmin
131
+ span_y = ymax - ymin
132
+
133
+ if "x" in axes or axes == "both":
134
+ self.camera.cx = float(cx)
135
+ self.camera.zoom_x = float(np.clip(2.0 / span_x, self.camera.zoom_min, self.camera.zoom_max))
136
+
137
+ if "y" in axes or axes == "both":
138
+ self.camera.cy = float(cy)
139
+ self.camera.zoom_y = float(np.clip(2.0 / span_y, self.camera.zoom_min, self.camera.zoom_max))
140
+
141
+ def reset_view(self) -> None:
142
+ self.camera.cx = 0.0
143
+ self.camera.cy = 0.0
144
+ self.camera.zoom_x = 1.0
145
+ self.camera.zoom_y = 1.0
@@ -21,6 +21,7 @@ class RenderContext:
21
21
  fb_height: int # Framebuffer height
22
22
 
23
23
  mode: RenderMode # EXACT, INTERACTIVE, or PICKING
24
+ is_density: bool = False # Flag for density/heatmap accumulation mode
24
25
 
25
26
  # Passing global settings
26
27
  global_alpha: float = 1.0
@@ -51,9 +51,25 @@ class SceneData:
51
51
  class CameraState:
52
52
  cx: float = 0.0
53
53
  cy: float = 0.0
54
- zoom: float = 1.0
55
- zoom_min: float = 0.02
56
- zoom_max: float = 500.0
54
+ zoom_x: float = 1.0
55
+ zoom_y: float = 1.0
56
+ zoom_min: float = 1e-12
57
+ zoom_max: float = 1e12
58
+
59
+ @property
60
+ def zoom(self) -> float:
61
+ """Legacy property: returns the vertical zoom."""
62
+ return self.zoom_y
63
+
64
+ @zoom.setter
65
+ def zoom(self, value: float) -> None:
66
+ """Legacy setter: scales both axes proportionally to preserve anisotropy."""
67
+ if self.zoom_y == 0:
68
+ self.zoom_x = self.zoom_y = float(value)
69
+ else:
70
+ factor = float(value) / self.zoom_y
71
+ self.zoom_x *= factor
72
+ self.zoom_y *= factor
57
73
 
58
74
  @dataclass
59
75
  class InteractionState:
@@ -67,10 +83,12 @@ class InteractionState:
67
83
  ctrl_down: bool = False
68
84
  alt_down: bool = False
69
85
 
70
- drag_mode: str = "pan" # "pan", "move"
86
+ drag_mode: str = "pan" # "pan", "move", "ratio"
71
87
  selected_layer_id: Optional[int] = None
72
88
  drag_start_world: Tuple[float, float] = (0.0, 0.0)
73
89
  drag_start_translation: Optional[Tuple[float, float]] = None
90
+ drag_start_zoom_x: float = 1.0
91
+ drag_start_zoom_y: float = 1.0
74
92
  explicit_pick_requested: bool = False # Set on Shift+Click; bypasses the shift_down gate
75
93
 
76
94
  hover_idx: int = -1