@toclocoinc/lattice-grid 1.34.1 → 1.35.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 (68) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +197 -1
  3. package/docs/api-detail.html +3 -2
  4. package/lattice-grid.d.ts +244 -12
  5. package/lattice-grid.esm.min.js +4 -4
  6. package/lattice-grid.min.cjs +4 -4
  7. package/lattice-grid.min.js +4 -4
  8. package/modules/angular.esm.min.js +2 -2
  9. package/modules/angular.min.cjs +2 -2
  10. package/modules/angular.min.js +2 -2
  11. package/modules/chart-alluvial.esm.min.js +1 -1
  12. package/modules/chart-arc.esm.min.js +1 -1
  13. package/modules/chart-bubblemap.esm.min.js +1 -1
  14. package/modules/chart-bump.esm.min.js +1 -1
  15. package/modules/chart-calendar.esm.min.js +1 -1
  16. package/modules/chart-decomposition.esm.min.js +1 -1
  17. package/modules/chart-diverging.esm.min.js +1 -1
  18. package/modules/chart-dumbbell.esm.min.js +1 -1
  19. package/modules/chart-fan.esm.min.js +1 -1
  20. package/modules/chart-hexbin.esm.min.js +1 -1
  21. package/modules/chart-hexmap.esm.min.js +1 -1
  22. package/modules/chart-icicle.esm.min.js +1 -1
  23. package/modules/chart-parallel.esm.min.js +1 -1
  24. package/modules/chart-ridgeline.esm.min.js +1 -1
  25. package/modules/chart-roc.esm.min.js +1 -1
  26. package/modules/chart-slope.esm.min.js +1 -1
  27. package/modules/chart-splom.esm.min.js +1 -1
  28. package/modules/chart-waffle.esm.min.js +1 -1
  29. package/modules/charts.esm.min.js +4 -4
  30. package/modules/charts.min.cjs +4 -4
  31. package/modules/charts.min.js +4 -4
  32. package/modules/data-router.esm.min.js +4 -4
  33. package/modules/data-router.min.cjs +4 -4
  34. package/modules/data-router.min.js +4 -4
  35. package/modules/devtools.esm.min.js +2 -2
  36. package/modules/devtools.min.cjs +2 -2
  37. package/modules/devtools.min.js +2 -2
  38. package/modules/dhtmlx-compat.esm.min.js +4 -4
  39. package/modules/dhtmlx-compat.min.cjs +4 -4
  40. package/modules/dhtmlx-compat.min.js +4 -4
  41. package/modules/gantt.esm.min.js +782 -40
  42. package/modules/gantt.min.cjs +782 -40
  43. package/modules/gantt.min.js +782 -40
  44. package/modules/htmx.esm.min.js +4 -4
  45. package/modules/htmx.min.cjs +4 -4
  46. package/modules/htmx.min.js +4 -4
  47. package/modules/kanban.esm.min.js +176 -23
  48. package/modules/kanban.min.cjs +176 -23
  49. package/modules/kanban.min.js +176 -23
  50. package/modules/kpi.esm.min.js +978 -0
  51. package/modules/kpi.min.cjs +981 -0
  52. package/modules/kpi.min.js +981 -0
  53. package/modules/mock-socket.esm.min.js +2 -2
  54. package/modules/mock-socket.min.cjs +2 -2
  55. package/modules/mock-socket.min.js +2 -2
  56. package/modules/react.esm.min.js +2 -2
  57. package/modules/react.min.cjs +2 -2
  58. package/modules/react.min.js +2 -2
  59. package/modules/svelte.esm.min.js +2 -2
  60. package/modules/svelte.min.cjs +2 -2
  61. package/modules/svelte.min.js +2 -2
  62. package/modules/vue.esm.min.js +2 -2
  63. package/modules/vue.min.cjs +2 -2
  64. package/modules/vue.min.js +2 -2
  65. package/modules/webcomponent.esm.min.js +4 -4
  66. package/modules/webcomponent.min.cjs +4 -4
  67. package/modules/webcomponent.min.js +4 -4
  68. 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.34.1 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.35.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -4204,7 +4204,7 @@ router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">
4204
4204
  <table>
4205
4205
  <thead><tr><th>Member</th><th>Description</th></tr></thead>
4206
4206
  <tbody>
4207
- <tr><td class="sig">createDataRouter({ key, rowKey?, overlap?, onUnrouted? })</td><td class="desc">Create a router. <code>key</code> is the partition (property or <code>fn(row)</code>); <code>rowKey</code> the default identity within a grid; <code>overlap</code> fans a record to all matching routes; <code>onUnrouted</code> is a sink for unmatched records.</td></tr>
4207
+ <tr><td class="sig">createDataRouter({ key, rowKey?, overlap?, onUnrouted?, seq?, dedupe?, batch?, coalesce?, time?, now?, onWrite?, onConflict?, config? })</td><td class="desc">Create a router. <code>key</code> is the partition (property or <code>fn(row)</code>); <code>rowKey</code> the default identity within a grid; <code>overlap</code> fans a record to all matching routes; <code>onUnrouted</code> is a sink for unmatched records. <strong>v3:</strong> <code>seq</code> (a version field or <code>fn(row)</code>) turns on ordered de-duplication, <code>dedupe: false</code> opts out; <code>batch</code> (interval ms or <code>{ intervalMs }</code>) / <code>coalesce: true</code> buffer a high-frequency <code>push</code>. <strong>v4:</strong> <code>time</code> (a timestamp field or <code>fn(row)</code>) and an injectable <code>now</code> clock drive time-domain scrubbing. <strong>v8:</strong> <code>onWrite</code>/<code>onConflict</code> are the router-global write-back callbacks. <strong>v5:</strong> <code>config</code> is a declarative routing spec, desugared through <code>configure</code>.</td></tr>
4208
4208
  <tr><td class="sig">attach(grid, predicate, { rowKey? })</td><td class="desc">Route to <code>grid</code> when <code>predicate</code> matches: a value compared to <code>key</code>, or a <code>fn(row) =&gt; boolean</code>. <code>rowKey</code> overrides the router default for this grid.</td></tr>
4209
4209
  <tr><td class="sig">attachDefault(grid, { rowKey? })</td><td class="desc">The "rest" sink: the grid that receives every record no explicit route matched.</td></tr>
4210
4210
  <tr><td class="sig">load(snapshot)</td><td class="desc">Apply a full snapshot as a keyed diff per grid. Returns per-route <code>{ added, updated, removed }</code> counts in attach order.</td></tr>
@@ -4221,6 +4221,23 @@ router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">
4221
4221
  <tr><td class="sig">subscribe(predicate, handler, opts?)</td><td class="desc"><strong>v5:</strong> route a slice to any non-grid view. <code>handler(change)</code> receives the same keyed diff <code>{ add, update, remove }</code> a grid does &mdash; drive a KPI tile, detail pane, map or form. A peer to <code>attach</code>: same partitioning and the same <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> options; grids and charts are unchanged.</td></tr>
4222
4222
  <tr><td class="sig">alert(predicate, condition, handler, { filter?, debounce?, rowKey? })</td><td class="desc"><strong>v5:</strong> watch a slice and emit rather than render. <code>condition(rows)</code> is evaluated over the slice on every load and delta; when it first becomes truthy, <code>handler(signal, rows)</code> fires. Edge-triggered (once per crossing, re-arms on release), <code>debounce</code> coalesces a burst, and it never competes for a partition or touches a grid.</td></tr>
4223
4223
  <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>
4224
+ <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>
4225
+ <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>
4226
+ <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>
4227
+ <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>
4228
+ <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>
4229
+ <tr><td class="sig">scrubTo(target, { by? })</td><td class="desc"><strong>v4:</strong> scrub the grids to a past point &mdash; the base snapshot plus the buffered deltas up to <code>target</code> (a seq when the router has one, else a timestamp; <code>{ by: 'seq' | 'time' }</code> chooses). Pushed by keyed diff, so each view keeps scroll and selection and only changed rows repaint. Live deltas keep arriving into the buffer but do not disturb the view.</td></tr>
4230
+ <tr><td class="sig">replay(from, to, { speed?, by? })</td><td class="desc"><strong>v4:</strong> walk an incident &mdash; scrub to <code>from</code>, then apply each buffered delta in <code>(from, to]</code> in order, one per <code>speed</code> ms (default <code>0</code>). Returns a promise resolving when the range finishes (or is superseded); the router stays parked at <code>to</code> until <code>live()</code>.</td></tr>
4231
+ <tr><td class="sig">pause() / resume()</td><td class="desc"><strong>v4:</strong> pause an in-flight <code>replay</code> at the current step and resume it from where it stopped. No-ops when nothing is replaying / not paused.</td></tr>
4232
+ <tr><td class="sig">live()</td><td class="desc"><strong>v4:</strong> return to the head &mdash; rebuild the base plus every buffered delta (including those that arrived while scrubbed) and push it by keyed diff, then resume normal live application. A single diff animates the view from the past straight to the present, keeping scroll and selection.</td></tr>
4233
+ <tr><td class="sig">traveling / buffered</td><td class="desc"><strong>v4:</strong> whether the grids currently show a reconstructed past, and how many deltas are held in the bounded buffer.</td></tr>
4234
+ <tr><td class="sig">broadcast({ channel })</td><td class="desc"><strong>v6 (BACKLOG-0000913):</strong> mirror the router's ordered, de-duplicated deltas to other browser tabs/windows over a <strong>BroadcastChannel</strong>, so a grid popped into its own tab joins the same feed with no second socket. Each tab runs its own router on the same <code>channel</code> name; an inbound mirror is applied without re-broadcasting (no echo loop), and <code>broadcast</code> announces the tab so a peer holding the feed resyncs it mid-stream (snapshot + replay). Off by default; needs a <code>seq</code>/<code>dedupe</code> router to drop replayed deltas cleanly.</td></tr>
4235
+ <tr><td class="sig">broadcasting</td><td class="desc"><strong>v6:</strong> whether the router is currently mirroring to a BroadcastChannel.</td></tr>
4236
+ <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.</td></tr>
4237
+ <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>
4238
+ <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>
4239
+ <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>
4240
+ <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>
4224
4241
  <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>
4225
4242
  <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>
4226
4243
  </tbody>
@@ -4403,6 +4420,119 @@ router.apply([{ op: 'upsert', row: { id: 'o2', type: 'invoice', amt: 25 } }]);
4403
4420
  orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
4404
4421
  <span class="kw">return</span> [fanned, moved, router.unrouted].join(' | ');</code></pre>
4405
4422
 
4423
+ <p><strong>Time-travel buffering (v4, BACKLOG-0000911).</strong> <code>buffer({ window?, max? })</code> records the ordered, de-duplicated stream into a <em>bounded</em> ring on top of a base snapshot, so a consumer can <code>scrubTo</code> a past point, <code>replay</code> a range (<code>pause</code>/<code>resume</code> it), and jump back to <code>live()</code> &mdash; every reconstructed state pushed to the grids by the ordinary keyed diff, so views keep scroll and selection and only changed rows repaint. The bound is a time <code>window</code> and/or a <code>max</code> delta count; eviction folds the oldest delta into a moving base, so memory stays bounded. Live deltas keep arriving into the buffer while scrubbed but do not disturb the (time-travelled) view; <code>traveling</code> and <code>buffered</code> report the state. Opt-in and off by default &mdash; a router that never calls <code>buffer()</code> behaves exactly as v1/v2/v3.</p>
4424
+ <h3 id="datarouter-v4-example">Scrub and return to live, executed</h3>
4425
+ <p class="section-note">A versioned feed buffered into a bounded ring; the view scrubs to a past seq, reads the
4426
+ reconstructed value, then returns to the live head. Run headless on every build.</p>
4427
+ <pre data-run="js" data-expect="30 | 10 | true | 30" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4428
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4429
+
4430
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
4431
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v' });
4432
+ router.attach(g, () =&gt; true);
4433
+ router.buffer({ max: 100 }); <span class="cmt">// record into a bounded ring</span>
4434
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 10, v: 1 } }]);
4435
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]);
4436
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
4437
+ <span class="kw">const</span> head = g.rows.value('a', 'n'); <span class="cmt">// 30: the live head</span>
4438
+ router.scrubTo(1, { by: 'seq' }); <span class="cmt">// reconstruct the state at seq 1</span>
4439
+ <span class="kw">const</span> past = g.rows.value('a', 'n'); <span class="cmt">// 10</span>
4440
+ <span class="kw">const</span> traveling = router.traveling; <span class="cmt">// true</span>
4441
+ router.live(); <span class="cmt">// back to the head, by keyed diff</span>
4442
+ <span class="kw">const</span> back = g.rows.value('a', 'n'); <span class="cmt">// 30</span>
4443
+
4444
+ g.destroy(); router.destroy();
4445
+ <span class="kw">return</span> [head, past, traveling, back].join(' | ');</code></pre>
4446
+
4447
+ <p><strong>Cross-tab / pop-out window sync (v6, BACKLOG-0000913).</strong> <code>broadcast({ channel })</code> mirrors the router's ordered, de-duplicated deltas to other browser tabs/windows over a <strong>BroadcastChannel</strong>, so a routed grid popped into its own tab joins the <em>same</em> feed with no second socket. Each tab runs its own router on the same channel name and applies the mirrored deltas through the ordinary keyed-diff path, so its grids stay dumb and keep scroll/selection. What is mirrored is exactly what the router applied (post-order, post-dedupe); an inbound mirror is applied <em>without</em> re-broadcasting, so there is no echo loop. Calling <code>broadcast</code> announces the tab, and any peer already holding the feed answers with a snapshot (current world + resume checkpoint) so the new tab resyncs mid-stream via the v3 reconnect path. Off by default; <code>broadcasting</code> reports whether it is on, <code>destroy()</code> closes the channel. Needs a <code>seq</code>/<code>dedupe</code> router to drop replayed deltas cleanly. (Not demonstrated headless: it depends on the browser's BroadcastChannel delivering across tabs asynchronously.)</p>
4448
+
4449
+ <p><strong>DFQL/DuckDB query-slice routing (v7, BACKLOG-0000914).</strong> <code>query(adapter, request?)</code> sources the router from a query rather than a pushed feed: it runs <code>adapter.execute</code> (any pushdown adapter &mdash; a DFQL/DuckDB one, or a <code>createPushdownSource</code>-style object with <code>capabilities</code> and <code>execute(query)</code>), partitions the result across the routes, and drives the grids by the same keyed diff <code>load()</code> uses. Where a route declares a <code>where</code> (a filter-wire condition <code>{ col, op, value }</code> or an <code>and</code>/<code>or</code>/<code>not</code> group), that filter is pushed <em>down</em> into the engine where the adapter's capability model allows and the residual is finished client-side; routes without a <code>where</code> share one base query and are partitioned client-side. It composes with the per-route <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> (v3) and cross-grid links/graph, and <code>lastQueryPlan()</code> reports the pushed/residual split per fetch. Async.</p>
4450
+ <h3 id="datarouter-v7-example">Query-slice routing, executed</h3>
4451
+ <p class="section-note">Two routes over one query, each with its own <code>where</code>; a capability-free adapter pushes
4452
+ nothing, so each residual is finished client-side, and the plan records the split. Run headless on every build.</p>
4453
+ <pre data-run="js" data-expect="2 | 1 | 2" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4454
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4455
+
4456
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }];
4457
+ <span class="kw">const</span> big = createHeadlessGrid({ rowKey: 'id', columns: cols });
4458
+ <span class="kw">const</span> small = createHeadlessGrid({ rowKey: 'id', columns: cols });
4459
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4460
+
4461
+ <span class="cmt">// Each route carries a `where`, pushed down where the adapter allows.</span>
4462
+ router.attach(big, 'order', { where: { col: 'amt', op: 'gte', value: 20 } });
4463
+ router.attach(small, 'order', { where: { col: 'amt', op: 'lt', value: 20 } });
4464
+
4465
+ <span class="cmt">// A capability-free adapter: nothing pushes, so every `where` is residual.</span>
4466
+ <span class="kw">const</span> adapter = {
4467
+ capabilities: {},
4468
+ execute: <span class="kw">async</span> (query) =&gt; ({ rows: [
4469
+ { id: 'o1', type: 'order', amt: 10 },
4470
+ { id: 'o2', type: 'order', amt: 30 },
4471
+ { id: 'o3', type: 'order', amt: 20 },
4472
+ ] }),
4473
+ };
4474
+ <span class="kw">await</span> router.query(adapter);
4475
+ <span class="kw">const</span> bigN = big.rows.count(); <span class="cmt">// 2: o2, o3 (amt &gt;= 20)</span>
4476
+ <span class="kw">const</span> smallN = small.rows.count(); <span class="cmt">// 1: o1 (amt &lt; 20)</span>
4477
+ <span class="kw">const</span> plan = router.lastQueryPlan(); <span class="cmt">// per-fetch pushed/residual split</span>
4478
+
4479
+ big.destroy(); small.destroy(); router.destroy();
4480
+ <span class="kw">return</span> [bigN, smallN, plan.length].join(' | ');</code></pre>
4481
+
4482
+ <p><strong>Write-back routing (v8, BACKLOG-0000912).</strong> A route made <strong>writable</strong> &mdash; <code>attach(grid, predicate, { writable: true })</code> &mdash; has its grid's committed edits routed <em>back</em> to a write target the host persists. The router captures edits off the grid's public edit surface (it subscribes to <code>grid.on('cell:changed')</code> and re-enters accepted writes through <code>grid.edit.setCells</code>, so grid-core is untouched) and hands each change to <code>onWrite(change, { route, source })</code> &mdash; the per-route callback here, or the router-global <code>onWrite</code> passed to <code>createDataRouter</code>. A rejected write reverts the cell; an accepted one re-enters as a normal delta. <code>onConflict(change, { serverRow })</code> surfaces a last-write-wins conflict. A derived route (one carrying <code>rollup</code> or <code>transform</code>) cannot be writable &mdash; its edits are reverted and warned. (Documented here from the shipped surface; the grid-driven write-back commit path is demonstrated by the grid's own write-back example above rather than repeated on the router.)</p>
4483
+
4484
+ <p><strong>Fan-in: many feeds, one router (v9, BACKLOG-0000931).</strong> One router can ingest <em>many</em> feeds. <code>addSource(feed, { map?, key? })</code> returns a per-feed handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>, plus <code>id</code> and <code>size</code>) whose rows are normalized to the common shape 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 in the shared keyed store, then merged through the router's ordinary apply/load path &mdash; partitioned, routed, linked, deduped, buffered and (v8) written back exactly as the single-source path. <code>removeSource(ref)</code> (or the handle's <code>remove</code>) drops exactly the rows a feed contributed; <code>sources()</code> lists the registered ids.</p>
4485
+ <h3 id="datarouter-v9-example">Fan-in from two feeds, executed</h3>
4486
+ <p class="section-note">Two feeds with a colliding raw id, namespaced per source so they merge without clobbering;
4487
+ removing one feed drops exactly its rows. Run headless on every build.</p>
4488
+ <pre data-run="js" data-expect="3 | crm,erp | 2" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4489
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4490
+
4491
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'n', field: 'n', type: 'number' }];
4492
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
4493
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4494
+ router.attach(g, 'order');
4495
+
4496
+ <span class="cmt">// Two feeds, each namespaced by its source id so raw ids cannot collide.</span>
4497
+ <span class="kw">const</span> crm = router.addSource('crm', { key: true });
4498
+ <span class="kw">const</span> erp = router.addSource('erp', { key: true });
4499
+ crm.load([{ id: '1', type: 'order', n: 10 }, { id: '2', type: 'order', n: 20 }]);
4500
+ erp.load([{ id: '1', type: 'order', n: 99 }]); <span class="cmt">// same raw id '1' — merged, not clobbered</span>
4501
+ <span class="kw">const</span> merged = g.rows.count(); <span class="cmt">// 3</span>
4502
+ <span class="kw">const</span> ids = router.sources().join(','); <span class="cmt">// crm,erp</span>
4503
+
4504
+ router.removeSource('erp'); <span class="cmt">// drops exactly erp's row</span>
4505
+ <span class="kw">const</span> afterRemove = g.rows.count(); <span class="cmt">// 2</span>
4506
+
4507
+ g.destroy(); router.destroy();
4508
+ <span class="kw">return</span> [merged, ids, afterRemove].join(' | ');</code></pre>
4509
+
4510
+ <p><strong>Observability (v10, BACKLOG-0000932).</strong> <code>metrics()</code> is a cheap point-in-time snapshot of the router's runtime &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> (duplicate) / <code>buffered</code> (buffer depth) / <code>lag</code> figures. Throughput is sampled, so it is measured over the interval since the last read or emit. <code>on('metrics', handler)</code> drives it on a periodic timer (the <code>metricsInterval</code> ms, default 1000; <code>0</code> disables it) and returns an unsubscribe &mdash; the timer runs only while a listener is registered, so collection is off-by-default. <code>mountDevtools(el, { interval? })</code> mounts an opt-in DOM panel (in the module's own <code>devtools.js</code>, so the core stays DOM-free) that renders <code>metrics()</code> and refreshes on each emit.</p>
4511
+ <h3 id="datarouter-v10-example">A metrics snapshot, executed</h3>
4512
+ <p class="section-note">A snapshot fanned to a route and a sink; the metrics read reports the route's row count and the
4513
+ unrouted total, and <code>on('metrics')</code> returns an unsubscribe. Run headless on every build.</p>
4514
+ <pre data-run="js" data-expect="2 | 1 | function" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4515
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4516
+
4517
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
4518
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
4519
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4520
+ router.attach(g, 'order');
4521
+
4522
+ <span class="kw">const</span> off = router.on('metrics', () =&gt; {}); <span class="cmt">// register; starts the timer, returns unsubscribe</span>
4523
+ router.load([
4524
+ { id: 'o1', type: 'order' },
4525
+ { id: 'o2', type: 'order' },
4526
+ { id: 'x1', type: 'ticket' }, <span class="cmt">// matches no route</span>
4527
+ ]);
4528
+ <span class="kw">const</span> m = router.metrics();
4529
+ <span class="kw">const</span> rows = m.routes[0].rows; <span class="cmt">// 2</span>
4530
+ <span class="kw">const</span> unrouted = m.unrouted; <span class="cmt">// 1</span>
4531
+ off(); <span class="cmt">// stops the timer (last listener gone)</span>
4532
+
4533
+ g.destroy(); router.destroy();
4534
+ <span class="kw">return</span> [rows, unrouted, typeof off].join(' | ');</code></pre>
4535
+
4406
4536
  <h2 id="ganttmodule">The Gantt module</h2>
4407
4537
  <p><code>modules/gantt</code> is a separate, opt-in project-planning module &mdash; its own bundle, imported only when you want it, changing nothing in the grid core. It turns a task list into a real schedule: a <strong>CPM (Critical Path Method) engine</strong> computes each task's early/late start and finish, its slack (total float), and the zero-float <strong>critical path</strong>, recomputing on every edit. <code>computeSchedule(tasks, deps)</code> is the pure engine; <code>createGantt(opts)</code> is a controller that holds the model, recomputes on <code>setTasks</code>/<code>setDependencies</code>/<code>applyEdit</code>, and emits <code>schedule</code> (or <code>error</code>). Dependencies are the four standard link types &mdash; <code>LINK_TYPES</code> is <code>['FS','SS','FF','SF']</code> &mdash; each with optional lag/lead. A <strong>milestone</strong> is a zero-duration task scheduled as a point; a <strong>summary</strong> task (any task named as another's <code>parent</code>) is derived from its children (start = earliest child, end = latest child, duration-weighted progress) and is not scheduled itself. Bad input never throws or loops: a dependency cycle is refused and reported with a code from <code>SCHEDULE_ERROR</code>, and <code>findViolations</code> flags any task placed earlier than its predecessors allow. <code>toISODate</code> converts an engine day-number back to a calendar date for display.</p>
4408
4538
  <pre><code>import { createGantt, computeSchedule } from '@toclocoinc/lattice-grid/modules/gantt';
@@ -4728,6 +4858,72 @@ router.apply([{ op: 'upsert', row: { id: 't1', kind: 'task', status: 'done', tit
4728
4858
  router.destroy();
4729
4859
  <span class="kw">return</span> [hydrated, afterDelta].join(' | ');</code></pre>
4730
4860
 
4861
+ <h2 id="kpi">The KPI (stat-tile) view</h2>
4862
+ <p><code>modules/kpi</code> is an opt-in view of a dataset as a panel of <strong>stat tiles</strong> &mdash; each tile an aggregate over the routed rows: a sum, an average, a min/max, a count, a distinct count, or a host reducer. It is the fourth first-class viewer beside the grid, the kanban and the gantt, in the same shape: a separate bundle that adds no weight to a page that does not load it, changes nothing in grid core, and pulls in no dependency. A KPI panel is <em>just another dataset viewer</em>: it consumes data through the same keyed-diff contract a grid exposes, <code>kpi.rows.apply({ add, update, remove })</code>, so a Data Router can <code>attach(value, kpi)</code> and drive a KPI panel beside a grid, a kanban and a chart off one feed.</p>
4863
+ <pre><code>import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
4864
+
4865
+ const kpi = createKPI(document.querySelector('#kpis'), {
4866
+ rows, <span class="cmt">// or { grid } to read a live grid's rows</span>
4867
+ rowKey: 'id',
4868
+ columns: 4, <span class="cmt">// responsive tile columns</span>
4869
+ tiles: [
4870
+ { id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
4871
+ { id: 'avg', label: 'Avg deal', aggregation: 'avg', field: 'amount',
4872
+ format: { type: 'currency', decimals: 0 }, baseline: 5000 }, <span class="cmt">// delta vs a baseline</span>
4873
+ { id: 'open', label: 'Open deals', aggregation: 'count',
4874
+ filter: (r) =&gt; r.stage !== 'won',
4875
+ thresholds: { warn: 10, critical: 25, direction: 'lowerIsBetter' } }, <span class="cmt">// good/warn/critical bands</span>
4876
+ ],
4877
+ onTileClick: ({ tile }) =&gt; drillInto(tile.id),
4878
+ });</code></pre>
4879
+ <p>Each tile names an <strong>aggregation</strong> (<code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>, <code>countDistinct</code>, or a <code>custom</code> reducer <code>(rows, tile) =&gt; value</code>), the <strong>field</strong> it reads, and an optional <strong>filter</strong> predicate. Formatting (<code>number</code>/<code>currency</code>/<code>percent</code>/<code>compact</code>, with decimals, currency and locale), a <strong>baseline</strong> for a delta, and semantic <strong>threshold bands</strong> (two cut points with a direction, or an explicit <code>bands</code> list) are all per tile; the band is a semantic name (<code>good</code>/<code>warn</code>/<code>critical</code>), separate from any accent colour. An optional <strong>sparkline</strong> plots a <code>{ x, y }</code> series in sequence order. Each tile is a labelled <code>&lt;figure&gt;</code>, focusable and keyboard-activatable, its value announced; the sparkline transition respects <code>prefers-reduced-motion</code>.</p>
4880
+ <p><strong>Incremental, not recomputed.</strong> Each tile keeps a running accumulator, so a routed <code>rows.apply</code> delta adjusts only the rows it carries &mdash; an add contributes, a remove reverses, an update reverses the old row and contributes the new one &mdash; rather than re-reading the whole dataset per delta. The two bounded exceptions are honest: an extreme (<code>min</code>/<code>max</code>) removed at its current value triggers a rescan of that tile's own value multiset, and a <code>custom</code> reducer is recomputed over the (filtered) store because an arbitrary function has no inverse.</p>
4881
+ <div class="table-wrap">
4882
+ <table>
4883
+ <thead><tr><th>Member</th><th>Description</th></tr></thead>
4884
+ <tbody>
4885
+ <tr><td class="sig">createKPI(el, config)</td><td class="desc">Create a KPI panel. Pass a DOM element to render into, or <code>null</code> for a headless panel that computes the same tile model without a DOM.</td></tr>
4886
+ <tr><td class="sig">rows.apply({ add, update, remove })</td><td class="desc">The keyed-diff consumer contract a grid shares, so the panel is a drop-in Data Router target and updates each tile incrementally. Also <code>rows.forEach</code> and <code>rows.count</code>.</td></tr>
4887
+ <tr><td class="sig">tiles() / tile(id) / value(id)</td><td class="desc">Every computed tile model, one tile by id (<code>value</code>, <code>formatted</code>, <code>status</code>, <code>delta</code>, <code>deltaPercent</code>, <code>count</code>, <code>sparkline</code>), or a tile's raw value.</td></tr>
4888
+ <tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or re-seed and recompute.</td></tr>
4889
+ <tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">Serialise and restore the panel's row set, so a headless panel round-trips.</td></tr>
4890
+ <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>tile:click</code>, <code>tile:dblclick</code>, <code>tile:contextmenu</code>, and <code>change</code> (after every update).</td></tr>
4891
+ <tr><td class="sig">destroy()</td><td class="desc">Empty the element and drop the listeners. The host still owns any bound grid.</td></tr>
4892
+ </tbody>
4893
+ </table>
4894
+ </div>
4895
+ <p><strong>Interaction is light and host-driven.</strong> A tile emits <code>tile:click</code> (also from the keyboard) carrying the tile model, so a host can drill down or, in a demo, filter a routed grid &mdash; the wiring lives in the host, not the module. This is deliberately not a dashboard layout engine (that is the parked dashboard generator) and charting beyond a minimal sparkline belongs to the charts module.</p>
4896
+ <h3 id="kpi-live-example">Live, driven by a Data Router alongside a grid, executed</h3>
4897
+ <p class="section-note">One feed fans out (<code>overlap</code>) to a KPI panel through the same keyed-diff contract a grid uses:
4898
+ a snapshot seeds the tiles, then a delta removes the current max and the min/max rescans. Run headless on every build.</p>
4899
+ <pre data-run="js" data-expect="350,250 | 100,100 | good" data-covers="export:createKPI"><code><span class="kw">const</span> { createKPI } = <span class="kw">await</span> import('../packages/modules/kpi/index.js');
4900
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4901
+
4902
+ <span class="kw">const</span> kpi = createKPI(null, {
4903
+ rowKey: 'id',
4904
+ tiles: [
4905
+ { id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
4906
+ { id: 'max', label: 'Biggest', aggregation: 'max', field: 'amount' },
4907
+ { id: 'open', label: 'Open', aggregation: 'count', filter: (r) =&gt; r.stage === 'open',
4908
+ thresholds: { warn: 1, critical: 3, direction: 'lowerIsBetter' } },
4909
+ ],
4910
+ });
4911
+
4912
+ <span class="kw">const</span> router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
4913
+ router.attach(kpi, 'deal'); <span class="cmt">// a KPI panel is a drop-in router target</span>
4914
+ router.load([
4915
+ { id: 'd1', kind: 'deal', amount: 100, stage: 'open' },
4916
+ { id: 'd2', kind: 'deal', amount: 250, stage: 'won' },
4917
+ ]);
4918
+ <span class="kw">const</span> seeded = kpi.value('total') + ',' + kpi.value('max'); <span class="cmt">// 350,250</span>
4919
+
4920
+ router.apply([{ op: 'delete', row: { id: 'd2' } }]); <span class="cmt">// removes the current max</span>
4921
+ <span class="kw">const</span> afterRemove = kpi.value('total') + ',' + kpi.value('max'); <span class="cmt">// 100,100 (max rescanned)</span>
4922
+ <span class="kw">const</span> band = kpi.tile('open').status; <span class="cmt">// 'good' — 1 open, lowerIsBetter</span>
4923
+
4924
+ router.destroy();
4925
+ <span class="kw">return</span> [seeded, afterRemove, band].join(' | ');</code></pre>
4926
+
4731
4927
  <h2 id="mocksocket">The mock socket</h2>
4732
4928
  <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>
4733
4929
  <pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
@@ -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.34.1</p>
440
+ <p class="rail__sub">Developer guide · v1.35.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -6238,8 +6238,9 @@ grid.state.apply(savedView.state);
6238
6238
  <tr><td class="name">canChartRange</td><td class="desc">Whether <code>chartRange</code> would draw something for the grid’s current selection — the question a menu asks before offering the item.</td></tr>
6239
6239
  <tr><td class="name">deriveRangeSpec</td><td class="desc">Decide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for <code>createChart</code>.</td></tr>
6240
6240
  <tr><td class="name">regressionPlots</td><td class="desc">Turn a fitted regression model into diagnostic chart specs ready for <code>createChart</code>: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped.</td></tr>
6241
- <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: <code>subscribe(value, handler)</code> routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; <code>alert(value, condition, handler)</code> evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and <code>configure(spec)</code> (or <code>createDataRouter({ config })</code>) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
6241
+ <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: <code>subscribe(value, handler)</code> routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; <code>alert(value, condition, handler)</code> evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and <code>configure(spec)</code> (or <code>createDataRouter({ config })</code>) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. v3 also adds per-route reshaping — <code>transform</code>/<code>filter</code>/<code>sort</code> and <code>rollup</code> ({ groupBy, aggregate }) summaries — a relationship graph (<code>relate(edges)</code>: multi-hop, several-into-one AND, and mutual edges) that scales v2's pairwise <code>link</code>, and stream hygiene: a <code>seq</code>/version orders and de-duplicates a feed (stale/duplicate deltas dropped, counted in <code>dropped</code>), <code>push</code> with a <code>batch</code>/<code>coalesce</code> buffers a high-frequency feed (<code>flushStream</code> for a deterministic point), and <code>lastSeq</code>/<code>checkpoint</code>/<code>seenThrough</code> resume precisely after a dropped socket. v4 adds time-travel: <code>buffer({ window, max })</code> records the ordered stream into a bounded ring over a moving base, so <code>scrubTo</code> reconstructs a past point, <code>replay</code> (with <code>pause</code>/<code>resume</code>) walks a range, and <code>live</code> returns to the head — every state pushed by the same keyed diff, <code>traveling</code>/<code>buffered</code> reporting the state. v6 adds cross-tab sync: <code>broadcast({ channel })</code> mirrors the ordered, de-duplicated deltas to other tabs/windows over a BroadcastChannel with no echo loop, a popped-out grid joining the same feed with no second socket and resyncing mid-stream via the reconnect path. v7 adds query-slice routing: <code>query(adapter, request)</code> sources the router from a DFQL/DuckDB (or any pushdown) adapter, partitioning one result across the routes; a route-level <code>where</code> is pushed down where the adapter's capabilities allow and the residual finished client-side, with <code>lastQueryPlan</code> reporting the split. v8 adds write-back: a <code>writable</code> route captures the grid's committed edits off its public edit surface and routes them to <code>onWrite(change, ctx)</code> (per-route or router-global), reverting on reject, re-entering an accepted write as a delta, and surfacing a last-write-wins <code>onConflict</code>; a derived route cannot be writable. v9 adds fan-in: <code>addSource(feed, { map, key })</code> returns a per-feed handle (<code>load</code>/<code>apply</code>/<code>push</code>/<code>remove</code>) whose rows are normalized and namespaced so many feeds merge into one keyed store without id collisions, <code>removeSource</code> dropping exactly a feed's rows and <code>sources</code> listing them. v10 adds observability: <code>metrics()</code> is a cheap snapshot of per-route/per-source counts and throughput plus the global unrouted/dropped/buffered/lag figures, <code>on('metrics')</code> drives a periodic emit (off unless a listener is registered), and <code>mountDevtools(el)</code> renders a live panel from the module's own DOM file. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
6242
6242
  <tr><td class="name">createKanban</td><td class="desc">A board (kanban) view of rows as cards, grouped into columns by a configurable property (a status, a stage, a state field), with per-column card count and an optional points sum, a WIP over-limit flag, configured columns shown even when empty, granular readonly (whole board / per column / per card), field-mapped card templates that reuse the grid’s own column formatters when a grid is bound, and the core pointer events (<code>card:click</code>, <code>card:dblclick</code>, <code>card:contextmenu</code>). It is a dataset viewer like any other: it consumes data through the same keyed-diff <code>rows.apply({ add, update, remove })</code> contract a grid exposes, so a Data Router can <code>attach(value, board)</code> and drive a kanban beside a grid and a chart off one feed. Accessibility is built in from the start — a labelled group of labelled column lists, cards in a roving-tabindex focus ring with arrow-key navigation, and a polite live region. Every structural property is named in config (<code>columnProperty</code>, <code>pointsProperty</code>, <code>orderProperty</code>, <code>swimlaneProperty</code>, <code>sprintProperty</code>, <code>epicProperty</code>) so it maps DemandFlow and any customer schema without code change. Pass <code>null</code> as the element for a headless board that computes the same model without a DOM. Cards drag between columns (writing the column property) and within a column into a position (writing the order property with fractional ranking), and the same move is keyboard-accessible — <kbd>Space</kbd> to grab, arrows to choose a target, <kbd>Space</kbd>/<kbd>Enter</kbd> to drop, <kbd>Escape</kbd> to cancel — announced on the live region; multi-select drags every selected card. A move calls <code>onBeforeMove(card, from, to, index)</code> first (return <code>false</code> to veto) and then persists through the grid’s shipped write-back path — grid-bound via <code>grid.edit.setCells</code> (the same public edit-commit path inline editing uses, so the grid’s pipeline owns optimistic apply / confirm / revert), standalone with a revert when <code>onCardMove</code> rejects — emitting <code>card:move</code>. A configurable per-card <code>contextMenu</code> replaces the <code>card:contextmenu</code> event when present. With <code>swimlanes: true</code> it renders a 2D lane&times;column grid grouped by <code>swimlaneProperty</code> (per-lane count/points, columns aligned across lanes, a cross-lane drag writing the swimlane property); columns and lanes collapse (state surviving a keyed-diff update), columns reorder by header drag, and <code>setQuickFilter</code>/<code>setFilter</code>/<code>facets</code> drive search and faceting. <code>setSprint</code>/<code>showBacklog</code>/<code>sprints</code> give a sprint view, switcher and backlog; <code>setEpic</code>/<code>epicRollup</code>/<code>rollup</code> give an epic view and rollups (count, points, progress toward the <code>done</code> columns). A card can pop out a nested grid of its children (an epic's stories, a story's tasks, recursively) via a <code>childrenProperty</code> and/or <code>loadChildren(card)</code>: the child is a full composed <code>createGrid</code> (or, with <code>asBoard</code>, a nested board) opened in a drawer/modal/inline container — reuse by composition, no grid-core coupling — emitting <code>card:expand</code>/<code>card:drill</code>. Live updates arrive through the same keyed-diff contract a grid uses, so a Data Router drives the board directly (<code>attach(board, predicate)</code>); a live <code>rows.apply</code> re-renders preserving scroll, focus, selection, collapse and any open pop-out. <code>virtualize</code> renders only a scroll window of a tall column; <code>getState</code>/<code>setState</code> (and <code>config.state</code>) save and restore collapse, order, filter and sprint/epic selection; <code>setLoading</code>/<code>setError</code> give loading and error states. The move is fully keyboard-driven — Space to grab, arrows for column/position, Alt+Up/Down across swimlanes, Space/Enter to drop, Escape to cancel — announced on a live region. A field opted in with <code>card: { title: { field, edit: true } }</code> edits inline (double-click or <code>editCard</code>): grid-bound through the grid's own field editor via its public edit path, standalone through a host editor factory or a default input with an <code>onCardEdit</code> revert; a per-column add-card (<code>config.addCard</code>/<code>onAddCard</code>, or <code>grid.edit.addRow</code>) creates a card and opens it in edit. The module imports nothing from the grid's DOM package.</td></tr>
6243
+ <tr><td class="name">createKPI</td><td class="desc">A KPI / stat-tile view (module <code>kpi</code>) of a dataset as a panel of aggregate tiles — each tile a <code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>, <code>countDistinct</code> or a <code>custom</code> reducer over the routed rows, with an optional <code>filter</code> predicate, number <code>format</code> (<code>number</code>/<code>currency</code>/<code>percent</code>/<code>compact</code>), a <code>baseline</code> for a delta, semantic threshold bands (<code>thresholds</code> with two cut points and a direction, or an explicit <code>bands</code> list, giving a <code>good</code>/<code>warn</code>/<code>critical</code> status kept separate from any accent), and an optional <code>sparkline</code> series. It is a dataset viewer like any other: it consumes data through the same keyed-diff <code>rows.apply({ add, update, remove })</code> contract a grid exposes, so a Data Router can <code>attach(value, kpi)</code> and drive a KPI panel beside a grid, a kanban and a chart off one feed. Updates are incremental — a delta adjusts each tile's running accumulator by only the rows it carries (an add contributes, a remove reverses, an update reverses-then-contributes), the two bounded exceptions being a <code>min</code>/<code>max</code> whose current extreme is removed (a rescan of that tile's own value multiset) and a <code>custom</code> reducer (recomputed over the filtered store, an arbitrary function having no inverse). Each tile is a labelled <code>&lt;figure&gt;</code>, focusable and keyboard-activatable, its value announced, the sparkline respecting <code>prefers-reduced-motion</code>; a <code>tile:click</code> event (also from the keyboard) lets a host drill down or filter a routed grid. Pass <code>null</code> as the element for a headless panel that computes the same tile model without a DOM. Not a dashboard layout engine (that is the parked dashboard generator) and no charting beyond the minimal sparkline (that is the charts module).</td></tr>
6243
6244
  <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
6244
6245
  <tr><td class="name">createGantt</td><td class="desc">Create a project-planning Gantt controller (module <code>gantt</code>) over a task list and a dependency list. A CPM engine (<code>computeSchedule</code>) computes each task's early/late start and finish, its slack and the zero-float critical path, honouring the four link types (<code>LINK_TYPES</code>: FS/SS/FF/SF) with lag, and recomputes on every edit — emitting <code>schedule</code> or, on a dependency cycle or bad input, <code>error</code> (a code from <code>SCHEDULE_ERROR</code>). Milestones are zero-duration points; summary (WBS) tasks are derived from their children (earliest start, latest finish, weighted progress) rather than scheduled; <code>findViolations</code> flags a task placed earlier than its predecessors allow, and <code>toISODate</code> maps an engine day-number back to a calendar date.</td></tr>
6245
6246
  <tr><td class="name">createLatticeGridElement</td><td class="desc">Build the element class without registering it, for a custom registry.</td></tr>