@toclocoinc/lattice-grid 1.31.1 → 1.32.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 (39) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +150 -3
  3. package/docs/api-detail.html +1 -1
  4. package/lattice-grid.d.ts +129 -4
  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/charts.esm.min.js +4 -4
  12. package/modules/charts.min.cjs +4 -4
  13. package/modules/charts.min.js +4 -4
  14. package/modules/data-router.esm.min.js +873 -23
  15. package/modules/data-router.min.cjs +873 -23
  16. package/modules/data-router.min.js +873 -23
  17. package/modules/devtools.esm.min.js +2 -2
  18. package/modules/devtools.min.cjs +2 -2
  19. package/modules/devtools.min.js +2 -2
  20. package/modules/dhtmlx-compat.esm.min.js +4 -4
  21. package/modules/dhtmlx-compat.min.cjs +4 -4
  22. package/modules/dhtmlx-compat.min.js +4 -4
  23. package/modules/htmx.esm.min.js +4 -4
  24. package/modules/htmx.min.cjs +4 -4
  25. package/modules/htmx.min.js +4 -4
  26. package/modules/mock-socket.esm.min.js +242 -0
  27. package/modules/react.esm.min.js +2 -2
  28. package/modules/react.min.cjs +2 -2
  29. package/modules/react.min.js +2 -2
  30. package/modules/svelte.esm.min.js +2 -2
  31. package/modules/svelte.min.cjs +2 -2
  32. package/modules/svelte.min.js +2 -2
  33. package/modules/vue.esm.min.js +2 -2
  34. package/modules/vue.min.cjs +2 -2
  35. package/modules/vue.min.js +2 -2
  36. package/modules/webcomponent.esm.min.js +4 -4
  37. package/modules/webcomponent.min.cjs +4 -4
  38. package/modules/webcomponent.min.js +4 -4
  39. 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.31.1 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.32.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -419,6 +419,7 @@
419
419
  <a href="#quickfilter">Quick filter</a>
420
420
  <a href="#units">Units of your own</a>
421
421
  <a href="#chartsmodule">The charts module</a>
422
+ <a href="#mocksocket">The mock socket</a>
422
423
  <a href="#charts">In-cell charts</a>
423
424
  <a href="#formulas">Formulas</a>
424
425
  <a href="#custom-menu">Custom items</a>
@@ -4124,8 +4125,14 @@ router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">
4124
4125
  <tr><td class="sig">unrouted</td><td class="desc">How many records matched no route (reset by <code>load</code>, running for deltas).</td></tr>
4125
4126
  <tr><td class="sig">link(source, target, relation)</td><td class="desc"><strong>v2:</strong> make a selection in <code>source</code> filter what <code>target</code> receives. <code>relation</code> is a key map <code>{ from, to }</code> (target rows whose <code>to</code> value is among the selected source rows' <code>from</code> values &mdash; multi-select is an IN set, ANY match) or a function <code>fn(selectedSourceRows) =&gt; (row) =&gt; boolean</code>. No selection shows the full partition; changes are debounced.</td></tr>
4126
4127
  <tr><td class="sig">flush()</td><td class="desc"><strong>v2:</strong> apply any debounced selection refilter now, for a deterministic point (and for tests).</td></tr>
4127
- <tr><td class="sig">detach(grid)</td><td class="desc">Stop routing to a grid and forget its slice; drop any link it is part of (restoring a filtered sibling). The host still owns and destroys the grid.</td></tr>
4128
- <tr><td class="sig">destroy()</td><td class="desc">Detach every grid, drop every link and subscription. <strong>Detaches only</strong> &mdash; the host owns and destroys its grids.</td></tr>
4128
+ <tr><td class="sig">attach(grid, predicate, { rollup })</td><td class="desc"><strong>v3:</strong> feed the grid a grouped/summarised view &mdash; <code>rollup: { groupBy, aggregate }</code> gives one summary row per group (<code>op</code> of <code>sum</code>/<code>avg</code>/<code>min</code>/<code>max</code>/<code>count</code> over a <code>field</code>, or a <code>fn(rows)</code>). Applied by keyed diff, so only a moved group repaints.</td></tr>
4129
+ <tr><td class="sig">relate(edges)</td><td class="desc"><strong>v3:</strong> declare a relationship graph. Each edge <code>{ from, to, on, mutual? }</code>; the router resolves multi-hop chains, several sources into one target (AND), and mutual edges on any selection change. Composes with <code>link()</code>.</td></tr>
4130
+ <tr><td class="sig">push(delta)</td><td class="desc"><strong>v3:</strong> feed a live delta. With a <code>batch</code> interval or <code>coalesce: true</code> it buffers and coalesces rapid updates to one key; otherwise it applies at once.</td></tr>
4131
+ <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>
4132
+ <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>
4133
+ <tr><td class="sig">lastSeq() / checkpoint() / seenThrough(mark)</td><td class="desc"><strong>v3:</strong> the resume point &mdash; 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>
4134
+ <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>
4135
+ <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>
4129
4136
  </tbody>
4130
4137
  </table>
4131
4138
  </div>
@@ -4166,6 +4173,111 @@ customers.selection.set([]); <span class="cmt">// deselect restor
4166
4173
 
4167
4174
  customers.destroy(); orders.destroy(); router.destroy();
4168
4175
  <span class="kw">return</span> [full, oneRegion, both, restored].join(' | ');</code></pre>
4176
+
4177
+ <p><strong>Per-route transforms and route-level filter/sort (v3, BACKLOG-0000887).</strong> A route may reshape and narrow its slice before it reaches the grid, and the grid still stays dumb. <code>attach(grid, predicate, opts)</code> takes <code>transform(row) =&gt; row'</code> (map/rename/derive), <code>filter(row) =&gt; boolean</code> (the grid receives only the subset), and <code>sort</code> (a comparator or <code>{ key, dir }</code>, ordering what the grid receives). Filtering and the cross-grid link predicates run on the <em>original</em> row; the transform then produces the display row, and identity stays the row's <code>rowKey</code>, so the keyed diff is unaffected — an unchanged transformed row never repaints, and a delta that pushes a row across the filter threshold makes it enter or leave the view.</p>
4178
+ <pre data-run="js" data-expect="2 | o2,o3 | #o2:30 | held | shown" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4179
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4180
+
4181
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }];
4182
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
4183
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4184
+
4185
+ <span class="cmt">// This route shows only amt &gt;= 20, sorted high-to-low, with a derived label.</span>
4186
+ router.attach(g, 'order', {
4187
+ filter: (row) =&gt; row.amt &gt;= 20,
4188
+ sort: { key: 'amt', dir: 'desc' },
4189
+ transform: (row) =&gt; ({ ...row, label: `#${row.id}:${row.amt}` }),
4190
+ });
4191
+ router.load([
4192
+ { id: 'o1', type: 'order', amt: 10 }, <span class="cmt">// filtered out</span>
4193
+ { id: 'o2', type: 'order', amt: 30 },
4194
+ { id: 'o3', type: 'order', amt: 20 },
4195
+ ]);
4196
+ <span class="kw">const</span> keys = [];
4197
+ <span class="kw">for</span> (<span class="kw">let</span> i = 0; i &lt; g.rows.count(); i++) keys.push(g.rows.get(i).key);
4198
+
4199
+ <span class="cmt">// A delta below the threshold is held; raising it brings it into the view.</span>
4200
+ router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 5 } }]);
4201
+ <span class="kw">const</span> held = g.rows.count() === 2 ? 'held' : 'leaked';
4202
+ router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 40 } }]);
4203
+ <span class="kw">const</span> shown = g.rows.count() === 3 ? 'shown' : 'missing';
4204
+
4205
+ <span class="kw">const</span> out = [g.rows.count() &gt;= 2 ? 2 : 0, keys.join(','), g.rows.value('o2', 'label'), held, shown];
4206
+ g.destroy(); router.destroy();
4207
+ <span class="kw">return</span> out.join(' | ');</code></pre>
4208
+
4209
+ <p><strong>Aggregate/rollup routes (v3, BACKLOG-0000887).</strong> A route can be fed a <em>grouped, summarised</em> view of its partition instead of the raw rows &mdash; a per-category total for a chart route, say. <code>attach(grid, predicate, { rollup: { groupBy, aggregate } })</code> gives the grid one summary row per group: <code>groupBy</code> is a property, a <code>fn(row)</code> or an array of either, and each <code>aggregate</code> entry is a <code>{ op, field }</code> (<code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>) or a <code>fn(rows) =&gt; value</code>. A route <code>filter</code> runs on the raw rows before grouping; <code>sort</code> and <code>transform</code> run on the summaries. The summary is applied by the same keyed diff, so only a group that actually moved repaints &mdash; the router owns the roll-up, the grid stays dumb.</p>
4210
+ <pre data-run="js" data-expect="2 | amer | 115" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4211
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4212
+
4213
+ <span class="kw">const</span> cols = [{ id: 'region', field: 'region' }, { id: 'total', field: 'total', type: 'number' }];
4214
+ <span class="kw">const</span> chart = createHeadlessGrid({ rowKey: 'region', columns: cols });
4215
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4216
+
4217
+ <span class="cmt">// One summary row per region, the per-region total, biggest first.</span>
4218
+ router.attach(chart, 'order', {
4219
+ rollup: { groupBy: 'region', aggregate: { total: { op: 'sum', field: 'amt' } } },
4220
+ sort: { key: 'total', dir: 'desc' },
4221
+ });
4222
+ router.load([
4223
+ { id: 'o1', type: 'order', region: 'emea', amt: 10 },
4224
+ { id: 'o2', type: 'order', region: 'amer', amt: 20 },
4225
+ { id: 'o3', type: 'order', region: 'emea', amt: 5 },
4226
+ ]);
4227
+ <span class="kw">const</span> groups = chart.rows.count(); <span class="cmt">// 2: emea (15), amer (20)</span>
4228
+ <span class="kw">const</span> top = chart.rows.get(0).key; <span class="cmt">// amer — highest total</span>
4229
+
4230
+ <span class="cmt">// A new emea order repaints only the emea summary (keyed diff).</span>
4231
+ router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', region: 'emea', amt: 100 } }]);
4232
+ <span class="kw">const</span> emeaTotal = chart.rows.value('emea', 'total'); <span class="cmt">// 115</span>
4233
+
4234
+ chart.destroy(); router.destroy();
4235
+ <span class="kw">return</span> [groups, top, emeaTotal].join(' | ');</code></pre>
4236
+
4237
+ <p><strong>The relationship graph (v3, BACKLOG-0000887).</strong> <code>relate([...])</code> is the scalable form of v2's pairwise <code>link()</code>. Each edge is <code>{ from, to, on }</code>, where <code>on</code> is a key map <code>{ from, to }</code> or a function <code>fn(sourceRows) =&gt; (row) =&gt; boolean</code>. The router resolves the whole graph on any selection change, so it handles <strong>multi-hop</strong> chains (A&rarr;B&rarr;C: a selection in A narrows B and, through B's resulting rows, C &mdash; with no selection in B), <strong>several sources into one target</strong> (their filters AND together), and <strong>mutual</strong> edges (<code>mutual: true</code> &mdash; selecting in either linked view narrows the other; requires a key-map <code>on</code>). A node's effective set is its own selection when it has one, otherwise the rows its incoming edges leave &mdash; that is what carries a selection transitively down a chain. Additive to <code>link()</code>; the two compose.</p>
4238
+ <pre data-run="js" data-expect="o1,o3 | l1,l3,l4" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4239
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4240
+
4241
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region', type: 'text' }, { id: 'orderId', field: 'orderId', type: 'text' }];
4242
+ <span class="kw">const</span> mk = () =&gt; createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
4243
+ <span class="kw">const</span> customers = mk(), orders = mk(), lines = mk();
4244
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
4245
+ router.attach(customers, 'customer'); router.attach(orders, 'order'); router.attach(lines, 'line');
4246
+ router.load([
4247
+ { id: 'c1', type: 'customer', region: 'emea' }, { id: 'c2', type: 'customer', region: 'amer' },
4248
+ { id: 'o1', type: 'order', region: 'emea' }, { id: 'o2', type: 'order', region: 'amer' }, { id: 'o3', type: 'order', region: 'emea' },
4249
+ { id: 'l1', type: 'line', orderId: 'o1' }, { id: 'l2', type: 'line', orderId: 'o2' }, { id: 'l3', type: 'line', orderId: 'o3' }, { id: 'l4', type: 'line', orderId: 'o1' },
4250
+ ]);
4251
+ <span class="cmt">// customers -&gt; orders (by region) -&gt; lines (by order id): a two-hop chain.</span>
4252
+ router.relate([
4253
+ { from: customers, to: orders, on: { from: 'region', to: 'region' } },
4254
+ { from: orders, to: lines, on: { from: 'id', to: 'orderId' } },
4255
+ ]);
4256
+ customers.selection.set(['c1']); <span class="cmt">// emea; no selection in orders</span>
4257
+ <span class="kw">const</span> keysOf = (g) =&gt; { <span class="kw">const</span> a = []; <span class="kw">for</span> (<span class="kw">let</span> i = 0; i &lt; g.rows.count(); i++) a.push(g.rows.get(i).key); <span class="kw">return</span> a.sort().join(','); };
4258
+ <span class="kw">const</span> out = [keysOf(orders), keysOf(lines)]; <span class="cmt">// o1,o3 | l1,l3,l4</span>
4259
+ customers.destroy(); orders.destroy(); lines.destroy(); router.destroy();
4260
+ <span class="kw">return</span> out.join(' | ');</code></pre>
4261
+
4262
+ <p><strong>Stream hygiene (v3, BACKLOG-0000887).</strong> A production feed arrives out of order, gets replayed, and comes faster than a grid should repaint. Configure a <code>seq</code> (a version field or <code>fn(row)</code>) and the router orders each batch by it and <strong>drops</strong> any delta not newer than the one it already applied for that record (counted in <code>router.dropped</code>) &mdash; an out-of-order or replayed feed converges to the newest state. <code>push(delta)</code> with a <code>batch</code> interval or <code>coalesce: true</code> buffers a high-frequency feed and coalesces rapid updates to one key into a single apply (flush a deterministic point with <code>flushStream()</code>). After a dropped socket, resume precisely: <code>load</code> a fresh snapshot (a keyed diff that preserves grid state) and replay from <code>lastSeq()</code>/<code>checkpoint()</code> &mdash; the deltas the router already saw are dropped by the same gate. <code>seenThrough(mark)</code> primes the checkpoint from a persisted one.</p>
4263
+ <pre data-run="js" data-expect="30 | 1 | 4" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4264
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4265
+
4266
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
4267
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v' });
4268
+ router.attach(g, () =&gt; true);
4269
+ router.load([]);
4270
+
4271
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
4272
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]); <span class="cmt">// stale — dropped</span>
4273
+ <span class="kw">const</span> n = g.rows.value('a', 'n'); <span class="cmt">// 30: the stale delta did not clobber it</span>
4274
+ <span class="kw">const</span> dropped = router.dropped; <span class="cmt">// 1</span>
4275
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 99, v: 4 } }]); <span class="cmt">// fresh</span>
4276
+ <span class="kw">const</span> last = router.lastSeq(); <span class="cmt">// 4</span>
4277
+
4278
+ g.destroy(); router.destroy();
4279
+ <span class="kw">return</span> [n, dropped, last].join(' | ');</code></pre>
4280
+
4169
4281
  <h3 id="datarouter-example">One feed, three grids, executed</h3>
4170
4282
  <p class="section-note">A single snapshot fanned to an orders grid, an invoices grid and a "rest" sink, then a
4171
4283
  delta that changes a row's partition &mdash; proving the fan-out, the sink, and that a moved row
@@ -4201,6 +4313,38 @@ router.apply([{ op: 'upsert', row: { id: 'o2', type: 'invoice', amt: 25 } }]);
4201
4313
  orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
4202
4314
  <span class="kw">return</span> [fanned, moved, router.unrouted].join(' | ');</code></pre>
4203
4315
 
4316
+ <h2 id="mocksocket">The mock socket</h2>
4317
+ <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>
4318
+ <pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
4319
+
4320
+ const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }) });
4321
+ socket.onmessage = (event) =&gt; {
4322
+ const message = JSON.parse(event.data);
4323
+ if (message.kind === 'snapshot') router.load(message.rows);
4324
+ else router.apply(message.changes);
4325
+ };
4326
+
4327
+ <span class="cmt">// Going live is the one line that changes; everything above stays as written:</span>
4328
+ const socket = new WebSocket('wss://example.com/ops');</code></pre>
4329
+ <p><strong>The swap is literally one line.</strong> Both sockets frame their messages the same way, so the reader parses <code>event.data</code> and switches on <code>kind</code> either way. <strong>The feed is seedable and deterministic:</strong> the shipped generators carry their own seed and the timing jitter is seeded too, so the same inputs replay the same stream &mdash; which is what lets a tutorial and its runnable example show the same thing every time, and what lets a test assert on an exact stream rather than a plausible one. <strong>Bring your own generator:</strong> a feed is any iterator that yields <code>{ kind: 'snapshot', rows }</code> first and then <code>{ kind: 'delta', changes }</code> forever &mdash; a plain generator function is the easiest form &mdash; and <code>rng(seed)</code> is exported so a custom feed can be seeded the same way the shipped ones are.</p>
4330
+ <div class="table-wrap">
4331
+ <table>
4332
+ <thead><tr><th>Member</th><th>Description</th></tr></thead>
4333
+ <tbody>
4334
+ <tr><td class="sig">new MockWebSocket({ feed, rate?, jitter?, seed?, snapshotDelay?, pauseWhenHidden?, url? })</td><td class="desc">Open a mock socket driven by <code>feed</code> (a generator: snapshot first, then deltas). <code>rate</code> is the ms between deltas (default 1000); <code>jitter</code> a random plus-or-minus ms per gap (default 0); <code>seed</code> seeds that jitter (default 1); <code>snapshotDelay</code> the ms before it opens (default 60); <code>pauseWhenHidden</code> stops the feed while the tab is in the background (default true); <code>url</code> a cosmetic address so <code>socket.url</code> reads like the real thing.</td></tr>
4335
+ <tr><td class="sig">onopen / onmessage / onclose / onerror</td><td class="desc">The <code>WebSocket</code> handlers. <code>onmessage</code> receives an event whose <code>data</code> is the JSON-framed <code>FeedMessage</code>; a feed that ends closes the socket cleanly; a feed that throws surfaces as an <code>error</code> event, not an uncaught throw.</td></tr>
4336
+ <tr><td class="sig">addEventListener / removeEventListener</td><td class="desc">The <code>EventTarget</code> surface, alongside the <code>on*</code> handlers &mdash; both receive every event.</td></tr>
4337
+ <tr><td class="sig">send(data?)</td><td class="desc">Accepted and ignored: there is nothing upstream, so a page that calls <code>send</code> runs unchanged.</td></tr>
4338
+ <tr><td class="sig">close()</td><td class="desc">Close the socket and stop the feed, emitting a clean <code>close</code>.</td></tr>
4339
+ <tr><td class="sig">pause() / resume()</td><td class="desc">Hold the feed and continue it while the socket stays open &mdash; a demo and test affordance beyond the <code>WebSocket</code> surface.</td></tr>
4340
+ <tr><td class="sig">opsFeed({ seed?, orders?, shipments?, incidents?, batch? })</td><td class="desc">A mixed operations feed &mdash; orders, shipments and incidents across three regions plus a throughput rollup &mdash; the kind the data router partitions across several grids and a chart from one source. Yields a snapshot, then deltas forever.</td></tr>
4341
+ <tr><td class="sig">priceFeed({ seed?, symbols?, move?, batch?, spread? })</td><td class="desc">A market-data feed: instruments whose prices random-walk each tick, each record carrying <code>type: 'price'</code>, <code>symbol</code>, <code>last</code>, <code>chg</code> and a bid/ask straddling the last. Yields a snapshot, then deltas forever.</td></tr>
4342
+ <tr><td class="sig">rng(seed)</td><td class="desc">A small seeded pseudo-random generator (mulberry32), so a custom feed can be seeded the same way the shipped ones are: the same seed yields the same sequence.</td></tr>
4343
+ </tbody>
4344
+ </table>
4345
+ </div>
4346
+ <div class="note"><p>Every record on the shipped feeds carries a <code>type</code>, the property the data router partitions on, and an <code>id</code> (or <code>symbol</code>), its row key &mdash; so a mock feed drops straight into a routed screen. The module is plain JavaScript and timers: no dependencies, no <code>eval</code>, safe to paste into a page or a sandbox.</p></div>
4347
+
4204
4348
  <h2 id="charts">In-cell charts</h2>
4205
4349
  <p>Seven chart renderers for a cell. Each is a single SVG whose path data is the only thing a
4206
4350
  repaint writes, so they cost the same as any other cell as rows recycle.</p>
@@ -4750,7 +4894,7 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
4750
4894
 
4751
4895
  <h3 id="module-exports-example">Every module export, executed</h3>
4752
4896
  <p class="section-note">Nine shipped modules, each export resolved against its own barrel on every build.</p>
4753
- <pre data-run="js" data-expect="62" data-covers="export:ContextMenu export:Messages export:Registry export:autoInit export:createGrid export:createLocalViewStorage export:createMessages export:createStat export:deltaOf export:gridElementsWithin export:hydrateTable export:mountPanel export:readTable export:toneOf export:Chart export:PALETTE export:SCHEMES export:TYPES export:createChart export:chartRange export:canChartRange export:deriveRangeSpec export:regressionPlots export:registerScheme export:resolveScheme export:schemeNames export:setDefaultScheme export:HTML_ROW_WARNING_THRESHOLD export:QUERY_CHANGED_EVENT export:SCROLL_NEAR_END_EVENT export:attach export:destroyWithin export:driveInfiniteScroll export:driveOobUpdates export:driveServerMode export:ingestResponse export:initWithin export:queryParams export:restoreStateWithin export:rowsFromFragment export:rowsFromJson export:saveStateWithin export:ATTRIBUTE_CONFIG export:EVENT_PREFIX export:GridElementController export:LatticeGrid export:TAG_NAME export:createLatticeGridElement export:defineLatticeGrid export:domEventName export:observedAttributeNames export:CONSOLE_ACTIVATION export:createDevtools export:expose export:EVENT_NAMES export:handlerName export:createLatticeGrid export:dashedName export:createLatticeAction export:Grid export:warnIfLargeHtmlPayload export:createDataRouter"><code><span class="cmt">// Every declared export of every shipped module, resolved against its own</span>
4897
+ <pre data-run="js" data-expect="66" data-covers="export:ContextMenu export:Messages export:Registry export:autoInit export:createGrid export:createLocalViewStorage export:createMessages export:createStat export:deltaOf export:gridElementsWithin export:hydrateTable export:mountPanel export:readTable export:toneOf export:Chart export:PALETTE export:SCHEMES export:TYPES export:createChart export:chartRange export:canChartRange export:deriveRangeSpec export:regressionPlots export:registerScheme export:resolveScheme export:schemeNames export:setDefaultScheme export:HTML_ROW_WARNING_THRESHOLD export:QUERY_CHANGED_EVENT export:SCROLL_NEAR_END_EVENT export:attach export:destroyWithin export:driveInfiniteScroll export:driveOobUpdates export:driveServerMode export:ingestResponse export:initWithin export:queryParams export:restoreStateWithin export:rowsFromFragment export:rowsFromJson export:saveStateWithin export:ATTRIBUTE_CONFIG export:EVENT_PREFIX export:GridElementController export:LatticeGrid export:TAG_NAME export:createLatticeGridElement export:defineLatticeGrid export:domEventName export:observedAttributeNames export:CONSOLE_ACTIVATION export:createDevtools export:expose export:EVENT_NAMES export:handlerName export:createLatticeGrid export:dashedName export:createLatticeAction export:Grid export:warnIfLargeHtmlPayload export:createDataRouter export:MockWebSocket export:rng export:opsFeed export:priceFeed"><code><span class="cmt">// Every declared export of every shipped module, resolved against its own</span>
4754
4898
  <span class="cmt">// barrel. A module that stopped exporting something fails here.</span>
4755
4899
  <span class="kw">const</span> modules = [
4756
4900
  [<span class="kw">await</span> import('../packages/dom/src/index.js'), [
@@ -4794,6 +4938,9 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
4794
4938
  [<span class="kw">await</span> import('../packages/modules/data-router/index.js'), [
4795
4939
  'createDataRouter',
4796
4940
  ]],
4941
+ [<span class="kw">await</span> import('../packages/modules/mock-socket/index.js'), [
4942
+ 'MockWebSocket', 'rng', 'opsFeed', 'priceFeed',
4943
+ ]],
4797
4944
  ];
4798
4945
 
4799
4946
  <span class="kw">let</span> present = 0;
@@ -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.31.1</p>
440
+ <p class="rail__sub">Developer guide · v1.32.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.31.1, type declarations
2
+ * Lattice Grid 1.32.0, type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -5961,6 +5961,19 @@ declare module 'lattice-grid/modules/data-router' {
5961
5961
  /** A predicate: a property value (`row[key] === value`) or a `fn(row)`. */
5962
5962
  type RoutePredicate = unknown | ((row: RouterRecord) => boolean);
5963
5963
 
5964
+ /**
5965
+ * Per-route reshaping options (v3, BACKLOG-0000887): `transform` maps/renames/
5966
+ * derives each row before the grid sees it; `filter` gives the grid only the
5967
+ * rows it admits; `sort` (a comparator or `{ key, dir }`) orders what the grid
5968
+ * receives. `rowKey` overrides the router default. All optional.
5969
+ */
5970
+ interface RouteOptions {
5971
+ rowKey?: (string | ((row: RouterRecord) => unknown));
5972
+ transform?: (row: RouterRecord) => RouterRecord;
5973
+ filter?: (row: RouterRecord) => boolean;
5974
+ sort?: (((a: RouterRecord, b: RouterRecord) => number) | { key: string; dir?: 'asc' | 'desc' });
5975
+ }
5976
+
5964
5977
  /**
5965
5978
  * A cross-grid selection relation (v2, BACKLOG-0000880): a key map (target
5966
5979
  * rows whose `to` value is among the selected source rows' `from` values — an
@@ -5980,10 +5993,10 @@ declare module 'lattice-grid/modules/data-router' {
5980
5993
  * update or remove in place by `rowKey`, preserving selection and scroll.
5981
5994
  */
5982
5995
  interface DataRouter {
5983
- /** Attach a grid behind a predicate; `rowKey` overrides the router default. */
5984
- attach(grid: unknown, predicate: RoutePredicate, opts?: { rowKey?: (string | ((row: RouterRecord) => unknown)) }): DataRouter;
5996
+ /** Attach a grid behind a predicate; `opts` may reshape/filter/sort the route (v3). */
5997
+ attach(grid: unknown, predicate: RoutePredicate, opts?: RouteOptions): DataRouter;
5985
5998
  /** Attach the "rest" sink for records no explicit route matched. */
5986
- attachDefault(grid: unknown, opts?: { rowKey?: (string | ((row: RouterRecord) => unknown)) }): DataRouter;
5999
+ attachDefault(grid: unknown, opts?: RouteOptions): DataRouter;
5987
6000
  /** Detach a grid; the host still owns and destroys it. */
5988
6001
  detach(grid: unknown): DataRouter;
5989
6002
  /** Apply a full snapshot as a keyed diff per grid; returns per-route counts. */
@@ -6136,3 +6149,115 @@ declare module 'lattice-grid/modules/devtools' {
6136
6149
  export const CONSOLE_ACTIVATION: string;
6137
6150
  export default createDevtools;
6138
6151
  }
6152
+
6153
+ declare module 'lattice-grid/modules/mock-socket' {
6154
+ /** One record on a feed: any object. Its partition comes from a property and its identity from `rowKey`. */
6155
+ type FeedRow = Record<string, unknown>;
6156
+
6157
+ /** One change in a delta batch, in the shape the data router applies. */
6158
+ interface FeedChange { op: 'upsert' | 'delete'; row: FeedRow }
6159
+
6160
+ /**
6161
+ * A message on the wire. A snapshot carries the full opening set; a delta
6162
+ * carries the changes since. The reader parses `event.data` and switches on
6163
+ * `kind`, exactly as against a real feed that framed its messages the same way.
6164
+ */
6165
+ interface FeedMessage {
6166
+ kind: 'snapshot' | 'delta';
6167
+ /** Present on a snapshot: the full opening set of rows. */
6168
+ rows?: FeedRow[];
6169
+ /** Present on a delta: the changes to apply. */
6170
+ changes?: FeedChange[];
6171
+ }
6172
+
6173
+ /** A feed: any iterator that yields a snapshot first, then deltas forever. */
6174
+ type Feed = Iterator<FeedMessage>;
6175
+
6176
+ /**
6177
+ * A serverless stand-in for a live `WebSocket`. It presents the same surface
6178
+ * as the browser's `WebSocket` — `readyState` and the state constants,
6179
+ * `onopen`/`onmessage`/`onclose`/`onerror`, `addEventListener`, `send` and
6180
+ * `close` — so the code that reads it does not change when it is swapped for a
6181
+ * real socket. It opens after a short delay, emits the feed's first value as a
6182
+ * snapshot, then pumps one value per tick as a delta.
6183
+ */
6184
+ export class MockWebSocket {
6185
+ static readonly CONNECTING: 0;
6186
+ static readonly OPEN: 1;
6187
+ static readonly CLOSING: 2;
6188
+ static readonly CLOSED: 3;
6189
+ readonly CONNECTING: 0;
6190
+ readonly OPEN: 1;
6191
+ readonly CLOSING: 2;
6192
+ readonly CLOSED: 3;
6193
+ readyState: number;
6194
+ url: string;
6195
+ onopen: ((event: { type: string }) => void) | null;
6196
+ onmessage: ((event: { type: string; data: string }) => void) | null;
6197
+ onclose: ((event: { type: string; code: number; reason: string; wasClean: boolean }) => void) | null;
6198
+ onerror: ((event: { type: string; error: unknown }) => void) | null;
6199
+ /**
6200
+ * @param init the feed and its timing: `feed` (snapshot first, then deltas);
6201
+ * `rate` ms between deltas (default 1000); `jitter` random plus-or-minus ms
6202
+ * per gap (default 0); `seed` for that jitter (default 1); `snapshotDelay`
6203
+ * ms before opening (default 60); `pauseWhenHidden` stops while the tab is
6204
+ * hidden (default true); `url` a cosmetic address.
6205
+ */
6206
+ constructor(init: {
6207
+ feed: Feed;
6208
+ rate?: number;
6209
+ jitter?: number;
6210
+ seed?: number;
6211
+ snapshotDelay?: number;
6212
+ pauseWhenHidden?: boolean;
6213
+ url?: string;
6214
+ });
6215
+ addEventListener(type: string, fn: (event: unknown) => void): void;
6216
+ removeEventListener(type: string, fn: (event: unknown) => void): void;
6217
+ /** A real socket sends upstream; here it is accepted and ignored. */
6218
+ send(data?: unknown): void;
6219
+ /** Stop the feed until `resume()`; the socket stays open (a demo/test affordance). */
6220
+ pause(): void;
6221
+ /** Resume a paused feed. */
6222
+ resume(): void;
6223
+ /** Close the socket, stop the feed and emit a clean `close`. */
6224
+ close(): void;
6225
+ }
6226
+
6227
+ /**
6228
+ * mulberry32: a small seeded pseudo-random generator, so a custom feed can be
6229
+ * seeded the same way the shipped ones are. The same seed yields the same
6230
+ * sequence of values in `[0, 1)`.
6231
+ */
6232
+ export function rng(seed: number): () => number;
6233
+
6234
+ /**
6235
+ * A mixed operations feed — orders, shipments and incidents across three
6236
+ * regions plus a throughput rollup — the Data Router tutorial partitions
6237
+ * across several grids and a chart from one source. Yields a snapshot, then
6238
+ * deltas forever. Seedable for a repeatable stream.
6239
+ */
6240
+ export function opsFeed(options?: {
6241
+ seed?: number;
6242
+ orders?: number;
6243
+ shipments?: number;
6244
+ incidents?: number;
6245
+ batch?: number;
6246
+ }): Generator<FeedMessage>;
6247
+
6248
+ /**
6249
+ * A market-data feed: instruments whose prices random-walk each tick, each
6250
+ * record carrying `type: 'price'`, `symbol`, `last`, `chg` and a bid/ask. The
6251
+ * price/random-walk feed behind the trading-terminal tutorial. Yields a
6252
+ * snapshot, then deltas forever. Seedable for a repeatable stream.
6253
+ */
6254
+ export function priceFeed(options?: {
6255
+ seed?: number;
6256
+ symbols?: { symbol: string; last: number }[];
6257
+ move?: number;
6258
+ batch?: number;
6259
+ spread?: number;
6260
+ }): Generator<FeedMessage>;
6261
+
6262
+ export default MockWebSocket;
6263
+ }