-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathruntime.ts
More file actions
2437 lines (2196 loc) · 76.7 KB
/
runtime.ts
File metadata and controls
2437 lines (2196 loc) · 76.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Pyodide loader for DataLab-Web.
*
* This module owns the single Pyodide instance and the Sigima Python
* namespace. All calls into Python go through a tiny typed wrapper so the
* rest of the codebase never touches the Pyodide API directly.
*/
import bootstrapSource from "./bootstrap.py?raw";
import processorSource from "./processor.py?raw";
import dlwMainSource from "./dlw_main.py?raw";
import dlwPluginsSource from "./dlw_plugins.py?raw";
import dlwH5BrowserSource from "./dlw_h5browser.py?raw";
import dlwInteractiveFitSource from "./dlw_interactive_fit.py?raw";
// Tiny module that installs Sigima's ``PlaceholderTitleFormatter`` as the
// default; pushed into every Pyodide instance (main runtime + workers)
// so processed objects carry the same placeholder titles (later resolved
// to source ``oid``s by ``bootstrap.patch_title_with_ids``).
import dlwTitleFormatSource from "./dlw_title_format.py?raw";
// Until the released ``guidata`` ships the new JSON Schema helpers, we
// pre-load a copy of ``guidata/dataset/jsonschema.py`` and let it
// monkey-patch the ``guidata.dataset`` namespace.
import guidataJsonSchemaShim from "./_guidata_jsonschema_shim.py?raw";
// Bundle the portable ``datalab.*`` shim package — every .py file under
// src/sigima/dlplugins/ is mirrored to Pyodide's site-packages at startup
// so plugins can ``from datalab.plugins import PluginBase`` unmodified.
const shimSources = import.meta.glob("./dlplugins/**/*.py", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const builtinPluginSources = import.meta.glob("./builtin_plugins/*.py", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
// Bundle pluggable backend file from local guidata working tree
// (Phase 0 patches that aren't yet in a released wheel). Loaded after
// ``micropip install guidata`` and applied as monkey-patch.
const guidataBackendsSource = (() => {
const candidates = import.meta.glob("./_guidata_backends_shim.py", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const first = Object.values(candidates)[0];
return first ?? null;
})();
// Pyodide is loaded from a CDN <script> tag in index.html; declare its global.
declare global {
interface Window {
loadPyodide: (opts: { indexURL: string }) => Promise<PyodideAPI>;
}
}
export interface PyodideAPI {
runPythonAsync: (code: string) => Promise<unknown>;
loadPackage: (names: string | string[]) => Promise<void>;
globals: {
get: (name: string) => PyProxy | undefined;
};
FS: {
writeFile: (path: string, data: string | Uint8Array) => void;
mkdirTree?: (path: string) => void;
};
// Pyodide exposes more, but the MVP only needs the above.
}
export interface PyProxy {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(...args: any[]): unknown;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callKwargs?: (...args: any[]) => unknown;
toJs?: (opts?: {
dict_converter?: (entries: Iterable<[unknown, unknown]>) => unknown;
}) => unknown;
destroy?: () => void;
}
const PYODIDE_VERSION = "v0.26.4";
const PYODIDE_INDEX = `https://cdn.jsdelivr.net/pyodide/${PYODIDE_VERSION}/full/`;
export interface SignalMeta {
id: string;
uuid: string | null;
title: string;
size: number;
xlabel: string;
ylabel: string;
xunit: string;
yunit: string;
}
/** Optional per-curve style read from PlotPy/Sigima metadata. Honoured
* by :class:`SignalPlot` when overlaying multiple signals; falls back
* to the auto-cycling palette when fields are absent. */
export interface SignalStyle {
color: string | null;
/** PlotPy linestyle name (``SolidLine``, ``DashLine`` …) or Plotly
* ``line.dash`` value — :func:`plotlyDash` normalises both. */
linestyle: string | null;
linewidth: number | null;
/** PlotPy curve display mode (``Lines``, ``Sticks``, ``Steps``,
* ``Dots``, ``NoCurve``) — :func:`normalizeCurveStyle` normalises. */
curvestyle: string | null;
}
export interface SignalData extends SignalMeta {
x: number[];
y: number[];
style?: SignalStyle;
}
export interface ProcessingDescriptor {
id: string;
label: string;
has_params: boolean;
}
/** Snapshot returned by ``get_last_processing(oid)``: identifies the
* feature that produced *oid* and exposes its (optional) parameter
* schema with the values used in the latest invocation. */
export interface LastProcessingInfo {
feature_id: string;
label: string;
menu_path: string;
source_ids: string[];
operand_id: string | null;
has_params: boolean;
schema: SchemaWithValues["schema"] | null;
values: Record<string, unknown> | null;
}
export type Pattern = "1_to_1" | "2_to_1" | "n_to_1";
export interface FeatureDescriptor {
id: string;
label: string;
menu_path: string;
pattern: Pattern;
icon: string | null;
has_params: boolean;
operand_label: string;
object_kind: PanelKind;
/** Destination panel for results. Equals ``object_kind`` for the vast
* majority of features; differs for cross-kind features such as image
* profiles / projections / histogram (image → signal) and
* ``signals_to_image`` (signal → image). */
output_kind: PanelKind;
}
/** One entry of the "Processing > Fitting > Interactive fitting" submenu. */
export interface InteractiveFitInfo {
id: string;
label: string;
/** ``polynomial_fit`` exposes a ``degree`` extra; everything else is
* parameterless. */
needs_degree: boolean;
}
/** One adjustable parameter of an interactive fit (slider row). */
export interface InteractiveFitParam {
name: string;
/** Pretty label for display (Greek letters etc.). */
label: string;
value: number;
min: number;
max: number;
}
/** Payload returned by :meth:`DataLabRuntime.initInteractiveFit`. */
export interface InteractiveFitInit {
fit_id: string;
label: string;
/** Source signal X / Y arrays (full range). */
x: number[];
y: number[];
/** X / Y restricted to the active ROI (== full range when no ROI). */
x_roi: number[];
y_roi: number[];
params: InteractiveFitParam[];
/** Fit evaluated on the full X axis with the initial parameters. */
y_fit: number[];
needs_degree: boolean;
extras: Record<string, unknown>;
}
/** Payload returned by :meth:`DataLabRuntime.autoFitInteractive`. */
export interface InteractiveFitAuto {
values: Record<string, number>;
y_fit: number[];
residual_rms: number;
}
export interface PluginInfoMeta {
name: string;
version: string;
description: string;
icon: string | null;
}
export interface PluginRecord {
name: string;
filename: string;
module: string;
enabled: boolean;
loaded: boolean;
error: string | null;
info: PluginInfoMeta | null;
}
export interface PluginMenuAction {
action_id: string;
title: string;
menu_path: string[];
select_condition: string;
separator_before: boolean;
icon: string | null;
tip: string | null;
origin: string | null;
object_kind: PanelKind;
}
export type PanelKind = "signal" | "image";
export interface ObjectNode extends SignalMeta {
kind: PanelKind;
}
export interface GroupNode {
gid: string;
name: string;
objects: ObjectNode[];
}
export interface PanelTree {
kind: PanelKind;
groups: GroupNode[];
}
/** Editable metadata fields of a signal object (Phase 4). */
export interface ObjectMeta {
title: string;
xlabel: string;
ylabel: string;
xunit: string;
yunit: string;
}
/** Plotly shapes/annotations payload persisted alongside a signal. */
export interface PlotlyAnnotations {
shapes: unknown[];
annotations: unknown[];
}
/** A single ROI segment on a 1D signal (Phase 5). */
export interface SignalRoiSegment {
xmin: number;
xmax: number;
title?: string;
}
/** A single ROI on a 2D image — discriminated union by ``geometry``.
* All coordinates are physical (matching the image's ``x0``/``y0``/
* ``dx``/``dy``). ``inverse`` flips the masking logic (default false). */
export type ImageRoiSegment =
| {
geometry: "rectangle";
title?: string;
inverse?: boolean;
x0: number;
y0: number;
dx: number;
dy: number;
}
| {
geometry: "circle";
title?: string;
inverse?: boolean;
xc: number;
yc: number;
r: number;
}
| {
geometry: "polygon";
title?: string;
inverse?: boolean;
points: [number, number][];
};
/** Image data payload returned by ``get_image_data``. */
export interface ImageData {
id: string;
title: string;
width: number;
height: number;
/** Pixel grid. When the runtime requests ``encoding="bytes"``
* (the default for the front-end), this is an array of
* ``Float32Array`` rows, one per image line. Plotly accepts
* typed-array rows directly as ``z``. */
data: Float32Array[] | number[][];
dtype: string;
/** Pixel origin (top-left corner) and pixel spacing. */
x0: number;
y0: number;
dx: number;
dy: number;
/** Pre-computed extrema, used to seed the LUT range. */
data_min: number;
data_max: number;
xlabel: string;
ylabel: string;
zlabel: string;
xunit: string;
yunit: string;
zunit: string;
/** Optional Plotly colorscale name persisted in the image's metadata
* (``"Viridis"``, ``"Jet"``, ``"Gray"`` …). When ``null``, the
* viewer falls back to the :class:`ImagePlot` default. */
colormap?: string | null;
/** Reverse the colormap (appends ``_r`` to the Plotly colorscale). */
invert_colormap?: boolean;
}
/** Legacy synthetic image parameters (kept for backwards compatibility). */
export interface ImageCreationParams {
kind: "gauss" | "ramp" | "random";
title: string;
width: number;
height: number;
a?: number;
sigma?: number;
}
/** One entry of the image "Create" menu, mirrors :class:`SignalCreationType`. */
export interface ImageCreationType {
value: string;
label: string;
icon: string;
separator_before: boolean;
}
/** Diagnostic info returned by ``openWorkspaceHdf5``. */
export interface WorkspaceLoadResult {
signals: number;
images: number;
groups: number;
}
/** Parameters of the Text Import Wizard (mirrors
* :class:`datalab.widgets.textimport.SignalImportParam`). */
export interface TextImportParams {
delimiter: string;
decimal: string;
comment: string;
skipRows: number;
maxRows: number | null;
/** ``"infer"`` (auto), ``"none"`` (no header) or ``"first"`` (first row). */
header: "infer" | "none" | "first";
transpose: boolean;
firstColIsX: boolean;
dtypeStr: string;
}
/** Preview payload returned by :meth:`DataLabRuntime.parseTextImport`. */
export interface TextImportPreview {
headers: string[];
/** First N rows of the parsed matrix; NaN cells are encoded as
* ``"NaN"`` strings. */
preview_rows: (number | string)[][];
nrows: number;
ncols: number;
/** Candidate signal titles (after applying ``firstColIsX``). */
signal_titles: string[];
error: string | null;
}
/** Candidate-signal payload for the wizard's graphical preview page. */
export interface TextImportSignal {
title: string;
x: number[];
y: number[];
xlabel: string;
ylabel: string;
}
export interface TextImportSignals {
signals: TextImportSignal[];
error: string | null;
}
/** Final wizard payload (selection + shared labels). */
export interface TextImportCommitOptions {
selectedIndices: number[];
title?: string;
xlabel?: string;
ylabel?: string;
xunit?: string;
yunit?: string;
groupId?: string | null;
}
/** Default parameters for the Text Import Wizard. */
export function defaultTextImportParams(): TextImportParams {
return {
delimiter: ",",
decimal: ".",
comment: "#",
skipRows: 0,
maxRows: null,
header: "infer",
transpose: false,
firstColIsX: true,
dtypeStr: "float64",
};
}
/** Convert a TextImportParams object to bootstrap.py kwargs. */
export function textImportToPyKwargs(
params: TextImportParams,
): Record<string, unknown> {
return {
delimiter: params.delimiter,
decimal: params.decimal,
comment: params.comment,
skip_rows: params.skipRows,
max_rows: params.maxRows,
header: params.header,
transpose: params.transpose,
first_col_is_x: params.firstColIsX,
dtype_str: params.dtypeStr,
};
}
/** One node of the HDF5 browser tree (mirrors Qt H5TreeWidget rows). */
export interface H5BrowserNode {
id: string;
name: string;
icon_name: string;
shape_str: string;
dtype_str: string;
text: string;
description: string;
is_supported: boolean;
is_array: boolean;
is_group: boolean;
/** ``"group" | "scalar" | "text" | "array" | "compound" | "signal" | "image"``. */
kind: string;
children: H5BrowserNode[];
}
/** Result of opening one HDF5 file in the browser dialog. */
export interface H5BrowserFile {
file_id: string;
filename: string;
root: H5BrowserNode;
}
/** Attributes pane payload for one selected node. */
export interface H5BrowserNodeAttrs {
path: string;
name: string;
description: string;
text_preview: string;
attributes: Record<string, unknown>;
}
/** Preview data for the right-side Plotly pane. */
export type H5BrowserPreview =
| { kind: "unsupported"; error?: string }
| { kind: "signal"; title: string; x: number[]; y: number[] }
| {
kind: "image";
title: string;
width: number;
height: number;
data: number[][];
};
/** Raw array payload for the "Show array" spreadsheet view. */
export interface H5BrowserArray {
shape: number[];
dtype: string;
data: unknown;
}
/** Result of importing selected HDF5 nodes into the live model. */
export interface H5BrowserImportResult {
oids: string[];
uint32_clipped: boolean;
}
/**
* JSON Schema 2020-12 document augmented with ``x-guidata-*`` extensions,
* as produced by :func:`guidata.dataset.dataset_to_schema`.
*
* Treated as a loose dictionary on the JS side: each frontend widget
* inspects only the keys it cares about. The shape is documented in
* ``guidata/dataset/jsonschema.py``.
*/
export type JsonSchema = Record<string, unknown>;
export interface SchemaWithValues {
schema: JsonSchema;
values: Record<string, unknown>;
}
export interface DynamicChoice {
value: unknown;
label: string;
icon?: string;
}
/** Read-only stats summary returned by
* :meth:`DataLabRuntime.getObjectStats`. */
export type ObjectStats =
| {
kind: "signal";
n_points: number;
x_dtype: string;
y_dtype: string;
x_min: number | null;
x_max: number | null;
y_min: number | null;
y_max: number | null;
y_mean: number | null;
y_std: number | null;
y_median: number | null;
}
| {
kind: "image";
shape: number[];
dtype: string;
min: number | null;
max: number | null;
mean: number | null;
std: number | null;
median: number | null;
};
/** Discriminator for the metadata-editor widgets. */
export type MetadataValueType = "string" | "number" | "bool" | "json";
/** One row of the metadata editor (returned by
* :meth:`DataLabRuntime.listObjectMetadata`). */
export interface MetadataEntry {
key: string;
value_type: MetadataValueType;
value: string;
}
/** One entry of the "Create" menu, returned by
* :meth:`DataLabRuntime.listSignalCreationTypes`. */
export interface SignalCreationType {
/** Stable id (matches a ``SignalTypes`` enum value on the Python side). */
value: string;
/** Translated, human-readable label. */
label: string;
/** Bare SVG filename (e.g. ``"sine.svg"``) — the React UI maps it to a
* bundled URL via Vite's ``import.meta.glob``. */
icon: string;
/** When true, draw a separator *before* this entry in the menu (mirrors
* the desktop "Create" menu's grouping). */
separator_before: boolean;
}
/** One I/O format descriptor returned by
* :meth:`DataLabRuntime.listSignalIoFormats`. */
export interface SignalIoFormat {
name: string;
extensions: string[];
}
export interface SignalIoFormats {
read: SignalIoFormat[];
write: SignalIoFormat[];
all_read_extensions: string[];
all_write_extensions: string[];
}
/** Image I/O catalogue — identical shape to :class:`SignalIoFormats`. */
export type ImageIoFormats = SignalIoFormats;
/** Menu descriptor for one entry of the "Analysis" menu. */
export interface SignalAnalysisDescriptor {
/** Stable id (e.g. ``"fwhm"``). Matches the Sigima function name. */
id: string;
label: string;
/** Bare SVG filename or ``""`` for icon-less entries. */
icon: string;
/** Insert a separator above this entry (mirrors DataLab desktop). */
separator_before: boolean;
/** ``true`` when the analysis exposes a parameter DataSet. */
has_params: boolean;
}
/** Optional plot overlay attached to a result (currently only emitted
* by pulse-features tables — segments for baselines/plateau and
* vertical markers for x₀ / x₅₀ / x₁₀₀). */
export type AnalysisOverlay =
| {
kind: "segment";
x0: number;
y0: number;
x1: number;
y1: number;
label?: string;
color?: string;
}
| { kind: "vline"; x: number; label?: string; color?: string }
| { kind: "hline"; y: number; label?: string; color?: string };
/** Per-row geometry / table payload (one of two ``category`` values). */
export interface AnalysisResultBase {
metadata_key: string;
title: string;
func_name: string | null;
headers: string[];
roi_indices: number[] | null;
/** Optional plot overlays (segments, vlines) supplementing the
* numerical result. Currently emitted for pulse-features tables. */
overlays?: AnalysisOverlay[];
/** Set by image detection analyses (peak / blob / hough / contour) when
* the ``create_rois`` parameter caused fresh ROIs to be attached to
* the source object. The UI uses this flag to refresh the ROI overlay
* without re-fetching everything. */
roi_modified?: boolean;
}
export interface GeometryAnalysisResult extends AnalysisResultBase {
category: "geometry";
/** ``"point" | "marker" | "segment" | "rectangle" | "circle" | "ellipse" | "polygon"``. */
kind: string;
/** N×K array of physical coordinates (K depends on ``kind``). */
coords: number[][];
}
export interface TableAnalysisResult extends AnalysisResultBase {
category: "table";
kind: string;
data: (number | string | null)[][];
}
export type AnalysisResult = GeometryAnalysisResult | TableAnalysisResult;
export interface SignalCreationParams {
kind: "sine" | "cosine" | "gauss" | "noise";
title: string;
size: number;
xmin: number;
xmax: number;
a?: number;
freq?: number;
phase?: number;
mu?: number;
sigma?: number;
}
/** Lightweight macro entry returned by ``listMacros`` (no code). */
export interface MacroMeta {
id: string;
title: string;
}
/** Full macro record (with source code). */
export interface MacroRecord extends MacroMeta {
code: string;
}
/** Lightweight notebook entry returned by ``listNotebooks`` (no content). */
export interface NotebookMeta {
id: string;
title: string;
}
/** Full notebook record (with raw nbformat v4.5 JSON content). */
export interface NotebookRecord extends NotebookMeta {
/** Serialised nbformat v4.5 JSON document (opaque to Python). */
content: string;
}
/** Convert a PyProxy result into a plain JS value (recursively). */
function toJs(value: unknown): unknown {
if (value && typeof value === "object" && "toJs" in value) {
const proxy = value as PyProxy;
const result = proxy.toJs?.({
dict_converter: (entries) =>
Object.fromEntries(entries as Iterable<[string, unknown]>),
});
proxy.destroy?.();
return result;
}
return value;
}
/**
* Reshape a flat ``Float32`` byte buffer into a list of typed-array
* rows that Plotly's ``heatmap`` trace consumes directly as ``z``.
*
* When the Python helper was asked to encode the image as bytes it
* returns ``data`` either as a :class:`Uint8Array` (Pyodide's default
* for ``bytes``) or as a memoryview-like object exposing a ``buffer``.
* Either way we end up with ``H`` rows of ``W`` floats with zero
* structural copies — only the typed-array views are allocated.
*
* Inputs already in the legacy ``number[][]`` form (Python tests, the
* notebook display path) are returned unchanged.
*/
function decodeImagePayload<
T extends { encoding?: string; data: unknown; width: number; height: number },
>(payload: T): T {
if (payload.encoding !== "f32") return payload;
const raw = payload.data as ArrayBufferView | ArrayBuffer;
const view =
raw instanceof ArrayBuffer
? new Float32Array(raw)
: new Float32Array(
raw.buffer,
raw.byteOffset,
raw.byteLength / Float32Array.BYTES_PER_ELEMENT,
);
const w = payload.width;
const h = payload.height;
const rows: Float32Array[] = new Array(h);
for (let j = 0; j < h; j += 1) {
// ``subarray`` shares the underlying buffer — no per-row copy.
rows[j] = view.subarray(j * w, (j + 1) * w);
}
return { ...payload, data: rows };
}
export class DataLabRuntime {
private constructor(private readonly py: PyodideAPI) {}
/**
* Workspace mutation tracker.
*
* The set below lists every ``bootstrap.py`` callable that mutates
* the durable workspace state — i.e. anything whose effect is
* persisted by ``save_workspace_to_bytes`` and would be lost on
* reload unless saved as an HDF5 workspace. ``callPy`` invokes all
* registered mutation listeners after each successful call to one
* of these functions, so the React layer can flip a single
* "workspace dirty" flag without sprinkling ``markDirty()`` at
* every UI call site.
*
* Keep this list narrow on purpose: read-only helpers
* (``list_*``, ``get_*``, schema queries, IO format probes) must
* NOT appear here. ``open_workspace_from_bytes`` and
* ``save_workspace_to_bytes`` are intentionally absent — they're
* the *clean* transitions, handled separately in ``App.tsx``.
*/
private static readonly MUTATING_PY_FUNCTIONS: ReadonlySet<string> =
new Set<string>([
// Object model — signals & images
"create_signal",
"create_signal_typed",
"create_image",
"create_image_typed",
"add_signal_from_arrays",
"add_image_from_array",
"set_signal_style",
"update_signal_creation_params",
"update_image_creation_params",
"set_object_property_values",
"set_object_metadata_value",
"delete_object_metadata_key",
"set_object_meta",
"set_signal_roi",
"delete_signal_roi_at",
"set_image_roi",
"delete_image_roi_at",
"create_image_roi_grid",
"extract_signal_rois",
"extract_image_rois",
"delete_object",
"delete_signal",
"rename_object",
"move_object",
"move_objects",
"create_group",
"rename_group",
"delete_group",
"set_plotly_annotations",
"set_lut_range",
"set_colormap",
"clear_signal_results",
"clear_image_results",
"erase_image_area",
"distribute_images_on_grid",
"reset_image_positions",
"apply_feature",
"apply_processing",
"reapply_last_processing",
"run_signal_analysis",
"run_image_analysis",
"open_signal_from_bytes",
"open_image_from_bytes",
"import_signal_csv",
"commit_text_import",
"add_object_pickled",
"set_object_pickled",
"reset_all",
// Macros
"create_macro",
"rename_macro",
"delete_macro",
"set_macro_code",
"duplicate_macro",
"reorder_macros",
"replace_macros",
// Notebooks
"create_notebook",
"rename_notebook",
"delete_notebook",
"set_notebook_content",
"duplicate_notebook",
"reorder_notebooks",
"replace_notebooks",
]);
private mutationListeners: Set<(name: string) => void> = new Set();
/**
* Subscribe to durable-workspace mutations. The callback fires *after*
* any ``callPy`` invocation that mutates the workspace state.
*
* Returns an unsubscribe function. Listeners must not throw (errors
* are logged and otherwise ignored to keep the runtime queue clean).
*/
onWorkspaceMutation(listener: (name: string) => void): () => void {
this.mutationListeners.add(listener);
return () => {
this.mutationListeners.delete(listener);
};
}
private emitWorkspaceMutation(name: string): void {
if (this.mutationListeners.size === 0) return;
for (const cb of this.mutationListeners) {
try {
cb(name);
} catch (err) {
console.warn("[runtime] workspace-mutation listener threw", err);
}
}
}
static async load(
onProgress?: (msg: string) => void,
): Promise<DataLabRuntime> {
if (typeof window.loadPyodide !== "function") {
throw new Error(
"Pyodide failed to load from the CDN. Check the browser console " +
"and your network / Content-Security-Policy settings.",
);
}
onProgress?.("Loading Pyodide runtime…");
const py = await window.loadPyodide({ indexURL: PYODIDE_INDEX });
// ---------------------------------------------------------------
// Pin the Pyodide locale to ``C`` *before* any ``guidata`` /
// ``sigima`` import.
//
// Why: Sigima exposes user-facing labels (e.g. ``SignalTypes`` /
// ``ImageTypes`` enum entries shown in the ``Create`` menu, plus
// processing labels and parameter labels) wrapped in ``gettext
// _()``. ``guidata.configtools.get_translation`` falls back to the
// browser's locale via ``get_system_lang()`` when ``LANG`` is
// unset, so a French-locale browser would surface translated
// labels while the rest of the React UI is English-only — see the
// "Internationalisation" section of ``README.md``.
//
// Setting ``LANG=C`` (POSIX "no translation" locale) forces
// gettext to return the original ``msgid`` strings (English), which
// matches the rest of the UI. ``LANGUAGE`` is also unset to defeat
// GNU gettext's higher-priority override.
//
// This MUST run before ``loadPackage``/``micropip.install`` of
// ``guidata`` and before any ``runPythonAsync`` that touches the
// guidata shims, otherwise the translation object is cached at
// import time and the language change comes too late.
// ---------------------------------------------------------------
await py.runPythonAsync(`
import os
os.environ["LANG"] = "C"
os.environ["LANGUAGE"] = "C"
`);
onProgress?.("Loading scientific stack (numpy, scipy, h5py)…");
await py.loadPackage(["numpy", "scipy", "h5py", "micropip"]);
onProgress?.("Installing Sigima…");
await py.runPythonAsync(`
import micropip
await micropip.install(["sigima", "guidata"])
`);
onProgress?.("Initialising Sigima namespace…");
await py.runPythonAsync(guidataJsonSchemaShim);
if (guidataBackendsSource) {
await py.runPythonAsync(guidataBackendsSource);
}
// Mirror the portable ``datalab.*`` shim into Pyodide site-packages.
DataLabRuntime.installShim(py, shimSources);
// Make ``processor.py``/``dlw_main.py``/``dlw_plugins.py`` importable.
py.FS.writeFile("/home/pyodide/dlw_processor.py", processorSource);
py.FS.writeFile("/home/pyodide/dlw_main.py", dlwMainSource);
py.FS.writeFile("/home/pyodide/dlw_plugins.py", dlwPluginsSource);
py.FS.writeFile("/home/pyodide/dlw_h5browser.py", dlwH5BrowserSource);
py.FS.writeFile(
"/home/pyodide/dlw_interactive_fit.py",
dlwInteractiveFitSource,
);
py.FS.writeFile("/home/pyodide/dlw_title_format.py", dlwTitleFormatSource);
await py.runPythonAsync(bootstrapSource);
onProgress?.("Ready.");
const runtime = new DataLabRuntime(py);
runtime.installDialogBridge();
await runtime.installBuiltinPlugins(builtinPluginSources);
return runtime;
}
/**
* Mirror the portable ``datalab.*`` shim into Pyodide's site-packages.
*
* Each entry in *sources* is keyed by the workspace-relative path
* (``./dlplugins/datalab/plugins.py``); we strip the leading
* ``./dlplugins/`` and write to ``/home/pyodide/<rest>``.
*/
private static installShim(
py: PyodideAPI,
sources: Record<string, string>,
): void {
const seenDirs = new Set<string>();
for (const [path, source] of Object.entries(sources)) {
const rel = path.replace(/^\.\/dlplugins\//, "");
const target = `/home/pyodide/${rel}`;
const dir = target.slice(0, target.lastIndexOf("/"));
if (!seenDirs.has(dir)) {
py.FS.mkdirTree?.(dir);
seenDirs.add(dir);
}
py.FS.writeFile(target, source);
}
}
/** Install the JS callback that Python uses for async dialogs. */
private installDialogBridge(): void {
const handler = async (kind: unknown, payload: unknown) => {
const fn = this.dialogHandler;
if (fn === null) {
throw new Error(
`Dialog requested (${String(kind)}) but no handler is registered. ` +
"Call sigima.setDialogHandler(...) on the JS side first.",
);
}
// Python side converts dict payloads to plain JS objects via
// ``pyodide.ffi.to_js`` before calling us, so ``payload`` is normally
// a JS object already. Keep the legacy ``toJs`` fallback for safety
// (e.g. if a Python caller forgets the conversion), but never call
// ``destroy()`` on the proxy: Pyodide registers it with its own
// ``FinalizationRegistry`` after the awaited call resolves, and an
// explicit early destroy would race with that registration and abort
// the WASM runtime ("Object has already been destroyed").
const proxy = payload as PyProxy | null | undefined;
let data: unknown = payload;
if (typeof proxy?.toJs === "function") {
data = proxy.toJs({
dict_converter: (entries) =>
Object.fromEntries(entries as Iterable<[string, unknown]>),
});
}
return fn(String(kind), data);
};
const setBridge = this.py.globals.get("set_dialog_bridge");
if (setBridge) {
try {
// Pyodide auto-wraps JS functions into PyProxy callables.
(setBridge as unknown as (cb: typeof handler) => void)(handler);
} finally {
setBridge.destroy?.();
}
}
}
/** JS-side callback invoked for every async dialog request from Python. */
private dialogHandler:
| ((kind: string, payload: unknown) => Promise<unknown>)
| null = null;
setDialogHandler(
handler: ((kind: string, payload: unknown) => Promise<unknown>) | null,
): void {
this.dialogHandler = handler;