Architecture
Layers
flowchart TB
subgraph UI["React UI (src/pages · src/components)"]
A1["Welcome · Workbench<br/>(TopBar/Sidebar/Central/Right/Status)"]
A2["Settings · Share ·<br/>Plugin dialogs · Example-data dialogs"]
A3["Flow mode canvas<br/>Palette · Canvas · Node · Param editor<br/>Toolbar · Result preview"]
A4["Block mode<br/>Blockly workspace · Toolbar<br/>Preview / Variables / Console cards"]
end
subgraph State["State & Core Services"]
B1["Zustand stores<br/>app / project / plugin / settings / block / editor"]
B2["Core services<br/>storage (IndexedDB) · events (bus)<br/>i18n · theming · perf<br/>fileFormat · wasm · gpu<br/>scene3d · sandbox<br/>stats · io · plot · repro"]
end
subgraph Blocks["Block system (Flow mode, src/blocks)"]
D1["Catalog<br/>data_source · transform · filter<br/>math · statistics · plot · visualize"]
D2["Compiler<br/>pure · validates ports/types<br/>topological sort · diagnostics"]
D3["Executor<br/>incremental cache<br/>dirty propagation · run()"]
D4["Render bridge<br/>viz.* RenderedView → plugin.loadData"]
end
subgraph Editor["Block mode (src/editor)"]
E1["IR<br/>shared node union · validate · hash<br/>(also powers Code mode)"]
E2["Blockly engine + block definitions<br/>workspace JSON ⇄ IR convert"]
E3["Interpreter<br/>walks IR · calls studio.*"]
E4["Codegen<br/>IR → JS · IR → Python"]
E5["StudioApi<br/>load / transform / stats / plot / print<br/>(reuses src/blocks/ops)"]
end
subgraph Runtime["Runtime Layer"]
C1["Plugin runtime<br/>builtin/* (49 core + 10 fun)<br/>marketplace catalog<br/>cspkg loader (sandbox)<br/>registry & lifecycle"]
C2["Native core (Rust→WASM)<br/>device mgmt · compute<br/>kernel scheduling<br/>file-kind detection"]
end
UI --> B1
UI --> B2
B1 <--> B2
B1 --> C1
B2 --> C1
B2 --> C2
A3 --> B1
B1 --> D2
B1 --> D3
D1 --> D2
D3 --> D4
D4 --> C1
A4 --> E2
E2 --> E1
E1 --> E3
E1 --> E4
E3 --> E5
E5 --> D4One plugin bridge, two callers.
D4(Flow'sRenderedView→plugin.loadData) andE5 → D4(Block mode'sstudio.plot→RenderedView) collapse onto the same code path. Adding a visualisation in Flow mode lights up astudio.plot(...)variant in Block mode for free. See Block Mode for the editor layer in detail, and Flow Mode for the block system.
Scientific computing subsystems
Four pure-TypeScript, DOM-light, node-testable subsystems live under src/core/ and are consumed by the block system, the Studio API, and the workbench UI:
| Subsystem | Path | Surface |
|---|---|---|
| Statistics kernel | src/core/stats/ | descriptive stats, special functions, hypothesis tests, effect sizes, multiple-comparison corrections, power analysis |
| Scientific I/O | src/core/io/ | one dispatcher (loadScientificData) routing to HDF5 / NetCDF / FITS / Zarr / Parquet loaders, returning RawVariable[] |
| Plot engine | src/core/plot/ | pure-TS SVG rendering with linear/log/temporal scales, plus SVG/PDF export |
| Reproducibility | src/core/repro/ | seeded RNG (mulberry32), stable hashing, run manifests, DAG-to-Python export |
State management
Five Zustand stores hold all application state:
| Store | Responsibility |
|---|---|
appStore | host status, banners, notifications, perf metrics, panel toggles |
projectStore | current project, recent list, save/autosave, share, param persistence |
pluginStore | registry, load/activate lifecycle, file dispatch, host containers |
settingsStore | GPU mode, autosave interval, and other preferences |
editorStore | Block / Code mode sessions (IR, code, variables, console) |
Cross-cutting UI communication uses a small typed event bus (src/core/events.ts) — e.g. plugin:<id>:params, host:params:changed, host:file:choose-plugin.
Rendering pipeline
The central viewport owns three surfaces:
central-canvas— the shared 2D canvas every 2D plugin draws into.central-dom-host— a DOM container for plugins that need elements (also where the sandboxed canvas surfaces are mounted).Three.js scene — created lazily by
scene3d.tsonly when a plugin declaresrenderToScene. The plugin store decides visibility centrally on every activation:- plugin declares 3D → show the scene, clear any stale 2D frame;
- anything else → hide the scene immediately.
This guarantees a 3D coordinate system can never appear over a 2D viewport (and vice versa).
Native core
native/ergalics-core (Rust) compiles to wasm32-unknown-unknown and is bound with wasm-bindgen into src/native/. It requires the unstable web-sys WebGPU bindings, enabled via rustflags = ["--cfg=web_sys_unstable_apis"] in native/.cargo/config.toml. See Native Core & WebGPU for the exact API surface and the calling conventions.
Dependency rules
pages → stores → core— lower layers never import higher ones.coremust stay DOM-light: pure services (i18n, fileFormat, storage adapters) are unit-testable in a node environment.- The plugin contract (
src/types/plugin.ts) is the only shared vocabulary between the host and third-party code.
Research module layering
The research features (experiment tracking, uncertainty, units, lineage, chunked I/O, figure studio, supplementary bundles, notebook) all follow one three-layer convention — they grow into the existing structure:
- Data/logic layer —
src/core/<feature>/: pure TypeScript, no React, no store imports. May only depend on sibling core modules (repro,plot,io,gpu) andfflate/apache-arrowstyle leaf libraries. Every module ships unit tests undertests/mirroring the path. - Business layer —
src/stores/<feature>Store.ts: orchestration only — calls core, persists throughprojectStore/storage.ts, subscribes to host event channels. No UI logic, no direct DOM. - Presentation layer —
src/pages/andsrc/components/: consumes stores exclusively; styling via theglobal.cssdesign tokens (no hard-coded colors/spacing); every user-facing string goes through i18n (zh-CN+en-USin the same change).
Cross-module communication prefers the typed host channels at the bottom of src/core/events.ts (run:completed, data:ingested, lineage:changed, figure:exported, notebook:executed) over store→store calls; direct calls are reserved for genuine parent/child relationships (e.g. projectStore coordinating reset on project open).
Persistence split: bulky, per-project-but-not-portable data (run records) lives in dedicated IndexedDB stores (storage.ts) and is cascade-deleted with the project; portable document state (notebook cells, figure sheets) lives on ProjectState and travels inside the .clproj.
Research data flow (who writes which state, who hears which event):
┌────────────────────────────────────────────┐
│ event bus │
│ run:completed / data:ingested / │
│ lineage:changed / figure:exported / │
│ notebook:executed │
└──────▲───────────────▲─────────────▲───────┘
│ │ │
Flow/Block/Code/Notebook ─────┘ │ └───── figureStore
execution points │
(recordRun / chunkStore) │
│ │
▼ │
IndexedDB `runs` store ──► experimentStore ───┘
│ ▲
│ listRuns() │ writes through
▼ │
lineageStore ──► LineageCanvas│
▲ │
│ │
chunkStore ───► data:ingested │
│
project.state (portable, inside .clproj)
├── figureSheets ◄────── figureStore (write-through + dirty)
├── notebook ◄────── notebookStore (write-through + dirty)
└── blockGraph / editorSessions ◄── apply*() at save timeEvery arrow into the bus is a store emitting after a state transition; every arrow out is a long-lived subscription registered in the store's init* function (called once from App.tsx).