concertina 0.13.1 → 1.0.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/README.md CHANGED
@@ -465,6 +465,152 @@ The `inert` attribute shipped before `1lh` in every browser. No polyfills. No fa
465
465
 
466
466
  These are proposals, not commitments. If any would unblock your project, open an issue.
467
467
 
468
+ ---
469
+
470
+ ## Core Stability Engine (`concertina/core`)
471
+
472
+ The Core Stability Engine is a high-performance sub-package for virtualizing large datasets. Import from the sub-path:
473
+
474
+ ```tsx
475
+ import {
476
+ useStabilityOrchestrator,
477
+ VirtualChamber,
478
+ createRecordBatchStream,
479
+ } from "concertina/core";
480
+ import type { RowProxy, ColumnSchema } from "concertina/core";
481
+ ```
482
+
483
+ It runs all data work inside a dedicated Web Worker. The main thread receives only the rows currently visible on screen, as a single transferred `ArrayBuffer`. No data is ever copied; no JSON is ever parsed on the main thread.
484
+
485
+ ### Architecture
486
+
487
+ ```
488
+ Main thread DataWorker (off-thread)
489
+ ────────────────────── ──────────────────────────────────
490
+ useStabilityOrchestrator Columnar storage
491
+ │ NumericColumn (f64/i32/u32/bool)
492
+ ├─ ingest(stream) Utf8Column (offset + bytes)
493
+ │ └─ pump: one batch ─INGEST─▶ ListUtf8Column (3-level index)
494
+ │ await INGEST_ACK ◀──────── commit → INGEST_ACK
495
+ │ next batch → ...
496
+
497
+ ├─ scroll → SET_WINDOW ──────▶ packWindowBuffer()
498
+ │ └─ single ArrayBuffer ─WINDOW_UPDATE─▶
499
+ │ ◀─ transferred
500
+ ├─ rAF → FRAME_ACK ──────────▶ BackpressureController
501
+
502
+ └─ VirtualChamber
503
+ buildAccessors(buffer) ← reads transferred ArrayBuffer, zero-copy
504
+ buildRowProxy(accessors) ← column → scalar or string[]
505
+ pool nodes: constant DOM count, recycled by CSS transform
506
+ ```
507
+
508
+ ### Binary wire format
509
+
510
+ All multi-byte values are little-endian. Every INGEST payload and every WINDOW_UPDATE payload uses this layout:
511
+
512
+ ```
513
+ Header (16 bytes):
514
+ [0] u32 magic = 0xac1dc0de
515
+ [4] u32 seq monotonic batch sequence number
516
+ [8] u32 rowCount
517
+ [12] u32 colCount
518
+
519
+ Column Descriptors (colCount × 8 bytes):
520
+ [+0] u32 typeTag (0=f64, 1=i32, 2=u32, 3=bool, 4=timestamp_ms, 5=utf8, 6=list_utf8)
521
+ [+4] u32 byteLen byte length of this column's data block
522
+
523
+ Column Data Blocks (variable, one per column):
524
+
525
+ f64 / timestamp_ms rowCount × 8 bytes (Float64Array, little-endian)
526
+ i32 rowCount × 4 bytes (Int32Array)
527
+ u32 rowCount × 4 bytes (Uint32Array)
528
+ bool rowCount × 1 byte (Uint8Array, 0 or 1)
529
+
530
+ utf8 (rowCount+1) × 4 bytes Uint32 offsets (row i → bytes[offsets[i]..offsets[i+1]])
531
+ Σ(string lengths) bytes Uint8 data
532
+
533
+ list_utf8 4 bytes u32 totalItems
534
+ (rowCount+1) × 4 Uint32 rowOffsets
535
+ row i → items[rowOffsets[i]..rowOffsets[i+1])
536
+ (totalItems+1) × 4 Uint32 itemOffsets
537
+ item j → bytes[itemOffsets[j]..itemOffsets[j+1])
538
+ Σ(item byte lengths) Uint8 bytes (UTF-8)
539
+ ```
540
+
541
+ `list_utf8` is a three-level nested index. The decoder in `VirtualChamber` walks:
542
+ 1. `rowOffsets[localRow]..rowOffsets[localRow+1]` → item range for this row
543
+ 2. `itemOffsets[j]..itemOffsets[j+1]` → byte range for item j
544
+ 3. `TextDecoder.decode(bytes.subarray(...))` → string
545
+
546
+ `RowProxy.get()` returns `string[]` for `list_utf8` columns — no `JSON.parse` on the main thread.
547
+
548
+ ### INGEST_ACK backpressure protocol
549
+
550
+ Without flow control, a 1M-row dataset would queue all batches in the IPC channel simultaneously (~300 MB). The INGEST_ACK loop bounds this to one batch in flight at a time:
551
+
552
+ ```
553
+ Main thread pump DataWorker
554
+ ───────────────── ──────────
555
+ read batch N from stream
556
+ register ackResolvers[N] = {resolve, reject}
557
+ postMessage(INGEST, [buffer], N) → parseBatch()
558
+ commit to columnar storage
559
+ emit INGEST_ACK(N)
560
+ resolve(ackResolvers[N]) ←
561
+ read batch N+1 from stream
562
+ ...
563
+ ```
564
+
565
+ **IPC queue depth: O(1) regardless of dataset size.**
566
+
567
+ If the worker crashes (`onerror`), all pending `ackResolvers` are rejected immediately — the pump unblocks, and `store.setStatus("error")` is set. The pump does not zombie-wait.
568
+
569
+ ### Supported column types
570
+
571
+ | Schema type | JS input value | RowProxy return type |
572
+ |----------------|----------------|----------------------|
573
+ | `f64` | `number` | `number` |
574
+ | `i32` | `number` | `number` |
575
+ | `u32` | `number` | `number` |
576
+ | `bool` | `boolean` | `boolean` |
577
+ | `timestamp_ms` | `number` (epoch ms) | `number` |
578
+ | `utf8` | `string` | `string` |
579
+ | `list_utf8` | `string[]` | `string[]` |
580
+
581
+ ### Parallel list columns
582
+
583
+ For structs that require both an `id` and a `label` (e.g. `{ id: string; displayName: string }`), encode as two parallel `list_utf8` columns and zip them in `renderRow`:
584
+
585
+ ```tsx
586
+ // Schema
587
+ { name: "organism_ids", type: "list_utf8", maxContentChars: 36 },
588
+ { name: "organism_names", type: "list_utf8", maxContentChars: 80 },
589
+
590
+ // fileToRow
591
+ organism_ids: f.organisms.map(o => o.id),
592
+ organism_names: f.organisms.map(o => o.displayName),
593
+
594
+ // renderRow — O(k) zip, no JSON.parse
595
+ const ids = proxy.get("organism_ids") as string[];
596
+ const names = proxy.get("organism_names") as string[];
597
+ const orgs = ids.map((id, i) => ({ id, displayName: names[i] ?? "" }));
598
+ ```
599
+
600
+ The DataWorker enforces that parallel columns maintain identical row counts after every batch commit. A count mismatch emits `INGEST_ERROR` and the pump is still ACK'd (so it does not stall), but the store transitions to the error state.
601
+
602
+ ### Zero-Measurement layout
603
+
604
+ Column pixel widths are computed entirely in the worker from schema metadata — no DOM measurement ever happens:
605
+
606
+ ```
607
+ computedWidth = fixedWidth ?? (maxContentChars × charWidthHint + CELL_H_PADDING × 2)
608
+ ```
609
+
610
+ `CELL_H_PADDING` is 16 px. A 14 px monospace font uses `charWidthHint: 8`. Widths are resolved once at `INIT` and re-sent with every `WINDOW_UPDATE`.
611
+
612
+ ---
613
+
468
614
  ## License
469
615
 
470
616
  MIT
@@ -0,0 +1,550 @@
1
+ // src/core/types.ts
2
+ var asRowIndex = (n) => n;
3
+ var asPixelSize = (n) => n;
4
+ var asMs = (n) => n;
5
+ var asBatchSeq = (n) => n;
6
+ var BATCH_MAGIC = 2887631070;
7
+ var CELL_H_PADDING = 16;
8
+ var TYPE_TAG = {
9
+ f64: 0,
10
+ i32: 1,
11
+ u32: 2,
12
+ bool: 3,
13
+ timestamp_ms: 4,
14
+ utf8: 5,
15
+ list_utf8: 6
16
+ };
17
+ var TAG_TO_TYPE = {
18
+ 0: "f64",
19
+ 1: "i32",
20
+ 2: "u32",
21
+ 3: "bool",
22
+ 4: "timestamp_ms",
23
+ 5: "utf8",
24
+ 6: "list_utf8"
25
+ };
26
+ var INITIAL_STORE_STATE = {
27
+ status: "idle",
28
+ layout: null,
29
+ window: null,
30
+ backpressure: { strategy: "NOMINAL", queueDepth: 0, avgRenderMs: asMs(0) },
31
+ totalRows: 0,
32
+ error: null
33
+ };
34
+
35
+ // src/core/data-worker.ts
36
+ var RingBuffer = class {
37
+ constructor(capacity) {
38
+ this.capacity = capacity;
39
+ this.head = 0;
40
+ this.count = 0;
41
+ this.data = new Float64Array(capacity);
42
+ }
43
+ push(value) {
44
+ this.data[this.head] = value;
45
+ this.head = (this.head + 1) % this.capacity;
46
+ if (this.count < this.capacity) this.count++;
47
+ }
48
+ mean() {
49
+ if (this.count === 0) return 0;
50
+ let sum = 0;
51
+ for (let i = 0; i < this.count; i++) sum += this.data[i];
52
+ return sum / this.count;
53
+ }
54
+ get length() {
55
+ return this.count;
56
+ }
57
+ };
58
+ var NumericColumn = class {
59
+ constructor(colType, initialCapacity = 8192) {
60
+ this.colType = colType;
61
+ this.length = 0;
62
+ this.buf = this.alloc(initialCapacity);
63
+ }
64
+ alloc(n) {
65
+ switch (this.colType) {
66
+ case "f64":
67
+ case "timestamp_ms":
68
+ return new Float64Array(n);
69
+ case "i32":
70
+ return new Int32Array(n);
71
+ case "u32":
72
+ return new Uint32Array(n);
73
+ case "bool":
74
+ return new Uint8Array(n);
75
+ }
76
+ }
77
+ append(src) {
78
+ const needed = this.length + src.length;
79
+ if (needed > this.buf.length) {
80
+ const next = this.alloc(Math.max(this.buf.length * 2, needed));
81
+ next.set(this.buf.subarray(0, this.length));
82
+ this.buf = next;
83
+ }
84
+ this.buf.set(src, this.length);
85
+ this.length += src.length;
86
+ }
87
+ /** Returns a copy of rows [startRow, startRow+count) as ArrayBuffer. */
88
+ copySlice(startRow, count) {
89
+ const end = Math.min(startRow + count, this.length);
90
+ const actual = Math.max(0, end - startRow);
91
+ return this.buf.slice(startRow, startRow + actual).buffer;
92
+ }
93
+ get rowCount() {
94
+ return this.length;
95
+ }
96
+ };
97
+ var Utf8Column = class {
98
+ constructor(rowCapacity = 8192, bytesCapacity = 131072) {
99
+ this.offsetLen = 1;
100
+ this.bytesLen = 0;
101
+ this.offsets = new Uint32Array(rowCapacity + 1);
102
+ this.bytes = new Uint8Array(bytesCapacity);
103
+ this.offsets[0] = 0;
104
+ }
105
+ append(srcOffsets, srcBytes, rowCount) {
106
+ const addedBytes = srcOffsets[rowCount];
107
+ const baseAbsolute = this.offsets[this.offsetLen - 1];
108
+ if (this.offsetLen + rowCount > this.offsets.length) {
109
+ const next = new Uint32Array(Math.max(this.offsets.length * 2, this.offsetLen + rowCount + 1));
110
+ next.set(this.offsets.subarray(0, this.offsetLen));
111
+ this.offsets = next;
112
+ }
113
+ if (this.bytesLen + addedBytes > this.bytes.length) {
114
+ const next = new Uint8Array(Math.max(this.bytes.length * 2, this.bytesLen + addedBytes));
115
+ next.set(this.bytes.subarray(0, this.bytesLen));
116
+ this.bytes = next;
117
+ }
118
+ this.bytes.set(srcBytes.subarray(0, addedBytes), this.bytesLen);
119
+ this.bytesLen += addedBytes;
120
+ for (let i = 0; i < rowCount; i++) {
121
+ this.offsets[this.offsetLen + i] = baseAbsolute + srcOffsets[i + 1];
122
+ }
123
+ this.offsetLen += rowCount;
124
+ }
125
+ /** Returns { offsets: ArrayBuffer, bytes: ArrayBuffer } for rows [startRow, startRow+count). */
126
+ copySlice(startRow, count) {
127
+ const end = Math.min(startRow + count, this.rowCount);
128
+ const actual = Math.max(0, end - startRow);
129
+ const base = this.offsets[startRow];
130
+ const limit = this.offsets[startRow + actual];
131
+ const byteLen = limit - base;
132
+ const newOffsets = new Uint32Array(actual + 1);
133
+ for (let i = 0; i <= actual; i++) {
134
+ newOffsets[i] = this.offsets[startRow + i] - base;
135
+ }
136
+ const newBytes = this.bytes.slice(base, base + byteLen);
137
+ return {
138
+ offsets: newOffsets.buffer,
139
+ bytes: newBytes.buffer
140
+ };
141
+ }
142
+ get rowCount() {
143
+ return this.offsetLen - 1;
144
+ }
145
+ };
146
+ var ListUtf8Column = class {
147
+ constructor(rowCapacity = 8192, itemCapacity = 65536, bytesCapacity = 524288) {
148
+ this.rowOffLen = 1;
149
+ this.itemOffLen = 1;
150
+ this.bytesLen = 0;
151
+ this.rowOffsets = new Uint32Array(rowCapacity + 1);
152
+ this.itemOffsets = new Uint32Array(itemCapacity + 1);
153
+ this.bytes = new Uint8Array(bytesCapacity);
154
+ this.rowOffsets[0] = 0;
155
+ this.itemOffsets[0] = 0;
156
+ }
157
+ append(totalItems, srcRowOff, srcItemOff, srcBytes, rowCount) {
158
+ const baseItemIdx = this.itemOffLen - 1;
159
+ const baseBytesLen = this.bytesLen;
160
+ const addedBytes = totalItems > 0 ? srcItemOff[totalItems] : 0;
161
+ if (this.rowOffLen + rowCount > this.rowOffsets.length) {
162
+ const next = new Uint32Array(Math.max(this.rowOffsets.length * 2, this.rowOffLen + rowCount + 1));
163
+ next.set(this.rowOffsets.subarray(0, this.rowOffLen));
164
+ this.rowOffsets = next;
165
+ }
166
+ if (this.itemOffLen + totalItems > this.itemOffsets.length) {
167
+ const next = new Uint32Array(Math.max(this.itemOffsets.length * 2, this.itemOffLen + totalItems + 1));
168
+ next.set(this.itemOffsets.subarray(0, this.itemOffLen));
169
+ this.itemOffsets = next;
170
+ }
171
+ if (baseBytesLen + addedBytes > this.bytes.length) {
172
+ const next = new Uint8Array(Math.max(this.bytes.length * 2, baseBytesLen + addedBytes));
173
+ next.set(this.bytes.subarray(0, baseBytesLen));
174
+ this.bytes = next;
175
+ }
176
+ this.bytes.set(srcBytes.subarray(0, addedBytes), baseBytesLen);
177
+ this.bytesLen += addedBytes;
178
+ for (let j = 0; j < totalItems; j++) {
179
+ this.itemOffsets[this.itemOffLen + j] = baseBytesLen + srcItemOff[j + 1];
180
+ }
181
+ this.itemOffLen += totalItems;
182
+ for (let r = 0; r < rowCount; r++) {
183
+ this.rowOffsets[this.rowOffLen + r] = baseItemIdx + srcRowOff[r + 1];
184
+ }
185
+ this.rowOffLen += rowCount;
186
+ }
187
+ /** Returns the wire-format sub-buffers for rows [startRow, startRow+count). */
188
+ copySlice(startRow, count) {
189
+ const end = Math.min(startRow + count, this.rowCount);
190
+ const actual = Math.max(0, end - startRow);
191
+ const baseItemIdx = this.rowOffsets[startRow];
192
+ const endItemIdx = this.rowOffsets[startRow + actual];
193
+ const sliceItems = endItemIdx - baseItemIdx;
194
+ const baseByteIdx = sliceItems > 0 ? this.itemOffsets[baseItemIdx] : 0;
195
+ const endByteIdx = sliceItems > 0 ? this.itemOffsets[endItemIdx] : 0;
196
+ const header = new Uint32Array([sliceItems]);
197
+ const newRowOffsets = new Uint32Array(actual + 1);
198
+ const newItemOffsets = new Uint32Array(sliceItems + 1);
199
+ const newBytes = this.bytes.slice(baseByteIdx, endByteIdx);
200
+ for (let r = 0; r <= actual; r++) {
201
+ newRowOffsets[r] = this.rowOffsets[startRow + r] - baseItemIdx;
202
+ }
203
+ for (let j = 0; j <= sliceItems; j++) {
204
+ newItemOffsets[j] = this.itemOffsets[baseItemIdx + j] - baseByteIdx;
205
+ }
206
+ return {
207
+ totalItems: header.buffer,
208
+ rowOffsets: newRowOffsets.buffer,
209
+ itemOffsets: newItemOffsets.buffer,
210
+ bytes: newBytes.buffer
211
+ };
212
+ }
213
+ get rowCount() {
214
+ return this.rowOffLen - 1;
215
+ }
216
+ };
217
+ function parseBatch(buffer) {
218
+ const view = new DataView(buffer);
219
+ let cursor = 0;
220
+ const magic = view.getUint32(cursor, true);
221
+ cursor += 4;
222
+ if (magic !== BATCH_MAGIC) {
223
+ throw new Error(`Invalid batch magic: 0x${magic.toString(16)}; expected 0x${BATCH_MAGIC.toString(16)}`);
224
+ }
225
+ const seq = view.getUint32(cursor, true);
226
+ cursor += 4;
227
+ const rowCount = view.getUint32(cursor, true);
228
+ cursor += 4;
229
+ const colCount = view.getUint32(cursor, true);
230
+ cursor += 4;
231
+ const descriptors = [];
232
+ for (let i = 0; i < colCount; i++) {
233
+ const typeTag = view.getUint32(cursor, true);
234
+ cursor += 4;
235
+ const byteLen = view.getUint32(cursor, true);
236
+ cursor += 4;
237
+ const type = TAG_TO_TYPE[typeTag];
238
+ if (type === void 0) throw new Error(`Unknown type tag: ${typeTag}`);
239
+ descriptors.push({ type, byteLen });
240
+ }
241
+ const columns = [];
242
+ for (const { type, byteLen } of descriptors) {
243
+ const slice = buffer.slice(cursor, cursor + byteLen);
244
+ cursor += byteLen;
245
+ if (type === "utf8") {
246
+ const offsetByteLen = (rowCount + 1) * 4;
247
+ const utf8Offsets = new Uint32Array(slice.slice(0, offsetByteLen));
248
+ const utf8Bytes = new Uint8Array(slice.slice(offsetByteLen));
249
+ columns.push({ type, data: null, utf8Offsets, utf8Bytes });
250
+ } else if (type === "list_utf8") {
251
+ let off = 0;
252
+ const listTotalItems = new DataView(slice).getUint32(0, true);
253
+ off += 4;
254
+ const listRowOffsets = new Uint32Array(slice.slice(off, off + (rowCount + 1) * 4));
255
+ off += (rowCount + 1) * 4;
256
+ const listItemOffsets = new Uint32Array(slice.slice(off, off + (listTotalItems + 1) * 4));
257
+ off += (listTotalItems + 1) * 4;
258
+ const listBytes = new Uint8Array(slice.slice(off));
259
+ columns.push({ type, data: null, listTotalItems, listRowOffsets, listItemOffsets, listBytes });
260
+ } else {
261
+ let data;
262
+ switch (type) {
263
+ case "f64":
264
+ case "timestamp_ms":
265
+ data = new Float64Array(slice);
266
+ break;
267
+ case "i32":
268
+ data = new Int32Array(slice);
269
+ break;
270
+ case "u32":
271
+ data = new Uint32Array(slice);
272
+ break;
273
+ case "bool":
274
+ data = new Uint8Array(slice);
275
+ break;
276
+ }
277
+ columns.push({ type, data });
278
+ }
279
+ }
280
+ return { seq, rowCount, columns };
281
+ }
282
+ function packWindowBuffer(columns, schema, startRow, rowCount, seq) {
283
+ const colBufs = [];
284
+ for (let i = 0; i < columns.length; i++) {
285
+ const col = columns[i];
286
+ const type = schema[i].type;
287
+ if (col instanceof ListUtf8Column) {
288
+ const { totalItems, rowOffsets, itemOffsets, bytes: bytes2 } = col.copySlice(startRow, rowCount);
289
+ colBufs.push({ type, bufs: [totalItems, rowOffsets, itemOffsets, bytes2] });
290
+ } else if (col instanceof Utf8Column) {
291
+ const { offsets, bytes: bytes2 } = col.copySlice(startRow, rowCount);
292
+ colBufs.push({ type, bufs: [offsets, bytes2] });
293
+ } else {
294
+ colBufs.push({ type, bufs: [col.copySlice(startRow, rowCount)] });
295
+ }
296
+ }
297
+ const headerSize = 16;
298
+ const descriptorSize = colBufs.length * 8;
299
+ let dataSize = 0;
300
+ for (const { bufs } of colBufs) for (const b of bufs) dataSize += b.byteLength;
301
+ const out = new ArrayBuffer(headerSize + descriptorSize + dataSize);
302
+ const view = new DataView(out);
303
+ const bytes = new Uint8Array(out);
304
+ let cur = 0;
305
+ view.setUint32(cur, BATCH_MAGIC, true);
306
+ cur += 4;
307
+ view.setUint32(cur, seq, true);
308
+ cur += 4;
309
+ view.setUint32(cur, rowCount, true);
310
+ cur += 4;
311
+ view.setUint32(cur, colBufs.length, true);
312
+ cur += 4;
313
+ for (const { type, bufs } of colBufs) {
314
+ let byteLen = 0;
315
+ for (const b of bufs) byteLen += b.byteLength;
316
+ view.setUint32(cur, TYPE_TAG[type], true);
317
+ cur += 4;
318
+ view.setUint32(cur, byteLen, true);
319
+ cur += 4;
320
+ }
321
+ for (const { bufs } of colBufs) {
322
+ for (const b of bufs) {
323
+ bytes.set(new Uint8Array(b), cur);
324
+ cur += b.byteLength;
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+ function resolveLayout(schema, charWidthHint, rowHeightHint, totalRows, viewportHeight) {
330
+ const columns = schema.map((col, columnIndex) => ({
331
+ ...col,
332
+ computedWidth: asPixelSize(
333
+ col.fixedWidth ?? col.maxContentChars * charWidthHint + CELL_H_PADDING * 2
334
+ ),
335
+ columnIndex
336
+ }));
337
+ return {
338
+ columns,
339
+ rowHeight: asPixelSize(rowHeightHint),
340
+ totalRows,
341
+ totalHeight: asPixelSize(totalRows * rowHeightHint),
342
+ viewportRows: Math.ceil(viewportHeight / rowHeightHint) + 1
343
+ };
344
+ }
345
+ var BackpressureController = class {
346
+ constructor() {
347
+ this.history = new RingBuffer(8);
348
+ this.strategy = "NOMINAL";
349
+ }
350
+ record(renderMs) {
351
+ this.history.push(renderMs);
352
+ if (this.history.length < 4) return null;
353
+ const avg = this.history.mean();
354
+ const next = avg > 28 ? "SHED" : avg > 14 ? "BUFFER" : "NOMINAL";
355
+ if (next === this.strategy) return null;
356
+ this.strategy = next;
357
+ return next;
358
+ }
359
+ get avgMs() {
360
+ return this.history.mean();
361
+ }
362
+ get queueSnapshot() {
363
+ return {
364
+ strategy: this.strategy,
365
+ queueDepth: 0,
366
+ avgRenderMs: asMs(this.avgMs)
367
+ };
368
+ }
369
+ };
370
+ var _DataWorkerCore = class _DataWorkerCore {
371
+ constructor() {
372
+ this.schema = [];
373
+ this.columns = [];
374
+ this.charWidthHint = 8;
375
+ this.rowHeightHint = 32;
376
+ this.viewportHeight = 600;
377
+ this.totalRows = 0;
378
+ this.windowStart = 0;
379
+ this.windowCount = 0;
380
+ this.layout = null;
381
+ this.seqCounter = 0;
382
+ this.bp = new BackpressureController();
383
+ this.queue = [];
384
+ this.processing = false;
385
+ }
386
+ // ── Emit helper ──────────────────────────────────────────────────────────────
387
+ emit(event, transfer = []) {
388
+ self.postMessage(event, transfer);
389
+ }
390
+ // ── Command handlers ──────────────────────────────────────────────────────────
391
+ init(cmd) {
392
+ this.charWidthHint = cmd.charWidthHint;
393
+ this.rowHeightHint = cmd.rowHeightHint;
394
+ this.viewportHeight = cmd.viewportHeight;
395
+ this.columns = cmd.schema.map((col) => {
396
+ switch (col.type) {
397
+ case "utf8":
398
+ return new Utf8Column();
399
+ case "list_utf8":
400
+ return new ListUtf8Column();
401
+ default:
402
+ return new NumericColumn(col.type);
403
+ }
404
+ });
405
+ this.layout = resolveLayout(cmd.schema, this.charWidthHint, this.rowHeightHint, 0, this.viewportHeight);
406
+ this.schema = this.layout.columns;
407
+ this.windowCount = this.layout.viewportRows;
408
+ this.emit({ type: "LAYOUT_READY", layout: this.layout });
409
+ }
410
+ ingest(cmd) {
411
+ if (this.bp.strategy === "SHED" && this.queue.length >= _DataWorkerCore.MAX_QUEUE_DEPTH) {
412
+ this.queue.shift();
413
+ }
414
+ this.queue.push({ buffer: cmd.buffer, seq: cmd.seq });
415
+ this.scheduleProcess();
416
+ }
417
+ setWindow(cmd) {
418
+ this.windowStart = cmd.startRow;
419
+ this.windowCount = cmd.rowCount;
420
+ this.flushWindow();
421
+ }
422
+ resizeViewport(cmd) {
423
+ this.viewportHeight = cmd.height;
424
+ if (this.schema.length > 0) this.rebuildLayout();
425
+ }
426
+ frameAck(cmd) {
427
+ const changed = this.bp.record(cmd.renderMs);
428
+ if (changed !== null) {
429
+ const state = {
430
+ strategy: changed,
431
+ queueDepth: this.queue.length,
432
+ avgRenderMs: asMs(this.bp.avgMs)
433
+ };
434
+ this.emit({ type: "BACKPRESSURE", state });
435
+ }
436
+ }
437
+ // ── Internal processing ───────────────────────────────────────────────────────
438
+ scheduleProcess() {
439
+ if (this.processing) return;
440
+ this.processing = true;
441
+ setTimeout(() => this.processQueue(), 0);
442
+ }
443
+ processQueue() {
444
+ this.processing = false;
445
+ while (this.queue.length > 0) this.ingestBatch(this.queue.shift());
446
+ this.flushWindow();
447
+ }
448
+ ingestBatch(item) {
449
+ let batch;
450
+ try {
451
+ batch = parseBatch(item.buffer);
452
+ } catch (e) {
453
+ this.emit({ type: "INGEST_ERROR", seq: asBatchSeq(item.seq), message: String(e) });
454
+ this.emit({ type: "INGEST_ACK", seq: asBatchSeq(item.seq) });
455
+ return;
456
+ }
457
+ for (let i = 0; i < Math.min(batch.columns.length, this.columns.length); i++) {
458
+ const batchType = batch.columns[i].type;
459
+ const storeType = this.schema[i].type;
460
+ if (batchType !== storeType) {
461
+ const msg = `Schema type mismatch at column ${i} ("${this.schema[i].name}"): batch encodes "${batchType}" but store expects "${storeType}". Ensure the schema passed to INIT matches the encoder's schema exactly.`;
462
+ this.emit({ type: "INGEST_ERROR", seq: asBatchSeq(item.seq), message: msg });
463
+ this.emit({ type: "INGEST_ACK", seq: asBatchSeq(item.seq) });
464
+ return;
465
+ }
466
+ }
467
+ for (let i = 0; i < batch.columns.length && i < this.columns.length; i++) {
468
+ const col = batch.columns[i];
469
+ const store = this.columns[i];
470
+ if (col.type === "list_utf8" && store instanceof ListUtf8Column) {
471
+ store.append(
472
+ col.listTotalItems,
473
+ col.listRowOffsets,
474
+ col.listItemOffsets,
475
+ col.listBytes,
476
+ batch.rowCount
477
+ );
478
+ } else if (col.type === "utf8" && store instanceof Utf8Column) {
479
+ store.append(col.utf8Offsets, col.utf8Bytes, batch.rowCount);
480
+ } else if (store instanceof NumericColumn && col.data !== null) {
481
+ store.append(col.data);
482
+ }
483
+ }
484
+ this.totalRows += batch.rowCount;
485
+ for (let i = 0; i < this.columns.length; i++) {
486
+ const colRows = this.columns[i].rowCount;
487
+ if (colRows !== this.totalRows) {
488
+ const msg = `Integrity violation after batch commit: column "${this.schema[i].name}" has ${colRows} rows but totalRows=${this.totalRows}. Parallel list_utf8 columns must encode identical row counts per batch (e.g. organism_ids and organism_names arrays must have the same length).`;
489
+ this.emit({ type: "INGEST_ERROR", seq: asBatchSeq(item.seq), message: msg });
490
+ this.emit({ type: "INGEST_ACK", seq: asBatchSeq(item.seq) });
491
+ return;
492
+ }
493
+ }
494
+ this.rebuildLayout();
495
+ this.emit({ type: "TOTAL_ROWS_UPDATED", totalRows: this.totalRows });
496
+ this.emit({ type: "INGEST_ACK", seq: asBatchSeq(item.seq) });
497
+ }
498
+ rebuildLayout() {
499
+ this.layout = resolveLayout(
500
+ this.schema,
501
+ this.charWidthHint,
502
+ this.rowHeightHint,
503
+ this.totalRows,
504
+ this.viewportHeight
505
+ );
506
+ this.schema = this.layout.columns;
507
+ }
508
+ flushWindow() {
509
+ if (!this.layout || this.totalRows === 0 || this.windowCount === 0) return;
510
+ const start = Math.max(0, Math.min(this.windowStart, this.totalRows - 1));
511
+ const count = Math.min(this.windowCount, this.totalRows - start);
512
+ if (count <= 0) return;
513
+ const seq = this.seqCounter++;
514
+ const buffer = packWindowBuffer(this.columns, this.schema, start, count, seq);
515
+ const win = {
516
+ seq: asBatchSeq(seq),
517
+ startRow: asRowIndex(start),
518
+ rowCount: count,
519
+ layout: this.layout,
520
+ buffer
521
+ };
522
+ this.emit({ type: "WINDOW_UPDATE", window: win }, [buffer]);
523
+ }
524
+ };
525
+ _DataWorkerCore.MAX_QUEUE_DEPTH = 64;
526
+ var DataWorkerCore = _DataWorkerCore;
527
+ var core = new DataWorkerCore();
528
+ self.onmessage = (e) => {
529
+ const cmd = e.data;
530
+ switch (cmd.type) {
531
+ case "INIT":
532
+ core.init(cmd);
533
+ break;
534
+ case "INGEST":
535
+ core.ingest(cmd);
536
+ break;
537
+ case "SET_WINDOW":
538
+ core.setWindow(cmd);
539
+ break;
540
+ case "RESIZE_VIEWPORT":
541
+ core.resizeViewport(cmd);
542
+ break;
543
+ case "FRAME_ACK":
544
+ core.frameAck(cmd);
545
+ break;
546
+ case "TERMINATE":
547
+ self.close();
548
+ break;
549
+ }
550
+ };