@toclocoinc/lattice-grid 1.34.1 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/API.html +350 -2
- package/docs/api-detail.html +88 -2
- package/lattice-grid.d.ts +331 -13
- package/lattice-grid.esm.min.js +557 -98
- package/lattice-grid.min.cjs +556 -98
- package/lattice-grid.min.js +556 -98
- package/modules/angular.esm.min.js +12 -3
- package/modules/angular.min.cjs +12 -3
- package/modules/angular.min.js +12 -3
- package/modules/chart-alluvial.esm.min.js +1 -1
- package/modules/chart-arc.esm.min.js +1 -1
- package/modules/chart-bubblemap.esm.min.js +1 -1
- package/modules/chart-bump.esm.min.js +1 -1
- package/modules/chart-calendar.esm.min.js +1 -1
- package/modules/chart-decomposition.esm.min.js +1 -1
- package/modules/chart-diverging.esm.min.js +1 -1
- package/modules/chart-dumbbell.esm.min.js +1 -1
- package/modules/chart-fan.esm.min.js +1 -1
- package/modules/chart-hexbin.esm.min.js +1 -1
- package/modules/chart-hexmap.esm.min.js +1 -1
- package/modules/chart-icicle.esm.min.js +1 -1
- package/modules/chart-parallel.esm.min.js +1 -1
- package/modules/chart-ridgeline.esm.min.js +1 -1
- package/modules/chart-roc.esm.min.js +1 -1
- package/modules/chart-slope.esm.min.js +1 -1
- package/modules/chart-splom.esm.min.js +1 -1
- package/modules/chart-waffle.esm.min.js +1 -1
- package/modules/charts.esm.min.js +4 -4
- package/modules/charts.min.cjs +4 -4
- package/modules/charts.min.js +4 -4
- package/modules/data-router.esm.min.js +30 -11
- package/modules/data-router.min.cjs +30 -11
- package/modules/data-router.min.js +30 -11
- package/modules/devtools.esm.min.js +2 -2
- package/modules/devtools.min.cjs +2 -2
- package/modules/devtools.min.js +2 -2
- package/modules/dhtmlx-compat.esm.min.js +4 -4
- package/modules/dhtmlx-compat.min.cjs +4 -4
- package/modules/dhtmlx-compat.min.js +4 -4
- package/modules/gantt.esm.min.js +1144 -51
- package/modules/gantt.min.cjs +1144 -51
- package/modules/gantt.min.js +1144 -51
- package/modules/htmx.esm.min.js +555 -98
- package/modules/htmx.min.cjs +555 -98
- package/modules/htmx.min.js +555 -98
- package/modules/kanban.esm.min.js +281 -28
- package/modules/kanban.min.cjs +281 -28
- package/modules/kanban.min.js +281 -28
- package/modules/kpi.esm.min.js +978 -0
- package/modules/kpi.min.cjs +981 -0
- package/modules/kpi.min.js +981 -0
- package/modules/mock-socket.esm.min.js +2 -2
- package/modules/mock-socket.min.cjs +2 -2
- package/modules/mock-socket.min.js +2 -2
- package/modules/react.esm.min.js +12 -3
- package/modules/react.min.cjs +12 -3
- package/modules/react.min.js +12 -3
- package/modules/svelte.esm.min.js +12 -3
- package/modules/svelte.min.cjs +12 -3
- package/modules/svelte.min.js +12 -3
- package/modules/vue.esm.min.js +12 -3
- package/modules/vue.min.cjs +12 -3
- package/modules/vue.min.js +12 -3
- package/modules/webcomponent.esm.min.js +556 -98
- package/modules/webcomponent.min.cjs +556 -98
- package/modules/webcomponent.min.js +556 -98
- 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.
|
|
7
|
+
Version 1.36.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
package/docs/API.html
CHANGED
|
@@ -2478,6 +2478,49 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'tria
|
|
|
2478
2478
|
</table>
|
|
2479
2479
|
</div>
|
|
2480
2480
|
|
|
2481
|
+
<h3 id="url-source">Load from a URL: a JSON or NDJSON file</h3>
|
|
2482
|
+
<p class="section-note">
|
|
2483
|
+
<code>createUrlSource(url, opts)</code> points the grid straight at a file. A <strong>JSON</strong>
|
|
2484
|
+
file (a top-level array, or a nested array picked out with <code>rowsPath</code> or <code>map</code>)
|
|
2485
|
+
is read whole and handed over as rows. An <strong>NDJSON / JSONL</strong> file — one JSON value
|
|
2486
|
+
per line — is <strong>streamed in incrementally</strong>: the first rows render while the rest is
|
|
2487
|
+
still arriving, and a large file never sits in memory as one string. It is built on the
|
|
2488
|
+
<code>stream</code> source, so it inherits the frame-coalesced render, the <code>stream:chunk</code> /
|
|
2489
|
+
<code>stream:end</code> progress events and promotion to memory once a small file has fully landed.
|
|
2490
|
+
</p>
|
|
2491
|
+
<p>Format is resolved in order: an explicit <code>format: 'json' | 'ndjson'</code> wins; else the URL
|
|
2492
|
+
extension (<code>.ndjson</code>/<code>.jsonl</code> vs <code>.json</code>); else the
|
|
2493
|
+
<code>Content-Type</code> (<code>application/x-ndjson</code> vs <code>application/json</code>); else a
|
|
2494
|
+
sniff of the first bytes, or a clear error asking for an explicit <code>format</code>. Pass
|
|
2495
|
+
<code>fetch</code> to supply auth or a proxy (default is the global <code>fetch</code>),
|
|
2496
|
+
<code>headers</code> to merge request headers, <code>batchSize</code> to tune NDJSON chunking
|
|
2497
|
+
(default 500), <code>lenient: true</code> to skip a malformed NDJSON line with a warning rather
|
|
2498
|
+
than failing, and <code>poll</code> (ms) to re-fetch on an interval, replacing the rows each pass.
|
|
2499
|
+
Every failure — a non-2xx status, a network error, a bad body, a malformed line — surfaces
|
|
2500
|
+
as <code>source:error</code> and leaves the grid usable, never an uncaught throw. The fetch is aborted
|
|
2501
|
+
when the grid is destroyed. Zero new dependencies: <code>fetch</code>,
|
|
2502
|
+
<code>response.body.getReader()</code> and <code>TextDecoder</code>.</p>
|
|
2503
|
+
<p class="section-note">A JSON file loaded through a mock transport so the example runs headless with no
|
|
2504
|
+
network; in an app, omit <code>fetch</code> and the global one is used. Run on every build.</p>
|
|
2505
|
+
<pre data-run="js" data-expect="3" data-covers="export:createUrlSource"><code><span class="kw">const</span> { createHeadlessGrid, createUrlSource } = <span class="kw">await</span> import('../packages/core/src/index.js');
|
|
2506
|
+
|
|
2507
|
+
<span class="cmt">// A mock transport, so this runs with no network. In your app, drop `fetch`.</span>
|
|
2508
|
+
<span class="kw">const</span> file = JSON.stringify([{ id: 1, city: 'Oslo' }, { id: 2, city: 'Lima' }, { id: 3, city: 'Cairo' }]);
|
|
2509
|
+
<span class="kw">const</span> fetchImpl = <span class="kw">async</span> () => <span class="kw">new</span> Response(file, { headers: { 'content-type': 'application/json' } });
|
|
2510
|
+
|
|
2511
|
+
<span class="kw">const</span> grid = createHeadlessGrid({
|
|
2512
|
+
rowKey: 'id',
|
|
2513
|
+
columns: [{ field: 'id' }, { field: 'city' }],
|
|
2514
|
+
source: createUrlSource('https://example.test/cities.json', { fetch: fetchImpl }),
|
|
2515
|
+
});
|
|
2516
|
+
|
|
2517
|
+
<span class="kw">await</span> <span class="kw">new</span> Promise((r) => setTimeout(r, 50)); <span class="cmt">// let the file load</span>
|
|
2518
|
+
<span class="kw">const</span> count = grid.rows.count();
|
|
2519
|
+
grid.destroy();
|
|
2520
|
+
<span class="kw">return</span> count; <span class="cmt">// 3</span></code></pre>
|
|
2521
|
+
<p>For NDJSON, point it at a <code>.ndjson</code> or <code>.jsonl</code> URL —
|
|
2522
|
+
<code>createUrlSource('/events.ndjson')</code> — and the rows stream in as they parse.</p>
|
|
2523
|
+
|
|
2481
2524
|
<h3 id="derived-source">Derived sources: a grid built from another grid</h3>
|
|
2482
2525
|
<p class="section-note">
|
|
2483
2526
|
Most dashboards put a summary panel beside a table, the top five sales people, the
|
|
@@ -3639,6 +3682,35 @@ off(); <span class="cmt">// on() returns i
|
|
|
3639
3682
|
<tr><td class="name">presence:left</td><td class="desc">A peer disconnected.</td></tr>
|
|
3640
3683
|
<tr><td class="name">presence:failed</td><td class="desc">A presence transport error. Presence is lossy by design; this is informational.</td></tr>
|
|
3641
3684
|
<tr><td class="name">presence:lockRefused</td><td class="desc">An edit was refused because a peer holds the cell.</td></tr>
|
|
3685
|
+
<tr><td class="name">export:request</td><td class="type">{ request }</td><td class="desc">A remote export was requested. Past-tense notification.</td></tr>
|
|
3686
|
+
<tr><td class="name">export:done</td><td class="type">{ request, remote }</td><td class="desc">A remote export completed. Past-tense notification.</td></tr>
|
|
3687
|
+
<tr><td class="name">shortcuts:opened</td><td class="type">{}</td><td class="desc">The keyboard-shortcuts help overlay opened. Past-tense notification.</td></tr>
|
|
3688
|
+
<tr><td class="name">shortcuts:closed</td><td class="type">{}</td><td class="desc">The keyboard-shortcuts help overlay closed. Past-tense notification.</td></tr>
|
|
3689
|
+
<tr><td class="name">print:before</td><td class="type">{ rows }</td><td class="desc">Print mode is about to snapshot. Past-tense notification, not cancellable.</td></tr>
|
|
3690
|
+
<tr><td class="name">print:after</td><td class="type">{ rows }</td><td class="desc">Print mode restored the grid. Fires even if the print was cancelled by the browser.</td></tr>
|
|
3691
|
+
<tr><td colspan="3" class="desc"><strong>Cancellable before-events (BACKLOG-0000943).</strong> Each gates a user-initiated mutation. The handler receives a <code>BeforeEvent</code> carrying the action context plus <code>preventDefault(reason?)</code>, <code>defaultPrevented</code> and <code>reason</code>. Calling <code>preventDefault()</code> (or returning <code>false</code>) cancels the action; the handler may be <code>async</code>, and the mutation is held until every before-handler settles. On a veto the paired <code><action>:cancelled</code> fires with the reason. Host/API writes and remote/router-applied deltas (origin !== <code>'user'</code>) do not fire these. The <code>origin</code> field distinguishes a genuine user gesture from a module-driven re-entry, which is how a host deduplicates.</td></tr>
|
|
3692
|
+
<tr><td class="name">beforeEdit</td><td class="type">{ row, key, mode, changes, origin }</td><td class="desc">A validated cell/row commit is about to apply. <code>changes</code> is <code>[{ colId, oldValue, newValue }]</code>. Validation (<code>edit.validate</code>) is separate and runs first.</td></tr>
|
|
3693
|
+
<tr><td class="name">beforeSort</td><td class="type">{ sort, origin }</td><td class="desc">A sort is about to be set.</td></tr>
|
|
3694
|
+
<tr><td class="name">beforeFilter</td><td class="type">{ filters?, quick?, kind, origin }</td><td class="desc">A structured (<code>kind: 'structured'</code>) or quick (<code>kind: 'quick'</code>) filter is about to be set.</td></tr>
|
|
3695
|
+
<tr><td class="name">beforeColumnMove</td><td class="type">{ column, to, origin }</td><td class="desc">A column reorder is about to apply.</td></tr>
|
|
3696
|
+
<tr><td class="name">beforeColumnResize</td><td class="type">{ column, width, origin }</td><td class="desc">A column width change is about to apply.</td></tr>
|
|
3697
|
+
<tr><td class="name">beforeColumnHide</td><td class="type">{ columns, origin }</td><td class="desc">One or more columns are about to be hidden.</td></tr>
|
|
3698
|
+
<tr><td class="name">beforeSelect</td><td class="type">{ keys, previous, origin }</td><td class="desc">A user selection change is about to apply. A veto snaps back to the last announced selection.</td></tr>
|
|
3699
|
+
<tr><td class="name">beforeRowAdd</td><td class="type">{ row, origin }</td><td class="desc">An optimistic row append is about to apply.</td></tr>
|
|
3700
|
+
<tr><td class="name">beforeDelete</td><td class="type">{ key, rows, origin }</td><td class="desc">An optimistic row delete is about to apply — the canonical confirm-before-delete hook.</td></tr>
|
|
3701
|
+
<tr><td class="name">beforeRowMove</td><td class="type">{ key, from, to, origin }</td><td class="desc">A row reorder is about to apply.</td></tr>
|
|
3702
|
+
<tr><td class="name">beforeGroup</td><td class="type">{ key, expanded, origin }</td><td class="desc">A group/tree expand or collapse is about to apply.</td></tr>
|
|
3703
|
+
<tr><td class="name">edit:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeEdit</code> was vetoed. <code>reason</code> is <code>'stale'</code> when a live delta moved the cell during an async gate.</td></tr>
|
|
3704
|
+
<tr><td class="name">sort:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeSort</code> was vetoed.</td></tr>
|
|
3705
|
+
<tr><td class="name">filter:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeFilter</code> was vetoed.</td></tr>
|
|
3706
|
+
<tr><td class="name">columnMove:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeColumnMove</code> was vetoed.</td></tr>
|
|
3707
|
+
<tr><td class="name">columnResize:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeColumnResize</code> was vetoed.</td></tr>
|
|
3708
|
+
<tr><td class="name">columnHide:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeColumnHide</code> was vetoed.</td></tr>
|
|
3709
|
+
<tr><td class="name">selection:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeSelect</code> was vetoed; the selection snapped back.</td></tr>
|
|
3710
|
+
<tr><td class="name">rowAdd:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeRowAdd</code> was vetoed.</td></tr>
|
|
3711
|
+
<tr><td class="name">delete:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeDelete</code> was vetoed. <code>reason</code> is <code>'stale'</code> when the row was already gone.</td></tr>
|
|
3712
|
+
<tr><td class="name">rowMove:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeRowMove</code> was vetoed. <code>reason</code> is <code>'stale'</code> when the row had moved.</td></tr>
|
|
3713
|
+
<tr><td class="name">group:cancelled</td><td class="type">{ ...context, reason }</td><td class="desc">A <code>beforeGroup</code> was vetoed.</td></tr>
|
|
3642
3714
|
</tbody>
|
|
3643
3715
|
</table>
|
|
3644
3716
|
</div>
|
|
@@ -3672,6 +3744,67 @@ grid.destroy();
|
|
|
3672
3744
|
handlerName('annotation:changed'), <span class="cmt">// the adapter prop the frameworks expose</span>
|
|
3673
3745
|
].join(' | ');</code></pre>
|
|
3674
3746
|
|
|
3747
|
+
<h3 id="before-events-example">Cancellable before-events: guarded editing and confirm-before-delete, executed</h3>
|
|
3748
|
+
<p class="section-note">Every user-initiated mutation has a cancellable <code>before</code> event
|
|
3749
|
+
(BACKLOG-0000943). A handler cancels the pending action with
|
|
3750
|
+
<code>preventDefault(reason?)</code> and may be <code>async</code> — the mutation is held until it
|
|
3751
|
+
settles, which is what makes a confirm dialog or a server check a genuine gate. On a veto the paired
|
|
3752
|
+
<code><action>:cancelled</code> fires with the reason. Host/API writes and remote deltas do not
|
|
3753
|
+
fire them. Run headless on every build.</p>
|
|
3754
|
+
<pre data-run="js" data-expect="0|Ann|sort,edit locked" data-covers="event:beforeEdit event:beforeSort event:beforeFilter event:beforeColumnMove event:beforeColumnResize event:beforeColumnHide event:beforeSelect event:beforeRowAdd event:beforeDelete event:beforeRowMove event:beforeGroup event:edit:cancelled event:sort:cancelled event:filter:cancelled event:columnMove:cancelled event:columnResize:cancelled event:columnHide:cancelled event:selection:cancelled event:rowAdd:cancelled event:delete:cancelled event:rowMove:cancelled event:group:cancelled event:print:before event:print:after event:export:request event:export:done event:shortcuts:opened event:shortcuts:closed"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
|
|
3755
|
+
<span class="kw">const</span> grid = createHeadlessGrid({
|
|
3756
|
+
rowKey: 'id',
|
|
3757
|
+
columns: [{ field: 'id' }, { field: 'name', edit: { enabled: <span class="kw">true</span> } }, { field: 'v', type: 'number', edit: { enabled: <span class="kw">true</span> } }],
|
|
3758
|
+
rows: [{ id: 'a', name: 'Ann', v: 1 }, { id: 'b', name: 'Bo', v: 2 }],
|
|
3759
|
+
selection: 'multiple',
|
|
3760
|
+
});
|
|
3761
|
+
|
|
3762
|
+
<span class="kw">const</span> log = [];
|
|
3763
|
+
|
|
3764
|
+
<span class="cmt">// Guarded editing: veto a commit on a locked row, and hear the cancellation.</span>
|
|
3765
|
+
grid.on('beforeEdit', (e) => { <span class="kw">if</span> (e.key === 'a') e.preventDefault('locked'); });
|
|
3766
|
+
grid.on('edit:cancelled', (e) => log.push('edit ' + e.reason));
|
|
3767
|
+
|
|
3768
|
+
<span class="cmt">// Guard the query and layout surfaces.</span>
|
|
3769
|
+
grid.on('beforeSort', (e) => e.preventDefault('view-locked'));
|
|
3770
|
+
grid.on('sort:cancelled', () => log.push('sort'));
|
|
3771
|
+
grid.on('beforeFilter', (e) => e.preventDefault('no-filter'));
|
|
3772
|
+
grid.on('filter:cancelled', () => log.push('filter'));
|
|
3773
|
+
grid.on('beforeColumnMove', (e) => e.preventDefault('fixed'));
|
|
3774
|
+
grid.on('columnMove:cancelled', () => log.push('colmove'));
|
|
3775
|
+
grid.on('beforeColumnResize', (e) => e.preventDefault('fixed'));
|
|
3776
|
+
grid.on('columnResize:cancelled', () => log.push('colresize'));
|
|
3777
|
+
grid.on('beforeColumnHide', (e) => e.preventDefault('mandatory'));
|
|
3778
|
+
grid.on('columnHide:cancelled', () => log.push('colhide'));
|
|
3779
|
+
grid.on('beforeGroup', (e) => e.preventDefault('frozen'));
|
|
3780
|
+
grid.on('group:cancelled', () => log.push('group'));
|
|
3781
|
+
grid.on('beforeRowMove', (e) => e.preventDefault('ordered'));
|
|
3782
|
+
grid.on('rowMove:cancelled', () => log.push('rowmove'));
|
|
3783
|
+
grid.on('beforeRowAdd', (e) => e.preventDefault('quota'));
|
|
3784
|
+
grid.on('rowAdd:cancelled', () => log.push('rowadd'));
|
|
3785
|
+
grid.on('beforeSelect', () => {});
|
|
3786
|
+
grid.on('selection:cancelled', () => log.push('sel'));
|
|
3787
|
+
|
|
3788
|
+
<span class="cmt">// Confirm before delete: an async handler holds the delete until it settles.</span>
|
|
3789
|
+
grid.on('beforeDelete', <span class="kw">async</span> (e) => { <span class="kw">await</span> Promise.resolve(); e.preventDefault('user cancelled'); });
|
|
3790
|
+
grid.on('delete:cancelled', (e) => log.push('delete ' + e.reason));
|
|
3791
|
+
|
|
3792
|
+
<span class="cmt">// Past-tense notifications a host can also observe (not gates): print, remote</span>
|
|
3793
|
+
<span class="cmt">// export, and the keyboard-shortcuts overlay.</span>
|
|
3794
|
+
grid.on('print:before', () => log.push('print-before'));
|
|
3795
|
+
grid.on('print:after', () => log.push('print-after'));
|
|
3796
|
+
grid.on('export:request', () => {});
|
|
3797
|
+
grid.on('export:done', () => {});
|
|
3798
|
+
grid.on('shortcuts:opened', () => {});
|
|
3799
|
+
grid.on('shortcuts:closed', () => {});
|
|
3800
|
+
|
|
3801
|
+
<span class="cmt">// A vetoed sort (sync) and an edit blocked on the locked row.</span>
|
|
3802
|
+
grid.sort.set([{ col: 'v', dir: 'desc' }]);
|
|
3803
|
+
grid.edit.start('a', 'name');
|
|
3804
|
+
grid.edit.stop(<span class="kw">false</span>, { value: 'Nope' });
|
|
3805
|
+
|
|
3806
|
+
<span class="kw">return</span> grid.sort.get().length + '|' + grid.rows.byKey('a').data.name + '|' + log.join(',');</code></pre>
|
|
3807
|
+
|
|
3675
3808
|
<h2 id="rules">Conditional formatting</h2>
|
|
3676
3809
|
<p>Rules compile into the function <code>cell.style</code> already takes, so a compiled rule set
|
|
3677
3810
|
installs exactly like a hand-written style function.</p>
|
|
@@ -4200,11 +4333,18 @@ router.attachDefault(restGrid); <span class="cmt">// the "rest" s
|
|
|
4200
4333
|
const counts = router.load(snapshot); <span class="cmt">// keyed diff per grid: [{added,updated,removed}, ...]</span>
|
|
4201
4334
|
router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">// in-place deltas by rowKey</span></code></pre>
|
|
4202
4335
|
<p><strong>A snapshot is a keyed diff, not a replace.</strong> <code>load</code> re-partitions the whole dataset and, per grid, adds the new rows, updates only the changed ones and removes the gone ones — so an unchanged row never repaints and selection, scroll and edit state survive. <strong>Deltas are applied in place</strong> by <code>rowKey</code>, last-writer-wins within a batch; an <code>upsert</code> whose partition property has changed <em>moves</em> the row (it leaves the route it no longer matches and joins the one it now does, never duplicated). A record matching no route is counted in <code>router.unrouted</code>, handed to <code>onUnrouted</code>, and — if an <code>attachDefault</code> grid exists — routed there, so nothing is ever silently lost. By default a record goes to the first route it matches; <code>overlap: true</code> fans it to every matching route.</p>
|
|
4336
|
+
<div class="note"><p><strong>Fanning one partition value to several viewers? Set <code>overlap: true</code>.</strong> The headline "route one feed to many viewers" — a grid <em>and</em> a KPI panel <em>and</em> a chart, all off one feed — needs <code>overlap: true</code>. With the default <code>overlap: false</code> a record stops at the <strong>first</strong> route it matches, so a second viewer attached to the <em>same</em> value silently receives nothing. Distinct values (one viewer per value) do not need it. When two routes claim the same value while <code>overlap</code> is false, the router emits a one-time dev warning naming the value.</p>
|
|
4337
|
+
<pre><code><span class="cmt">// One value, several viewers: a grid, a KPI panel and a chart all see 'deal'.</span>
|
|
4338
|
+
<span class="kw">const</span> router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: <span class="kw">true</span> });
|
|
4339
|
+
router.attach(dealsGrid, 'deal'); <span class="cmt">// the grid</span>
|
|
4340
|
+
router.subscribe('deal', kpiPanel); <span class="cmt">// a KPI tile off the same value</span>
|
|
4341
|
+
router.attach(dealsChart, 'deal'); <span class="cmt">// a chart off the same value</span>
|
|
4342
|
+
router.load(snapshot); <span class="cmt">// every viewer fills; without overlap:true only the grid would</span></code></pre></div>
|
|
4203
4343
|
<div class="table-wrap">
|
|
4204
4344
|
<table>
|
|
4205
4345
|
<thead><tr><th>Member</th><th>Description</th></tr></thead>
|
|
4206
4346
|
<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
|
|
4347
|
+
<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
4348
|
<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) => boolean</code>. <code>rowKey</code> overrides the router default for this grid.</td></tr>
|
|
4209
4349
|
<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
4350
|
<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>
|
|
@@ -4218,9 +4358,26 @@ router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">
|
|
|
4218
4358
|
<tr><td class="sig">flushStream()</td><td class="desc"><strong>v3:</strong> apply the buffered deltas now, coalesced into a single apply (a deterministic point, and for tests).</td></tr>
|
|
4219
4359
|
<tr><td class="sig">dropped</td><td class="desc"><strong>v3:</strong> how many stale/duplicate deltas the <code>seq</code> dedupe gate has dropped.</td></tr>
|
|
4220
4360
|
<tr><td class="sig">lastSeq() / checkpoint() / seenThrough(mark)</td><td class="desc"><strong>v3:</strong> the resume point — the highest applied <code>seq</code>, a per-record checkpoint to persist, and a way to prime it after a reconnect so an early replay is dropped.</td></tr>
|
|
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 — 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>
|
|
4361
|
+
<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 — 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. To drive a KPI/pane <em>and</em> a grid off the <strong>same</strong> partition value, create the router with <code>overlap: true</code> — with the default <code>overlap: false</code> only the first route on that value receives rows (the router warns once when it detects the clash).</td></tr>
|
|
4222
4362
|
<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
4363
|
<tr><td class="sig">configure(spec)</td><td class="desc"><strong>v5:</strong> the whole routing graph as one data spec — <code>routes</code> (grid/<code>default</code>/<code>subscribe</code>/<code>alert</code> entries), <code>links</code>, <code>relate</code>, <code>buffer</code> — desugared to the imperative API. Composes with imperative calls and round-trips to identical behaviour. Also accepted as <code>createDataRouter({ config })</code>.</td></tr>
|
|
4364
|
+
<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> — the router captures the grid's committed edits off its public edit surface (<code>grid.on('cell:changed')</code> → <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 — its edits are reverted and warned.</td></tr>
|
|
4365
|
+
<tr><td class="sig">attach(grid, predicate, { where })</td><td class="desc"><strong>v7 (BACKLOG-0000914):</strong> a route-level <code>where</code> — a filter-wire condition <code>{ col, op, value }</code> or an <code>and</code>/<code>or</code>/<code>not</code> group — 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>
|
|
4366
|
+
<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 — resolves once every slice is fetched and applied.</td></tr>
|
|
4367
|
+
<tr><td class="sig">lastQueryPlan()</td><td class="desc"><strong>v7:</strong> the pushed/residual split of the last <code>query()</code>, per fetch — 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>
|
|
4368
|
+
<tr><td class="sig">buffer({ window?, max? })</td><td class="desc"><strong>v4 (BACKLOG-0000911):</strong> turn on time-travel buffering — 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>
|
|
4369
|
+
<tr><td class="sig">scrubTo(target, { by? })</td><td class="desc"><strong>v4:</strong> scrub the grids to a past point — 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>
|
|
4370
|
+
<tr><td class="sig">replay(from, to, { speed?, by? })</td><td class="desc"><strong>v4:</strong> walk an incident — 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>
|
|
4371
|
+
<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>
|
|
4372
|
+
<tr><td class="sig">live()</td><td class="desc"><strong>v4:</strong> return to the head — 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>
|
|
4373
|
+
<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>
|
|
4374
|
+
<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>
|
|
4375
|
+
<tr><td class="sig">broadcasting</td><td class="desc"><strong>v6:</strong> whether the router is currently mirroring to a BroadcastChannel.</td></tr>
|
|
4376
|
+
<tr><td class="sig">addSource(feed, { map?, key? })</td><td class="desc"><strong>v9 (BACKLOG-0000931):</strong> register a source feed — 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 — 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>
|
|
4377
|
+
<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>
|
|
4378
|
+
<tr><td class="sig">metrics()</td><td class="desc"><strong>v10 (BACKLOG-0000932):</strong> a cheap point-in-time observability snapshot — 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>
|
|
4379
|
+
<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>
|
|
4380
|
+
<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
4381
|
<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
4382
|
<tr><td class="sig">destroy()</td><td class="desc">Detach every grid, drop every link, edge and subscription. <strong>Detaches only</strong> — the host owns and destroys its grids.</td></tr>
|
|
4226
4383
|
</tbody>
|
|
@@ -4403,6 +4560,119 @@ router.apply([{ op: 'upsert', row: { id: 'o2', type: 'invoice', amt: 25 } }]);
|
|
|
4403
4560
|
orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
|
|
4404
4561
|
<span class="kw">return</span> [fanned, moved, router.unrouted].join(' | ');</code></pre>
|
|
4405
4562
|
|
|
4563
|
+
<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> — 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 — a router that never calls <code>buffer()</code> behaves exactly as v1/v2/v3.</p>
|
|
4564
|
+
<h3 id="datarouter-v4-example">Scrub and return to live, executed</h3>
|
|
4565
|
+
<p class="section-note">A versioned feed buffered into a bounded ring; the view scrubs to a past seq, reads the
|
|
4566
|
+
reconstructed value, then returns to the live head. Run headless on every build.</p>
|
|
4567
|
+
<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');
|
|
4568
|
+
<span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
|
|
4569
|
+
|
|
4570
|
+
<span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
|
|
4571
|
+
<span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v' });
|
|
4572
|
+
router.attach(g, () => true);
|
|
4573
|
+
router.buffer({ max: 100 }); <span class="cmt">// record into a bounded ring</span>
|
|
4574
|
+
router.apply([{ op: 'upsert', row: { id: 'a', n: 10, v: 1 } }]);
|
|
4575
|
+
router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]);
|
|
4576
|
+
router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
|
|
4577
|
+
<span class="kw">const</span> head = g.rows.value('a', 'n'); <span class="cmt">// 30: the live head</span>
|
|
4578
|
+
router.scrubTo(1, { by: 'seq' }); <span class="cmt">// reconstruct the state at seq 1</span>
|
|
4579
|
+
<span class="kw">const</span> past = g.rows.value('a', 'n'); <span class="cmt">// 10</span>
|
|
4580
|
+
<span class="kw">const</span> traveling = router.traveling; <span class="cmt">// true</span>
|
|
4581
|
+
router.live(); <span class="cmt">// back to the head, by keyed diff</span>
|
|
4582
|
+
<span class="kw">const</span> back = g.rows.value('a', 'n'); <span class="cmt">// 30</span>
|
|
4583
|
+
|
|
4584
|
+
g.destroy(); router.destroy();
|
|
4585
|
+
<span class="kw">return</span> [head, past, traveling, back].join(' | ');</code></pre>
|
|
4586
|
+
|
|
4587
|
+
<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>
|
|
4588
|
+
|
|
4589
|
+
<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 — 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>
|
|
4590
|
+
<h3 id="datarouter-v7-example">Query-slice routing, executed</h3>
|
|
4591
|
+
<p class="section-note">Two routes over one query, each with its own <code>where</code>; a capability-free adapter pushes
|
|
4592
|
+
nothing, so each residual is finished client-side, and the plan records the split. Run headless on every build.</p>
|
|
4593
|
+
<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');
|
|
4594
|
+
<span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
|
|
4595
|
+
|
|
4596
|
+
<span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }];
|
|
4597
|
+
<span class="kw">const</span> big = createHeadlessGrid({ rowKey: 'id', columns: cols });
|
|
4598
|
+
<span class="kw">const</span> small = createHeadlessGrid({ rowKey: 'id', columns: cols });
|
|
4599
|
+
<span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
|
|
4600
|
+
|
|
4601
|
+
<span class="cmt">// Each route carries a `where`, pushed down where the adapter allows.</span>
|
|
4602
|
+
router.attach(big, 'order', { where: { col: 'amt', op: 'gte', value: 20 } });
|
|
4603
|
+
router.attach(small, 'order', { where: { col: 'amt', op: 'lt', value: 20 } });
|
|
4604
|
+
|
|
4605
|
+
<span class="cmt">// A capability-free adapter: nothing pushes, so every `where` is residual.</span>
|
|
4606
|
+
<span class="kw">const</span> adapter = {
|
|
4607
|
+
capabilities: {},
|
|
4608
|
+
execute: <span class="kw">async</span> (query) => ({ rows: [
|
|
4609
|
+
{ id: 'o1', type: 'order', amt: 10 },
|
|
4610
|
+
{ id: 'o2', type: 'order', amt: 30 },
|
|
4611
|
+
{ id: 'o3', type: 'order', amt: 20 },
|
|
4612
|
+
] }),
|
|
4613
|
+
};
|
|
4614
|
+
<span class="kw">await</span> router.query(adapter);
|
|
4615
|
+
<span class="kw">const</span> bigN = big.rows.count(); <span class="cmt">// 2: o2, o3 (amt >= 20)</span>
|
|
4616
|
+
<span class="kw">const</span> smallN = small.rows.count(); <span class="cmt">// 1: o1 (amt < 20)</span>
|
|
4617
|
+
<span class="kw">const</span> plan = router.lastQueryPlan(); <span class="cmt">// per-fetch pushed/residual split</span>
|
|
4618
|
+
|
|
4619
|
+
big.destroy(); small.destroy(); router.destroy();
|
|
4620
|
+
<span class="kw">return</span> [bigN, smallN, plan.length].join(' | ');</code></pre>
|
|
4621
|
+
|
|
4622
|
+
<p><strong>Write-back routing (v8, BACKLOG-0000912).</strong> A route made <strong>writable</strong> — <code>attach(grid, predicate, { writable: true })</code> — 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> — 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 — 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>
|
|
4623
|
+
|
|
4624
|
+
<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 — 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>
|
|
4625
|
+
<h3 id="datarouter-v9-example">Fan-in from two feeds, executed</h3>
|
|
4626
|
+
<p class="section-note">Two feeds with a colliding raw id, namespaced per source so they merge without clobbering;
|
|
4627
|
+
removing one feed drops exactly its rows. Run headless on every build.</p>
|
|
4628
|
+
<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');
|
|
4629
|
+
<span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
|
|
4630
|
+
|
|
4631
|
+
<span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'n', field: 'n', type: 'number' }];
|
|
4632
|
+
<span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
|
|
4633
|
+
<span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
|
|
4634
|
+
router.attach(g, 'order');
|
|
4635
|
+
|
|
4636
|
+
<span class="cmt">// Two feeds, each namespaced by its source id so raw ids cannot collide.</span>
|
|
4637
|
+
<span class="kw">const</span> crm = router.addSource('crm', { key: true });
|
|
4638
|
+
<span class="kw">const</span> erp = router.addSource('erp', { key: true });
|
|
4639
|
+
crm.load([{ id: '1', type: 'order', n: 10 }, { id: '2', type: 'order', n: 20 }]);
|
|
4640
|
+
erp.load([{ id: '1', type: 'order', n: 99 }]); <span class="cmt">// same raw id '1' — merged, not clobbered</span>
|
|
4641
|
+
<span class="kw">const</span> merged = g.rows.count(); <span class="cmt">// 3</span>
|
|
4642
|
+
<span class="kw">const</span> ids = router.sources().join(','); <span class="cmt">// crm,erp</span>
|
|
4643
|
+
|
|
4644
|
+
router.removeSource('erp'); <span class="cmt">// drops exactly erp's row</span>
|
|
4645
|
+
<span class="kw">const</span> afterRemove = g.rows.count(); <span class="cmt">// 2</span>
|
|
4646
|
+
|
|
4647
|
+
g.destroy(); router.destroy();
|
|
4648
|
+
<span class="kw">return</span> [merged, ids, afterRemove].join(' | ');</code></pre>
|
|
4649
|
+
|
|
4650
|
+
<p><strong>Observability (v10, BACKLOG-0000932).</strong> <code>metrics()</code> is a cheap point-in-time snapshot of the router's runtime — 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 — 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>
|
|
4651
|
+
<h3 id="datarouter-v10-example">A metrics snapshot, executed</h3>
|
|
4652
|
+
<p class="section-note">A snapshot fanned to a route and a sink; the metrics read reports the route's row count and the
|
|
4653
|
+
unrouted total, and <code>on('metrics')</code> returns an unsubscribe. Run headless on every build.</p>
|
|
4654
|
+
<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');
|
|
4655
|
+
<span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
|
|
4656
|
+
|
|
4657
|
+
<span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
|
|
4658
|
+
<span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
|
|
4659
|
+
<span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
|
|
4660
|
+
router.attach(g, 'order');
|
|
4661
|
+
|
|
4662
|
+
<span class="kw">const</span> off = router.on('metrics', () => {}); <span class="cmt">// register; starts the timer, returns unsubscribe</span>
|
|
4663
|
+
router.load([
|
|
4664
|
+
{ id: 'o1', type: 'order' },
|
|
4665
|
+
{ id: 'o2', type: 'order' },
|
|
4666
|
+
{ id: 'x1', type: 'ticket' }, <span class="cmt">// matches no route</span>
|
|
4667
|
+
]);
|
|
4668
|
+
<span class="kw">const</span> m = router.metrics();
|
|
4669
|
+
<span class="kw">const</span> rows = m.routes[0].rows; <span class="cmt">// 2</span>
|
|
4670
|
+
<span class="kw">const</span> unrouted = m.unrouted; <span class="cmt">// 1</span>
|
|
4671
|
+
off(); <span class="cmt">// stops the timer (last listener gone)</span>
|
|
4672
|
+
|
|
4673
|
+
g.destroy(); router.destroy();
|
|
4674
|
+
<span class="kw">return</span> [rows, unrouted, typeof off].join(' | ');</code></pre>
|
|
4675
|
+
|
|
4406
4676
|
<h2 id="ganttmodule">The Gantt module</h2>
|
|
4407
4677
|
<p><code>modules/gantt</code> is a separate, opt-in project-planning module — 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 — <code>LINK_TYPES</code> is <code>['FS','SS','FF','SF']</code> — 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
4678
|
<pre><code>import { createGantt, computeSchedule } from '@toclocoinc/lattice-grid/modules/gantt';
|
|
@@ -4728,6 +4998,72 @@ router.apply([{ op: 'upsert', row: { id: 't1', kind: 'task', status: 'done', tit
|
|
|
4728
4998
|
router.destroy();
|
|
4729
4999
|
<span class="kw">return</span> [hydrated, afterDelta].join(' | ');</code></pre>
|
|
4730
5000
|
|
|
5001
|
+
<h2 id="kpi">The KPI (stat-tile) view</h2>
|
|
5002
|
+
<p><code>modules/kpi</code> is an opt-in view of a dataset as a panel of <strong>stat tiles</strong> — 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>
|
|
5003
|
+
<pre><code>import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
|
|
5004
|
+
|
|
5005
|
+
const kpi = createKPI(document.querySelector('#kpis'), {
|
|
5006
|
+
rows, <span class="cmt">// or { grid } to read a live grid's rows</span>
|
|
5007
|
+
rowKey: 'id',
|
|
5008
|
+
columns: 4, <span class="cmt">// responsive tile columns</span>
|
|
5009
|
+
tiles: [
|
|
5010
|
+
{ id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
|
|
5011
|
+
{ id: 'avg', label: 'Avg deal', aggregation: 'avg', field: 'amount',
|
|
5012
|
+
format: { type: 'currency', decimals: 0 }, baseline: 5000 }, <span class="cmt">// delta vs a baseline</span>
|
|
5013
|
+
{ id: 'open', label: 'Open deals', aggregation: 'count',
|
|
5014
|
+
filter: (r) => r.stage !== 'won',
|
|
5015
|
+
thresholds: { warn: 10, critical: 25, direction: 'lowerIsBetter' } }, <span class="cmt">// good/warn/critical bands</span>
|
|
5016
|
+
],
|
|
5017
|
+
onTileClick: ({ tile }) => drillInto(tile.id),
|
|
5018
|
+
});</code></pre>
|
|
5019
|
+
<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) => 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><figure></code>, focusable and keyboard-activatable, its value announced; the sparkline transition respects <code>prefers-reduced-motion</code>.</p>
|
|
5020
|
+
<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 — an add contributes, a remove reverses, an update reverses the old row and contributes the new one — 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>
|
|
5021
|
+
<div class="table-wrap">
|
|
5022
|
+
<table>
|
|
5023
|
+
<thead><tr><th>Member</th><th>Description</th></tr></thead>
|
|
5024
|
+
<tbody>
|
|
5025
|
+
<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>
|
|
5026
|
+
<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>
|
|
5027
|
+
<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>
|
|
5028
|
+
<tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or re-seed and recompute.</td></tr>
|
|
5029
|
+
<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>
|
|
5030
|
+
<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>
|
|
5031
|
+
<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>
|
|
5032
|
+
</tbody>
|
|
5033
|
+
</table>
|
|
5034
|
+
</div>
|
|
5035
|
+
<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 — 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>
|
|
5036
|
+
<h3 id="kpi-live-example">Live, driven by a Data Router alongside a grid, executed</h3>
|
|
5037
|
+
<p class="section-note">One feed fans out (<code>overlap</code>) to a KPI panel through the same keyed-diff contract a grid uses:
|
|
5038
|
+
a snapshot seeds the tiles, then a delta removes the current max and the min/max rescans. Run headless on every build.</p>
|
|
5039
|
+
<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');
|
|
5040
|
+
<span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
|
|
5041
|
+
|
|
5042
|
+
<span class="kw">const</span> kpi = createKPI(null, {
|
|
5043
|
+
rowKey: 'id',
|
|
5044
|
+
tiles: [
|
|
5045
|
+
{ id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
|
|
5046
|
+
{ id: 'max', label: 'Biggest', aggregation: 'max', field: 'amount' },
|
|
5047
|
+
{ id: 'open', label: 'Open', aggregation: 'count', filter: (r) => r.stage === 'open',
|
|
5048
|
+
thresholds: { warn: 1, critical: 3, direction: 'lowerIsBetter' } },
|
|
5049
|
+
],
|
|
5050
|
+
});
|
|
5051
|
+
|
|
5052
|
+
<span class="kw">const</span> router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
|
|
5053
|
+
router.attach(kpi, 'deal'); <span class="cmt">// a KPI panel is a drop-in router target</span>
|
|
5054
|
+
router.load([
|
|
5055
|
+
{ id: 'd1', kind: 'deal', amount: 100, stage: 'open' },
|
|
5056
|
+
{ id: 'd2', kind: 'deal', amount: 250, stage: 'won' },
|
|
5057
|
+
]);
|
|
5058
|
+
<span class="kw">const</span> seeded = kpi.value('total') + ',' + kpi.value('max'); <span class="cmt">// 350,250</span>
|
|
5059
|
+
|
|
5060
|
+
router.apply([{ op: 'delete', row: { id: 'd2' } }]); <span class="cmt">// removes the current max</span>
|
|
5061
|
+
<span class="kw">const</span> afterRemove = kpi.value('total') + ',' + kpi.value('max'); <span class="cmt">// 100,100 (max rescanned)</span>
|
|
5062
|
+
<span class="kw">const</span> band = kpi.tile('open').status; <span class="cmt">// 'good' — 1 open, lowerIsBetter</span>
|
|
5063
|
+
|
|
5064
|
+
router.destroy();
|
|
5065
|
+
<span class="kw">return</span> [seeded, afterRemove, band].join(' | ');</code></pre>
|
|
5066
|
+
|
|
4731
5067
|
<h2 id="mocksocket">The mock socket</h2>
|
|
4732
5068
|
<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> — 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> — 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
5069
|
<pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
|
|
@@ -5901,6 +6237,18 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
|
|
|
5901
6237
|
</tbody>
|
|
5902
6238
|
</table>
|
|
5903
6239
|
</div>
|
|
6240
|
+
<h3 id="type-BeforeEvent">BeforeEvent</h3>
|
|
6241
|
+
<p class="section-note">A cancellable *before*-event (BACKLOG-0000943), delivered to `on('beforeX')` handlers before a user-initiated mutation is applied. A handler cancels the pending action by calling `preventDefault(reason?)`; the mutation is then abandoned and a past-tense `<action>:cancelled` event carries the reason. A handler may be `async` (or return a Promise): the grid awaits every before-handler before deciding, so a confirm dialog or a server check can gate the write. Any one handler preventing cancels the action. The action-specific fields (the edited cells, the target index, the affected rows) are spread alongside these, so a handler decides without reaching into grid internals. `origin` distinguishes a genuine user gesture from a host/module-driven or remote write, which is how a module whose move re-enters core is deduplicated by the host.</p>
|
|
6242
|
+
<div class="table-wrap">
|
|
6243
|
+
<table>
|
|
6244
|
+
<thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
|
|
6245
|
+
<tbody>
|
|
6246
|
+
<tr><td class="name">preventDefault</td><td class="type">(reason?: string): void</td><td class="desc">Cancel the pending action; the optional reason is surfaced on the cancellation event.</td></tr>
|
|
6247
|
+
<tr><td class="name">defaultPrevented</td><td class="type">boolean</td><td class="desc">True once any handler has called `preventDefault` or returned false.</td></tr>
|
|
6248
|
+
<tr><td class="name">reason</td><td class="type">string | null</td><td class="desc">The reason given to `preventDefault`, or null; `'stale'` when re-validation failed.</td></tr>
|
|
6249
|
+
</tbody>
|
|
6250
|
+
</table>
|
|
6251
|
+
</div>
|
|
5904
6252
|
<h3 id="type-BooleanFormat">BooleanFormat</h3>
|
|
5905
6253
|
<div class="table-wrap">
|
|
5906
6254
|
<table>
|