syd 0.1.5__py3-none-any.whl → 0.1.7__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.
@@ -0,0 +1,302 @@
1
+ from typing import Dict, Any, Optional, List
2
+ import warnings
3
+ from functools import wraps
4
+ from dataclasses import dataclass
5
+ from contextlib import contextmanager
6
+ from time import time
7
+ import base64
8
+ from io import BytesIO
9
+ import threading
10
+ import webbrowser
11
+ from pathlib import Path
12
+
13
+ from flask import Flask, render_template, jsonify, request, Response
14
+ import matplotlib as mpl
15
+ import matplotlib.pyplot as plt
16
+
17
+ from ..parameters import ParameterUpdateWarning
18
+ from ..viewer import Viewer
19
+ from .components import BaseComponent, ComponentStyle, create_component
20
+
21
+
22
+ @contextmanager
23
+ def _plot_context():
24
+ plt.ioff()
25
+ try:
26
+ yield
27
+ finally:
28
+ plt.ion()
29
+
30
+
31
+ def get_backend_type():
32
+ """
33
+ Determines the current matplotlib backend type and returns relevant info
34
+ """
35
+ backend = mpl.get_backend().lower()
36
+ if "agg" in backend:
37
+ return "agg"
38
+ elif "inline" in backend:
39
+ return "inline"
40
+ else:
41
+ # Force Agg backend for Flask
42
+ mpl.use("Agg")
43
+ return "agg"
44
+
45
+
46
+ def debounce(wait_time):
47
+ """
48
+ Decorator to prevent a function from being called more than once every wait_time seconds.
49
+ """
50
+
51
+ def decorator(fn):
52
+ last_called = [0.0] # Using list to maintain state in closure
53
+
54
+ @wraps(fn)
55
+ def debounced(*args, **kwargs):
56
+ current_time = time()
57
+ if current_time - last_called[0] >= wait_time:
58
+ fn(*args, **kwargs)
59
+ last_called[0] = current_time
60
+
61
+ return debounced
62
+
63
+ return decorator
64
+
65
+
66
+ @dataclass
67
+ class LayoutConfig:
68
+ """Configuration for the viewer layout."""
69
+
70
+ controls_position: str = "left" # Options are: 'left', 'top', 'right', 'bottom'
71
+ figure_width: float = 8.0
72
+ figure_height: float = 6.0
73
+ controls_width_percent: int = 30
74
+ template_path: Optional[str] = None
75
+ static_path: Optional[str] = None
76
+
77
+ def __post_init__(self):
78
+ valid_positions = ["left", "top", "right", "bottom"]
79
+ if self.controls_position not in valid_positions:
80
+ raise ValueError(
81
+ f"Invalid controls position: {self.controls_position}. Must be one of {valid_positions}"
82
+ )
83
+
84
+ @property
85
+ def is_horizontal(self) -> bool:
86
+ return self.controls_position == "left" or self.controls_position == "right"
87
+
88
+
89
+ class FlaskDeployer:
90
+ """
91
+ A deployment system for Viewer in Flask web applications.
92
+ Built around the parameter component system for clean separation of concerns.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ viewer: Viewer,
98
+ controls_position: str = "left",
99
+ figure_width: float = 8.0,
100
+ figure_height: float = 6.0,
101
+ controls_width_percent: int = 30,
102
+ continuous: bool = False,
103
+ suppress_warnings: bool = False,
104
+ host: str = "127.0.0.1",
105
+ port: int = 5000,
106
+ template_path: Optional[str] = None,
107
+ static_path: Optional[str] = None,
108
+ ):
109
+ self.viewer = viewer
110
+ self.config = LayoutConfig(
111
+ controls_position=controls_position,
112
+ figure_width=figure_width,
113
+ figure_height=figure_height,
114
+ controls_width_percent=controls_width_percent,
115
+ template_path=template_path,
116
+ static_path=static_path,
117
+ )
118
+ self.continuous = continuous
119
+ self.suppress_warnings = suppress_warnings
120
+ self.host = host
121
+ self.port = port
122
+
123
+ # Initialize containers
124
+ self.backend_type = get_backend_type()
125
+ self.parameter_components: Dict[str, BaseComponent] = {}
126
+ self.app = self._create_flask_app()
127
+
128
+ # Flag to prevent circular updates
129
+ self._updating = False
130
+
131
+ # Last figure to close when new figures are created
132
+ self._last_figure = None
133
+
134
+ def _create_flask_app(self) -> Flask:
135
+ """Create and configure the Flask application."""
136
+ template_path = self.config.template_path or str(
137
+ Path(__file__).parent / "templates"
138
+ )
139
+ static_path = self.config.static_path or str(Path(__file__).parent / "static")
140
+
141
+ app = Flask(__name__, template_folder=template_path, static_folder=static_path)
142
+
143
+ # Register routes
144
+ @app.route("/")
145
+ def index():
146
+ # Generate initial plot
147
+ initial_plot = self._generate_plot()
148
+ if initial_plot is not None:
149
+ buffer = BytesIO()
150
+ initial_plot.savefig(buffer, format="png", bbox_inches="tight")
151
+ buffer.seek(0)
152
+ initial_plot_data = base64.b64encode(buffer.getvalue()).decode()
153
+ else:
154
+ initial_plot_data = ""
155
+
156
+ return render_template(
157
+ "viewer.html",
158
+ components=self._get_component_html(),
159
+ controls_position=self.config.controls_position,
160
+ controls_width=self.config.controls_width_percent,
161
+ figure_width=self.config.figure_width,
162
+ figure_height=self.config.figure_height,
163
+ continuous=self.continuous,
164
+ initial_plot=initial_plot_data,
165
+ )
166
+
167
+ @app.route("/update/<name>", methods=["POST"])
168
+ def update_parameter(name: str):
169
+ if name not in self.viewer.parameters:
170
+ return jsonify({"error": f"Parameter {name} not found"}), 404
171
+
172
+ try:
173
+ value = request.json["value"]
174
+ self._handle_parameter_update(name, value)
175
+ return jsonify({"success": True})
176
+ except Exception as e:
177
+ return jsonify({"error": str(e)}), 400
178
+
179
+ @app.route("/state")
180
+ def get_state():
181
+ return jsonify(self.viewer.state)
182
+
183
+ @app.route("/plot")
184
+ def get_plot():
185
+ # Generate plot and convert to base64 PNG
186
+ figure = self._generate_plot()
187
+ if figure is None:
188
+ return jsonify({"error": "Failed to generate plot"}), 500
189
+
190
+ # Save plot to bytes buffer
191
+ buffer = BytesIO()
192
+ figure.savefig(buffer, format="png", bbox_inches="tight")
193
+ buffer.seek(0)
194
+ image_base64 = base64.b64encode(buffer.getvalue()).decode()
195
+
196
+ return jsonify({"image": image_base64})
197
+
198
+ return app
199
+
200
+ def _create_parameter_components(self) -> None:
201
+ """Create component instances for all parameters."""
202
+ style = ComponentStyle(
203
+ width="100%",
204
+ margin="10px 0",
205
+ description_width="auto",
206
+ )
207
+
208
+ for name, param in self.viewer.parameters.items():
209
+ component = create_component(
210
+ param,
211
+ continuous=self.continuous,
212
+ style=style,
213
+ )
214
+ self.parameter_components[name] = component
215
+
216
+ def _get_component_html(self) -> List[str]:
217
+ """Get HTML for all components."""
218
+ if not self.parameter_components:
219
+ self._create_parameter_components()
220
+ return [comp.html for comp in self.parameter_components.values()]
221
+
222
+ @debounce(0.2)
223
+ def _handle_parameter_update(self, name: str, value: Any) -> None:
224
+ """Handle updates to parameter values."""
225
+ if self._updating:
226
+ print(
227
+ "Already updating -- there's a circular dependency!"
228
+ "This is probably caused by failing to disable callbacks for a parameter."
229
+ "It's a bug --- tell the developer on github issues please."
230
+ )
231
+ return
232
+
233
+ try:
234
+ self._updating = True
235
+
236
+ # Optionally suppress warnings during parameter updates
237
+ with warnings.catch_warnings():
238
+ if self.suppress_warnings:
239
+ warnings.filterwarnings("ignore", category=ParameterUpdateWarning)
240
+
241
+ component = self.parameter_components[name]
242
+ if component._is_action:
243
+ parameter = self.viewer.parameters[name]
244
+ parameter.callback(self.viewer.state)
245
+ else:
246
+ self.viewer.set_parameter_value(name, value)
247
+
248
+ # Update any components that changed due to dependencies
249
+ self._sync_components_with_state(exclude=name)
250
+
251
+ finally:
252
+ self._updating = False
253
+
254
+ def _sync_components_with_state(self, exclude: Optional[str] = None) -> None:
255
+ """Sync component values with viewer state."""
256
+ for name, parameter in self.viewer.parameters.items():
257
+ if name == exclude:
258
+ continue
259
+
260
+ component = self.parameter_components[name]
261
+ if not component.matches_parameter(parameter):
262
+ component.update_from_parameter(parameter)
263
+
264
+ def _generate_plot(self) -> Optional[plt.Figure]:
265
+ """Generate the current plot."""
266
+ try:
267
+ with _plot_context():
268
+ figure = self.viewer.plot(self.viewer.state)
269
+
270
+ # Close the last figure if it exists to keep matplotlib clean
271
+ if self._last_figure is not None:
272
+ plt.close(self._last_figure)
273
+
274
+ self._last_figure = figure
275
+ return figure
276
+ except Exception as e:
277
+ print(f"Error generating plot: {e}")
278
+ return None
279
+
280
+ def deploy(self, open_browser: bool = True) -> None:
281
+ """
282
+ Deploy the viewer as a Flask web application.
283
+
284
+ Parameters
285
+ ----------
286
+ open_browser : bool, optional
287
+ Whether to automatically open the browser when deploying (default: True)
288
+ """
289
+ with self.viewer._deploy_app():
290
+ if open_browser:
291
+ # Open browser in a separate thread to not block
292
+ threading.Timer(
293
+ 1.0, lambda: webbrowser.open(f"http://{self.host}:{self.port}")
294
+ ).start()
295
+
296
+ # Start Flask app
297
+ self.app.run(
298
+ host=self.host,
299
+ port=self.port,
300
+ debug=False, # Debug mode doesn't work well with matplotlib
301
+ use_reloader=False, # Reloader causes issues with matplotlib
302
+ )
@@ -0,0 +1,82 @@
1
+ /* General layout */
2
+ html, body {
3
+ height: 100vh;
4
+ margin: 0;
5
+ padding: 0;
6
+ overflow: hidden;
7
+ }
8
+
9
+ .viewer-container {
10
+ height: 100vh;
11
+ width: 100vw;
12
+ }
13
+
14
+ /* Controls container */
15
+ .controls-container {
16
+ background-color: #f8f9fa;
17
+ border-right: 1px solid #dee2e6;
18
+ overflow-y: auto;
19
+ }
20
+
21
+ .w-controls {
22
+ width: var(--controls-width);
23
+ }
24
+
25
+ /* Plot container */
26
+ .plot-container {
27
+ background-color: white;
28
+ min-height: 300px;
29
+ }
30
+
31
+ .plot-container img {
32
+ max-width: var(--figure-width);
33
+ max-height: var(--figure-height);
34
+ object-fit: contain;
35
+ }
36
+
37
+ /* Form controls */
38
+ .form-control {
39
+ margin-bottom: 1rem;
40
+ }
41
+
42
+ .form-label {
43
+ font-weight: 500;
44
+ margin-bottom: 0.5rem;
45
+ }
46
+
47
+ /* Range inputs */
48
+ input[type="range"] {
49
+ width: 100%;
50
+ }
51
+
52
+ output {
53
+ margin-left: 0.5rem;
54
+ }
55
+
56
+ /* Multiple select */
57
+ select[multiple] {
58
+ height: auto;
59
+ min-height: 100px;
60
+ }
61
+
62
+ /* Button styling */
63
+ .btn-primary {
64
+ width: 100%;
65
+ margin-bottom: 1rem;
66
+ }
67
+
68
+ /* Responsive adjustments */
69
+ @media (max-width: 768px) {
70
+ .viewer-container {
71
+ flex-direction: column !important;
72
+ }
73
+
74
+ .controls-container {
75
+ width: 100% !important;
76
+ max-height: 40vh;
77
+ }
78
+
79
+ .plot-container {
80
+ height: 60vh;
81
+ }
82
+ }
@@ -0,0 +1,174 @@
1
+ class SydViewer {
2
+ constructor(config) {
3
+ this.config = {
4
+ controlsPosition: 'left',
5
+ continuous: false,
6
+ updateInterval: 200,
7
+ ...config
8
+ };
9
+
10
+ this.form = document.getElementById('controls-form');
11
+ this.plot = document.getElementById('plot');
12
+ this.updateTimer = null;
13
+ this.setupEventListeners();
14
+ this.updatePlot();
15
+ }
16
+
17
+ setupEventListeners() {
18
+ // Handle all form input changes
19
+ this.form.addEventListener('input', (event) => {
20
+ const input = event.target;
21
+ if (input.dataset.continuous === 'true' || this.config.continuous) {
22
+ this.debounceUpdate(() => this.handleInputChange(input));
23
+ }
24
+ });
25
+
26
+ // Handle form changes for non-continuous updates
27
+ this.form.addEventListener('change', (event) => {
28
+ const input = event.target;
29
+ if (input.dataset.continuous !== 'true' && !this.config.continuous) {
30
+ this.handleInputChange(input);
31
+ }
32
+ });
33
+
34
+ // Handle button clicks
35
+ this.form.querySelectorAll('button[type="button"]').forEach(button => {
36
+ button.addEventListener('click', () => this.handleButtonClick(button));
37
+ });
38
+ }
39
+
40
+ debounceUpdate(callback) {
41
+ if (this.updateTimer) {
42
+ clearTimeout(this.updateTimer);
43
+ }
44
+ this.updateTimer = setTimeout(callback, this.config.updateInterval);
45
+ }
46
+
47
+ async handleInputChange(input) {
48
+ let value = this.getInputValue(input);
49
+ const name = input.name;
50
+
51
+ try {
52
+ const response = await fetch(`/update/${name}`, {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ },
57
+ body: JSON.stringify({ value }),
58
+ });
59
+
60
+ if (!response.ok) {
61
+ throw new Error(`HTTP error! status: ${response.status}`);
62
+ }
63
+
64
+ await this.updatePlot();
65
+ } catch (error) {
66
+ console.error('Error updating parameter:', error);
67
+ }
68
+ }
69
+
70
+ async handleButtonClick(button) {
71
+ const name = button.name;
72
+ try {
73
+ const response = await fetch(`/update/${name}`, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ },
78
+ body: JSON.stringify({ value: null }),
79
+ });
80
+
81
+ if (!response.ok) {
82
+ throw new Error(`HTTP error! status: ${response.status}`);
83
+ }
84
+
85
+ await this.updatePlot();
86
+ } catch (error) {
87
+ console.error('Error handling button click:', error);
88
+ }
89
+ }
90
+
91
+ getInputValue(input) {
92
+ switch (input.type) {
93
+ case 'checkbox':
94
+ return input.checked;
95
+ case 'number':
96
+ return parseFloat(input.value);
97
+ case 'range':
98
+ if (input.name.endsWith('_low')) {
99
+ const high = document.getElementById(input.id.replace('_low', '_high')).value;
100
+ return [parseFloat(input.value), parseFloat(high)];
101
+ } else if (input.name.endsWith('_high')) {
102
+ const low = document.getElementById(input.id.replace('_high', '_low')).value;
103
+ return [parseFloat(low), parseFloat(input.value)];
104
+ }
105
+ return parseFloat(input.value);
106
+ case 'select-multiple':
107
+ return Array.from(input.selectedOptions).map(option => {
108
+ const value = option.value;
109
+ return !isNaN(value) ? parseFloat(value) : value;
110
+ });
111
+ default:
112
+ const value = input.value;
113
+ return !isNaN(value) ? parseFloat(value) : value;
114
+ }
115
+ }
116
+
117
+ async updatePlot() {
118
+ try {
119
+ const response = await fetch('/plot');
120
+ if (!response.ok) {
121
+ throw new Error(`HTTP error! status: ${response.status}`);
122
+ }
123
+
124
+ const data = await response.json();
125
+ if (data.error) {
126
+ throw new Error(data.error);
127
+ }
128
+
129
+ this.plot.src = `data:image/png;base64,${data.image}`;
130
+ } catch (error) {
131
+ console.error('Error updating plot:', error);
132
+ }
133
+ }
134
+
135
+ async updateState() {
136
+ try {
137
+ const response = await fetch('/state');
138
+ if (!response.ok) {
139
+ throw new Error(`HTTP error! status: ${response.status}`);
140
+ }
141
+
142
+ const state = await response.json();
143
+ this.syncFormWithState(state);
144
+ } catch (error) {
145
+ console.error('Error updating state:', error);
146
+ }
147
+ }
148
+
149
+ syncFormWithState(state) {
150
+ for (const [name, value] of Object.entries(state)) {
151
+ const input = this.form.elements[name];
152
+ if (!input) continue;
153
+
154
+ if (input.type === 'checkbox') {
155
+ input.checked = value;
156
+ } else if (input.type === 'select-multiple') {
157
+ Array.from(input.options).forEach(option => {
158
+ option.selected = value.includes(option.value);
159
+ });
160
+ } else if (input.type === 'range' && Array.isArray(value)) {
161
+ const [low, high] = value;
162
+ document.getElementById(`param_${name}_low`).value = low;
163
+ document.getElementById(`param_${name}_high`).value = high;
164
+ document.querySelector(`output[for="param_${name}_low"]`).value = low;
165
+ document.querySelector(`output[for="param_${name}_high"]`).value = high;
166
+ } else {
167
+ input.value = value;
168
+ if (input.type === 'range') {
169
+ document.querySelector(`output[for="${input.id}"]`).value = value;
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}Syd Viewer{% endblock %}</title>
7
+
8
+ <!-- Bootstrap CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
10
+
11
+ <!-- Custom CSS -->
12
+ <link href="{{ url_for('static', filename='css/viewer.css') }}" rel="stylesheet">
13
+
14
+ {% block extra_head %}{% endblock %}
15
+ </head>
16
+ <body>
17
+ <div class="container-fluid p-0">
18
+ {% block content %}{% endblock %}
19
+ </div>
20
+
21
+ <!-- Bootstrap Bundle with Popper -->
22
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
23
+
24
+ <!-- Custom JavaScript -->
25
+ <script src="{{ url_for('static', filename='js/viewer.js') }}"></script>
26
+
27
+ {% block extra_scripts %}{% endblock %}
28
+ </body>
29
+ </html>
@@ -0,0 +1,51 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block extra_head %}
4
+ <style>
5
+ :root {
6
+ --controls-width: {{ controls_width }}%;
7
+ --figure-width: {{ figure_width }}px;
8
+ --figure-height: {{ figure_height }}px;
9
+ }
10
+ </style>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="viewer-container {% if controls_position in ['left', 'right'] %}d-flex flex-row{% else %}d-flex flex-column{% endif %} h-100"
15
+ data-controls-position="{{ controls_position }}"
16
+ data-continuous="{{ continuous|default(false)|tojson }}">
17
+ {% if controls_position in ['left', 'top'] %}
18
+ <div class="controls-container {% if controls_position == 'left' %}w-controls{% else %}w-100{% endif %}">
19
+ <form id="controls-form" class="p-3">
20
+ {{ components|join('')|safe }}
21
+ </form>
22
+ </div>
23
+ {% endif %}
24
+
25
+ <div class="plot-container {% if controls_position in ['left', 'right'] %}flex-grow-1{% else %}w-100{% endif %} d-flex justify-content-center align-items-center p-3">
26
+ <img id="plot" src="{% if initial_plot %}data:image/png;base64,{{ initial_plot }}{% endif %}" alt="Plot" class="img-fluid">
27
+ </div>
28
+
29
+ {% if controls_position in ['right', 'bottom'] %}
30
+ <div class="controls-container {% if controls_position == 'right' %}w-controls{% else %}w-100{% endif %}">
31
+ <form id="controls-form" class="p-3">
32
+ {{ components|join('')|safe }}
33
+ </form>
34
+ </div>
35
+ {% endif %}
36
+ </div>
37
+ {% endblock %}
38
+
39
+ {% block extra_scripts %}
40
+ <script>
41
+ // Initialize the viewer with configuration
42
+ document.addEventListener('DOMContentLoaded', function() {
43
+ const container = document.querySelector('.viewer-container');
44
+ window.viewer = new SydViewer({
45
+ controlsPosition: container.dataset.controlsPosition,
46
+ continuous: JSON.parse(container.dataset.continuous),
47
+ updateInterval: 200
48
+ });
49
+ });
50
+ </script>
51
+ {% endblock %}