@toclocoinc/lattice-grid 1.46.1 → 1.47.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.
Files changed (71) hide show
  1. package/README.md +131 -1
  2. package/docs/API.html +68 -4
  3. package/docs/api-detail.html +5 -2
  4. package/lattice-grid.d.ts +228 -6
  5. package/lattice-grid.esm.min.js +13 -5
  6. package/lattice-grid.min.cjs +13 -5
  7. package/lattice-grid.min.js +13 -5
  8. package/modules/ai.esm.min.js +195 -27
  9. package/modules/ai.min.cjs +193 -26
  10. package/modules/ai.min.js +193 -26
  11. package/modules/angular.esm.min.js +2 -2
  12. package/modules/angular.min.cjs +2 -2
  13. package/modules/angular.min.js +2 -2
  14. package/modules/chart-alluvial.esm.min.js +1 -1
  15. package/modules/chart-arc.esm.min.js +1 -1
  16. package/modules/chart-bubblemap.esm.min.js +1 -1
  17. package/modules/chart-bump.esm.min.js +1 -1
  18. package/modules/chart-calendar.esm.min.js +1 -1
  19. package/modules/chart-decomposition.esm.min.js +1 -1
  20. package/modules/chart-diverging.esm.min.js +1 -1
  21. package/modules/chart-dumbbell.esm.min.js +1 -1
  22. package/modules/chart-fan.esm.min.js +1 -1
  23. package/modules/chart-hexbin.esm.min.js +1 -1
  24. package/modules/chart-hexmap.esm.min.js +1 -1
  25. package/modules/chart-icicle.esm.min.js +1 -1
  26. package/modules/chart-parallel.esm.min.js +8 -3
  27. package/modules/chart-ridgeline.esm.min.js +1 -1
  28. package/modules/chart-roc.esm.min.js +1 -1
  29. package/modules/chart-slope.esm.min.js +1 -1
  30. package/modules/chart-splom.esm.min.js +1 -1
  31. package/modules/chart-waffle.esm.min.js +1 -1
  32. package/modules/charts.esm.min.js +225 -13
  33. package/modules/charts.min.cjs +225 -13
  34. package/modules/charts.min.js +225 -13
  35. package/modules/data-router.esm.min.js +4 -4
  36. package/modules/data-router.min.cjs +4 -4
  37. package/modules/data-router.min.js +4 -4
  38. package/modules/devtools.esm.min.js +2 -2
  39. package/modules/devtools.min.cjs +2 -2
  40. package/modules/devtools.min.js +2 -2
  41. package/modules/dhtmlx-compat.esm.min.js +4 -4
  42. package/modules/dhtmlx-compat.min.cjs +4 -4
  43. package/modules/dhtmlx-compat.min.js +4 -4
  44. package/modules/gantt.esm.min.js +13 -4
  45. package/modules/gantt.min.cjs +13 -4
  46. package/modules/gantt.min.js +13 -4
  47. package/modules/htmx.esm.min.js +13 -5
  48. package/modules/htmx.min.cjs +13 -5
  49. package/modules/htmx.min.js +13 -5
  50. package/modules/kanban.esm.min.js +59 -8
  51. package/modules/kanban.min.cjs +59 -8
  52. package/modules/kanban.min.js +59 -8
  53. package/modules/kpi.esm.min.js +4 -4
  54. package/modules/kpi.min.cjs +4 -4
  55. package/modules/kpi.min.js +4 -4
  56. package/modules/mock-socket.esm.min.js +2 -2
  57. package/modules/mock-socket.min.cjs +2 -2
  58. package/modules/mock-socket.min.js +2 -2
  59. package/modules/react.esm.min.js +2 -2
  60. package/modules/react.min.cjs +2 -2
  61. package/modules/react.min.js +2 -2
  62. package/modules/svelte.esm.min.js +2 -2
  63. package/modules/svelte.min.cjs +2 -2
  64. package/modules/svelte.min.js +2 -2
  65. package/modules/vue.esm.min.js +2 -2
  66. package/modules/vue.min.cjs +2 -2
  67. package/modules/vue.min.js +2 -2
  68. package/modules/webcomponent.esm.min.js +13 -5
  69. package/modules/webcomponent.min.cjs +13 -5
  70. package/modules/webcomponent.min.js +13 -5
  71. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  dependencies, no build step required. Optional adapters for React, Vue, Svelte
5
5
  and Web Components ship alongside it.
6
6
 
7
- Version 1.46.1 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.47.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
@@ -36,6 +36,10 @@ Every module is optional and none of them is loaded unless you import it.
36
36
  | `modules/dhtmlx-compat.esm.min.js` | A compatibility wrapper for dhtmlx Grid, for moving an existing integration across without rewriting it. |
37
37
  | `modules/devtools.esm.min.js` | The devtools panel, including the accessibility checks. |
38
38
 
39
+ This is the short list. [Modules & entry points](#modules--entry-points) is the
40
+ complete map — every framework adapter, feature module and chart-type extension,
41
+ with its npm import, CDN path and browser global.
42
+
39
43
  ---
40
44
 
41
45
  ## What it does
@@ -270,6 +274,132 @@ your editor tells you and what the grid does cannot drift apart.
270
274
 
271
275
  ---
272
276
 
277
+ ## Modules & entry points
278
+
279
+ The package ships the core plus a set of optional modules, each as its own file.
280
+ Nothing is loaded unless you import it, and a page that imports none of the
281
+ modules pays for none of them. This section is the complete map: every public
282
+ entry point, how to import it from npm, how to reach it on a CDN, and — for the
283
+ `<script>`-tag builds — the browser global it leaves behind.
284
+
285
+ **Three variants ship for most entry points.** Pick the one your page loads by:
286
+
287
+ - **ESM** — `*.esm.min.js`. The default for a bundler or a native `<script
288
+ type="module">`. This is what the bare `@toclocoinc/lattice-grid` and
289
+ `@toclocoinc/lattice-grid/modules/<name>` specifiers resolve to.
290
+ - **UMD** — `*.min.js`. For a plain `<script src>` load; it defines a browser
291
+ global (below). Reach it from npm with the explicit `.js` specifier
292
+ (`@toclocoinc/lattice-grid/modules/<name>.js`).
293
+ - **CJS** — `*.min.cjs`. For `require()`. Reach it with the explicit `.cjs`
294
+ specifier, or let `require('@toclocoinc/lattice-grid')` resolve it.
295
+
296
+ Alongside those: the stylesheet `lattice-grid.min.css` (required, imported as
297
+ `@toclocoinc/lattice-grid/css`) and the TypeScript declarations
298
+ `lattice-grid.d.ts` (wired through `package.json`, so no configuration).
299
+
300
+ **On a CDN**, every file below sits under the same base — jsDelivr serves the
301
+ published package directly:
302
+
303
+ ```
304
+ https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@<version>/<file>
305
+ ```
306
+
307
+ Pin an exact `<version>` in production. The `<file>` column in each table below
308
+ is exactly what you append.
309
+
310
+ ### Core
311
+
312
+ | npm import | CDN `<file>` | Browser global |
313
+ |---|---|---|
314
+ | `@toclocoinc/lattice-grid` (ESM) | `lattice-grid.esm.min.js` | — |
315
+ | `@toclocoinc/lattice-grid` (CJS `require`) | `lattice-grid.min.cjs` | — |
316
+ | `@toclocoinc/lattice-grid/lattice-grid.min.js` (UMD) | `lattice-grid.min.js` | `LatticeGrid` |
317
+ | `@toclocoinc/lattice-grid/css` | `lattice-grid.min.css` | — (stylesheet) |
318
+ | types | `lattice-grid.d.ts` | — (declarations) |
319
+
320
+ ### Framework adapters
321
+
322
+ Each adapter ships all three variants (`modules/<name>.esm.min.js`,
323
+ `modules/<name>.min.js`, `modules/<name>.min.cjs`). The npm ESM specifier is
324
+ shown; append `.js` for the UMD build or `.cjs` for CommonJS.
325
+
326
+ | Module | npm import (ESM) | CDN `<file>` (UMD) | Browser global | What it is |
327
+ |---|---|---|---|---|
328
+ | react | `@toclocoinc/lattice-grid/modules/react` | `modules/react.min.js` | `LatticeGridReact` | React adapter. |
329
+ | vue | `@toclocoinc/lattice-grid/modules/vue` | `modules/vue.min.js` | `LatticeGridVue` | Vue adapter. |
330
+ | svelte | `@toclocoinc/lattice-grid/modules/svelte` | `modules/svelte.min.js` | `LatticeGridSvelte` | Svelte adapter. |
331
+ | angular | `@toclocoinc/lattice-grid/modules/angular` | `modules/angular.min.js` | `LatticeGridAngular` | Angular adapter. |
332
+ | webcomponent | `@toclocoinc/lattice-grid/modules/webcomponent` | `modules/webcomponent.min.js` | `LatticeGrid` (extends it) | `<lattice-grid>` as a self-contained custom element. |
333
+ | htmx | `@toclocoinc/lattice-grid/modules/htmx` | `modules/htmx.min.js` | `LatticeGridHtmx` | htmx integration that survives DOM swaps and hydrates from a server-rendered `<table>`. |
334
+
335
+ The framework adapters take the framework and `createGrid` handed in rather than
336
+ importing either (see [Quick start](#react-vue-svelte-and-web-components)). The
337
+ web component and htmx are self-contained: they carry the grid, so load them on
338
+ their own, not beside the base package.
339
+
340
+ ### Feature modules
341
+
342
+ Each ships all three variants. The npm ESM specifier is shown; append `.js` for
343
+ the UMD build or `.cjs` for CommonJS.
344
+
345
+ | Module | npm import (ESM) | CDN `<file>` (UMD) | Browser global | What it is |
346
+ |---|---|---|---|---|
347
+ | charts | `@toclocoinc/lattice-grid/modules/charts` | `modules/charts.min.js` | `LatticeGrid` (extends it) | Charts bound to the grid's own result (`createChart`). |
348
+ | data-router | `@toclocoinc/lattice-grid/modules/data-router` | `modules/data-router.min.js` | `LatticeGridDataRouter` | One stream split by property and routed to many grids or charts. |
349
+ | kanban | `@toclocoinc/lattice-grid/modules/kanban` | `modules/kanban.min.js` | `LatticeGridKanban` | Board view: grid rows as cards grouped into columns (`createKanban`). |
350
+ | gantt | `@toclocoinc/lattice-grid/modules/gantt` | `modules/gantt.min.js` | `LatticeGridGantt` | Editable, dependency-aware project plan with a computed critical path (`createGantt`). |
351
+ | kpi | `@toclocoinc/lattice-grid/modules/kpi` | `modules/kpi.min.js` | `LatticeGridKPI` | A grid of stat tiles, each an aggregate over a dataset (`createKPI`). |
352
+ | ai | `@toclocoinc/lattice-grid/modules/ai` | `modules/ai.min.js` | `LatticeGridAI` | Bring-your-own-model narrative and insights grounded on computed figures (`createAI`). |
353
+ | mock-socket | `@toclocoinc/lattice-grid/modules/mock-socket` | `modules/mock-socket.min.js` | `LatticeGridMockSocket` | A serverless stand-in for a live WebSocket feed (`MockWebSocket`, `opsFeed`). |
354
+ | devtools | `@toclocoinc/lattice-grid/modules/devtools` | `modules/devtools.min.js` | `LatticeGrid` (extends it) | The in-page diagnostic panel, including the accessibility checks (`createDevtools`). |
355
+ | dhtmlx-compat | `@toclocoinc/lattice-grid/modules/dhtmlx-compat` | `modules/dhtmlx-compat.min.js` | `LatticeGrid` (extends it) | A dhtmlx `Grid`-shaped API for moving an existing integration across. |
356
+
357
+ The four modules that extend `LatticeGrid` (charts, devtools, dhtmlx-compat,
358
+ webcomponent) fold their exports into the core global, so load the core
359
+ `<script>` first and then the module — for example `LatticeGrid.createChart(...)`
360
+ becomes available once both are loaded. With a bundler they share the one core
361
+ the page already imported rather than carrying a second copy.
362
+
363
+ ### Chart-type extensions
364
+
365
+ Eighteen additional chart types ship as separate, tree-shakeable ESM modules.
366
+ Each one **self-registers** its type onto the charts module's shared registry the
367
+ moment it is imported — so load the base charts module first, import the
368
+ extension for its side effect, then name the type in `createChart`. They ship as
369
+ ESM only (import for side effect; there is no global to reach).
370
+
371
+ ```js
372
+ import '@toclocoinc/lattice-grid/modules/charts'; // the base registry
373
+ import '@toclocoinc/lattice-grid/modules/chart-alluvial'; // registers 'alluvial'
374
+ createChart({ grid, container, type: 'alluvial', source: 'from', target: 'to', value: 'count' });
375
+ ```
376
+
377
+ Each is imported from `@toclocoinc/lattice-grid/modules/chart-<type>` (CDN
378
+ `<file>`: `modules/chart-<type>.esm.min.js`), and registers the `type` shown.
379
+
380
+ | Import / `type` | What it draws |
381
+ |---|---|
382
+ | `chart-alluvial` → `alluvial` | Alluvial diagram: categorical flow from one dimension to another. |
383
+ | `chart-arc` → `arc` | Arc diagram: nodes on a line, links as arcs. |
384
+ | `chart-bubblemap` → `bubblemap` | Symbol / bubble map. |
385
+ | `chart-bump` → `bump` | Bump chart: rank over time. |
386
+ | `chart-calendar` → `calendar` | Calendar heatmap. |
387
+ | `chart-decomposition` → `decomposition` | Seasonal decomposition panel. |
388
+ | `chart-diverging` → `diverging` | Diverging bar chart. |
389
+ | `chart-dumbbell` → `dumbbell` | Dumbbell / connected-dot plot. |
390
+ | `chart-fan` → `fan` | Fan / forecast chart. |
391
+ | `chart-hexbin` → `hexbin` | Hexbin / 2D-density plot. |
392
+ | `chart-hexmap` → `hexmap` | Hexbin map. |
393
+ | `chart-icicle` → `icicle` | Icicle chart. |
394
+ | `chart-parallel` → `parallel` | Parallel coordinates. |
395
+ | `chart-ridgeline` → `ridgeline` | Ridgeline (joy) plot. |
396
+ | `chart-roc` → `roc` | ROC / PR / calibration curves. |
397
+ | `chart-slope` → `slope` | Slope chart. |
398
+ | `chart-splom` → `splom` | Scatter-plot matrix. |
399
+ | `chart-waffle` → `waffle` | Waffle / dot-matrix chart. |
400
+
401
+ ---
402
+
273
403
  ## Full-screen mode
274
404
 
275
405
  A grid usually lives in whatever box the page layout gave it, and that box is
package/docs/API.html CHANGED
@@ -4538,6 +4538,34 @@ charts.registerChartType('ridgeline', { draw: ridge.drawRidgeline });
4538
4538
  typeof parallel.drawParallel, typeof parallel.bindParallel,
4539
4539
  ].join(' | ');</code></pre>
4540
4540
 
4541
+ <p>When parallel coordinates is given a <code>colourBy</code> column it colours each line by its category — but a colour with no key is a code, so the chart now emits a <strong>legend</strong> of those categories (BACKLOG-0000999), one entry per category in the order the colours were assigned, exactly the shape every other coloured type returns. The base draws it and wires the click, so a click on a category toggles it off through the same hide-a-series gesture the rest of the module has, and the drawer skips a hidden category's lines. With no <code>colourBy</code> there is nothing to key and no legend is drawn. The categories the key is built from are the ones <code>bindParallel</code> returns as <code>groups</code>:</p>
4542
+ <pre data-run="js" data-expect="key red,green,blue" data-covers="export:bindParallel"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4543
+ <span class="kw">const</span> { bindParallel } = <span class="kw">await</span> import('../packages/modules/chart-parallel/index.js');
4544
+ <span class="kw">const</span> cats = ['red', 'green', 'blue'];
4545
+ <span class="kw">const</span> rows = Array.from({ length: 9 }, (unused, i) =&gt; ({ id: `R${i}`, a: i, b: i % 4, grp: cats[i % 3] }));
4546
+ <span class="kw">const</span> grid = createHeadlessGrid({
4547
+ columns: [{ field: 'a', type: 'number' }, { field: 'b', type: 'number' }, { field: 'grp', type: 'text' }],
4548
+ rows, rowKey: 'id',
4549
+ });
4550
+ <span class="cmt">// The colourBy categories, in colour order — each becomes a legend entry whose</span>
4551
+ <span class="cmt">// index picks the same colour its lines use.</span>
4552
+ <span class="kw">const</span> bound = bindParallel(grid, { columns: ['a', 'b'], colourBy: 'grp' });
4553
+ grid.destroy();
4554
+ <span class="kw">return</span> `key ${bound.groups.join(',')}`;</code></pre>
4555
+
4556
+ <p>A least-squares forecast on the trend overlay (<code>trend: { method: 'linear', forecast: n }</code>) no longer draws a bare dashed line: it shades the <strong>uncertainty band</strong> around the projection (BACKLOG-0000975). By default that is the Student-t <em>prediction</em> band (a future observation); <code>band: 'confidence'</code> shades the narrower mean-response band the fitted line's own doubt describes, and <code>band: false</code> leaves the bare line. The band widens as the line runs further past the data — the honest shape, since a projection is least certain where it reaches furthest — and <code>confidence</code> (default 0.95) sets its level. It is the exact interval the core <code>forecast</code> kernel reports for the linear method, computed locally in the charts bundle (never imported, for the bundle reason the trend maths already is) and asserted equal to the engine's to the last digit:</p>
4557
+ <pre data-run="js" data-expect="match true; conf 0.95" data-covers="export:forecast"><code><span class="kw">const</span> { forecast } = <span class="kw">await</span> import('../packages/core/src/index.js');
4558
+ <span class="kw">const</span> { linearTrend } = <span class="kw">await</span> import('../packages/modules/charts/trendline.js');
4559
+ <span class="kw">const</span> ys = [2, 5, 6, 9, 11, 12];
4560
+ <span class="kw">const</span> pairs = ys.map((y, i) =&gt; ({ x: i, y }));
4561
+ <span class="cmt">// The trend overlay's forecast band, three steps ahead at 95%…</span>
4562
+ <span class="kw">const</span> overlay = linearTrend(pairs, 3, { confidence: 0.95 });
4563
+ <span class="cmt">// …is the core forecast kernel's linear prediction band, to the last digit.</span>
4564
+ <span class="kw">const</span> engine = forecast(ys, { method: 'linear', horizon: 3, confidence: 0.95 });
4565
+ <span class="kw">const</span> b = overlay.band.points[3];
4566
+ <span class="kw">const</span> e = engine.points[2];
4567
+ <span class="kw">return</span> `match ${b.lower === e.lower &amp;&amp; b.upper === e.upper}; conf ${overlay.band.confidence}`;</code></pre>
4568
+
4541
4569
  <p>The hierarchy, flow and geographic remainder ships the same way — treemap, sunburst, funnel, radar, sankey, chord and network are already built in, so the new opt-in modules are: <strong>icicle</strong> (<code>drawIcicle</code>, <code>type: 'icicle'</code>, drawn from the grid's group tree), <strong>waffle</strong> (<code>drawWaffle</code>, <code>type: 'waffle'</code>), <strong>alluvial</strong> (<code>drawAlluvial</code>/<code>bindAlluvial</code>, <code>type: 'alluvial'</code>), <strong>arc diagram</strong> (<code>drawArc</code>/<code>bindArc</code>, <code>type: 'arc'</code>), <strong>bubble map</strong> (<code>drawBubbleMap</code>/<code>bindBubbleMap</code>, <code>type: 'bubblemap'</code>) and <strong>hexbin map</strong> (<code>drawHexMap</code>/<code>bindHexMap</code>, <code>type: 'hexmap'</code>). The two maps place <code>lon</code>/<code>lat</code> directly, so they need no outline data and fetch nothing.</p>
4542
4570
  <pre data-run="js" data-expect="true | function | function | function | function | function | function | function | function | function | function" data-covers="export:drawIcicle export:drawWaffle export:drawAlluvial export:bindAlluvial export:drawArc export:bindArc export:drawBubbleMap export:bindBubbleMap export:drawHexMap export:bindHexMap"><code><span class="kw">const</span> charts = <span class="kw">await</span> import('../packages/modules/charts/index.js');
4543
4571
  <span class="kw">const</span> icicle = <span class="kw">await</span> import('../packages/modules/chart-icicle/index.js');
@@ -4606,6 +4634,7 @@ router.load(snapshot); <span class="cmt">// every viewer
4606
4634
  <tr><td class="sig">configure(spec)</td><td class="desc"><strong>v5:</strong> the whole routing graph as one data spec &mdash; <code>routes</code> (grid/<code>default</code>/<code>subscribe</code>/<code>alert</code> entries), <code>links</code>, <code>relate</code>, <code>buffer</code> &mdash; desugared to the imperative API. Composes with imperative calls and round-trips to identical behaviour. Also accepted as <code>createDataRouter({ config })</code>.</td></tr>
4607
4635
  <tr><td class="sig">attach(grid, predicate, { writable, onWrite?, onConflict? })</td><td class="desc"><strong>v8 (BACKLOG-0000912):</strong> make a route <strong>writable</strong> &mdash; the router captures the grid's committed edits off its public edit surface (<code>grid.on('cell:changed')</code> &rarr; <code>grid.edit.setCells</code>) and routes them to <code>onWrite(change, { route, source })</code> (per-route here, or the router-global <code>onWrite</code>), reverting the cell on reject and re-entering an accepted write as a normal delta. <code>onConflict(change, { serverRow })</code> surfaces a last-write-wins conflict. A derived (<code>rollup</code>/<code>transform</code>) route cannot be writable &mdash; its edits are reverted and warned.</td></tr>
4608
4636
  <tr><td class="sig">attach(grid, predicate, { where })</td><td class="desc"><strong>v7 (BACKLOG-0000914):</strong> a route-level <code>where</code> &mdash; a filter-wire condition <code>{ col, op, value }</code> or an <code>and</code>/<code>or</code>/<code>not</code> group &mdash; used only by query-slice routing (<code>query()</code>): the router pushes it <em>down</em> to the engine where the adapter allows and finishes the residual client-side. Distinct from <code>filter</code> (a <code>fn(row)</code> that only ever runs in the browser).</td></tr>
4637
+ <tr><td class="sig">attach(grid, predicate, { label, backpressure })</td><td class="desc"><strong>v10/v13:</strong> a human <code>label</code> for the route (shown in <code>metrics()</code> and the devtools panel), and a per-route <strong>backpressure</strong> policy that throttles / coalesces / samples how that route's viewer is refreshed under load &mdash; <em>without</em> touching the keyed store or any other route. <code>backpressure: { maxHz, minInterval?, sample?, maxLag? }</code>: <code>maxLag</code> (a backlog depth) sets when it engages (below it, changes pass straight through); <code>maxHz</code>/<code>minInterval</code> cap the refresh rate; <code>sample</code> (an integer &gt; 1) thins intermediate refreshes. A trailing flush always lands the latest state (deletes included), so the viewer converges and is never left stale.</td></tr>
4609
4638
  <tr><td class="sig">query(adapter, request?)</td><td class="desc"><strong>v7 (BACKLOG-0000914):</strong> source the router from a DFQL/DuckDB (or any pushdown) adapter. Runs <code>adapter.execute</code>, partitions the result across the routes and drives the grids by the same keyed diff <code>load()</code> uses; a route's <code>where</code> is planned against the adapter's capabilities (pushed down where allowed, residual finished client-side). Composes with per-route <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> and links/graph. Async &mdash; resolves once every slice is fetched and applied.</td></tr>
4610
4639
  <tr><td class="sig">lastQueryPlan()</td><td class="desc"><strong>v7:</strong> the pushed/residual split of the last <code>query()</code>, per fetch &mdash; whether a filter reached the engine and what work was left client-side. <code>null</code> before any query. Provenance, so a slow slice is diagnosed rather than guessed.</td></tr>
4611
4640
  <tr><td class="sig">buffer({ window?, max? })</td><td class="desc"><strong>v4 (BACKLOG-0000911):</strong> turn on time-travel buffering &mdash; record the ordered, de-duplicated stream into a <strong>bounded</strong> ring (a time <code>window</code> in ms and/or a <code>max</code> delta count; eviction folds the oldest into a moving base, so memory never grows unbounded; a default cap applies if you name neither). Seeded from the current world, so it can be turned on at any time. Opt-in and off by default.</td></tr>
@@ -4618,9 +4647,12 @@ router.load(snapshot); <span class="cmt">// every viewer
4618
4647
  <tr><td class="sig">broadcasting</td><td class="desc"><strong>v6:</strong> whether the router is currently mirroring to a BroadcastChannel.</td></tr>
4619
4648
  <tr><td class="sig">addSource(feed, { map?, key? })</td><td class="desc"><strong>v9 (BACKLOG-0000931):</strong> register a source feed &mdash; fan-in. Returns a handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>, plus <code>id</code>/<code>size</code>) whose rows are normalized by <code>map</code> and namespaced by <code>key</code> (a prefix string, <code>true</code> to prefix with the source id, or a <code>keyFn(row)</code>) so ids from different feeds cannot collide, then merged through the router's ordinary path &mdash; partitioned, routed, linked, deduped, buffered and written back exactly as the single-source path. <code>feed</code> is an optional source id or an options object. A source may also carry a <code>join</code> spec (v11) to <strong>enrich</strong> its rows with fields looked up from another source.</td></tr>
4620
4649
  <tr><td class="sig">removeSource(ref) / sources()</td><td class="desc"><strong>v9:</strong> drop exactly the rows a feed contributed (by source id or handle) from every route and unregister it; and list the registered source ids.</td></tr>
4621
- <tr><td class="sig">metrics()</td><td class="desc"><strong>v10 (BACKLOG-0000932):</strong> a cheap point-in-time observability snapshot &mdash; per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global <code>unrouted</code> / <code>dropped</code> / <code>buffered</code> / <code>lag</code> figures. Throughput is sampled over the interval since the last <code>metrics()</code> call or emit.</td></tr>
4650
+ <tr><td class="sig">metrics()</td><td class="desc"><strong>v10 (BACKLOG-0000932):</strong> a cheap point-in-time observability snapshot &mdash; per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global <code>unrouted</code> / <code>dropped</code> / <code>buffered</code> / <code>lag</code> figures. Throughput is sampled over the interval since the last <code>metrics()</code> call or emit. Each entry in <code>routes[]</code> also carries its <code>label</code> and, when the route declares a backpressure policy, a <code>backpressure: { pending, coalesced }</code> object &mdash; <code>pending</code> is the held backlog since the last flush (the route's lag) and <code>coalesced</code> the cumulative change-events it has absorbed into deferred refreshes (<code>null</code> when the route has no policy).</td></tr>
4622
4651
  <tr><td class="sig">on('metrics', handler)</td><td class="desc"><strong>v10:</strong> subscribe to the periodic <code>metrics</code> emit (the <code>metricsInterval</code> ms, default 1000; <code>0</code> disables it). The timer runs only while at least one listener is registered and stops when the last is removed. Returns an unsubscribe function.</td></tr>
4623
4652
  <tr><td class="sig">mountDevtools(el, { interval? })</td><td class="desc"><strong>v10:</strong> mount an opt-in, DOM-touching live panel (in the module's own <code>devtools.js</code>, so the core stays DOM-free) that renders <code>metrics()</code> into <code>el</code> and refreshes on each emit. Returns a controller with <code>destroy()</code>. Off unless called.</td></tr>
4653
+ <tr><td class="sig">persist({ key?, debounce?, storage?, indexedDB?, dbName?, storeName? })</td><td class="desc"><strong>v12 (BACKLOG-0000961):</strong> turn on durable persistence &mdash; snapshot the keyed store and the time-travel ring to a durable async key/value store so an offline reload or a browser refresh resumes exactly where it left off. The default backend is <strong>IndexedDB</strong> (native, no dependency), opened lazily and guarded so private-mode or blocked storage degrades to in-memory with a one-time warning rather than throwing. Writes a coalesced snapshot after each <code>load</code>/<code>apply</code> (debounced by <code>debounce</code> ms, default 250; <code>0</code> is eager). Pass <code>storage</code> &mdash; any object with async <code>get(key)</code>/<code>set(key, value)</code> &mdash; to use another backend (a server, a test double). Opt-in and off by default.</td></tr>
4654
+ <tr><td class="sig">restore()</td><td class="desc"><strong>v12:</strong> resume from the durable snapshot. Read the last persisted state and apply it &mdash; <code>load</code> the live head through the ordinary keyed diff (so grids attached before this call repaint only what differs), restore the resume checkpoint and, when the snapshot carried a time-travel ring, restore buffering and the ring so <code>scrubTo</code>/<code>replay</code>/<code>live</code> work straight after a reload. Call it once, after attaching the grids. <code>async</code>; resolves <code>true</code> when a snapshot was found and applied, <code>false</code> when persistence is off/degraded or nothing was stored.</td></tr>
4655
+ <tr><td class="sig">flushPersist() / persisting</td><td class="desc"><strong>v12:</strong> flush any pending durable write now (<code>async</code>; cancels the debounce and resolves once the write settles &mdash; for a <code>beforeunload</code> handler, a deterministic checkpoint, or a test), and whether durable persistence is on and not degraded to in-memory.</td></tr>
4624
4656
  <tr><td class="sig">detach(grid)</td><td class="desc">Stop routing to a grid and forget its slice; drop any link/edge it is part of (restoring a filtered sibling). The host still owns and destroys the grid.</td></tr>
4625
4657
  <tr><td class="sig">destroy()</td><td class="desc">Detach every grid, drop every link, edge and subscription. <strong>Detaches only</strong> &mdash; the host owns and destroys its grids.</td></tr>
4626
4658
  </tbody>
@@ -4891,6 +4923,7 @@ g.destroy(); router.destroy();
4891
4923
  <span class="kw">return</span> [merged, ids, afterRemove].join(' | ');</code></pre>
4892
4924
 
4893
4925
  <p><strong>Fan-in JOIN / enrichment (v11, BACKLOG-0000957).</strong> Fan-in above <em>merges</em> feeds side by side; a <code>join</code> spec goes further and <em>enriches</em> one feed's rows with fields looked up from <em>another</em> registered source &mdash; e.g. an <code>orders</code> feed enriched with <code>name</code>/<code>tier</code> from a <code>customers</code> source keyed by <code>customerId</code>. Declared per source: <code>addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name', 'tier'], missing: 'hold' } })</code>. <code>localKey</code> is the field on the enriched (left) row holding the foreign key (a field name or <code>fn(row)</code>); <code>foreignKey</code> is the field matched on the lookup row (defaults to <code>localKey</code>'s name); <code>fields</code> is what to pull &mdash; an array, a <code>{ src: dest }</code> rename map, or <code>select(lookupRow, leftRow) =&gt; object</code>. No second store is built: the lookup source <em>is</em> an ordinary fan-in source, and the join probes its existing keyed store by an index of join-key&nbsp;&rarr;&nbsp;store-id. <code>missing</code> chooses what happens when the lookup is absent or late: <code>hold</code> withholds the row from viewers until its lookup arrives, <code>passthrough</code> (the default) lets it flow unenriched, and <code>null</code> flows it with the pulled fields set to <code>null</code>. Enriched rows reach viewers through the ordinary keyed-diff path. <strong>Late lookups re-enrich:</strong> when a lookup row arrives, changes, or is deleted, every already-seated left row that references it is re-enriched and re-emitted &mdash; a held row is released, a <code>null</code>/<code>passthrough</code> row gains its fields, and a row whose lookup vanished is nulled/stripped (or, under <code>hold</code>, withheld again). Enrichment always recomputes from the untouched base row, so it is idempotent.</p>
4926
+ <p><strong>Durable resume and backpressure (v12/v13, BACKLOG-0000961 / BACKLOG-0000962).</strong> <code>persist({ key })</code> turns on durability: the router snapshots its keyed store and time-travel ring to a durable async store (IndexedDB by default, or any <code>{ get, set }</code> you pass as <code>storage</code>) after each <code>load</code>/<code>apply</code>, and <code>await router.restore()</code> &mdash; called once after the grids are attached &mdash; rehydrates them through the ordinary keyed diff, so an offline reload or a browser refresh resumes exactly where it left off (blocked/private storage degrades to in-memory with a one-time warning, never a throw). Independently, a route can declare <strong>backpressure</strong> &mdash; <code>attach(grid, type, { label, backpressure: { maxHz } })</code> &mdash; to cap how often its viewer repaints under load without slowing the store or any sibling route: <code>maxHz</code>/<code>minInterval</code> rate-limit the refresh, <code>sample</code> thins intermediate ones, and <code>maxLag</code> sets the backlog depth at which throttling engages; a trailing flush always lands the latest state so the viewer converges. What it cost is observable: <code>router.metrics().routes[].backpressure</code> is <code>{ pending, coalesced }</code> &mdash; the held backlog and the cumulative change-events folded into deferred refreshes (<code>null</code> for a route with no policy).</p>
4894
4927
  <h3 id="datarouter-v11-example">A JOIN with a late lookup, executed</h3>
4895
4928
  <p class="section-note">An order arrives before its customer, so under <code>hold</code> it is withheld; when the customer
4896
4929
  feed loads, the order is released and enriched with the looked-up name. Run headless on every build.</p>
@@ -5130,9 +5163,10 @@ const board = createKanban(document.querySelector('#board'), {
5130
5163
  <tr><td class="sig">editCard(key, field) / applyEdit(key, field, value)</td><td class="desc">Inline-edit a card field opted in with <code>card: { title: { field, edit: true } }</code>: grid-bound it commits through the grid's own field editor path (<code>grid.edit.setCells</code>); standalone it uses a host editor factory or a default input, reverting when <code>onCardEdit</code> rejects. Double-click a card to edit; emits <code>card:edit</code>.</td></tr>
5131
5164
  <tr><td class="sig">addCard(columnId, seed?)</td><td class="desc">Add a card to a column (with <code>config.addCard</code>'s per-column affordance) and open it in inline edit; a host <code>onAddCard(columnId)</code> supplies the row, or one is generated (grid-bound via <code>grid.edit.addRow</code>). Emits <code>card:add</code>.</td></tr>
5132
5165
  <tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">Serialise and restore the board state — collapsed columns/lanes, column order, quick filter, sprint/epic selection and selection. Also accepted as <code>config.state</code> at construction.</td></tr>
5166
+ <tr><td class="sig">sla</td><td class="desc">The card-aging / SLA monitor, present only when a <code>sla</code> config is supplied. Read <code>sla.states()</code>, <code>sla.breaches()</code>/<code>sla.warnings()</code> and <code>sla.stateFor(cardOrKey)</code> for each card's age and level; <code>sla.evaluate()</code> re-checks and fires crossings. See the card-aging note below.</td></tr>
5133
5167
  <tr><td class="sig">setLoading(bool) / setError(message)</td><td class="desc">A loading state and a host-supplied error banner; empty columns already render their placeholder.</td></tr>
5134
5168
  <tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or recompute and re-render.</td></tr>
5135
- <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>, <code>card:move</code>, <code>card:reverted</code>, <code>selection:changed</code>, <code>drag:start</code>, <code>drag:end</code> (plus the vocabulary the later cycles emit).</td></tr>
5169
+ <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>, <code>card:move</code>, <code>card:reverted</code>, <code>card:confirmed</code>, <code>card:sla</code>, <code>selection:changed</code>, <code>drag:start</code>, <code>drag:end</code> (plus the vocabulary the later cycles emit). On a grid-bound board a move fires <code>card:move</code> optimistically; the grid's write-back then settles it with <code>card:confirmed</code> or, if the server rejects, <code>card:reverted</code> (the card re-reads and the flow transition log rolls the optimistic move back).</td></tr>
5136
5170
  <tr><td class="sig">readonly(scope)</td><td class="desc">Whether a scope is readonly &mdash; the whole board, a <code>{ column }</code> or a <code>{ card }</code>. A readonly card is not draggable; a move into a readonly column is refused.</td></tr>
5137
5171
  <tr><td class="sig">destroy()</td><td class="desc">Empty the element and drop the model. The host still owns any bound grid.</td></tr>
5138
5172
  </tbody>
@@ -5143,6 +5177,7 @@ const board = createKanban(document.querySelector('#board'), {
5143
5177
  <p><strong>Sprint, epic and card pop-out.</strong> <code>setSprint(id)</code> shows one sprint, <code>showBacklog()</code> the cards with no sprint, and <code>sprints()</code> feeds a switcher; <code>setEpic(id)</code> narrows to an epic and <code>epicRollup()</code> (or <code>rollup(property)</code>) returns per-epic count, points and progress toward the <code>done</code> columns. A card can <strong>pop out a nested grid of its children</strong> — an epic's stories, a story's tasks, recursively. The child relationship is a <code>childrenProperty</code> (parent-id within the dataset) and/or a <code>loadChildren(card)</code> (per-card dataset or async fetch), and the child is a full composed <code>createGrid</code> (sort/filter/edit/write-back) — supplied as <code>children.factory</code> — opened in a <code>drawer</code> (default), <code>modal</code> or <code>inline</code>. With <code>children.asBoard</code> the child is itself a board, so it can pop its own children. This reuses the grid by composition and adds no grid-core coupling. <code>expand(key)</code> and the per-card drill affordance emit <code>card:expand</code>; a deeper open emits <code>card:drill</code>.</p>
5144
5178
  <p><strong>Live updates.</strong> Because the board consumes data through the same keyed-diff contract a grid does, a <a href="#datarouter">Data Router</a> drives it directly — <code>router.attach(board, predicate)</code> — and one feed fans out to a grid, a kanban, a chart and a KPI tile at once. A live <code>rows.apply({ add, update, remove })</code> is applied as a keyed diff (an unchanged card keeps its model) and re-rendered <strong>preserving</strong> scroll, focus, selection, collapsed columns/lanes and any open pop-out, so a card can appear, move or update under the user without losing their place.</p>
5145
5179
  <p><strong>Scale, state and accessibility.</strong> <code>virtualize</code> renders only a scroll window of a tall column (with true-height spacers so the scrollbar stays honest), for boards of thousands of cards. <code>getState()</code>/<code>setState()</code> (and <code>config.state</code>) save and restore the collapsed columns and lanes, the column order, the quick filter and the sprint/epic selection, so a reopened board comes back as it was; <code>setLoading</code>/<code>setError</code> add loading and error states. Accessibility runs throughout: the board is a labelled group of labelled column lists, cards are a roving-tabindex focus ring (arrows to move focus, Enter to activate), the move is fully keyboard-driven (<kbd>Space</kbd> grab, arrows for column/position, <kbd>Alt</kbd>+<kbd>↑/↓</kbd> across swimlanes, <kbd>Space</kbd>/<kbd>Enter</kbd> drop, <kbd>Escape</kbd> cancel) with live-region announcements, and every affordance carries a name.</p>
5180
+ <p><strong>Card aging / SLA.</strong> A <code>sla</code> config ages each card and highlights the ones sitting too long. Thresholds are a raw millisecond count or a <code>{ days, hours, … }</code> spec, set globally as <code>{ warn, breach }</code>, per column (either <code>sla.columns[id]</code>, or a column def's own <code>sla</code>/<code>slaWarn</code>/<code>slaBreach</code>) and per swimlane (<code>sla.lanes[id]</code>); the most specific wins, lane&nbsp;&rarr;&nbsp;column&nbsp;&rarr;&nbsp;global. <code>basis</code> chooses whether the clock is time-in-current-column (default) or age-on-the-board, resolved from the flow transition log, an <code>enteredProperty</code>/<code>createdProperty</code> timestamp, or arrival. The view puts an age chip on aged cards (<code>showAge: 'always'</code> shows it on every card) and a highlight on breached ones. A rising crossing (ok&rarr;warn, warn&rarr;breach) fires the <code>card:sla</code> event <em>and</em> the <code>onWarn</code>/<code>onBreach(level, rows)</code> callbacks — the same <code>(signal, rows)</code> shape a <a href="#datarouter">Data Router</a> <code>alert</code> route uses, so one handler serves both. It is reached at runtime as <code>board.sla</code>; done-column cards are exempt by default (<code>ignoreDone: false</code> opts them in), and an optional <code>tick</code> re-checks so a card that breaches by simply sitting still still lights up. Example: <code>createKanban(el, { …, sla: { warn: { days: 2 }, breach: { days: 4 }, onBreach: notify } })</code>.</p>
5146
5181
  <p><strong>Inline edit and add-card.</strong> A field is opted into inline edit with the object mapping form — <code>card: { title: { field: 'title', edit: true } }</code>. Double-clicking a card (or <code>editCard(key, field)</code>) edits it in place: <strong>grid-bound</strong>, through the grid's own field editor for that column via its public edit path, so the column's parse, validate and optimistic/confirm/revert all run; <strong>standalone</strong>, through a host <code>editor</code> factory (or a default input), reverting when <code>onCardEdit</code> rejects. A per-column add-card affordance (<code>config.addCard</code>) creates a card carrying the column's group value — from a host <code>onAddCard(columnId)</code>, or generated, or appended through <code>grid.edit.addRow</code> when bound — and opens it straight in inline edit on its title, so the user just types. The module imports nothing from the grid's DOM package: grid-bound edits ride the grid's public edit API, standalone edits use the host's editor, so a board-only page never pulls the grid in.</p>
5147
5182
  <h3 id="kanban-example">A board, grouped and aggregated, executed</h3>
5148
5183
  <p class="section-note">A DemandFlow-shaped set &mdash; statuses as columns, points, an empty configured column,
@@ -5388,7 +5423,8 @@ const { text, flagged } = await ai.explain({ kind: 'column', colId: 'amount' });
5388
5423
  <thead><tr><th>Member</th><th>Description</th></tr></thead>
5389
5424
  <tbody>
5390
5425
  <tr><td class="sig">createAI(grid, config)</td><td class="desc">Create an AI narrative / insights controller over a live grid (headless or rendered). Config: <code>ask</code> (the host callback; falls back to the grid's <code>ai.ask</code>), <code>enable</code>, <code>maxRows</code>, <code>redact</code>, <code>tools</code>, <code>locale</code>, <code>reconcile</code> (<code>'strip'</code>/<code>'flag'</code>), <code>element</code>, <code>onNarrative</code>, <code>onError</code>.</td></tr>
5391
- <tr><td class="sig">explain(target?, opts?) / narrate(...)</td><td class="desc">Produce a grounded, reconciled narrative. <code>target</code> is <code>{ kind: 'view' }</code>, <code>{ kind: 'column', colId }</code>, <code>{ kind: 'forecast', colId, options }</code>, or <code>{ kind: 'kpi'|'chart', facts }</code>. Resolves to <code>{ text, facts, grounded, flagged, rounds, mode }</code>.</td></tr>
5426
+ <tr><td class="sig">explain(target?, opts?) / narrate(...)</td><td class="desc">Produce a grounded, reconciled narrative. <code>target</code> is <code>{ kind: 'view' }</code>, <code>{ kind: 'column', colId }</code>, <code>{ kind: 'forecast', colId, options }</code>, <code>{ kind: 'kpi'|'chart', facts }</code>, or <code>{ kind: 'risk', gantt, board }</code> for a board / Gantt risk summary. Resolves to <code>{ text, facts, grounded, flagged, rounds, mode }</code>.</td></tr>
5427
+ <tr><td class="sig">riskSummary(sources?, opts?)</td><td class="desc">A board / Gantt <strong>RISK SUMMARY</strong> &mdash; &ldquo;3 tasks at risk on the critical path, SPI 0.67, 2 SLA breaches&rdquo; &mdash; grounded on the separate Gantt / Kanban modules' outputs (<code>gantt</code>, <code>board</code>/<code>sla</code>, or their precomputed <code>earnedValue</code>/<code>schedule</code>/<code>breaches</code>). A convenience over <code>explain({ kind: 'risk' })</code>, through the same reconciliation guard.</td></tr>
5392
5428
  <tr><td class="sig">insights(el?, opts?)</td><td class="desc">Mount (or re-target) the insights panel into an element, its generate control wired to a view narrative. Keeps the grid usable on an <code>ask()</code> error.</td></tr>
5393
5429
  <tr><td class="sig">attachExplain(target, opts?)</td><td class="desc">Build an &ldquo;Explain&rdquo; button bound to a target (a KPI tile, a chart datum, a column). Clicking it narrates the target.</td></tr>
5394
5430
  <tr><td class="sig">facts(target?, opts?)</td><td class="desc">Build the facts packet for a target <em>without</em> calling <code>ask()</code> &mdash; the exact grounded set a narrative would use, and what would leave the browser.</td></tr>
@@ -5426,6 +5462,32 @@ ai.destroy();
5426
5462
  grid.destroy();
5427
5463
  <span class="kw">return</span> `${result.flagged.length} flagged | ${kept} kept | ${stripped} stripped`;</code></pre>
5428
5464
 
5465
+ <h3 id="ai-risk">Board / Gantt risk summary (<code>modules/ai</code>)</h3>
5466
+ <p>A project manager wants one line: <em>&ldquo;3 tasks at risk on the critical path, SPI 0.67, 2 SLA breaches.&rdquo;</em> <code>ai.riskSummary(&hellip;)</code> (and the <code>{ kind: 'risk' }</code> target of <code>explain</code>) produces exactly that, grounded on figures the <strong>separate</strong> optional modules have already computed: <code>gantt.earnedValue()</code> for SPI/CPI and the schedule/cost variances, <code>gantt.schedule</code> for the critical path and the tasks at risk on it, and <code>board.sla</code> for the SLA breach and warning counts. Every figure runs through the <strong>same number-reconciliation guard</strong> as the rest of the narrative &mdash; an ungrounded figure is stripped before the user sees it.</p>
5467
+ <p><strong>The AI bundle imports neither the Gantt nor the Kanban module.</strong> You pass the module instances (or their already-computed outputs) on the target, and the layer reads them duck-typed &mdash; so a page that loads the AI module without those bundles carries none of their weight. <code>buildRiskFacts(target, opts)</code> is exported to build (and preview) the exact grounded facts a risk summary would use, without calling <code>ask()</code>. <strong>Redaction:</strong> a risk summary carries aggregates only by default &mdash; counts and the EVM indices/variances; it withholds task names and card contents. <code>includeTaskNames</code> adds the at-risk task names (bounded by <code>maxTasks</code>) and <code>includeCost</code> adds the money figures (BAC/PV/EV/AC), each the host's explicit opt-in, reported back in <code>meta.exposed</code>.</p>
5468
+ <pre data-run="js" data-expect="2 at risk | SPI 0.8 | 2 breaches" data-covers="export:buildRiskFacts"><code><span class="kw">const</span> { buildRiskFacts } = <span class="kw">await</span> import('../packages/modules/ai/index.js');
5469
+
5470
+ <span class="cmt">// The public outputs a host already holds from the SEPARATE gantt / kanban</span>
5471
+ <span class="cmt">// modules. The AI bundle imports neither — it reads these duck-typed.</span>
5472
+ <span class="kw">const</span> earnedValue = { ok: <span class="kw">true</span>, project: { spi: 0.8, cpi: 0.9, sv: -1000, cv: -500 } };
5473
+ <span class="kw">const</span> schedule = {
5474
+ ok: <span class="kw">true</span>,
5475
+ order: ['a', 'b', 'c'],
5476
+ critical: ['a', 'b', 'c'], <span class="cmt">// all three on the critical path</span>
5477
+ tasks: <span class="kw">new</span> Map([
5478
+ ['a', { id: 'a', name: 'Design', percentComplete: 100, totalFloat: 0 }],
5479
+ ['b', { id: 'b', name: 'Build', percentComplete: 40, totalFloat: 0 }],
5480
+ ['c', { id: 'c', name: 'Ship', percentComplete: 0, totalFloat: -2 }],
5481
+ ]),
5482
+ };
5483
+ <span class="kw">const</span> breaches = [{ key: 'CARD-1' }, { key: 'CARD-2' }]; <span class="cmt">// from board.sla.breaches()</span>
5484
+
5485
+ <span class="kw">const</span> { facts } = buildRiskFacts({ kind: 'risk', earnedValue, schedule, breaches });
5486
+ <span class="kw">const</span> by = Object.fromEntries(facts.map((f) =&gt; [f.id, f]));
5487
+
5488
+ <span class="cmt">// Two incomplete tasks (Build, Ship) are on the critical path — at risk.</span>
5489
+ <span class="kw">return</span> `${by['risk.atRisk'].display} at risk | SPI ${by['risk.spi'].display} | ${by['risk.sla.breaches'].display} breaches`;</code></pre>
5490
+
5429
5491
  <h2 id="mocksocket">The mock socket</h2>
5430
5492
  <p><code>modules/mock-socket</code> is a serverless stand-in for a live <code>WebSocket</code> feed, for building and demonstrating a real-time UI with <strong>no backend</strong>. <code>MockWebSocket</code> presents the same surface as the browser's <code>WebSocket</code> &mdash; the same <code>readyState</code> and state constants, the same <code>onopen</code>, <code>onmessage</code>, <code>onclose</code> and <code>onerror</code>, <code>addEventListener</code>, <code>send</code> and <code>close</code> &mdash; so the code that reads it does not change when it is swapped for a real one. It fires an initial snapshot the moment it opens, then a stream of deltas on a timer, all from a generator you hand it. It is a dev and test utility: optional, imports nothing from the grid, and is never pulled into the core bundle. It pairs naturally with the data router (one mock stream, partitioned to many grids), but depends on it no more than a real socket does.</p>
5431
5493
  <pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
@@ -6925,6 +6987,8 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
6925
6987
  <tr><td class="name">kind</td><td class="type">'ses' | 'holt'</td><td class="desc">For the exponential method, single smoothing (`ses`) or Holt's level+trend (`holt`). <small>(optional)</small></td></tr>
6926
6988
  <tr><td class="name">alpha</td><td class="type">number</td><td class="desc">For the exponential method, the level factor in `[0, 1]`; omit to fit it. <small>(optional)</small></td></tr>
6927
6989
  <tr><td class="name">beta</td><td class="type">number</td><td class="desc">For Holt's exponential smoothing, the trend factor in `[0, 1]`; omit to fit it. <small>(optional)</small></td></tr>
6990
+ <tr><td class="name">band</td><td class="type">boolean | 'prediction' | 'confidence'</td><td class="desc">The uncertainty band shaded around a linear `forecast` (BACKLOG-0000975). The Student-t `prediction` band (a future observation) by default; `confidence` shades the narrower mean-response band; `false` opts out and leaves the bare dashed line. Ignored where there is no linear forecast to put a band on. <small>(optional)</small></td></tr>
6991
+ <tr><td class="name">confidence</td><td class="type">number</td><td class="desc">The forecast band's confidence level in `(0, 1)`; 0.95 by default. <small>(optional)</small></td></tr>
6928
6992
  <tr><td class="name">label</td><td class="type">boolean</td><td class="desc">`false` suppresses the R² label on a linear trend. <small>(optional)</small></td></tr>
6929
6993
  </tbody>
6930
6994
  </table>
@@ -7222,7 +7286,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7222
7286
  <tr><td class="name">show</td><td class="type">(ids: string | string[]): void</td><td class="desc"></td></tr>
7223
7287
  <tr><td class="name">hide</td><td class="type">(ids: string | string[]): void</td><td class="desc"></td></tr>
7224
7288
  <tr><td class="name">move</td><td class="type">(id: string, to: number): void</td><td class="desc"></td></tr>
7225
- <tr><td class="name">groupColumns</td><td class="type">(ids: string | string[], opts?: { title?: string; at?: number; groupId?: string }): string | null</td><td class="desc">Wrap leaf columns in a banded header, or add them to an existing band (BACKLOG-0000739). Header banding, not row grouping (see {@link group}); the band is a {@link ColumnGroup} node so a drag-, keyboard- or config-built band is the same tree, and it round-trips through a saved view. Emits `columngroup:changed`.</td></tr>
7289
+ <tr><td class="name">groupColumns</td><td class="type">(ids: string | string[], opts?: { title?: string; at?: number; groupId?: string; id?: string }): string | null</td><td class="desc">Wrap leaf columns in a banded header, or add them to an existing band (BACKLOG-0000739). Header banding, not row grouping (see {@link group}); the band is a {@link ColumnGroup} node so a drag-, keyboard- or config-built band is the same tree, and it round-trips through a saved view. Emits `columngroup:changed`. Pass `groupId` to add to the band already carrying that id, or `id` (BACKLOG-0000985) to create a new band with a caller-chosen stable id you can reference later; `groupId` wins if both are given and an `id` already in use warns and no-ops.</td></tr>
7226
7290
  <tr><td class="name">ungroupColumn</td><td class="type">(id: string): void</td><td class="desc">Take a leaf out of its band; a band emptied by the move is dissolved.</td></tr>
7227
7291
  <tr><td class="name">renameGroup</td><td class="type">(groupId: string, title: string): void</td><td class="desc">Rename a banded header.</td></tr>
7228
7292
  <tr><td class="name">dissolveGroup</td><td class="type">(groupId: string): void</td><td class="desc">Dissolve a band, returning its columns to the enclosing level in place.</td></tr>
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.46.1</p>
440
+ <p class="rail__sub">Developer guide · v1.47.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -2482,7 +2482,10 @@ grid.setPinnedRows([], { edge: 'top' }); <span class="cmt">// clear</span
2482
2482
  </p>
2483
2483
  <div class="why">
2484
2484
  <p><strong>The API.</strong> <code>groupColumns(ids, { title, groupId })</code> wraps columns
2485
- in a new band or adds them to an existing one; <code>ungroupColumn(id)</code> takes a column
2485
+ in a new band or adds them to an existing one; pass <code>id</code> instead of
2486
+ <code>groupId</code> to create a new band with a caller-chosen, stable id you can address
2487
+ later (<code>groupColumns(ids, { title: 'Traffic', id: 'g-traffic' })</code>).
2488
+ <code>ungroupColumn(id)</code> takes a column
2486
2489
  out (dissolving a band it empties); <code>renameGroup(id, title)</code>,
2487
2490
  <code>dissolveGroup(id)</code> and <code>moveGroup(id, to)</code> do the rest. Each emits
2488
2491
  <code>columngroup:changed</code>. A band's columns are always contiguous, and a nested band