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.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "dynsim",
3
+ "version": "0.1.0",
4
+ "description": "Interactive dynamical systems simulator with PyScript/Python integration and Plotly visualization",
5
+ "type": "module",
6
+ "main": "dist/dynsim.umd.js",
7
+ "module": "dist/dynsim.esm.js",
8
+ "unpkg": "dist/dynsim.umd.js",
9
+ "jsdelivr": "dist/dynsim.umd.js",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/dynsim.esm.js",
13
+ "require": "./dist/dynsim.umd.js",
14
+ "default": "./dist/dynsim.esm.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "scripts": {
22
+ "build": "node build.js",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "keywords": [
28
+ "dynamical-systems",
29
+ "simulation",
30
+ "plotly",
31
+ "pyscript",
32
+ "visualization",
33
+ "interactive"
34
+ ],
35
+ "license": "MIT",
36
+ "peerDependencies": {
37
+ "plotly.js-dist": ">=2.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "plotly.js-dist": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "esbuild": "^0.24.0",
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * SimulationController — wires Simulation + SimulationView.
3
+ *
4
+ * Owns the requestAnimationFrame loop. Each tick:
5
+ * reads input from view → calls simulation.step() → updates view.
6
+ */
7
+ import { Simulation } from './simulation.js';
8
+ import { SimulationView } from './view.js';
9
+
10
+ export class SimulationController {
11
+ /**
12
+ * @param {object} options
13
+ * @param {HTMLElement} options.container - DOM element for the simulation view
14
+ * @param {object} options.config - Parsed system config (from registry)
15
+ * @param {function} options.stepProvider - () => stepFunction
16
+ */
17
+ constructor({ container, config, stepProvider }) {
18
+ this.isRunning = true;
19
+ this.animationId = null;
20
+
21
+ this.simulation = new Simulation(config, stepProvider);
22
+
23
+ this.view = new SimulationView({
24
+ container,
25
+ params: config.params,
26
+ initialX: config.initialX ?? 0,
27
+ height: config.height || 400,
28
+ plotType: config.plotType || 'timeseries',
29
+ plotConfig: config.plotConfig || {},
30
+ spikeThreshold: config.spikeThreshold ?? null,
31
+ callbacks: {
32
+ onReset: () => this.reset(),
33
+ onPauseToggle: () => this.togglePause()
34
+ }
35
+ });
36
+ }
37
+
38
+ start() {
39
+ if (this.animationId) {
40
+ cancelAnimationFrame(this.animationId);
41
+ }
42
+ this.animate();
43
+ }
44
+
45
+ stop() {
46
+ if (this.animationId) {
47
+ cancelAnimationFrame(this.animationId);
48
+ this.animationId = null;
49
+ }
50
+ }
51
+
52
+ reset() {
53
+ this.simulation.reset();
54
+ this.view.initPlot();
55
+ }
56
+
57
+ togglePause() {
58
+ this.isRunning = !this.isRunning;
59
+ // Clear pause time so the simulation can continue past it
60
+ if (this.isRunning && this.simulation.paused) {
61
+ this.simulation.pauseTime = null;
62
+ }
63
+ this.view.setPauseState(this.isRunning);
64
+ }
65
+
66
+ animate() {
67
+ if (this.isRunning && !this.simulation.paused) {
68
+ const inputValue = this.view.getInput();
69
+ const paramValues = this.view.getParameters();
70
+
71
+ try {
72
+ this.simulation.step(inputValue, paramValues);
73
+ } catch (e) {
74
+ console.error('[DynSim] Step error:', e);
75
+ this.stop();
76
+ return;
77
+ }
78
+
79
+ // Auto-pause when pause time is reached
80
+ if (this.simulation.paused) {
81
+ this.isRunning = false;
82
+ this.view.setPauseState(false);
83
+ }
84
+ }
85
+
86
+ const plotArrays = this.simulation.getPlotArrays();
87
+ const xRange = this.simulation.plotType === 'timeseries'
88
+ ? this.simulation.getTimeseriesRange()
89
+ : undefined;
90
+ const spikeTimes = this.simulation.spikes ? this.simulation.spikeTimes : undefined;
91
+ this.view.updatePlot(plotArrays, xRange, spikeTimes);
92
+
93
+ this.animationId = requestAnimationFrame(() => this.animate());
94
+ }
95
+
96
+ destroy() {
97
+ this.stop();
98
+ this.view.destroy();
99
+ }
100
+ }
package/src/editor.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * CodeEditor — in-page Python code editor with live replacement.
3
+ *
4
+ * Provides a textarea for editing the Python step function definition.
5
+ * On "Apply", re-executes the code via PyScript/Pyodide and updates
6
+ * the step function in the registry. The simulation continues
7
+ * seamlessly with the new dynamics on the next tick.
8
+ */
9
+ import * as registry from './registry.js';
10
+
11
+ export class CodeEditor {
12
+ /**
13
+ * @param {object} options
14
+ * @param {HTMLElement} options.container - DOM element to render the editor into
15
+ * @param {string} options.containerId - The simulation container ID (registry key)
16
+ * @param {string} options.initialCode - Initial Python source code
17
+ * @param {function} options.executePython - (code, containerId, config) => void
18
+ * Function that executes Python code in the PyScript/Pyodide runtime.
19
+ * The code should call registerPythonSystem which updates the registry.
20
+ */
21
+ constructor({ container, containerId, initialCode, executePython }) {
22
+ this.container = container;
23
+ this.containerId = containerId;
24
+ this.initialCode = initialCode || '';
25
+ this.executePython = executePython;
26
+ this.textarea = null;
27
+ this.statusEl = null;
28
+
29
+ this.render();
30
+ }
31
+
32
+ render() {
33
+ this.container.innerHTML = `
34
+ <div class="dynsim-editor" style="font-family: Arial, sans-serif; font-size: 0.9em; margin-bottom: 12px;">
35
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
36
+ <label style="font-weight: 600; font-size: 0.85em;">Python System Definition</label>
37
+ <div style="display: flex; gap: 8px; align-items: center;">
38
+ <span class="dynsim-editor-status" style="font-size: 0.8em; color: #666;"></span>
39
+ <button class="dynsim-editor-apply" style="
40
+ background: #0056b3; color: white; border: none; border-radius: 4px;
41
+ padding: 4px 12px; cursor: pointer; font-size: 0.85em;
42
+ ">Apply</button>
43
+ <button class="dynsim-editor-reset" style="
44
+ background: #6c757d; color: white; border: none; border-radius: 4px;
45
+ padding: 4px 12px; cursor: pointer; font-size: 0.85em;
46
+ ">Reset Code</button>
47
+ </div>
48
+ </div>
49
+ <textarea class="dynsim-editor-textarea" style="
50
+ width: 100%; min-height: 200px; font-family: monospace; font-size: 0.9em;
51
+ padding: 8px; border: 1px solid #ddd; border-radius: 6px;
52
+ box-sizing: border-box; resize: vertical; tab-size: 4;
53
+ " spellcheck="false">${this._escapeHtml(this.initialCode)}</textarea>
54
+ </div>
55
+ `;
56
+
57
+ this.textarea = this.container.querySelector('.dynsim-editor-textarea');
58
+ this.statusEl = this.container.querySelector('.dynsim-editor-status');
59
+
60
+ this.container.querySelector('.dynsim-editor-apply')
61
+ .addEventListener('click', () => this.apply());
62
+
63
+ this.container.querySelector('.dynsim-editor-reset')
64
+ .addEventListener('click', () => this.resetCode());
65
+
66
+ // Tab key inserts spaces instead of changing focus
67
+ this.textarea.addEventListener('keydown', (e) => {
68
+ if (e.key === 'Tab') {
69
+ e.preventDefault();
70
+ const start = this.textarea.selectionStart;
71
+ const end = this.textarea.selectionEnd;
72
+ this.textarea.value =
73
+ this.textarea.value.substring(0, start) +
74
+ ' ' +
75
+ this.textarea.value.substring(end);
76
+ this.textarea.selectionStart = this.textarea.selectionEnd = start + 4;
77
+ }
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Re-execute the current code and update the registry.
83
+ */
84
+ apply() {
85
+ const code = this.textarea.value;
86
+ const config = registry.getConfig(this.containerId);
87
+
88
+ try {
89
+ this.executePython(code, this.containerId, config);
90
+ this._setStatus('Applied', 'green');
91
+ } catch (e) {
92
+ console.error('[DynSim Editor] Error applying code:', e);
93
+ this._setStatus('Error: ' + e.message, 'red');
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Reset the textarea to the initial code.
99
+ */
100
+ resetCode() {
101
+ this.textarea.value = this.initialCode;
102
+ this._setStatus('Reset to original', '#666');
103
+ }
104
+
105
+ /**
106
+ * Get the current code from the editor.
107
+ * @returns {string}
108
+ */
109
+ getCode() {
110
+ return this.textarea.value;
111
+ }
112
+
113
+ _setStatus(text, color) {
114
+ this.statusEl.textContent = text;
115
+ this.statusEl.style.color = color;
116
+ // Clear status after 3 seconds
117
+ setTimeout(() => {
118
+ if (this.statusEl.textContent === text) {
119
+ this.statusEl.textContent = '';
120
+ }
121
+ }, 3000);
122
+ }
123
+
124
+ _escapeHtml(str) {
125
+ return str
126
+ .replace(/&/g, '&amp;')
127
+ .replace(/</g, '&lt;')
128
+ .replace(/>/g, '&gt;')
129
+ .replace(/"/g, '&quot;');
130
+ }
131
+ }
package/src/index.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * dynsim — Interactive dynamical systems simulator.
3
+ *
4
+ * ES module entry point. Exports all public API.
5
+ */
6
+ export { Simulation } from './simulation.js';
7
+ export { SimulationView } from './view.js';
8
+ export { SimulationController } from './controller.js';
9
+ export { CodeEditor } from './editor.js';
10
+ export * as registry from './registry.js';
11
+ export { pyToJs, injectPythonBridge } from './pybridge.js';
12
+
13
+ import { SimulationController } from './controller.js';
14
+ import * as registry from './registry.js';
15
+
16
+ /**
17
+ * Auto-initialize: expose globals for PyScript interop and
18
+ * bootstrap PyScript containers. Called automatically by the UMD build.
19
+ * Call manually when using as an ES module if you need the legacy behavior.
20
+ */
21
+ export async function autoInit() {
22
+ // Expose globals for PyScript interop
23
+ window.pythonSystems = {};
24
+ window.dynSimConfigs = {};
25
+
26
+ // JS-side registration — called by the Python bridge wrapper.
27
+ // The Python bridge (injected by injectPythonBridge) redefines
28
+ // window.registerPythonSystem to wrap step functions with to_py()
29
+ // conversion, then calls this function.
30
+ window._dynsimJsRegister = function (containerId, stepFunction, config) {
31
+ console.log('[DynSim] Registering Python system:', containerId);
32
+ registry.register(containerId, stepFunction, config);
33
+
34
+ if (document.readyState === 'complete' && typeof Plotly !== 'undefined') {
35
+ initializeContainer(containerId);
36
+ }
37
+ };
38
+
39
+ // Fallback: if the Python bridge hasn't loaded, provide a direct JS registration
40
+ if (!window.registerPythonSystem) {
41
+ window.registerPythonSystem = window._dynsimJsRegister;
42
+ }
43
+
44
+ if (document.readyState === 'loading') {
45
+ document.addEventListener('DOMContentLoaded', async () => {
46
+ await setupPyScriptContainers();
47
+ initializeAllContainers();
48
+ });
49
+ } else {
50
+ await setupPyScriptContainers();
51
+ initializeAllContainers();
52
+ }
53
+ }
54
+
55
+ // --- Internal helpers for autoInit ---
56
+
57
+ const controllers = {};
58
+
59
+ function initializeContainer(containerId) {
60
+ const container = document.getElementById(containerId);
61
+ if (!container || container.querySelector('.dynsim-container')) return;
62
+
63
+ const config = registry.getConfig(containerId);
64
+ if (!config) return;
65
+
66
+ controllers[containerId] = new SimulationController({
67
+ container,
68
+ config,
69
+ stepProvider: () => registry.getStep(containerId)
70
+ });
71
+ controllers[containerId].start();
72
+ }
73
+
74
+ function initializeAllContainers() {
75
+ let attempts = 0;
76
+ const MAX_ATTEMPTS = 20;
77
+
78
+ function tryInit() {
79
+ attempts++;
80
+ if (typeof Plotly === 'undefined') {
81
+ if (attempts < MAX_ATTEMPTS) setTimeout(tryInit, 50);
82
+ return;
83
+ }
84
+
85
+ const ids = registry.getContainerIds();
86
+ if (ids.length === 0 && attempts < MAX_ATTEMPTS) {
87
+ setTimeout(tryInit, 50);
88
+ return;
89
+ }
90
+
91
+ ids.forEach(initializeContainer);
92
+ }
93
+
94
+ tryInit();
95
+ }
96
+
97
+ async function setupPyScriptContainers() {
98
+ // Wait for data file
99
+ let attempts = 0;
100
+ while (!window.dynSimSystemsData && attempts < 20) {
101
+ await new Promise(resolve => setTimeout(resolve, 500));
102
+ attempts++;
103
+ }
104
+ if (!window.dynSimSystemsData) return;
105
+
106
+ // Wait for PyScript bootstrap
107
+ attempts = 0;
108
+ while (!window.executeDynSimCode && attempts < 40) {
109
+ await new Promise(resolve => setTimeout(resolve, 500));
110
+ attempts++;
111
+ }
112
+ if (!window.executeDynSimCode) return;
113
+
114
+ const containers = document.querySelectorAll('.dynsim-python-container');
115
+ for (const container of containers) {
116
+ const systemData = window.dynSimSystemsData[container.id];
117
+ if (!systemData) continue;
118
+
119
+ try {
120
+ window.dynSimConfigs[container.id] = systemData.config;
121
+ window.executeDynSimCode(systemData.pythonCode, container.id, systemData.config);
122
+ } catch (e) {
123
+ console.error('[DynSim] Error processing container:', container.id, e);
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * PyBridge — transparent type conversion between JS and Python (Pyodide).
3
+ *
4
+ * Injects a Python-side bridge script that wraps step functions so that
5
+ * JS objects are automatically converted to Python dicts before the user's
6
+ * step function is called. Neither side needs to know about the other.
7
+ *
8
+ * Flow:
9
+ * 1. UMD load → injectPythonBridge() inserts <script type="py"> into DOM
10
+ * 2. PyScript processes it before user scripts (document order)
11
+ * 3. Python bridge redefines window.registerPythonSystem to wrap step functions
12
+ * 4. User's Python code calls registerPythonSystem as normal
13
+ * 5. The wrapper converts JsProxy args to Python dicts, calls real step,
14
+ * and returns the result
15
+ */
16
+
17
+ const PYTHON_BRIDGE = `
18
+ def _dynsim_init_bridge():
19
+ from pyscript import window as _w
20
+ from pyodide.ffi import create_proxy, to_js
21
+ from js import Object
22
+
23
+ _js_register = _w._dynsimJsRegister
24
+
25
+ def _register(container_id, step_fn, config):
26
+ def wrapped_step(x, state, params):
27
+ # Convert JS inputs to Python dicts
28
+ s = state.to_py() if hasattr(state, 'to_py') else state
29
+ p = params.to_py() if hasattr(params, 'to_py') else params
30
+ result = step_fn(float(x), s, p)
31
+ # Convert Python outputs to plain JS objects
32
+ return to_js(result, dict_converter=Object.fromEntries)
33
+ # create_proxy prevents the wrapped function from being garbage collected
34
+ # after this call returns — it's called repeatedly from requestAnimationFrame
35
+ _js_register(container_id, create_proxy(wrapped_step), config)
36
+
37
+ _w.registerPythonSystem = _register
38
+
39
+ _dynsim_init_bridge()
40
+ del _dynsim_init_bridge
41
+ `;
42
+
43
+ /**
44
+ * Inject the Python bridge script into the DOM.
45
+ * Must be called synchronously during UMD script load (before PyScript processes scripts).
46
+ */
47
+ export function injectPythonBridge() {
48
+ const script = document.createElement('script');
49
+ script.type = 'py';
50
+ script.textContent = PYTHON_BRIDGE;
51
+ // Insert as first child of <head> so it runs before user's <script type="py"> tags
52
+ document.head.insertBefore(script, document.head.firstChild);
53
+ }
54
+
55
+ /**
56
+ * Convert a Python proxy value to a plain JS value.
57
+ */
58
+ export function pyToJs(value) {
59
+ if (value != null && typeof value.toJs === 'function') {
60
+ try {
61
+ return value.toJs({ dict_converter: Object.fromEntries });
62
+ } catch {
63
+ try { return value.toJs(); } catch {}
64
+ }
65
+ }
66
+ return value;
67
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Registry for step functions, keyed by container ID.
3
+ *
4
+ * Supports live replacement: update a step function at any time
5
+ * and the next simulation tick will pick it up via the stepProvider pattern.
6
+ */
7
+
8
+ const systems = {};
9
+
10
+ /**
11
+ * Ensure a value is a plain JS object (not a PyProxy or other foreign wrapper).
12
+ * Uses Pyodide's toJs() if available, otherwise falls back to JSON round-trip.
13
+ */
14
+ function toPlainObject(value) {
15
+ if (value == null) return value;
16
+ // Pyodide PyProxy — use toJs with dict_converter for proper dict→object conversion
17
+ if (typeof value.toJs === 'function') {
18
+ try {
19
+ return value.toJs({ dict_converter: Object.fromEntries });
20
+ } catch {
21
+ try { return value.toJs(); } catch {}
22
+ }
23
+ }
24
+ // Fallback: JSON round-trip (works for plain JS objects)
25
+ try {
26
+ return JSON.parse(JSON.stringify(value));
27
+ } catch {
28
+ return value;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Register (or replace) a step function and config for a container.
34
+ * @param {string} containerId
35
+ * @param {function} stepFunction - step(x, state, params) => [x_new, state_new]
36
+ * @param {object} rawConfig - Config object (plain JS or Python dict proxy — converted implicitly)
37
+ */
38
+ export function register(containerId, stepFunction, rawConfig) {
39
+ // Convert potential PyProxy to plain JS object
40
+ const cfg = toPlainObject(rawConfig);
41
+ systems[containerId] = {
42
+ step: stepFunction,
43
+ config: {
44
+ params: typeof cfg.params === 'string' ? JSON.parse(cfg.params) : (cfg.params || []),
45
+ plotType: cfg.plotType || 'timeseries',
46
+ plotConfig: typeof cfg.plotConfig === 'string' ? JSON.parse(cfg.plotConfig) : (cfg.plotConfig || {}),
47
+ initialState: typeof cfg.initialState === 'string' ? JSON.parse(cfg.initialState) : (cfg.initialState || { t: 0 }),
48
+ initialX: cfg.initialX ?? 0,
49
+ height: cfg.height || 400,
50
+ dt: cfg.dt || 0.01,
51
+ pauseTime: cfg.pauseTime ?? null,
52
+ spikes: cfg.spikes || null,
53
+ spikeThreshold: cfg.spikeThreshold ?? null
54
+ }
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Get the current step function for a container.
60
+ * @param {string} containerId
61
+ * @returns {function|null}
62
+ */
63
+ export function getStep(containerId) {
64
+ return systems[containerId]?.step || null;
65
+ }
66
+
67
+ /**
68
+ * Get the parsed config for a container.
69
+ * @param {string} containerId
70
+ * @returns {object|null}
71
+ */
72
+ export function getConfig(containerId) {
73
+ return systems[containerId]?.config || null;
74
+ }
75
+
76
+ /**
77
+ * Replace just the step function (for live code editing).
78
+ * @param {string} containerId
79
+ * @param {function} stepFunction
80
+ */
81
+ export function replaceStep(containerId, stepFunction) {
82
+ if (systems[containerId]) {
83
+ systems[containerId].step = stepFunction;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get all registered container IDs.
89
+ * @returns {string[]}
90
+ */
91
+ export function getContainerIds() {
92
+ return Object.keys(systems);
93
+ }
94
+
95
+ /**
96
+ * Check if a container is registered.
97
+ * @param {string} containerId
98
+ * @returns {boolean}
99
+ */
100
+ export function has(containerId) {
101
+ return containerId in systems;
102
+ }