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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/dynsim.esm.js +81 -0
- package/dist/dynsim.esm.js.map +7 -0
- package/dist/dynsim.umd.js +81 -0
- package/dist/dynsim.umd.js.map +7 -0
- package/package.json +48 -0
- package/src/controller.js +100 -0
- package/src/editor.js +131 -0
- package/src/index.js +126 -0
- package/src/pybridge.js +67 -0
- package/src/registry.js +102 -0
- package/src/simulation.js +161 -0
- package/src/umd-entry.js +22 -0
- package/src/view.js +235 -0
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, '&')
|
|
127
|
+
.replace(/</g, '<')
|
|
128
|
+
.replace(/>/g, '>')
|
|
129
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|
package/src/pybridge.js
ADDED
|
@@ -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
|
+
}
|
package/src/registry.js
ADDED
|
@@ -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
|
+
}
|