@toclocoinc/lattice-grid 1.30.0 → 1.31.1

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 +151 -6
  3. package/docs/api-detail.html +5 -1
  4. package/lattice-grid.d.ts +94 -4
  5. package/lattice-grid.esm.min.js +65 -13
  6. package/lattice-grid.min.cjs +65 -13
  7. package/lattice-grid.min.css +1 -1
  8. package/lattice-grid.min.js +65 -13
  9. package/modules/angular.esm.min.js +3 -2
  10. package/modules/angular.min.cjs +3 -2
  11. package/modules/angular.min.js +3 -2
  12. package/modules/charts.esm.min.js +426 -4
  13. package/modules/charts.min.cjs +426 -4
  14. package/modules/charts.min.js +426 -4
  15. package/modules/data-router.esm.min.js +246 -0
  16. package/modules/data-router.min.cjs +249 -0
  17. package/modules/data-router.min.js +249 -0
  18. package/modules/devtools.esm.min.js +2 -2
  19. package/modules/devtools.min.cjs +2 -2
  20. package/modules/devtools.min.js +2 -2
  21. package/modules/dhtmlx-compat.esm.min.js +4 -4
  22. package/modules/dhtmlx-compat.min.cjs +4 -4
  23. package/modules/dhtmlx-compat.min.js +4 -4
  24. package/modules/htmx.esm.min.js +65 -13
  25. package/modules/htmx.min.cjs +65 -13
  26. package/modules/htmx.min.js +65 -13
  27. package/modules/react.esm.min.js +3 -2
  28. package/modules/react.min.cjs +3 -2
  29. package/modules/react.min.js +3 -2
  30. package/modules/svelte.esm.min.js +3 -2
  31. package/modules/svelte.min.cjs +3 -2
  32. package/modules/svelte.min.js +3 -2
  33. package/modules/vue.esm.min.js +3 -2
  34. package/modules/vue.min.cjs +3 -2
  35. package/modules/vue.min.js +3 -2
  36. package/modules/webcomponent.esm.min.js +65 -13
  37. package/modules/webcomponent.min.cjs +65 -13
  38. package/modules/webcomponent.min.js +65 -13
  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.30.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.31.1 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -1675,18 +1675,21 @@ grid.presentation.stop(); <span class="cmt">// or Esc</sp
1675
1675
 
1676
1676
  <h2 id="annotate">grid.annotate</h2>
1677
1677
  <p>The drawing layer over the grid: pixels on a transparent canvas, never data. A presenter picks a tool (<code>pen</code>, <code>arrow</code>, <code>rect</code>, <code>highlight</code>) and draws; the layer is inert until one is chosen, so scrolling and selection pass straight through otherwise. Marks are stored in <strong>content coordinates</strong>, so a circle drawn round a cell stays on that cell as the grid scrolls and resizes rather than hanging over the viewport.</p>
1678
- <p>Marks can also be <strong>seeded and added without drawing</strong> (BACKLOG-0000813), which is what lets a host ship a pre-drawn callout or restore one from storage. A mark descriptor is <code>{ type, points, colour? }</code> — <code>type</code> is <code>freehand</code>, <code>arrow</code>, <code>rect</code> or <code>highlight</code> (<code>pen</code> is accepted as an alias for <code>freehand</code>); <code>points</code> are <code>{x, y}</code> in content coordinates (a trail for freehand, the two endpoints for an arrow or rectangle). Seeded and added marks are <em>durable</em>: they survive a presentation ending, unlike a live-drawn mark, and they round-trip through <code>getState</code> and a saved view.</p>
1678
+ <p>Marks can also be <strong>seeded and added without drawing</strong> (BACKLOG-0000813), which is what lets a host ship a pre-drawn callout or restore one from storage. A mark descriptor is <code>{ type, points, colour? }</code> — <code>type</code> is <code>freehand</code>, <code>arrow</code>, <code>rect</code>, <code>highlight</code> or <code>text</code> (<code>pen</code> is accepted as an alias for <code>freehand</code>); <code>points</code> are <code>{x, y}</code> in content coordinates (a trail for freehand, the two endpoints for an arrow or rectangle, a single anchor for text). Seeded and added marks are <em>durable</em>: they survive a presentation ending, unlike a live-drawn mark, and they round-trip through <code>getState</code> and a saved view.</p>
1679
+ <p>A <code>text</code> mark (BACKLOG-0000875) is a label anchored at one content point, carrying its <code>text</code> string and a basic style: <code>colour</code>, an optional <code>fontSize</code> in content pixels (default 14, scaled with a presentation), and an optional <code>background</code> colour drawn behind it. Like every mark it is held in content coordinates, so the label tracks the cell it annotates through scroll and resize.</p>
1679
1680
  <pre><code><span class="cmt">// Seed a mark at construction — rendered on first paint, the way redaction seeds.</span>
1680
1681
  createGrid(el, {
1681
1682
  columns, rows,
1682
1683
  annotate: true,
1683
1684
  state: { annotations: [
1684
1685
  { type: 'arrow', points: [{ x: 40, y: 120 }, { x: 220, y: 80 }], colour: '#e0245e' },
1686
+ { type: 'text', text: 'Q3 spike', points: [{ x: 232, y: 72 }], colour: '#1a6bc7', background: '#fffbe6' },
1685
1687
  ] },
1686
1688
  });
1687
1689
 
1688
1690
  <span class="cmt">// Or add one durably at runtime — no synthesised pointer input.</span>
1689
1691
  grid.annotate.add({ type: 'rect', points: [{ x: 40, y: 100 }, { x: 260, y: 160 }] });
1692
+ grid.annotate.add({ type: 'text', text: 'review', points: [{ x: 48, y: 108 }], fontSize: 16 });
1690
1693
 
1691
1694
  <span class="cmt">// Persist and restore: seeded and added marks come back out of the state.</span>
1692
1695
  const marks = grid.getState().annotations; <span class="cmt">// [{ type, points, colour }, …]</span>
@@ -3612,6 +3615,7 @@ off(); <span class="cmt">// on() returns i
3612
3615
  <tr><td class="name">timeline:detached</td><td class="desc">The brush was removed.</td></tr>
3613
3616
  <tr><td class="name">timeline:seek</td><td class="desc">The brush settled on a range.</td></tr>
3614
3617
  <tr><td class="name">timeline:seeking</td><td class="desc">The brush is being dragged. Throttled.</td></tr>
3618
+ <tr><td class="name">annotation:changed</td><td class="type">{ tool, count }</td><td class="desc">A drawing annotation was added, edited or cleared. Carries the active <code>tool</code> and the <code>count</code> of marks. A first-class event, so <code>grid.on('annotation:changed', ...)</code> and the framework adapters' <code>onAnnotationChanged</code> reach it directly rather than through the <code>'*'</code> wildcard.</td></tr>
3615
3619
  <tr><td class="name">presentation:started</td><td class="desc">Presentation mode began. Carries the scale, options, views and starting index. <code>presentation:changed</code> covers a later change to the same options, so a listener can tell entry from adjustment.</td></tr>
3616
3620
  <tr><td class="name">presentation:changed</td><td class="desc">The options of a running presentation changed.</td></tr>
3617
3621
  <tr><td class="name">presentation:ended</td><td class="desc">Presentation mode ended. Annotations are cleared here.</td></tr>
@@ -3640,6 +3644,33 @@ off(); <span class="cmt">// on() returns i
3640
3644
 
3641
3645
  <div class="note"><p>This list is complete, and stays complete: <code>tools/check.js</code> compares every <code>emit()</code> in the grid against the declared event names and fails the build on a mismatch. A chart raises its own events, which belong to the charts module rather than to this bus.</p></div>
3642
3646
 
3647
+ <h3 id="event-known-example">A declared event is reachable directly, executed</h3>
3648
+ <p class="section-note">Every name in the table above is a first-class event: <code>grid.on(name, ...)</code>
3649
+ binds it without the unknown-name warning, and each maps to a framework handler prop. Shown for
3650
+ <code>annotation:changed</code>, which BACKLOG-876 promoted from a wildcard-only emission to a
3651
+ declared event. Run headless on every build.</p>
3652
+ <pre data-run="js" data-expect="true | 0 | onAnnotationChanged" data-covers="event:annotation:changed"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
3653
+ <span class="kw">const</span> { isKnownEvent } = <span class="kw">await</span> import('../packages/core/src/events/index.js');
3654
+ <span class="kw">const</span> { handlerName } = <span class="kw">await</span> import('../packages/modules/shared/adapter.js');
3655
+
3656
+ <span class="kw">const</span> grid = createHeadlessGrid({ columns: [{ field: 'a' }], rows: [] });
3657
+
3658
+ <span class="cmt">// Binding a declared event does not trip the unknown-name warning that an</span>
3659
+ <span class="cmt">// undeclared one would — that warning is exactly what BACKLOG-876 removed.</span>
3660
+ <span class="kw">const</span> warnings = [];
3661
+ <span class="kw">const</span> original = console.warn;
3662
+ console.warn = (...a) =&gt; warnings.push(a.join(' '));
3663
+ <span class="kw">const</span> off = grid.on('annotation:changed', () =&gt; {});
3664
+ console.warn = original;
3665
+ off();
3666
+ grid.destroy();
3667
+
3668
+ <span class="kw">return</span> [
3669
+ isKnownEvent('annotation:changed'), <span class="cmt">// declared at on() time</span>
3670
+ warnings.length, <span class="cmt">// 0: no unknown-event warning</span>
3671
+ handlerName('annotation:changed'), <span class="cmt">// the adapter prop the frameworks expose</span>
3672
+ ].join(' | ');</code></pre>
3673
+
3643
3674
  <h2 id="rules">Conditional formatting</h2>
3644
3675
  <p>Rules compile into the function <code>cell.style</code> already takes, so a compiled rule set
3645
3676
  installs exactly like a hand-written style function.</p>
@@ -3881,7 +3912,7 @@ const chart = createChart({
3881
3912
  <tr><td class="name">type</td><td class="type">string</td><td class="desc">One of the thirty above.</td></tr>
3882
3913
  <tr><td class="name">x / y</td><td class="type">string</td><td class="desc">Category and measure columns.</td></tr>
3883
3914
  <tr><td class="name">series</td><td class="type">string</td><td class="desc">Splits the measure into one series per distinct value.</td></tr>
3884
- <tr><td class="name">measures</td><td class="type">object[]</td><td class="desc"><code>{col, fn, type, axis}</code>: several measures at once, each reduced by any totals kernel.</td></tr>
3915
+ <tr><td class="name">measures</td><td class="type">object[]</td><td class="desc"><code>{col, fn, type, axis}</code>: several measures at once, each reduced by an aggregation. <code>fn</code> is one of <code>sum</code>, <code>avg</code> (alias <code>mean</code>), <code>min</code>, <code>max</code>, <code>count</code>, <code>countValues</code>, <code>first</code>, <code>last</code>; it defaults to <code>sum</code>. An <code>fn</code> that is none of these is a mistake, not a silent <code>sum</code>: it warns once, naming the value and the supported set, and falls back to <code>sum</code> so the chart still draws.</td></tr>
3885
3916
  <tr><td class="name">title</td><td class="type">string</td><td class="desc">Drawn above the plot.</td></tr>
3886
3917
  <tr><td class="name">scheme</td><td class="type">string | string[]</td><td class="desc">A named scheme or your own colours. <code>schemeNames()</code> lists the built-in ones, including a colour-blind-safe palette.</td></tr>
3887
3918
  <tr><td class="name">legend</td><td class="type">boolean | object</td><td class="desc"><code>position</code>, and <code>isolate</code> so a click shows only that series, which is what a reader with eight series wants, and what plain toggling makes them do in seven clicks.</td></tr>
@@ -4062,6 +4093,114 @@ createGrid(el, {
4062
4093
 
4063
4094
  <div class="note"><p>The module imports nothing from the grid: <code>createChart</code> is handed a grid rather than importing one. That is what keeps the charts bundle to the drawing, and it is why the grid must be created first, and why a chart cannot outlive it.</p></div>
4064
4095
 
4096
+ <h2 id="datarouter">The data router</h2>
4097
+ <p><code>modules/data-router</code> is a host-layer demultiplexer: it takes <strong>one</strong> arriving stream or dataset, splits it by what each record <em>is</em>, and routes each partition to its own grid &mdash; or to a headless grid driving a chart. One round-trip, or one live feed, hydrates a whole screen of grids that each see only their slice. It is optional, imports nothing from the grid, and adds no core hook: every grid is driven through the <strong>public</strong> incremental path, <code>grid.rows.apply({ add, update, remove })</code>.</p>
4098
+ <pre><code>import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
4099
+
4100
+ const router = createDataRouter({
4101
+ key: 'entityType', <span class="cmt">// partition: a property, or fn(row) =&gt; value</span>
4102
+ rowKey: 'id', <span class="cmt">// identity within a grid: a property, or fn(row)</span>
4103
+ overlap: false, <span class="cmt">// default: first matching route wins</span>
4104
+ onUnrouted: (item) =&gt; {}, <span class="cmt">// optional sink for records that match no route</span>
4105
+ });
4106
+
4107
+ router.attach(ordersGrid, 'order'); <span class="cmt">// a property value...</span>
4108
+ router.attach(bigGrid, (row) =&gt; row.amt &gt; 1e6); <span class="cmt">// ...or a composite predicate</span>
4109
+ router.attach(headlessGrid, 'metric', { rowKey: 'ts' }); <span class="cmt">// per-attach rowKey override; feeds a chart</span>
4110
+ router.attachDefault(restGrid); <span class="cmt">// the "rest" sink: nothing is dropped</span>
4111
+
4112
+ const counts = router.load(snapshot); <span class="cmt">// keyed diff per grid: [{added,updated,removed}, ...]</span>
4113
+ router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">// in-place deltas by rowKey</span></code></pre>
4114
+ <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 &mdash; 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 &mdash; if an <code>attachDefault</code> grid exists &mdash; 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>
4115
+ <div class="table-wrap">
4116
+ <table>
4117
+ <thead><tr><th>Member</th><th>Description</th></tr></thead>
4118
+ <tbody>
4119
+ <tr><td class="sig">createDataRouter({ key, rowKey?, overlap?, onUnrouted? })</td><td class="desc">Create a router. <code>key</code> is the partition (property or <code>fn(row)</code>); <code>rowKey</code> the default identity within a grid; <code>overlap</code> fans a record to all matching routes; <code>onUnrouted</code> is a sink for unmatched records.</td></tr>
4120
+ <tr><td class="sig">attach(grid, predicate, { rowKey? })</td><td class="desc">Route to <code>grid</code> when <code>predicate</code> matches: a value compared to <code>key</code>, or a <code>fn(row) =&gt; boolean</code>. <code>rowKey</code> overrides the router default for this grid.</td></tr>
4121
+ <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>
4122
+ <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>
4123
+ <tr><td class="sig">apply(deltas)</td><td class="desc">Apply <code>{ op: 'upsert' | 'delete', row }</code> deltas in place by <code>rowKey</code>.</td></tr>
4124
+ <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
+ <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
+ <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>
4129
+ </tbody>
4130
+ </table>
4131
+ </div>
4132
+ <p>The router keeps a small <code>Map&lt;rowKey, row&gt;</code> per route to compute the snapshot diff. That is deliberate for v1; a future optimisation could diff against the grid's own key index rather than a shadow copy. Ordering and dedupe across a live feed are the host's to guarantee &mdash; a caller that must drop stale out-of-order deltas can carry its own version or sequence field and filter before <code>apply</code>; v1 imposes no version scheme.</p>
4133
+ <p><strong>Cross-grid selection filtering (v2, BACKLOG-0000880).</strong> <code>link</code> keeps each target's <em>full partition</em> separate from what it currently shows: when the source's selection changes, the router recomputes the shown subset from the relation and re-pushes it through the same keyed-diff path, so the target grid stays dumb &mdash; it only ever receives rows, never a query or a reference to the source. Selection <em>in</em> the target survives an unrelated refilter, because the keyed path preserves it. No selection (or one the router cannot resolve to routed rows) shows the full partition, and deselecting restores it. The source grid must have selection enabled; still no grid-core change. Debounce is controlled by <code>selectionDebounce</code> (default 16&nbsp;ms; <code>0</code> is synchronous), and <code>flush()</code> forces it.</p>
4134
+ <h3 id="datarouter-v2-example">Cross-grid selection filtering, executed</h3>
4135
+ <p class="section-note">A customers grid and an orders grid off one feed; selecting customers filters the orders
4136
+ grid to their regions through the keyed-diff path, and deselecting restores the full set. Run headless
4137
+ on every build.</p>
4138
+ <pre data-run="js" data-expect="3 | 2 | 3 | 3" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4139
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4140
+
4141
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region', type: 'text' }];
4142
+ <span class="kw">const</span> customers = createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
4143
+ <span class="kw">const</span> orders = createHeadlessGrid({ rowKey: 'id', columns: cols });
4144
+
4145
+ <span class="cmt">// debounce 0 so a selection refilters synchronously in this example.</span>
4146
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
4147
+ router.attach(customers, 'customer');
4148
+ router.attach(orders, 'order');
4149
+ router.load([
4150
+ { id: 'c1', type: 'customer', region: 'emea' },
4151
+ { id: 'c2', type: 'customer', region: 'amer' },
4152
+ { id: 'o1', type: 'order', region: 'emea' },
4153
+ { id: 'o2', type: 'order', region: 'amer' },
4154
+ { id: 'o3', type: 'order', region: 'emea' },
4155
+ ]);
4156
+ <span class="cmt">// A selection in customers filters the orders grid by region.</span>
4157
+ router.link(customers, orders, { from: 'region', to: 'region' });
4158
+
4159
+ <span class="kw">const</span> full = orders.rows.count(); <span class="cmt">// 3: no selection, full partition</span>
4160
+ customers.selection.set(['c1']); <span class="cmt">// emea</span>
4161
+ <span class="kw">const</span> oneRegion = orders.rows.count(); <span class="cmt">// 2: o1, o3</span>
4162
+ customers.selection.set(['c1', 'c2']); <span class="cmt">// emea + amer (IN set)</span>
4163
+ <span class="kw">const</span> both = orders.rows.count(); <span class="cmt">// 3</span>
4164
+ customers.selection.set([]); <span class="cmt">// deselect restores</span>
4165
+ <span class="kw">const</span> restored = orders.rows.count(); <span class="cmt">// 3</span>
4166
+
4167
+ customers.destroy(); orders.destroy(); router.destroy();
4168
+ <span class="kw">return</span> [full, oneRegion, both, restored].join(' | ');</code></pre>
4169
+ <h3 id="datarouter-example">One feed, three grids, executed</h3>
4170
+ <p class="section-note">A single snapshot fanned to an orders grid, an invoices grid and a "rest" sink, then a
4171
+ delta that changes a row's partition &mdash; proving the fan-out, the sink, and that a moved row
4172
+ leaves its old grid and joins the new one rather than being duplicated. Run headless on every build.</p>
4173
+ <pre data-run="js" data-expect="2,1,1 | 1/2 | 0" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4174
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4175
+
4176
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }];
4177
+ <span class="kw">const</span> orders = createHeadlessGrid({ rowKey: 'id', columns: cols });
4178
+ <span class="kw">const</span> invoices = createHeadlessGrid({ rowKey: 'id', columns: cols });
4179
+ <span class="kw">const</span> rest = createHeadlessGrid({ rowKey: 'id', columns: cols });
4180
+
4181
+ <span class="cmt">// One router keyed on `type`; each grid sees only its slice, the "rest" sink</span>
4182
+ <span class="cmt">// catches anything that matches no explicit route.</span>
4183
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4184
+ router.attach(orders, 'order');
4185
+ router.attach(invoices, 'invoice');
4186
+ router.attachDefault(rest);
4187
+
4188
+ <span class="cmt">// One snapshot hydrates all three grids at once.</span>
4189
+ router.load([
4190
+ { id: 'o1', type: 'order', amt: 10 },
4191
+ { id: 'o2', type: 'order', amt: 20 },
4192
+ { id: 'i1', type: 'invoice', amt: 99 },
4193
+ { id: 'x1', type: 'ticket', amt: 1 }, <span class="cmt">// matches no route -&gt; the sink, not dropped</span>
4194
+ ]);
4195
+ <span class="kw">const</span> fanned = [orders.rows.count(), invoices.rows.count(), rest.rows.count()].join(',');
4196
+
4197
+ <span class="cmt">// o2's partition changes: it MOVES from orders to invoices, not duplicated.</span>
4198
+ router.apply([{ op: 'upsert', row: { id: 'o2', type: 'invoice', amt: 25 } }]);
4199
+ <span class="kw">const</span> moved = orders.rows.count() + '/' + invoices.rows.count();
4200
+
4201
+ orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
4202
+ <span class="kw">return</span> [fanned, moved, router.unrouted].join(' | ');</code></pre>
4203
+
4065
4204
  <h2 id="charts">In-cell charts</h2>
4066
4205
  <p>Seven chart renderers for a cell. Each is a single SVG whose path data is the only thing a
4067
4206
  repaint writes, so they cost the same as any other cell as rows recycle.</p>
@@ -4611,7 +4750,7 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
4611
4750
 
4612
4751
  <h3 id="module-exports-example">Every module export, executed</h3>
4613
4752
  <p class="section-note">Nine shipped modules, each export resolved against its own barrel on every build.</p>
4614
- <pre data-run="js" data-expect="61" 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"><code><span class="cmt">// Every declared export of every shipped module, resolved against its own</span>
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>
4615
4754
  <span class="cmt">// barrel. A module that stopped exporting something fails here.</span>
4616
4755
  <span class="kw">const</span> modules = [
4617
4756
  [<span class="kw">await</span> import('../packages/dom/src/index.js'), [
@@ -4652,6 +4791,9 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
4652
4791
  [<span class="kw">await</span> import('../packages/modules/dhtmlx-compat/index.js'), [
4653
4792
  'Grid',
4654
4793
  ]],
4794
+ [<span class="kw">await</span> import('../packages/modules/data-router/index.js'), [
4795
+ 'createDataRouter',
4796
+ ]],
4655
4797
  ];
4656
4798
 
4657
4799
  <span class="kw">let</span> present = 0;
@@ -5120,14 +5262,17 @@ return `methods ${ANOMALY_METHODS.length}; flagged ${z.flagged}; upper ${fence.u
5120
5262
  </table>
5121
5263
  </div>
5122
5264
  <h3 id="type-AnnotationMark">AnnotationMark</h3>
5123
- <p class="section-note">A durable annotation mark descriptor (BACKLOG-0000813) — the shape a host seeds through `state.annotations`, adds through {@link AnnotationApi.add}, and reads back through {@link AnnotationApi.list} and `getState`. `points` are in **content coordinates** (the same space user-drawn marks are stored in), so a mark tracks scroll and resize rather than hanging over the viewport. A `freehand` mark is a trail of points; `arrow` and `rect` are their two endpoints. Text marks are a deliberate follow-up. `pen` is accepted as an alias for `freehand` on input; `list()` reports `freehand`.</p>
5265
+ <p class="section-note">A durable annotation mark descriptor (BACKLOG-0000813) — the shape a host seeds through `state.annotations`, adds through {@link AnnotationApi.add}, and reads back through {@link AnnotationApi.list} and `getState`. `points` are in **content coordinates** (the same space user-drawn marks are stored in), so a mark tracks scroll and resize rather than hanging over the viewport. A `freehand` mark is a trail of points; `arrow` and `rect` are their two endpoints. A `text` mark is a label anchored at a single content point, carrying its `text` string and an optional basic style (BACKLOG-0000875). `pen` is accepted as an alias for `freehand` on input; `list()` reports `freehand`.</p>
5124
5266
  <div class="table-wrap">
5125
5267
  <table>
5126
5268
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
5127
5269
  <tbody>
5128
- <tr><td class="name">type</td><td class="type">'freehand' | 'arrow' | 'rect' | 'highlight'</td><td class="desc"></td></tr>
5129
- <tr><td class="name">points</td><td class="type">{ x: number; y: number }[]</td><td class="desc"></td></tr>
5270
+ <tr><td class="name">type</td><td class="type">'freehand' | 'arrow' | 'rect' | 'highlight' | 'text'</td><td class="desc"></td></tr>
5271
+ <tr><td class="name">points</td><td class="type">{ x: number; y: number }[]</td><td class="desc">Content coordinates. A `text` mark carries a single anchor point; `arrow` and `rect` carry their two corners, and `freehand` a trail.</td></tr>
5130
5272
  <tr><td class="name">colour</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
5273
+ <tr><td class="name">text</td><td class="type">string</td><td class="desc">The label of a `text` mark. Required for `text`, ignored for other types. <small>(optional)</small></td></tr>
5274
+ <tr><td class="name">fontSize</td><td class="type">number</td><td class="desc">A `text` mark's font size in content pixels (before presentation scale). Defaults to 14. <small>(optional)</small></td></tr>
5275
+ <tr><td class="name">background</td><td class="type">string</td><td class="desc">An optional backing colour drawn behind a `text` mark's label. <small>(optional)</small></td></tr>
5131
5276
  </tbody>
5132
5277
  </table>
5133
5278
  </div>
@@ -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.30.0</p>
440
+ <p class="rail__sub">Developer guide · v1.31.1</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -4835,6 +4835,8 @@ grid.on('presentation:captured', (e) =&gt; {
4835
4835
  <p class="example__label">Drawing over the grid</p>
4836
4836
  <pre><code>grid.annotate.use('pen'); <span class="cmt">// pen · arrow · rect · highlight</span>
4837
4837
  grid.annotate.use('arrow', { colour: '#e0245e' });
4838
+ <span class="cmt">// A durable text label anchored to a cell, seeded or added (BACKLOG-875).</span>
4839
+ grid.annotate.add({ type: 'text', text: 'Q3 spike', points: [{ x: 232, y: 72 }], background: '#fffbe6' });
4838
4840
  grid.annotate.undo();
4839
4841
  grid.annotate.clear();
4840
4842
  grid.annotate.use(null); <span class="cmt">// hand the grid back</span></code></pre>
@@ -6236,6 +6238,7 @@ grid.state.apply(savedView.state);
6236
6238
  <tr><td class="name">canChartRange</td><td class="desc">Whether <code>chartRange</code> would draw something for the grid’s current selection — the question a menu asks before offering the item.</td></tr>
6237
6239
  <tr><td class="name">deriveRangeSpec</td><td class="desc">Decide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for <code>createChart</code>.</td></tr>
6238
6240
  <tr><td class="name">regressionPlots</td><td class="desc">Turn a fitted regression model into diagnostic chart specs ready for <code>createChart</code>: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped.</td></tr>
6241
+ <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
6239
6242
  <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
6240
6243
  <tr><td class="name">createLatticeGridElement</td><td class="desc">Build the element class without registering it, for a custom registry.</td></tr>
6241
6244
  <tr><td class="name">createMessages</td><td class="desc">Build a message catalogue. A partial set lays over the built-in British English one.</td></tr>
@@ -6460,6 +6463,7 @@ el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
6460
6463
  <tr><td class="name">timeline:detached</td><td class="desc">The brush was removed.</td></tr>
6461
6464
  <tr><td class="name">timeline:seek</td><td class="desc">The brush settled on a range.</td></tr>
6462
6465
  <tr><td class="name">timeline:seeking</td><td class="desc">The brush is being dragged. Throttled.</td></tr>
6466
+ <tr><td class="name">annotation:changed</td><td class="desc">A drawing annotation was added, edited or cleared. Carries the active tool and the mark count. Declared (BACKLOG-876) so grid.on('annotation:changed', ...) and the adapters' onAnnotationChanged reach it directly instead of via the '*' wildcard.</td></tr>
6463
6467
  <tr><td class="name">view:applied</td><td class="desc">Emits no storage write: applying a view changes nothing to persist.</td></tr>
6464
6468
  <tr><td class="name">view:default</td><td class="desc">view is null when the default was cleared.</td></tr>
6465
6469
  <tr><td class="name">view:removed</td><td class="desc">A saved view was deleted.</td></tr>
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.30.0, type declarations
2
+ * Lattice Grid 1.31.1, type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -3439,6 +3439,8 @@ export type EventName =
3439
3439
  /* Comparison and time */
3440
3440
  | 'diff:changed' | 'diff:swapped'
3441
3441
  | 'timeline:attached' | 'timeline:detached' | 'timeline:seek' | 'timeline:seeking'
3442
+ /* Annotations */
3443
+ | 'annotation:changed'
3442
3444
  /* Export */
3443
3445
  | 'export:progress'
3444
3446
  /* Every event at once, for logging and debugging. */
@@ -3940,13 +3942,25 @@ export interface CaptureOptions {
3940
3942
  * `points` are in **content coordinates** (the same space user-drawn marks are
3941
3943
  * stored in), so a mark tracks scroll and resize rather than hanging over the
3942
3944
  * viewport. A `freehand` mark is a trail of points; `arrow` and `rect` are their
3943
- * two endpoints. Text marks are a deliberate follow-up. `pen` is accepted as an
3944
- * alias for `freehand` on input; `list()` reports `freehand`.
3945
+ * two endpoints. A `text` mark is a label anchored at a single content point,
3946
+ * carrying its `text` string and an optional basic style (BACKLOG-0000875).
3947
+ * `pen` is accepted as an alias for `freehand` on input; `list()` reports
3948
+ * `freehand`.
3945
3949
  */
3946
3950
  export interface AnnotationMark {
3947
- type: 'freehand' | 'arrow' | 'rect' | 'highlight';
3951
+ type: 'freehand' | 'arrow' | 'rect' | 'highlight' | 'text';
3952
+ /**
3953
+ * Content coordinates. A `text` mark carries a single anchor point; `arrow`
3954
+ * and `rect` carry their two corners, and `freehand` a trail.
3955
+ */
3948
3956
  points: { x: number; y: number }[];
3949
3957
  colour?: string;
3958
+ /** The label of a `text` mark. Required for `text`, ignored for other types. */
3959
+ text?: string;
3960
+ /** A `text` mark's font size in content pixels (before presentation scale). Defaults to 14. */
3961
+ fontSize?: number;
3962
+ /** An optional backing colour drawn behind a `text` mark's label. */
3963
+ background?: string;
3950
3964
  }
3951
3965
 
3952
3966
  export interface AnnotationApi {
@@ -5934,6 +5948,82 @@ declare module 'lattice-grid/modules/angular' {
5934
5948
  export default createLatticeGrid;
5935
5949
  }
5936
5950
 
5951
+ declare module 'lattice-grid/modules/data-router' {
5952
+ /**
5953
+ * A record routed through a data router: any object. Its partition comes from
5954
+ * the router's `key` and its identity within a grid from `rowKey`.
5955
+ */
5956
+ type RouterRecord = Record<string, unknown>;
5957
+
5958
+ /** A per-route diff summary returned by `load`. */
5959
+ interface RouteDiff { added: number; updated: number; removed: number }
5960
+
5961
+ /** A predicate: a property value (`row[key] === value`) or a `fn(row)`. */
5962
+ type RoutePredicate = unknown | ((row: RouterRecord) => boolean);
5963
+
5964
+ /**
5965
+ * A cross-grid selection relation (v2, BACKLOG-0000880): a key map (target
5966
+ * rows whose `to` value is among the selected source rows' `from` values — an
5967
+ * IN set), or a function handed the selected source rows that returns a
5968
+ * target-row predicate.
5969
+ */
5970
+ type SelectionRelation =
5971
+ | { from: string; to: string }
5972
+ | ((selected: RouterRecord[]) => ((row: RouterRecord) => boolean));
5973
+
5974
+ /**
5975
+ * A data router: one arriving stream, partitioned by a property (or composite
5976
+ * predicate), fanned out to a grid per partition (BACKLOG-0000879). Each grid
5977
+ * sees only its slice, updated by keyed diff through the public
5978
+ * `grid.rows.apply` path — no grid-core change, no cross-references between
5979
+ * grids. Snapshots apply keyed diffs (unchanged rows never repaint); deltas add,
5980
+ * update or remove in place by `rowKey`, preserving selection and scroll.
5981
+ */
5982
+ 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;
5985
+ /** Attach the "rest" sink for records no explicit route matched. */
5986
+ attachDefault(grid: unknown, opts?: { rowKey?: (string | ((row: RouterRecord) => unknown)) }): DataRouter;
5987
+ /** Detach a grid; the host still owns and destroys it. */
5988
+ detach(grid: unknown): DataRouter;
5989
+ /** Apply a full snapshot as a keyed diff per grid; returns per-route counts. */
5990
+ load(snapshot: RouterRecord[]): RouteDiff[];
5991
+ /** Apply incremental deltas, routed and applied in place by `rowKey`. */
5992
+ apply(deltas: { op: 'upsert' | 'delete'; row: RouterRecord }[]): void;
5993
+ /**
5994
+ * Link a source grid's selection to what a target grid receives (v2,
5995
+ * BACKLOG-0000880): the target shows the subset of its partition the
5996
+ * `relation` admits, re-pushed through the keyed-diff path. No selection
5997
+ * shows the full partition; changes are debounced.
5998
+ */
5999
+ link(source: unknown, target: unknown, relation: SelectionRelation): DataRouter;
6000
+ /** Apply any debounced selection refilter synchronously (for tests/determinism). */
6001
+ flush(): DataRouter;
6002
+ /** How many records matched no route. */
6003
+ readonly unrouted: number;
6004
+ /** Detach every grid and drop every link (the host destroys the grids themselves). */
6005
+ destroy(): void;
6006
+ }
6007
+
6008
+ /**
6009
+ * Create a data router that partitions one stream to many grids.
6010
+ *
6011
+ * `key` is the partition property or `fn(row)`; `rowKey` is the identity within
6012
+ * a grid; `overlap` fans a record to every matching route (default: first match
6013
+ * wins); `onUnrouted` receives records that match none; `selectionDebounce` is
6014
+ * the debounce in ms for cross-grid selection refilters (default 16; `0` is
6015
+ * synchronous).
6016
+ */
6017
+ export function createDataRouter(opts: {
6018
+ key: (string | ((row: RouterRecord) => unknown));
6019
+ rowKey?: (string | ((row: RouterRecord) => unknown));
6020
+ overlap?: boolean;
6021
+ onUnrouted?: (item: unknown) => void;
6022
+ selectionDebounce?: number;
6023
+ }): DataRouter;
6024
+ export default createDataRouter;
6025
+ }
6026
+
5937
6027
  declare module 'lattice-grid/modules/webcomponent' {
5938
6028
  /**
5939
6029
  * Register `<lattice-grid>`.