concertina 0.13.0 → 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
@@ -1351,6 +1351,7 @@ function injectStyles() {
1351
1351
  document.head.appendChild(style);
1352
1352
  injected = true;
1353
1353
  }
1354
+ injectStyles();
1354
1355
 
1355
1356
  // src/accordion/content.tsx
1356
1357
  var import_jsx_runtime11 = require("react/jsx-runtime");
package/dist/accordion.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  Trigger2,
9
9
  useConcertina,
10
10
  useExpanded
11
- } from "./chunk-OGJMPKZX.js";
11
+ } from "./chunk-6UMIJ4S7.js";
12
12
  export {
13
13
  ConcertinaContext,
14
14
  ConcertinaStore,
@@ -1312,6 +1312,7 @@ function injectStyles() {
1312
1312
  document.head.appendChild(style);
1313
1313
  injected = true;
1314
1314
  }
1315
+ injectStyles();
1315
1316
 
1316
1317
  // src/accordion/content.tsx
1317
1318
  import { jsx as jsx10 } from "react/jsx-runtime";