@toclocoinc/lattice-grid 1.31.1 → 1.33.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 (59) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +240 -3
  3. package/docs/api-detail.html +3 -2
  4. package/lattice-grid.d.ts +364 -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/chart-alluvial.esm.min.js +143 -0
  12. package/modules/chart-arc.esm.min.js +102 -0
  13. package/modules/chart-bubblemap.esm.min.js +86 -0
  14. package/modules/chart-bump.esm.min.js +105 -0
  15. package/modules/chart-calendar.esm.min.js +124 -0
  16. package/modules/chart-decomposition.esm.min.js +127 -0
  17. package/modules/chart-diverging.esm.min.js +99 -0
  18. package/modules/chart-dumbbell.esm.min.js +110 -0
  19. package/modules/chart-fan.esm.min.js +135 -0
  20. package/modules/chart-hexbin.esm.min.js +144 -0
  21. package/modules/chart-hexmap.esm.min.js +120 -0
  22. package/modules/chart-icicle.esm.min.js +92 -0
  23. package/modules/chart-parallel.esm.min.js +114 -0
  24. package/modules/chart-ridgeline.esm.min.js +114 -0
  25. package/modules/chart-roc.esm.min.js +177 -0
  26. package/modules/chart-slope.esm.min.js +95 -0
  27. package/modules/chart-splom.esm.min.js +120 -0
  28. package/modules/chart-waffle.esm.min.js +92 -0
  29. package/modules/charts.esm.min.js +107 -9
  30. package/modules/charts.min.cjs +105 -9
  31. package/modules/charts.min.js +105 -9
  32. package/modules/data-router.esm.min.js +2048 -24
  33. package/modules/data-router.min.cjs +2048 -24
  34. package/modules/data-router.min.js +2048 -24
  35. package/modules/devtools.esm.min.js +2 -2
  36. package/modules/devtools.min.cjs +2 -2
  37. package/modules/devtools.min.js +2 -2
  38. package/modules/dhtmlx-compat.esm.min.js +4 -4
  39. package/modules/dhtmlx-compat.min.cjs +4 -4
  40. package/modules/dhtmlx-compat.min.js +4 -4
  41. package/modules/htmx.esm.min.js +4 -4
  42. package/modules/htmx.min.cjs +4 -4
  43. package/modules/htmx.min.js +4 -4
  44. package/modules/mock-socket.esm.min.js +242 -0
  45. package/modules/mock-socket.min.cjs +242 -0
  46. package/modules/mock-socket.min.js +242 -0
  47. package/modules/react.esm.min.js +2 -2
  48. package/modules/react.min.cjs +2 -2
  49. package/modules/react.min.js +2 -2
  50. package/modules/svelte.esm.min.js +2 -2
  51. package/modules/svelte.min.cjs +2 -2
  52. package/modules/svelte.min.js +2 -2
  53. package/modules/vue.esm.min.js +2 -2
  54. package/modules/vue.min.cjs +2 -2
  55. package/modules/vue.min.js +2 -2
  56. package/modules/webcomponent.esm.min.js +4 -4
  57. package/modules/webcomponent.min.cjs +4 -4
  58. package/modules/webcomponent.min.js +4 -4
  59. 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.33.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>
@@ -4093,6 +4094,93 @@ createGrid(el, {
4093
4094
 
4094
4095
  <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>
4095
4096
 
4097
+ <h3 id="chart-extension-types">Extension chart types — pay only for what you draw</h3>
4098
+ <p>The base charts bundle draws the built-in <code>TYPES</code> and nothing else. A new chart type is a <strong>separate, opt-in module</strong> a caller imports only if they use it (BACKLOG-0000886, on the slim-core seam BACKLOG-0000884). Importing it self-registers the type with the base module through <code>registerChartType</code>; the base <code>Chart</code> consults that registry for any type it does not draw natively. Because the base never imports the extension, the base bundle <strong>does not grow</strong> for a type a caller never uses.</p>
4099
+ <pre><code><span class="kw">import</span> '@toclocoinc/lattice-grid/modules/charts'; <span class="cmt">// the base</span>
4100
+ <span class="kw">import</span> '@toclocoinc/lattice-grid/modules/chart-ridgeline'; <span class="cmt">// opt in to one type</span>
4101
+
4102
+ createChart({ grid, container: '#dist', type: 'ridgeline', x: 'segment', y: 'value' });</code></pre>
4103
+ <p>An extension declares <code>{ draw, bind?, freeform?, labelled? }</code>. Its <code>draw(ctx)</code> receives the same context a built-in drawer gets — <code>plot</code>, <code>bound</code>, <code>groups</code>, <code>scheme</code>, <code>typography</code>, <code>labels</code>, <code>grid</code>, <code>spec</code> — plus <code>ctx.helpers</code>, the base's own toolkit (element factory, scales, axis drawers, mark pool, distribution kernels). So an extension imports nothing heavy from the base: it receives the toolkit and ships only its own geometry. <code>registeredChartTypes()</code> lists what is registered. <strong>Ridgeline</strong> (<code>drawRidgeline</code>) is the first: one kernel-density ridge per category, stacked and overlapping, over the distribution of a measure — the reading for "how did this distribution change across segments".</p>
4104
+ <pre data-run="js" data-expect="true | true | function" data-covers="export:registerChartType export:registeredChartTypes export:drawRidgeline"><code><span class="kw">const</span> charts = <span class="kw">await</span> import('../packages/modules/charts/index.js');
4105
+ <span class="kw">const</span> ridge = <span class="kw">await</span> import('../packages/modules/chart-ridgeline/index.js');
4106
+
4107
+ <span class="cmt">// Importing the module self-registered the type against the base registry.</span>
4108
+ <span class="kw">const</span> registered = charts.registeredChartTypes().includes('ridgeline');
4109
+ <span class="cmt">// registerChartType is idempotent by name, so re-registering is safe.</span>
4110
+ charts.registerChartType('ridgeline', { draw: ridge.drawRidgeline });
4111
+
4112
+ <span class="kw">return</span> [
4113
+ registered,
4114
+ charts.registeredChartTypes().includes('ridgeline'),
4115
+ typeof ridge.drawRidgeline,
4116
+ ].join(' | ');</code></pre>
4117
+
4118
+ <p>More lead-pick types ship the same way, each in its own opt-in module — the base bundle stays flat as they are added. <strong>Calendar heatmap</strong> (<code>drawCalendar</code>, <code>type: 'calendar'</code>) lays a measure out value-by-day, GitHub-style, from a date column. <strong>Scatter-plot matrix</strong> (<code>drawSplom</code>/<code>bindSplom</code>, <code>type: 'splom'</code>) crosses every pair of numeric <code>columns</code>. <strong>Hexbin</strong> (<code>drawHexbin</code>/<code>bindHexbin</code>, <code>type: 'hexbin'</code>) bins a scatter into count-shaded hexagons so a million rows read as a density field. Each reads the grid through the public row API, so it follows the grid's filters and sort.</p>
4119
+ <pre data-run="js" data-expect="true | function | function | function | function | function" data-covers="export:drawCalendar export:drawSplom export:bindSplom export:drawHexbin export:bindHexbin"><code><span class="kw">const</span> charts = <span class="kw">await</span> import('../packages/modules/charts/index.js');
4120
+ <span class="kw">const</span> cal = <span class="kw">await</span> import('../packages/modules/chart-calendar/index.js');
4121
+ <span class="kw">const</span> splom = <span class="kw">await</span> import('../packages/modules/chart-splom/index.js');
4122
+ <span class="kw">const</span> hex = <span class="kw">await</span> import('../packages/modules/chart-hexbin/index.js');
4123
+
4124
+ <span class="cmt">// Each import self-registered its type; the base bundle carries none of them.</span>
4125
+ <span class="kw">const</span> types = charts.registeredChartTypes();
4126
+ <span class="kw">return</span> [
4127
+ ['calendar', 'splom', 'hexbin'].every((t) =&gt; types.includes(t)),
4128
+ typeof cal.drawCalendar,
4129
+ typeof splom.drawSplom, typeof splom.bindSplom,
4130
+ typeof hex.drawHexbin, typeof hex.bindHexbin,
4131
+ ].join(' | ');</code></pre>
4132
+
4133
+ <p>The model-evaluation and time-series lead picks ship the same way. <strong>ROC / PR / calibration</strong> (<code>drawRoc</code>/<code>bindRoc</code>, <code>type: 'roc'</code>, <code>curve: 'roc' | 'pr' | 'calibration'</code>) evaluates a classifier from a <code>label</code> and a <code>score</code> column, with the AUC. <strong>Fan / forecast</strong> (<code>drawFan</code>/<code>bindFan</code>, <code>type: 'fan'</code>) draws history, a point forecast, and a widening prediction interval from <code>y</code>/<code>forecast</code>/<code>lower</code>/<code>upper</code>. <strong>Decomposition panel</strong> (<code>drawDecomposition</code>/<code>bindDecomposition</code>, <code>type: 'decomposition'</code>) stacks the <code>observed</code>/<code>trend</code>/<code>seasonal</code>/<code>residual</code> components on one x axis — the companion to the grid's own time-series shadow columns.</p>
4134
+ <pre data-run="js" data-expect="true | function | function | function | function | function | function" data-covers="export:drawRoc export:bindRoc export:drawFan export:bindFan export:drawDecomposition export:bindDecomposition"><code><span class="kw">const</span> charts = <span class="kw">await</span> import('../packages/modules/charts/index.js');
4135
+ <span class="kw">const</span> roc = <span class="kw">await</span> import('../packages/modules/chart-roc/index.js');
4136
+ <span class="kw">const</span> fan = <span class="kw">await</span> import('../packages/modules/chart-fan/index.js');
4137
+ <span class="kw">const</span> decomp = <span class="kw">await</span> import('../packages/modules/chart-decomposition/index.js');
4138
+
4139
+ <span class="kw">const</span> types = charts.registeredChartTypes();
4140
+ <span class="kw">return</span> [
4141
+ ['roc', 'fan', 'decomposition'].every((t) =&gt; types.includes(t)),
4142
+ typeof roc.drawRoc, typeof roc.bindRoc,
4143
+ typeof fan.drawFan, typeof fan.bindFan,
4144
+ typeof decomp.drawDecomposition, typeof decomp.bindDecomposition,
4145
+ ].join(' | ');</code></pre>
4146
+
4147
+ <p>The comparison and ranking family ships the same way. <strong>Slope</strong> (<code>drawSlope</code>, <code>type: 'slope'</code>) connects each series across two periods; <strong>dumbbell</strong> (<code>drawDumbbell</code>/<code>bindDumbbell</code>, <code>type: 'dumbbell'</code>) shows a <code>start</code>/<code>end</code> gap per category; <strong>bump</strong> (<code>drawBump</code>, <code>type: 'bump'</code>) plots rank-over-time; <strong>diverging</strong> (<code>drawDiverging</code>, <code>type: 'diverging'</code>) grows bars from a central zero; <strong>parallel coordinates</strong> (<code>drawParallel</code>/<code>bindParallel</code>, <code>type: 'parallel'</code>) draws one polyline per row across several numeric axes.</p>
4148
+ <pre data-run="js" data-expect="true | function | function | function | function | function | function | function" data-covers="export:drawSlope export:drawDumbbell export:bindDumbbell export:drawBump export:drawDiverging export:drawParallel export:bindParallel"><code><span class="kw">const</span> charts = <span class="kw">await</span> import('../packages/modules/charts/index.js');
4149
+ <span class="kw">const</span> slope = <span class="kw">await</span> import('../packages/modules/chart-slope/index.js');
4150
+ <span class="kw">const</span> dumbbell = <span class="kw">await</span> import('../packages/modules/chart-dumbbell/index.js');
4151
+ <span class="kw">const</span> bump = <span class="kw">await</span> import('../packages/modules/chart-bump/index.js');
4152
+ <span class="kw">const</span> diverging = <span class="kw">await</span> import('../packages/modules/chart-diverging/index.js');
4153
+ <span class="kw">const</span> parallel = <span class="kw">await</span> import('../packages/modules/chart-parallel/index.js');
4154
+
4155
+ <span class="kw">const</span> types = charts.registeredChartTypes();
4156
+ <span class="kw">return</span> [
4157
+ ['slope', 'dumbbell', 'bump', 'diverging', 'parallel'].every((t) =&gt; types.includes(t)),
4158
+ typeof slope.drawSlope,
4159
+ typeof dumbbell.drawDumbbell, typeof dumbbell.bindDumbbell,
4160
+ typeof bump.drawBump,
4161
+ typeof diverging.drawDiverging,
4162
+ typeof parallel.drawParallel, typeof parallel.bindParallel,
4163
+ ].join(' | ');</code></pre>
4164
+
4165
+ <p>The hierarchy, flow and geographic remainder ships the same way — treemap, sunburst, funnel, radar, sankey, chord and network are already built in, so the new opt-in modules are: <strong>icicle</strong> (<code>drawIcicle</code>, <code>type: 'icicle'</code>, drawn from the grid's group tree), <strong>waffle</strong> (<code>drawWaffle</code>, <code>type: 'waffle'</code>), <strong>alluvial</strong> (<code>drawAlluvial</code>/<code>bindAlluvial</code>, <code>type: 'alluvial'</code>), <strong>arc diagram</strong> (<code>drawArc</code>/<code>bindArc</code>, <code>type: 'arc'</code>), <strong>bubble map</strong> (<code>drawBubbleMap</code>/<code>bindBubbleMap</code>, <code>type: 'bubblemap'</code>) and <strong>hexbin map</strong> (<code>drawHexMap</code>/<code>bindHexMap</code>, <code>type: 'hexmap'</code>). The two maps place <code>lon</code>/<code>lat</code> directly, so they need no outline data and fetch nothing.</p>
4166
+ <pre data-run="js" data-expect="true | function | function | function | function | function | function | function | function | function | function" data-covers="export:drawIcicle export:drawWaffle export:drawAlluvial export:bindAlluvial export:drawArc export:bindArc export:drawBubbleMap export:bindBubbleMap export:drawHexMap export:bindHexMap"><code><span class="kw">const</span> charts = <span class="kw">await</span> import('../packages/modules/charts/index.js');
4167
+ <span class="kw">const</span> icicle = <span class="kw">await</span> import('../packages/modules/chart-icicle/index.js');
4168
+ <span class="kw">const</span> waffle = <span class="kw">await</span> import('../packages/modules/chart-waffle/index.js');
4169
+ <span class="kw">const</span> alluvial = <span class="kw">await</span> import('../packages/modules/chart-alluvial/index.js');
4170
+ <span class="kw">const</span> arc = <span class="kw">await</span> import('../packages/modules/chart-arc/index.js');
4171
+ <span class="kw">const</span> bubblemap = <span class="kw">await</span> import('../packages/modules/chart-bubblemap/index.js');
4172
+ <span class="kw">const</span> hexmap = <span class="kw">await</span> import('../packages/modules/chart-hexmap/index.js');
4173
+
4174
+ <span class="kw">const</span> types = charts.registeredChartTypes();
4175
+ <span class="kw">return</span> [
4176
+ ['icicle', 'waffle', 'alluvial', 'arc', 'bubblemap', 'hexmap'].every((t) =&gt; types.includes(t)),
4177
+ typeof icicle.drawIcicle, typeof waffle.drawWaffle,
4178
+ typeof alluvial.drawAlluvial, typeof alluvial.bindAlluvial,
4179
+ typeof arc.drawArc, typeof arc.bindArc,
4180
+ typeof bubblemap.drawBubbleMap, typeof bubblemap.bindBubbleMap,
4181
+ typeof hexmap.drawHexMap, typeof hexmap.bindHexMap,
4182
+ ].join(' | ');</code></pre>
4183
+
4096
4184
  <h2 id="datarouter">The data router</h2>
4097
4185
  <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
4186
  <pre><code>import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
@@ -4124,8 +4212,17 @@ router.apply([{ op: 'upsert', row }, { op: 'delete', row }]); <span class="cmt">
4124
4212
  <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
4213
  <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
4214
  <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>
4215
+ <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>
4216
+ <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>
4217
+ <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>
4218
+ <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
+ <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
+ <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>
4221
+ <tr><td class="sig">subscribe(predicate, handler, opts?)</td><td class="desc"><strong>v5:</strong> route a slice to any non-grid view. <code>handler(change)</code> receives the same keyed diff <code>{ add, update, remove }</code> a grid does &mdash; drive a KPI tile, detail pane, map or form. A peer to <code>attach</code>: same partitioning and the same <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> options; grids and charts are unchanged.</td></tr>
4222
+ <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
+ <tr><td class="sig">configure(spec)</td><td class="desc"><strong>v5:</strong> the whole routing graph as one data spec &mdash; <code>routes</code> (grid/<code>default</code>/<code>subscribe</code>/<code>alert</code> entries), <code>links</code>, <code>relate</code>, <code>buffer</code> &mdash; desugared to the imperative API. Composes with imperative calls and round-trips to identical behaviour. Also accepted as <code>createDataRouter({ config })</code>.</td></tr>
4224
+ <tr><td class="sig">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
+ <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
4226
  </tbody>
4130
4227
  </table>
4131
4228
  </div>
@@ -4166,6 +4263,111 @@ customers.selection.set([]); <span class="cmt">// deselect restor
4166
4263
 
4167
4264
  customers.destroy(); orders.destroy(); router.destroy();
4168
4265
  <span class="kw">return</span> [full, oneRegion, both, restored].join(' | ');</code></pre>
4266
+
4267
+ <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>
4268
+ <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');
4269
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4270
+
4271
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number' }, { id: 'label', field: 'label' }];
4272
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
4273
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4274
+
4275
+ <span class="cmt">// This route shows only amt &gt;= 20, sorted high-to-low, with a derived label.</span>
4276
+ router.attach(g, 'order', {
4277
+ filter: (row) =&gt; row.amt &gt;= 20,
4278
+ sort: { key: 'amt', dir: 'desc' },
4279
+ transform: (row) =&gt; ({ ...row, label: `#${row.id}:${row.amt}` }),
4280
+ });
4281
+ router.load([
4282
+ { id: 'o1', type: 'order', amt: 10 }, <span class="cmt">// filtered out</span>
4283
+ { id: 'o2', type: 'order', amt: 30 },
4284
+ { id: 'o3', type: 'order', amt: 20 },
4285
+ ]);
4286
+ <span class="kw">const</span> keys = [];
4287
+ <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);
4288
+
4289
+ <span class="cmt">// A delta below the threshold is held; raising it brings it into the view.</span>
4290
+ router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 5 } }]);
4291
+ <span class="kw">const</span> held = g.rows.count() === 2 ? 'held' : 'leaked';
4292
+ router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', amt: 40 } }]);
4293
+ <span class="kw">const</span> shown = g.rows.count() === 3 ? 'shown' : 'missing';
4294
+
4295
+ <span class="kw">const</span> out = [g.rows.count() &gt;= 2 ? 2 : 0, keys.join(','), g.rows.value('o2', 'label'), held, shown];
4296
+ g.destroy(); router.destroy();
4297
+ <span class="kw">return</span> out.join(' | ');</code></pre>
4298
+
4299
+ <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>
4300
+ <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');
4301
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4302
+
4303
+ <span class="kw">const</span> cols = [{ id: 'region', field: 'region' }, { id: 'total', field: 'total', type: 'number' }];
4304
+ <span class="kw">const</span> chart = createHeadlessGrid({ rowKey: 'region', columns: cols });
4305
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
4306
+
4307
+ <span class="cmt">// One summary row per region, the per-region total, biggest first.</span>
4308
+ router.attach(chart, 'order', {
4309
+ rollup: { groupBy: 'region', aggregate: { total: { op: 'sum', field: 'amt' } } },
4310
+ sort: { key: 'total', dir: 'desc' },
4311
+ });
4312
+ router.load([
4313
+ { id: 'o1', type: 'order', region: 'emea', amt: 10 },
4314
+ { id: 'o2', type: 'order', region: 'amer', amt: 20 },
4315
+ { id: 'o3', type: 'order', region: 'emea', amt: 5 },
4316
+ ]);
4317
+ <span class="kw">const</span> groups = chart.rows.count(); <span class="cmt">// 2: emea (15), amer (20)</span>
4318
+ <span class="kw">const</span> top = chart.rows.get(0).key; <span class="cmt">// amer — highest total</span>
4319
+
4320
+ <span class="cmt">// A new emea order repaints only the emea summary (keyed diff).</span>
4321
+ router.apply([{ op: 'upsert', row: { id: 'o4', type: 'order', region: 'emea', amt: 100 } }]);
4322
+ <span class="kw">const</span> emeaTotal = chart.rows.value('emea', 'total'); <span class="cmt">// 115</span>
4323
+
4324
+ chart.destroy(); router.destroy();
4325
+ <span class="kw">return</span> [groups, top, emeaTotal].join(' | ');</code></pre>
4326
+
4327
+ <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>
4328
+ <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');
4329
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4330
+
4331
+ <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' }];
4332
+ <span class="kw">const</span> mk = () =&gt; createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
4333
+ <span class="kw">const</span> customers = mk(), orders = mk(), lines = mk();
4334
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
4335
+ router.attach(customers, 'customer'); router.attach(orders, 'order'); router.attach(lines, 'line');
4336
+ router.load([
4337
+ { id: 'c1', type: 'customer', region: 'emea' }, { id: 'c2', type: 'customer', region: 'amer' },
4338
+ { id: 'o1', type: 'order', region: 'emea' }, { id: 'o2', type: 'order', region: 'amer' }, { id: 'o3', type: 'order', region: 'emea' },
4339
+ { id: 'l1', type: 'line', orderId: 'o1' }, { id: 'l2', type: 'line', orderId: 'o2' }, { id: 'l3', type: 'line', orderId: 'o3' }, { id: 'l4', type: 'line', orderId: 'o1' },
4340
+ ]);
4341
+ <span class="cmt">// customers -&gt; orders (by region) -&gt; lines (by order id): a two-hop chain.</span>
4342
+ router.relate([
4343
+ { from: customers, to: orders, on: { from: 'region', to: 'region' } },
4344
+ { from: orders, to: lines, on: { from: 'id', to: 'orderId' } },
4345
+ ]);
4346
+ customers.selection.set(['c1']); <span class="cmt">// emea; no selection in orders</span>
4347
+ <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(','); };
4348
+ <span class="kw">const</span> out = [keysOf(orders), keysOf(lines)]; <span class="cmt">// o1,o3 | l1,l3,l4</span>
4349
+ customers.destroy(); orders.destroy(); lines.destroy(); router.destroy();
4350
+ <span class="kw">return</span> out.join(' | ');</code></pre>
4351
+
4352
+ <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>
4353
+ <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');
4354
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
4355
+
4356
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
4357
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v' });
4358
+ router.attach(g, () =&gt; true);
4359
+ router.load([]);
4360
+
4361
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 30, v: 3 } }]);
4362
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 20, v: 2 } }]); <span class="cmt">// stale — dropped</span>
4363
+ <span class="kw">const</span> n = g.rows.value('a', 'n'); <span class="cmt">// 30: the stale delta did not clobber it</span>
4364
+ <span class="kw">const</span> dropped = router.dropped; <span class="cmt">// 1</span>
4365
+ router.apply([{ op: 'upsert', row: { id: 'a', n: 99, v: 4 } }]); <span class="cmt">// fresh</span>
4366
+ <span class="kw">const</span> last = router.lastSeq(); <span class="cmt">// 4</span>
4367
+
4368
+ g.destroy(); router.destroy();
4369
+ <span class="kw">return</span> [n, dropped, last].join(' | ');</code></pre>
4370
+
4169
4371
  <h3 id="datarouter-example">One feed, three grids, executed</h3>
4170
4372
  <p class="section-note">A single snapshot fanned to an orders grid, an invoices grid and a "rest" sink, then a
4171
4373
  delta that changes a row's partition &mdash; proving the fan-out, the sink, and that a moved row
@@ -4201,6 +4403,38 @@ router.apply([{ op: 'upsert', row: { id: 'o2', type: 'invoice', amt: 25 } }]);
4201
4403
  orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
4202
4404
  <span class="kw">return</span> [fanned, moved, router.unrouted].join(' | ');</code></pre>
4203
4405
 
4406
+ <h2 id="mocksocket">The mock socket</h2>
4407
+ <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>
4408
+ <pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
4409
+
4410
+ const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }) });
4411
+ socket.onmessage = (event) =&gt; {
4412
+ const message = JSON.parse(event.data);
4413
+ if (message.kind === 'snapshot') router.load(message.rows);
4414
+ else router.apply(message.changes);
4415
+ };
4416
+
4417
+ <span class="cmt">// Going live is the one line that changes; everything above stays as written:</span>
4418
+ const socket = new WebSocket('wss://example.com/ops');</code></pre>
4419
+ <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>
4420
+ <div class="table-wrap">
4421
+ <table>
4422
+ <thead><tr><th>Member</th><th>Description</th></tr></thead>
4423
+ <tbody>
4424
+ <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>
4425
+ <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>
4426
+ <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>
4427
+ <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>
4428
+ <tr><td class="sig">close()</td><td class="desc">Close the socket and stop the feed, emitting a clean <code>close</code>.</td></tr>
4429
+ <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>
4430
+ <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>
4431
+ <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>
4432
+ <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>
4433
+ </tbody>
4434
+ </table>
4435
+ </div>
4436
+ <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>
4437
+
4204
4438
  <h2 id="charts">In-cell charts</h2>
4205
4439
  <p>Seven chart renderers for a cell. Each is a single SVG whose path data is the only thing a
4206
4440
  repaint writes, so they cost the same as any other cell as rows recycle.</p>
@@ -4750,7 +4984,7 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
4750
4984
 
4751
4985
  <h3 id="module-exports-example">Every module export, executed</h3>
4752
4986
  <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>
4987
+ <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
4988
  <span class="cmt">// barrel. A module that stopped exporting something fails here.</span>
4755
4989
  <span class="kw">const</span> modules = [
4756
4990
  [<span class="kw">await</span> import('../packages/dom/src/index.js'), [
@@ -4794,6 +5028,9 @@ spans.addSpan('R0', 'name', 3, 2); <span class="cmt">// rowspan 3, colspan 2</sp
4794
5028
  [<span class="kw">await</span> import('../packages/modules/data-router/index.js'), [
4795
5029
  'createDataRouter',
4796
5030
  ]],
5031
+ [<span class="kw">await</span> import('../packages/modules/mock-socket/index.js'), [
5032
+ 'MockWebSocket', 'rng', 'opsFeed', 'priceFeed',
5033
+ ]],
4797
5034
  ];
4798
5035
 
4799
5036
  <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.33.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -6238,12 +6238,13 @@ grid.state.apply(savedView.state);
6238
6238
  <tr><td class="name">canChartRange</td><td class="desc">Whether <code>chartRange</code> would draw something for the grid’s current selection — the question a menu asks before offering the item.</td></tr>
6239
6239
  <tr><td class="name">deriveRangeSpec</td><td class="desc">Decide what a chart of a range should be without drawing it: the type, the category column, the measures, and a spec ready for <code>createChart</code>.</td></tr>
6240
6240
  <tr><td class="name">regressionPlots</td><td class="desc">Turn a fitted regression model into diagnostic chart specs ready for <code>createChart</code>: the fit line with its confidence band, residuals-vs-fitted, a QQ plot of the residuals, and a multicollinearity correlogram with the model’s VIF. The plots that need a per-row or per-coefficient quantity the grid has no column for (scale-location, residuals-vs-leverage, the coefficient forest) are returned as a null spec carrying the reason rather than dropped.</td></tr>
6241
- <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
6241
+ <tr><td class="name">createDataRouter</td><td class="desc">Split one arriving stream or dataset across many grids by what each record is — a property or a predicate — driving each grid through the public keyed <code>rows.apply</code> path so a snapshot is a diff, a delta is applied in place, a moved partition moves the row rather than duplicating it, and an unmatched record is counted, sunk and never dropped. v2 adds cross-grid selection filtering: <code>link(source, target, relation)</code> makes a selection in one grid filter what another receives — by a key map or a predicate function, multi-select as an IN set, debounced — re-pushed through the same keyed-diff path so the target stays dumb. v5 adds wedge-conversion primitives: <code>subscribe(value, handler)</code> routes a slice to any non-grid view (KPI tile, detail pane, map, form) as the same keyed diff a grid gets; <code>alert(value, condition, handler)</code> evaluates a condition over a slice and emits (edge-triggered, debounced) rather than rendering; and <code>configure(spec)</code> (or <code>createDataRouter({ config })</code>) takes the whole routing graph as one declarative data spec that desugars to the imperative API and composes with it. Detaches its grids on <code>destroy</code>; the host owns them.</td></tr>
6242
6242
  <tr><td class="name">createDevtools</td><td class="desc">Mount the devtools panel against a grid, including its accessibility checks.</td></tr>
6243
6243
  <tr><td class="name">createLatticeGridElement</td><td class="desc">Build the element class without registering it, for a custom registry.</td></tr>
6244
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>
6245
6245
  <tr><td class="name">defineLatticeGrid</td><td class="desc">Register &amp;lt;lattice-grid&amp;gt;, or your own tag name.</td></tr>
6246
6246
  <tr><td class="name">defineUnit</td><td class="desc">Add one unit to a system, or override one of ours under the same name.</td></tr>
6247
+ <tr><td class="name">registerChartType</td><td class="desc">Register an extension chart type so <code>createChart({ type })</code> can draw it (BACKLOG-0000886). Extension types ship as their own opt-in modules (e.g. <code>modules/chart-ridgeline</code>), so the base charts bundle does not grow for a type a caller never imports — you pay only for the charts you use. <code>registeredChartTypes()</code> lists what is registered.</td></tr>
6247
6248
  <tr><td class="name">registerModules</td><td class="desc">Install optional modules once, for every grid on the page.</td></tr>
6248
6249
  <tr><td class="name">registerScheme</td><td class="desc">Add a colour scheme, or replace one of ours under the same name.</td></tr>
6249
6250
  <tr><td class="name">restoreStateWithin</td><td class="desc">Put it back afterwards.</td></tr>