dynsim 0.1.0

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,161 @@
1
+ /**
2
+ * Pure simulation engine — no DOM, no Plotly.
3
+ *
4
+ * Manages state, stepping, and plot data buffering.
5
+ * The step function is resolved via a stepProvider on each tick,
6
+ * enabling live code replacement.
7
+ */
8
+ export class Simulation {
9
+ /**
10
+ * @param {object} config
11
+ * @param {Array} config.params - Parameter definitions [{id, label, min, max, step, value}]
12
+ * @param {string} config.plotType - 'timeseries' | '3d' | '2d'
13
+ * @param {object} config.plotConfig - Plotly layout config (title, axes, etc.)
14
+ * @param {object} config.initialState - Initial state object
15
+ * @param {number} config.initialX - Initial input/output value
16
+ * @param {number} config.dt - Time step
17
+ * @param {number} [config.maxPoints=1000] - Max buffered points (non-timeseries)
18
+ * @param {number} [config.maxPoints=1000] - Max buffered points (non-timeseries)
19
+ * @param {number} [config.pauseTime=null] - Pause simulation at this time (null = run forever)
20
+ * @param {string} [config.spikes=null] - State variable name to check for spikes (e.g. 'z'). Falsy = no spikes.
21
+ * @param {function} stepProvider - () => stepFunction. Called each tick to get the current step function.
22
+ */
23
+ constructor(config, stepProvider) {
24
+ this.params = config.params;
25
+ this.plotType = config.plotType || 'timeseries';
26
+ this.plotConfig = config.plotConfig || {};
27
+ this.initialState = config.initialState || { t: 0 };
28
+ this.initialX = config.initialX ?? 0;
29
+ this.dt = config.dt || 0.01;
30
+ this.maxPoints = config.maxPoints || 1000;
31
+ this.pauseTime = config.pauseTime ?? null;
32
+ this.spikes = config.spikes || null;
33
+
34
+ this.stepProvider = stepProvider;
35
+
36
+ this.x = this.initialX;
37
+ this.state = { ...this.initialState };
38
+ this.time = 0;
39
+ this.plotData = [];
40
+ this.spikeTimes = [];
41
+ }
42
+
43
+ /**
44
+ * Advance one time step.
45
+ * @param {number} inputValue - Current input from the user (slider)
46
+ * @param {object} paramValues - Parameter values keyed by param ID
47
+ * @returns {{ x: number, state: object, dataPoint: Array }} result of the step
48
+ * @throws if stepProvider returns null or step function throws
49
+ */
50
+ step(inputValue, paramValues) {
51
+ const stepFn = this.stepProvider();
52
+ if (!stepFn) {
53
+ throw new Error('No step function available');
54
+ }
55
+
56
+ // Add dt to params
57
+ const params = { ...paramValues, dt: this.dt };
58
+
59
+ const result = stepFn(inputValue, this.state, params);
60
+ this.x = result[0];
61
+ this.state = result[1];
62
+ this.time += this.dt;
63
+
64
+ // Record spike if the configured state variable is truthy
65
+ if (this.spikes && this.state[this.spikes]) {
66
+ this.spikeTimes.push(this.time);
67
+ }
68
+
69
+ const dataPoint = this._collectDataPoint();
70
+ this.plotData.push(dataPoint);
71
+ this._manageBuffer();
72
+
73
+ return { x: this.x, state: this.state, dataPoint };
74
+ }
75
+
76
+ /**
77
+ * Whether the simulation has reached its pause time.
78
+ * @returns {boolean}
79
+ */
80
+ get paused() {
81
+ return this.pauseTime != null && this.time >= this.pauseTime;
82
+ }
83
+
84
+ /**
85
+ * Reset simulation to initial conditions.
86
+ */
87
+ reset() {
88
+ this.x = this.initialX;
89
+ this.state = { ...this.initialState };
90
+ this.time = 0;
91
+ this.plotData = [];
92
+ this.spikeTimes = [];
93
+ }
94
+
95
+ /**
96
+ * Get current plot data arrays suitable for Plotly.
97
+ * @returns {object} { x, y, z? } arrays
98
+ */
99
+ getPlotArrays() {
100
+ if (this.plotType === '3d') {
101
+ return {
102
+ x: this.plotData.map(d => d[0]),
103
+ y: this.plotData.map(d => d[1]),
104
+ z: this.plotData.map(d => d[2])
105
+ };
106
+ }
107
+ return {
108
+ x: this.plotData.map(d => d[0]),
109
+ y: this.plotData.map(d => d[1])
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Compute the current x-axis range for timeseries plots.
115
+ * @returns {[number, number]} [min, max]
116
+ */
117
+ getTimeseriesRange() {
118
+ const windowSize = (this.plotConfig.xaxis?.range?.[1] - this.plotConfig.xaxis?.range?.[0]) || 50;
119
+ const originalEnd = this.plotConfig.xaxis?.range?.[1] || 50;
120
+
121
+ if (this.time > originalEnd) {
122
+ return [this.time - windowSize, this.time];
123
+ }
124
+ return this.plotConfig.xaxis?.range || [0, 50];
125
+ }
126
+
127
+ // --- Private ---
128
+
129
+ _collectDataPoint() {
130
+ if (this.plotType === '3d') {
131
+ return [this.state.x || this.x, this.state.y || 0, this.state.z || 0];
132
+ } else if (this.plotType === 'timeseries') {
133
+ return [this.time, this.x];
134
+ } else {
135
+ return [this.x, this.state.y || 0];
136
+ }
137
+ }
138
+
139
+ _manageBuffer() {
140
+ if (this.plotType === 'timeseries') {
141
+ const windowSize = this.plotConfig.xaxis?.range?.[1] || 50;
142
+ const pointsPerWindow = Math.ceil(windowSize / this.dt);
143
+ const bufferPoints = Math.ceil(pointsPerWindow * 0.5);
144
+ const targetPoints = pointsPerWindow + bufferPoints;
145
+
146
+ if (this.plotData.length > targetPoints * 2) {
147
+ this.plotData = this.plotData.slice(-targetPoints);
148
+ // Trim old spike times outside the buffer
149
+ if (this.spikeTimes.length > 0) {
150
+ const cutoff = this.plotData[0][0]; // earliest time in buffer
151
+ const firstKeep = this.spikeTimes.findIndex(t => t >= cutoff);
152
+ if (firstKeep > 0) this.spikeTimes = this.spikeTimes.slice(firstKeep);
153
+ }
154
+ }
155
+ } else {
156
+ if (this.plotData.length > this.maxPoints) {
157
+ this.plotData.shift();
158
+ }
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * UMD entry point — auto-initializes for <script> tag usage.
3
+ * Exposes everything on the global DynSim namespace and calls autoInit().
4
+ *
5
+ * Injects the Python bridge FIRST (synchronously) so that PyScript
6
+ * processes it before any user <script type="py"> tags.
7
+ */
8
+ export { Simulation } from './simulation.js';
9
+ export { SimulationView } from './view.js';
10
+ export { SimulationController } from './controller.js';
11
+ export { CodeEditor } from './editor.js';
12
+ export * as registry from './registry.js';
13
+ export { autoInit } from './index.js';
14
+
15
+ import { injectPythonBridge } from './pybridge.js';
16
+ import { autoInit } from './index.js';
17
+
18
+ // 1. Inject Python bridge before PyScript runs
19
+ injectPythonBridge();
20
+
21
+ // 2. Set up JS-side registration and auto-init
22
+ autoInit();
package/src/view.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * SimulationView — DOM + Plotly rendering.
3
+ *
4
+ * Creates the UI (sliders, buttons, plot area), reads user input,
5
+ * and updates the Plotly chart. Emits events via callbacks.
6
+ * Contains no simulation logic.
7
+ */
8
+ export class SimulationView {
9
+ /**
10
+ * @param {object} options
11
+ * @param {HTMLElement} options.container - DOM element to render into
12
+ * @param {Array} options.params - Parameter definitions [{id, label, min, max, step, value}]
13
+ * @param {number} options.initialX - Initial input value
14
+ * @param {number} options.height - Plot height in pixels
15
+ * @param {string} options.plotType - 'timeseries' | '3d' | '2d'
16
+ * @param {object} options.plotConfig - Plotly layout config
17
+ * @param {object} [options.callbacks] - { onReset, onPauseToggle }
18
+ */
19
+ constructor({ container, params, initialX, height, plotType, plotConfig, spikeThreshold, callbacks }) {
20
+ this.container = container;
21
+ this.params = params;
22
+ this.initialX = initialX;
23
+ this.height = height || 400;
24
+ this.plotType = plotType || 'timeseries';
25
+ this.plotConfig = plotConfig || {};
26
+ this.spikeThreshold = spikeThreshold;
27
+ this.callbacks = callbacks || {};
28
+
29
+ this.plotDiv = null;
30
+
31
+ this.createHTML();
32
+ this.initPlot();
33
+ }
34
+
35
+ createHTML() {
36
+ this.container.innerHTML = `
37
+ <div class="dynsim-container" style="font-family: Arial, sans-serif; font-size: 0.9em;">
38
+ <div class="dynsim-controls" style="background: #f8f9fa; padding: 12px; border-radius: 6px; border: 1px solid #ddd; margin-bottom: 12px; box-sizing: border-box;">
39
+ <div class="dynsim-params"></div>
40
+ </div>
41
+ <div style="width: 100%; height: ${this.height}px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; overflow: hidden;">
42
+ <div class="dynsim-plot" style="width: 100%; height: 100%;"></div>
43
+ </div>
44
+ </div>
45
+ `;
46
+
47
+ this._buildControls();
48
+ this.plotDiv = this.container.querySelector('.dynsim-plot');
49
+
50
+ // Typeset LaTeX in labels if MathJax is available
51
+ if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) {
52
+ MathJax.typesetPromise([this.container.querySelector('.dynsim-controls')]);
53
+ }
54
+ }
55
+
56
+ _buildControls() {
57
+ const paramsDiv = this.container.querySelector('.dynsim-params');
58
+
59
+ // Input slider row
60
+ let html = `
61
+ <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 8px;">
62
+ <label style="font-weight: 600; font-size: 0.85em; color: #0056b3; white-space: nowrap;">Input (x):</label>
63
+ <input type="range" class="dynsim-input"
64
+ min="-2" max="2" step="0.1" value="${this.initialX}"
65
+ style="flex: 1; height: 6px; min-width: 100px;">
66
+ <span class="dynsim-input-value" style="background: #cfe2ff; padding: 2px 8px; border-radius: 3px; font-size: 0.85em; min-width: 40px; text-align: center; font-family: monospace;">${this.initialX.toFixed(2)}</span>
67
+ <button class="dynsim-reset" style="background: transparent; border: none; cursor: pointer; padding: 4px; display: flex; align-items: center;" title="Reset">
68
+ <svg width="20" height="20" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
69
+ <path d="M21 12a9 9 0 1 1-9 -9c2.5 0 4.8 1 6.5 2.5l.5 .5"/>
70
+ <path d="M21 3v6h-6"/>
71
+ </svg>
72
+ </button>
73
+ </div>
74
+ `;
75
+
76
+ // Parameter sliders row
77
+ html += `<div style="display: flex; gap: 12px; align-items: center;">`;
78
+ html += this.params.map(param => `
79
+ <label style="font-weight: 600; font-size: 0.85em; white-space: nowrap;">${param.label}:</label>
80
+ <input type="range" class="dynsim-param" data-param="${param.id}"
81
+ min="${param.min}" max="${param.max}" step="${param.step}" value="${param.value}"
82
+ style="flex: 1; height: 6px; min-width: 100px;">
83
+ <span class="dynsim-param-value" style="background: #e9ecef; padding: 2px 8px; border-radius: 3px; font-size: 0.85em; min-width: 40px; text-align: center; font-family: monospace;">${param.value.toFixed(2)}</span>
84
+ `).join('');
85
+ html += `
86
+ <button class="dynsim-pause" style="background: transparent; border: none; cursor: pointer; padding: 4px; display: flex; align-items: center;" title="Pause">
87
+ ${SimulationView.PAUSE_ICON}
88
+ </button>
89
+ </div>`;
90
+
91
+ paramsDiv.innerHTML = html;
92
+
93
+ // Wire up slider display updates
94
+ paramsDiv.querySelector('.dynsim-input').addEventListener('input', (e) => {
95
+ e.target.closest('div').querySelector('.dynsim-input-value')
96
+ .textContent = parseFloat(e.target.value).toFixed(2);
97
+ });
98
+ paramsDiv.querySelectorAll('.dynsim-param').forEach(slider => {
99
+ slider.addEventListener('input', (e) => {
100
+ e.target.nextElementSibling.textContent = parseFloat(e.target.value).toFixed(2);
101
+ });
102
+ });
103
+
104
+ // Wire up button callbacks
105
+ this.container.querySelector('.dynsim-reset')
106
+ .addEventListener('click', () => this.callbacks.onReset?.());
107
+ this.container.querySelector('.dynsim-pause')
108
+ .addEventListener('click', () => this.callbacks.onPauseToggle?.());
109
+ }
110
+
111
+ initPlot() {
112
+ if (this.plotType === '3d') {
113
+ Plotly.newPlot(this.plotDiv, [{
114
+ x: [], y: [], z: [],
115
+ mode: 'lines',
116
+ type: 'scatter3d',
117
+ line: { color: '#2196f3', width: 4 }
118
+ }], {
119
+ title: this.plotConfig.title,
120
+ scene: {
121
+ xaxis: { title: this.plotConfig.xaxis?.title || 'X' },
122
+ yaxis: { title: this.plotConfig.yaxis?.title || 'Y' },
123
+ zaxis: { title: this.plotConfig.zaxis?.title || 'Z' }
124
+ },
125
+ margin: { l: 0, r: 0, t: 30, b: 0 }
126
+ });
127
+ } else {
128
+ const layout = { margin: { l: 50, r: 20, t: 40, b: 50 } };
129
+ if (this.plotConfig.title) layout.title = this.plotConfig.title;
130
+ if (this.plotConfig.xaxis) layout.xaxis = this.plotConfig.xaxis;
131
+ if (this.plotConfig.yaxis) layout.yaxis = this.plotConfig.yaxis;
132
+
133
+ Plotly.newPlot(this.plotDiv, [{
134
+ x: [], y: [],
135
+ mode: 'lines',
136
+ line: { color: '#2196f3', width: 2 }
137
+ }], layout);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Read the current input slider value.
143
+ * @returns {number}
144
+ */
145
+ getInput() {
146
+ return parseFloat(this.container.querySelector('.dynsim-input').value);
147
+ }
148
+
149
+ /**
150
+ * Read current parameter slider values as a dict keyed by param ID.
151
+ * @returns {object}
152
+ */
153
+ getParameters() {
154
+ const result = {};
155
+ this.container.querySelectorAll('.dynsim-param').forEach(slider => {
156
+ result[slider.dataset.param] = parseFloat(slider.value);
157
+ });
158
+ return result;
159
+ }
160
+
161
+ /**
162
+ * Update the Plotly chart with new data.
163
+ * @param {object} plotArrays - { x, y, z? } from Simulation.getPlotArrays()
164
+ * @param {[number, number]} [xRange] - x-axis range for timeseries
165
+ * @param {number[]} [spikeTimes] - spike times to render as vertical lines
166
+ */
167
+ updatePlot(plotArrays, xRange, spikeTimes) {
168
+ if (this.plotType === '3d') {
169
+ Plotly.animate(this.plotDiv, {
170
+ data: [{ x: plotArrays.x, y: plotArrays.y, z: plotArrays.z }]
171
+ }, { transition: { duration: 0 }, frame: { duration: 0 } });
172
+ } else if (this.plotType === 'timeseries') {
173
+ const layout = {
174
+ title: this.plotConfig.title,
175
+ xaxis: { title: this.plotConfig.xaxis?.title || 'Time', range: xRange },
176
+ yaxis: this.plotConfig.yaxis,
177
+ margin: { l: 50, r: 20, t: 40, b: 50 }
178
+ };
179
+
180
+ // Render spike markers and threshold line
181
+ const shapes = [];
182
+
183
+ // Spike threshold: horizontal dashed line
184
+ if (this.spikeThreshold != null) {
185
+ shapes.push({
186
+ type: 'line',
187
+ x0: 0, x1: 1, xref: 'paper',
188
+ y0: this.spikeThreshold, y1: this.spikeThreshold,
189
+ line: { color: 'grey', width: 1, dash: 'dash' }
190
+ });
191
+ }
192
+
193
+ // Spike times: vertical lines
194
+ if (spikeTimes && spikeTimes.length > 0) {
195
+ for (const t of spikeTimes) {
196
+ shapes.push({
197
+ type: 'line',
198
+ x0: t, x1: t,
199
+ y0: 0, y1: 1, yref: 'paper',
200
+ line: { color: 'rgba(255, 0, 0, 0.4)', width: 1 }
201
+ });
202
+ }
203
+ }
204
+
205
+ if (shapes.length > 0) layout.shapes = shapes;
206
+
207
+ Plotly.react(this.plotDiv,
208
+ [{ x: plotArrays.x, y: plotArrays.y, mode: 'lines', line: { color: '#2196f3', width: 2 } }],
209
+ layout
210
+ );
211
+ } else {
212
+ Plotly.animate(this.plotDiv, {
213
+ data: [{ x: plotArrays.x, y: plotArrays.y }]
214
+ }, { transition: { duration: 0 }, frame: { duration: 0 } });
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Update the pause button icon.
220
+ * @param {boolean} isRunning
221
+ */
222
+ setPauseState(isRunning) {
223
+ const btn = this.container.querySelector('.dynsim-pause');
224
+ btn.title = isRunning ? 'Pause' : 'Play';
225
+ btn.innerHTML = isRunning ? SimulationView.PAUSE_ICON : SimulationView.PLAY_ICON;
226
+ }
227
+
228
+ destroy() {
229
+ this.container.innerHTML = '';
230
+ }
231
+ }
232
+
233
+ SimulationView.PAUSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="10" y1="15" x2="10" y2="9"></line><line x1="14" y1="15" x2="14" y2="9"></line></svg>`;
234
+
235
+ SimulationView.PLAY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon></svg>`;