@toclocoinc/lattice-grid 1.63.2 → 1.64.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 (163) hide show
  1. package/README.md +2 -1
  2. package/docs/API.html +674 -53
  3. package/docs/CHART-CODES.md +24 -0
  4. package/docs/api-detail.html +111 -5
  5. package/lattice-grid.d.ts +93 -1
  6. package/lattice-grid.esm.min.js +154 -95
  7. package/lattice-grid.min.cjs +154 -95
  8. package/lattice-grid.min.js +154 -95
  9. package/modules/ai.d.ts +1 -1
  10. package/modules/ai.esm.min.js +3 -6
  11. package/modules/ai.min.cjs +3 -6
  12. package/modules/ai.min.js +3 -6
  13. package/modules/angular.d.ts +1 -1
  14. package/modules/angular.esm.min.js +1 -4
  15. package/modules/angular.min.cjs +1 -4
  16. package/modules/angular.min.js +1 -4
  17. package/modules/chart-alluvial.d.ts +1 -1
  18. package/modules/chart-alluvial.esm.min.js +1 -1
  19. package/modules/chart-alluvial.min.cjs +1 -1
  20. package/modules/chart-alluvial.min.js +1 -1
  21. package/modules/chart-arc.d.ts +1 -1
  22. package/modules/chart-arc.esm.min.js +1 -1
  23. package/modules/chart-arc.min.cjs +1 -1
  24. package/modules/chart-arc.min.js +1 -1
  25. package/modules/chart-bubblemap.d.ts +1 -1
  26. package/modules/chart-bubblemap.esm.min.js +1 -1
  27. package/modules/chart-bubblemap.min.cjs +1 -1
  28. package/modules/chart-bubblemap.min.js +1 -1
  29. package/modules/chart-bump.d.ts +1 -1
  30. package/modules/chart-bump.esm.min.js +1 -1
  31. package/modules/chart-bump.min.cjs +1 -1
  32. package/modules/chart-bump.min.js +1 -1
  33. package/modules/chart-calendar.d.ts +1 -1
  34. package/modules/chart-calendar.esm.min.js +1 -1
  35. package/modules/chart-calendar.min.cjs +1 -1
  36. package/modules/chart-calendar.min.js +1 -1
  37. package/modules/chart-decomposition.d.ts +1 -1
  38. package/modules/chart-decomposition.esm.min.js +1 -1
  39. package/modules/chart-decomposition.min.cjs +1 -1
  40. package/modules/chart-decomposition.min.js +1 -1
  41. package/modules/chart-diverging.d.ts +1 -1
  42. package/modules/chart-diverging.esm.min.js +1 -1
  43. package/modules/chart-diverging.min.cjs +1 -1
  44. package/modules/chart-diverging.min.js +1 -1
  45. package/modules/chart-dumbbell.d.ts +1 -1
  46. package/modules/chart-dumbbell.esm.min.js +1 -1
  47. package/modules/chart-dumbbell.min.cjs +1 -1
  48. package/modules/chart-dumbbell.min.js +1 -1
  49. package/modules/chart-fan.d.ts +1 -1
  50. package/modules/chart-fan.esm.min.js +1 -1
  51. package/modules/chart-fan.min.cjs +1 -1
  52. package/modules/chart-fan.min.js +1 -1
  53. package/modules/chart-hexbin.d.ts +1 -1
  54. package/modules/chart-hexbin.esm.min.js +1 -1
  55. package/modules/chart-hexbin.min.cjs +1 -1
  56. package/modules/chart-hexbin.min.js +1 -1
  57. package/modules/chart-hexmap.d.ts +1 -1
  58. package/modules/chart-hexmap.esm.min.js +1 -1
  59. package/modules/chart-hexmap.min.cjs +1 -1
  60. package/modules/chart-hexmap.min.js +1 -1
  61. package/modules/chart-icicle.d.ts +1 -1
  62. package/modules/chart-icicle.esm.min.js +1 -1
  63. package/modules/chart-icicle.min.cjs +1 -1
  64. package/modules/chart-icicle.min.js +1 -1
  65. package/modules/chart-markermap.d.ts +29 -0
  66. package/modules/chart-markermap.esm.min.js +313 -0
  67. package/modules/chart-markermap.min.cjs +317 -0
  68. package/modules/chart-markermap.min.js +317 -0
  69. package/modules/chart-parallel.d.ts +1 -1
  70. package/modules/chart-parallel.esm.min.js +1 -1
  71. package/modules/chart-parallel.min.cjs +1 -1
  72. package/modules/chart-parallel.min.js +1 -1
  73. package/modules/chart-ridgeline.d.ts +1 -1
  74. package/modules/chart-ridgeline.esm.min.js +1 -1
  75. package/modules/chart-ridgeline.min.cjs +1 -1
  76. package/modules/chart-ridgeline.min.js +1 -1
  77. package/modules/chart-roc.d.ts +1 -1
  78. package/modules/chart-roc.esm.min.js +1 -1
  79. package/modules/chart-roc.min.cjs +1 -1
  80. package/modules/chart-roc.min.js +1 -1
  81. package/modules/chart-slope.d.ts +1 -1
  82. package/modules/chart-slope.esm.min.js +1 -1
  83. package/modules/chart-slope.min.cjs +1 -1
  84. package/modules/chart-slope.min.js +1 -1
  85. package/modules/chart-splom.d.ts +1 -1
  86. package/modules/chart-splom.esm.min.js +1 -1
  87. package/modules/chart-splom.min.cjs +1 -1
  88. package/modules/chart-splom.min.js +1 -1
  89. package/modules/chart-waffle.d.ts +1 -1
  90. package/modules/chart-waffle.esm.min.js +1 -1
  91. package/modules/chart-waffle.min.cjs +1 -1
  92. package/modules/chart-waffle.min.js +1 -1
  93. package/modules/charts.d.ts +1 -1
  94. package/modules/charts.esm.min.js +395 -33
  95. package/modules/charts.min.cjs +395 -33
  96. package/modules/charts.min.js +395 -33
  97. package/modules/data-router.d.ts +313 -30
  98. package/modules/data-router.esm.min.js +3 -6
  99. package/modules/data-router.min.cjs +3 -6
  100. package/modules/data-router.min.js +3 -6
  101. package/modules/devtools.d.ts +1 -1
  102. package/modules/devtools.esm.min.js +1 -4
  103. package/modules/devtools.min.cjs +1 -4
  104. package/modules/devtools.min.js +1 -4
  105. package/modules/dhtmlx-compat.d.ts +1 -1
  106. package/modules/dhtmlx-compat.esm.min.js +3 -6
  107. package/modules/dhtmlx-compat.min.cjs +3 -6
  108. package/modules/dhtmlx-compat.min.js +3 -6
  109. package/modules/gantt.d.ts +1 -1
  110. package/modules/gantt.esm.min.js +3 -6
  111. package/modules/gantt.min.cjs +3 -6
  112. package/modules/gantt.min.js +3 -6
  113. package/modules/geo-europe-nuts.d.ts +1 -1
  114. package/modules/geo-europe-nuts.esm.min.js +1 -1
  115. package/modules/geo-uk.d.ts +1 -1
  116. package/modules/geo-uk.esm.min.js +1 -1
  117. package/modules/geo-us-states.d.ts +1 -1
  118. package/modules/geo-us-states.esm.min.js +1 -1
  119. package/modules/geo-world-110m.d.ts +1 -1
  120. package/modules/geo-world-110m.esm.min.js +1 -1
  121. package/modules/geo-world-50m.d.ts +1 -1
  122. package/modules/geo-world-50m.esm.min.js +1 -1
  123. package/modules/htmx.d.ts +1 -1
  124. package/modules/htmx.esm.min.js +154 -95
  125. package/modules/htmx.min.cjs +154 -95
  126. package/modules/htmx.min.js +154 -95
  127. package/modules/kanban.d.ts +1 -1
  128. package/modules/kanban.esm.min.js +3 -6
  129. package/modules/kanban.min.cjs +3 -6
  130. package/modules/kanban.min.js +3 -6
  131. package/modules/kpi.d.ts +56 -3
  132. package/modules/kpi.esm.min.js +202 -10
  133. package/modules/kpi.min.cjs +202 -10
  134. package/modules/kpi.min.js +202 -10
  135. package/modules/layout.d.ts +1 -1
  136. package/modules/layout.esm.min.js +3 -6
  137. package/modules/layout.min.cjs +3 -6
  138. package/modules/layout.min.js +3 -6
  139. package/modules/mock-socket.d.ts +1 -1
  140. package/modules/mock-socket.esm.min.js +1 -4
  141. package/modules/mock-socket.min.cjs +1 -4
  142. package/modules/mock-socket.min.js +1 -4
  143. package/modules/react.d.ts +1 -1
  144. package/modules/react.esm.min.js +3 -6
  145. package/modules/react.min.cjs +3 -6
  146. package/modules/react.min.js +3 -6
  147. package/modules/svelte.d.ts +1 -1
  148. package/modules/svelte.esm.min.js +1 -4
  149. package/modules/svelte.min.cjs +1 -4
  150. package/modules/svelte.min.js +1 -4
  151. package/modules/tabs.d.ts +1 -1
  152. package/modules/tabs.esm.min.js +3 -6
  153. package/modules/tabs.min.cjs +3 -6
  154. package/modules/tabs.min.js +3 -6
  155. package/modules/vue.d.ts +1 -1
  156. package/modules/vue.esm.min.js +1 -4
  157. package/modules/vue.min.cjs +1 -4
  158. package/modules/vue.min.js +1 -4
  159. package/modules/webcomponent.d.ts +1 -1
  160. package/modules/webcomponent.esm.min.js +154 -95
  161. package/modules/webcomponent.min.cjs +154 -95
  162. package/modules/webcomponent.min.js +154 -95
  163. package/package.json +1 -1
package/docs/API.html CHANGED
@@ -360,7 +360,7 @@
360
360
  <div class="shell">
361
361
  <aside class="rail">
362
362
  <p class="rail__brand">Lattice Grid</p>
363
- <p class="rail__sub">API reference · v1.63.2</p>
363
+ <p class="rail__sub">API reference · v1.64.0</p>
364
364
  <nav>
365
365
  <div class="rail__group">
366
366
  <span class="rail__label">Start</span>
@@ -421,6 +421,7 @@
421
421
  <a href="#units">Units of your own</a>
422
422
  <a href="#chartsmodule">The charts module</a>
423
423
  <a href="#layout">The dashboard layout</a>
424
+ <a href="#datarouter">The data router</a>
424
425
  <a href="#mocksocket">The mock socket</a>
425
426
  <a href="#charts">In-cell charts</a>
426
427
  <a href="#formulas">Formulas</a>
@@ -443,7 +444,7 @@
443
444
  </header>
444
445
 
445
446
  <p class="chips">
446
- <span class="chip">Version 1.63.2</span>
447
+ <span class="chip">Version 1.64.0</span>
447
448
  <span class="chip">Zero dependencies</span>
448
449
  <span class="chip"><a href="api-detail.html">Developer guide &rarr;</a></span>
449
450
  </p>
@@ -1392,7 +1393,7 @@ grid.destroy();
1392
1393
  <table>
1393
1394
  <thead><tr><th>Member</th><th>Returns</th><th>Description</th></tr></thead>
1394
1395
  <tbody>
1395
- <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.63.2'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
1396
+ <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.64.0'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
1396
1397
  <tr><td class="sig">get(key)</td><td class="type">unknown</td><td class="desc">Read any configuration key.</td></tr>
1397
1398
  <tr><td class="sig">set(key, value)</td><td class="type">void</td><td class="desc">Write one key. Every key is live; nothing needs a rebuild.</td></tr>
1398
1399
  <tr><td class="sig">setAll(values)</td><td class="type">void</td><td class="desc">Write several in one pass. Emits one <code>config:changed</code> for the batch, not one per key.</td></tr>
@@ -5163,7 +5164,7 @@ const chart = createChart({
5163
5164
  <tr><td class="sig">Part to whole</td><td><code>pie</code>, <code>donut</code>, <code>sunburst</code>, <code>treemap</code></td><td><code>x</code>, <code>y</code>; or the grid's grouping, see below</td></tr>
5164
5165
  <tr><td class="sig">Specialist</td><td><code>radar</code>, <code>gauge</code>, <code>funnel</code>, <code>candlestick</code></td><td>varies; candlestick takes four <code>measures</code> in open, high, low, close order</td></tr>
5165
5166
  <tr><td class="sig">Geographic</td><td><code>geomap</code></td><td><code>x</code> as an ISO code, <code>y</code> as the value</td></tr>
5166
- <tr><td class="sig">Flow</td><td><code>sankey</code>, <code>chord</code>, <code>network</code></td><td><code>source</code>, <code>target</code>, <code>y</code></td></tr>
5167
+ <tr><td class="sig">Flow</td><td><code>sankey</code>, <code>chord</code>, <code>network</code></td><td><code>source</code>, <code>target</code>, <code>y</code>; a <code>network</code> also takes <code>nodes</code> &mdash; see <a href="#network-map">Network diagrams</a></td></tr>
5167
5168
  <tr><td class="sig">Over time</td><td><code>stream</code>, <code>marimekko</code>, <code>violin</code>, <code>gantt</code></td><td>varies; gantt takes <code>label</code>, <code>start</code>, <code>end</code></td></tr>
5168
5169
  </tbody>
5169
5170
  </table>
@@ -5210,6 +5211,9 @@ const chart = createChart({
5210
5211
  <tr><td class="name">code / codeProperty</td><td class="type">string</td><td class="desc">A geomap's ISO code column, and the property carrying the code in your <code>shapes</code>.</td></tr>
5211
5212
  <tr><td class="name">columns / method / values</td><td class="type">string[] / string / boolean</td><td class="desc">Correlogram: which columns to correlate, by <code>pearson</code>, <code>spearman</code> or <code>kendall</code>, and whether to print the coefficients in the cells.</td></tr>
5212
5213
  <tr><td class="name">iterations</td><td class="type">number</td><td class="desc">Network layouts: how many relaxation passes to run.</td></tr>
5214
+ <tr><td class="name">nodes</td><td class="type">ChartNode[]</td><td class="desc">A <code>network</code>'s nodes, named by you rather than inferred from the rows: <code>{ id, label, icon, x, y }</code>. <code>id</code> matches a value in the <code>source</code> or <code>target</code> column; <code>icon</code> is any name in the grid's icon registry; <code>x</code>/<code>y</code> are fractions of the plot (0&nbsp;to&nbsp;1) and <strong>pin</strong> the node there, out of the force simulation. A node listed here that appears in no row is still drawn. See <a href="#network-map">Network diagrams</a>.</td></tr>
5215
+ <tr><td class="name">icon</td><td class="type">string</td><td class="desc">The default glyph for a <code>network</code> node that names none of its own. Unset, an undeclared node is a plain disc.</td></tr>
5216
+ <tr><td class="name">linkWidth</td><td class="type">number</td><td class="desc">Fix a <code>network</code> link's stroke width in pixels. Unset, width follows the link's value as a share of the heaviest link.</td></tr>
5213
5217
  <tr><td class="name">spec / baseline / rules / confidence</td><td class="type">object / number / string / number</td><td class="desc">Control and capability charts: a tolerance overriding the column's own <code>spec</code>, how many leading readings fix the control limits, which rule set judges the violations (<code>westernElectric</code> or <code>nelson</code>), and the level for the capability interval.</td></tr>
5214
5218
  </tbody>
5215
5219
  </table>
@@ -5485,6 +5489,97 @@ createChart({ grid, container: '#us', type: 'geomap', code: 'state', y: 'sales',
5485
5489
 
5486
5490
  <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>
5487
5491
 
5492
+ <h3 id="network-map">Network diagrams &mdash; icon nodes, links coloured by their value</h3>
5493
+ <p>A <code>network</code> draws the grid's rows as a graph: <code>source</code> and <code>target</code> name the two endpoint columns and <code>y</code> carries the value on the link between them. Three things make it a picture of <em>your</em> network rather than a generic hairball, and each is a fact only you have.</p>
5494
+ <p><strong>Nodes you name.</strong> <code>nodes: [{ id, label, icon, x, y }]</code> gives a node a glyph from the grid's own icon registry &mdash; a built-in name, or one you registered &mdash; and a label drawn beneath it. A node that appears in the rows but not in <code>nodes</code> takes the chart's <code>icon</code> default (a plain disc when there is none) and its own id as its label. A node listed in <code>nodes</code> that appears in <em>no</em> row is still drawn: a device with no links is a fact worth seeing. The glyphs are SVG paths from the same registry the cells paint from, so they are sharp at any chart size and take the chart's theme colours.</p>
5495
+ <p><strong>Positions you choose.</strong> <code>x</code> and <code>y</code> are fractions of the plot, measured from its top-left. A node giving <em>both</em> is <strong>pinned</strong> there and takes no part in the force simulation; everything else is laid out around it by the same deterministic relaxation as before, so &ldquo;core on top, regions below&rdquo; needs no hand-placed SVG. Half a position is not a position: a node with only <code>x</code> is laid out. A fraction outside 0&nbsp;to&nbsp;1 clamps to the edge of the plot rather than drawing where nobody can see it. Pinning one node never reshuffles the others &mdash; the layout's seeding draws for every node, pinned or not, precisely so that it cannot.</p>
5496
+ <p><strong>Colours from the rules you already wrote.</strong> Each link's stroke comes from the value column's own conditional-formatting rules, through <code>grid.formatting.styleFor(col, value)</code> &mdash; the colour order is <code>background</code>, then <code>backgroundColor</code>, then <code>color</code>; a gradient (a data bar, an icon set) is not a colour and is not read. A link no rule matches keeps the chart's default link colour. <strong>There is no chart-level threshold option, deliberately</strong>: a second place to say &ldquo;red above 80&rdquo; is a second place for the chart and the cell to disagree. The legend lists the rules that actually fired, with their own labels and their own swatches; a rule that matched nothing is not advertised. Change a rule and the links recolour on the next frame without the layout re-running, so nothing moves.</p>
5497
+ <p><strong>Links are undirected, and parallel links stay parallel.</strong> There are no arrowheads, and <code>A,B</code> is the same pair as <code>B,A</code> &mdash; a cable has two ends and no direction. <strong>Several rows between the same pair are drawn as several lines</strong>, side by side, offset perpendicular to the pair by 4&nbsp;px and symmetric about it, in row order, each with its own value and its own colour. They are <em>not</em> summed: three circuits between two sites are three readings, and one line carrying 120% would be a number nothing measured. The tooltip on any one line names both endpoints and that line's own value, and the pointer picks out the line you are actually over rather than the pair. Width follows the value unless <code>linkWidth</code> fixes it.</p>
5498
+ <p><strong>Linked like every other chart.</strong> The graph is drawn from the grid's filtered rows and follows filter and sort. With <code>selection: true</code>, clicking a link selects its row and clicking a node selects every row it is an end of; the grid's selection then lights those links and nodes and dims the rest. A <code>click</code> handler still fires first and can <code>preventDefault()</code>.</p>
5499
+
5500
+ <h4 id="network-map-example">The picture, executed</h4>
5501
+ <p class="section-note">Two core routers pinned across the top, three regional routers pinned below, two circuits between every pair, and one rule set on the <code>load</code> column doing all the colouring. Thirteen rows, thirteen lines, three colours, five glyphs, and a legend that names the three rules.</p>
5502
+ <pre data-run="js" data-expect="13 links, #107c41/#a4262c/#f0b400 | pinned true | gap 4 | 5 icons | Healthy,Busy,Saturated" data-covers="export:createChart export:createGrid config:formatting config:selection"><code>const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
5503
+ const { createGrid } = await import('../packages/dom/src/index.js');
5504
+ const { createChart } = await import('../packages/modules/charts/index.js');
5505
+
5506
+ const { document, root } = createTestDom({ width: 640, height: 420 });
5507
+
5508
+ // Two circuits between every core and every region: six pairs, twelve rows.
5509
+ const rows = [];
5510
+ for (const core of ['core-1', 'core-2']) {
5511
+ for (const site of ['emea', 'amer', 'apac']) {
5512
+ for (const [n, load] of [['a', 18], ['b', 92]]) {
5513
+ rows.push({ id: `${core}/${site}/${n}`, from: core, to: site, load });
5514
+ }
5515
+ }
5516
+ }
5517
+ rows.push({ id: 'core-1/core-2/x', from: 'core-1', to: 'core-2', load: 61 });
5518
+
5519
+ const grid = createGrid(root, {
5520
+ rowKey: 'id',
5521
+ selection: 'multiple',
5522
+ columns: [{ field: 'from' }, { field: 'to' }, { field: 'load', type: 'number' }],
5523
+ rows,
5524
+ // One rule set on the column. The cells and the links read it together.
5525
+ formatting: {
5526
+ load: [
5527
+ { id: 'ok', label: 'Healthy', when: { op: 'lt', value: 40 }, style: { background: '#107c41' } },
5528
+ { id: 'busy', label: 'Busy', when: { op: 'lt', value: 80 }, style: { background: '#f0b400' } },
5529
+ { id: 'hot', label: 'Saturated', when: { op: 'gte', value: 80 }, style: { background: '#a4262c' } },
5530
+ ],
5531
+ },
5532
+ });
5533
+
5534
+ const container = document.createElement('div');
5535
+ container.rect = { width: 600, height: 400, top: 0, left: 0 };
5536
+ root.appendChild(container);
5537
+
5538
+ const chart = createChart({
5539
+ grid, container, type: 'network', source: 'from', target: 'to',
5540
+ y: { col: 'load', fn: 'sum' }, selection: true,
5541
+ nodes: [
5542
+ { id: 'core-1', label: 'Core', icon: 'square', x: 0.3, y: 0.15 },
5543
+ { id: 'core-2', label: 'Core', icon: 'square', x: 0.7, y: 0.15 },
5544
+ { id: 'emea', label: 'EMEA', icon: 'circleFilled', x: 0.2, y: 0.8 },
5545
+ { id: 'amer', label: 'AMER', icon: 'circleFilled', x: 0.5, y: 0.8 },
5546
+ { id: 'apac', label: 'APAC', icon: 'circleFilled', x: 0.8, y: 0.8 },
5547
+ ],
5548
+ });
5549
+
5550
+ const find = (tag, cls) =&gt; [...container.querySelectorAll(tag)]
5551
+ .filter((n) =&gt; (n.getAttribute('class') || '').includes(cls));
5552
+ const at = (key) =&gt; find('circle', '__node').find((c) =&gt; c.getAttribute('data-node') === key);
5553
+ const num = (el, name) =&gt; Number(el.getAttribute(name));
5554
+
5555
+ // The picture: two cores on one row, three regions on another below them.
5556
+ const top = [at('core-1'), at('core-2')].map((c) =&gt; num(c, 'cy'));
5557
+ const bottom = ['emea', 'amer', 'apac'].map((k) =&gt; num(at(k), 'cy'));
5558
+ const rowsPinned = top[0] === top[1] &amp;&amp; bottom.every((y) =&gt; y === bottom[0]) &amp;&amp; bottom[0] &gt; top[0]
5559
+ &amp;&amp; num(at('core-1'), 'cx') &lt; num(at('core-2'), 'cx');
5560
+
5561
+ // Every link its own line, coloured by the rule its value matched.
5562
+ const edges = find('path', '__edge');
5563
+ const stroke = (e) =&gt; ((e.getAttribute('style') || '').match(/stroke:\s*([^;]+)/) || [])[1];
5564
+ const colours = [...new Set(edges.map(stroke))].sort();
5565
+
5566
+ // Two circuits between core-1 and emea, drawn side by side 4px apart.
5567
+ const ends = (d) =&gt; d.match(/-?\d+(?:\.\d+)?/g).map(Number);
5568
+ const pair = edges.slice(0, 2).map((e) =&gt; ends(e.getAttribute('d')));
5569
+ const gap = Math.round(Math.hypot(pair[0][0] - pair[1][0], pair[0][1] - pair[1][1]));
5570
+
5571
+ // Five glyphs, drawn from the registry the host extended.
5572
+ const glyphs = find('path', '__node-icon').length;
5573
+
5574
+ // The legend names the rules that fired, not a palette.
5575
+ const legend = [...container.querySelectorAll('button')]
5576
+ .filter((b) =&gt; (b.getAttribute('class') || '').includes('__legend-item'))
5577
+ .map((b) =&gt; b.textContent).join(',');
5578
+
5579
+ chart.destroy();
5580
+ grid.destroy();
5581
+ return `${edges.length} links, ${colours.join('/')} | pinned ${rowsPinned} | gap ${gap} | ${glyphs} icons | ${legend}`;</code></pre>
5582
+
5488
5583
  <h3 id="chart-extension-types">Extension chart types — pay only for what you draw</h3>
5489
5584
  <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>
5490
5585
  <pre><code><span class="kw">import</span> '@toclocoinc/lattice-grid/modules/charts'; <span class="cmt">// the base</span>
@@ -5600,6 +5695,62 @@ grid.destroy();
5600
5695
  typeof hexmap.drawHexMap, typeof hexmap.bindHexMap,
5601
5696
  ].join(' | ');</code></pre>
5602
5697
 
5698
+ <h3 id="chart-markermap">Map markers &mdash; a figure per location, coloured by its own rule</h3>
5699
+ <p><code>modules/chart-markermap</code> registers <code>markermap</code>: one marker per row, placed by <code>lon</code>/<code>lat</code> over a geometry pack's outlines, showing the row's <code>label</code> and its <code>value</code> beside the dot. Two things come from the grid rather than from the chart, and that is the whole point of the type. The <strong>number</strong> is the value column's own formatted cell text, so a percentage, a currency or a unit reads on the map exactly as it reads in the table. The <strong>colour</strong> is whatever <code>grid.formatting.styleFor(valueColumn, value)</code> returns for that row &mdash; the rule's <code>background</code>, or its <code>color</code> where it sets no background &mdash; so a red / amber / green availability wall is one rule set on one column plus one chart configuration. There is deliberately no chart-level thresholds option and no colour column: the rules are the one source, and a legend lists the rules that actually fired, with each rule's own swatch.</p>
5700
+ <p>With <code>shapes</code> it draws the pack's regions underneath, through the pack's own <code>projection</code>, and pans and zooms exactly as a <a href="#geometry-packs">geomap</a> of that pack does; without <code>shapes</code> the markers fall back to the projection alone. A row whose coordinates are absent, non-numeric or outside &plusmn;180 / &plusmn;90 draws no marker and is counted in <code>chart.data().unplaced</code>, which the map also writes under itself. Labels are deconflicted by trying four positions in a fixed order &mdash; right of the dot, then left, then above, then below &mdash; and a label with nowhere to go is dropped rather than overprinted; <code>labels: false</code> turns them all off on a dense map and leaves the tooltip, which carries the name, the value, the coordinates and the status. With <code>selection: true</code> a click on a marker selects that row in the grid, and the grid's selection emphasises the marker.</p>
5701
+ <pre data-run="js" data-expect="#1b7f3b #1b7f3b #c8a415 #c0392b | London 99.95% / Sydney 99.91% / Frankfurt 99.62% / New York 98.05% | unplaced 0" data-covers="export:drawMarkerMap export:bindMarkerMap"><code><span class="kw">const</span> { createTestDom } = <span class="kw">await</span> import('../packages/dom/src/renderer/testdom.js');
5702
+ <span class="kw">const</span> { document, root } = createTestDom({ width: 700, height: 460 });
5703
+ <span class="kw">const</span> panel = document.createElement('div');
5704
+ panel.rect = { width: 700, height: 460, top: 0, left: 0 };
5705
+ root.appendChild(panel);
5706
+
5707
+ <span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5708
+ <span class="kw">const</span> { createChart } = <span class="kw">await</span> import('../packages/modules/charts/index.js');
5709
+ <span class="cmt">// Importing the module registers `markermap`; its drawer is drawMarkerMap.</span>
5710
+ <span class="kw">const</span> markermap = <span class="kw">await</span> import('../packages/modules/chart-markermap/index.js');
5711
+ <span class="kw">const</span> { pack } = <span class="kw">await</span> import('../packages/modules/geo-world-110m/index.js');
5712
+
5713
+ <span class="kw">const</span> grid = createHeadlessGrid({
5714
+ rowKey: 'id',
5715
+ columns: [
5716
+ { field: 'site', type: 'text' }, { field: 'lng', type: 'number' },
5717
+ { field: 'lat', type: 'number' }, { field: 'avail', type: 'number', format: '0.00%' },
5718
+ ],
5719
+ rows: [
5720
+ { id: 'ldn', site: 'London', lng: -0.13, lat: 51.5, avail: 0.9995 },
5721
+ { id: 'syd', site: 'Sydney', lng: 151.2, lat: -33.87, avail: 0.9991 },
5722
+ { id: 'fra', site: 'Frankfurt', lng: 8.68, lat: 50.11, avail: 0.9962 },
5723
+ { id: 'nyc', site: 'New York', lng: -74.0, lat: 40.71, avail: 0.9805 },
5724
+ ],
5725
+ });
5726
+ <span class="cmt">// Three rules on the column. Nothing below repeats a threshold or a colour.</span>
5727
+ grid.formatting.add('avail', { when: { op: 'gte', value: 0.999 }, style: { background: '#1b7f3b' }, label: 'Healthy' });
5728
+ grid.formatting.add('avail', { when: { op: 'gte', value: 0.99 }, style: { background: '#c8a415' }, label: 'Watch' });
5729
+ grid.formatting.add('avail', { when: { op: 'lt', value: 0.99 }, style: { background: '#c0392b' }, label: 'Breached' });
5730
+
5731
+ <span class="kw">const</span> chart = createChart({
5732
+ grid, container: panel, type: 'markermap',
5733
+ lon: 'lng', lat: 'lat', label: 'site', value: 'avail', shapes: pack,
5734
+ });
5735
+
5736
+ <span class="cmt">// What was painted: a fill per marker, and the text beside each dot.</span>
5737
+ <span class="kw">const</span> fills = [];
5738
+ <span class="kw">const</span> labels = [];
5739
+ <span class="kw">const</span> walk = (node) =&gt; {
5740
+ <span class="kw">for</span> (<span class="kw">const</span> child <span class="kw">of</span> node.children || []) {
5741
+ <span class="kw">const</span> cls = String(child.getAttribute('class') || '');
5742
+ <span class="kw">if</span> (cls.includes('markermap-dot')) fills.push(child.getAttribute('fill'));
5743
+ <span class="kw">if</span> (cls.includes('data-label')) labels.push(child.textContent);
5744
+ walk(child);
5745
+ }
5746
+ };
5747
+ walk(chart.element);
5748
+
5749
+ <span class="cmt">// The binder is public too, for a host that wants the placed rows itself.</span>
5750
+ <span class="kw">const</span> bound = markermap.bindMarkerMap(grid, { lon: 'lng', lat: 'lat', label: 'site', value: 'avail' });
5751
+ chart.destroy();
5752
+ <span class="kw">return</span> `${fills.join(' ')} | ${labels.join(' / ')} | unplaced ${bound.unplaced}`;</code></pre>
5753
+
5603
5754
  <h2 id="datarouter">The data router</h2>
5604
5755
  <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>. <strong>The router never opens a connection itself &mdash; the host owns the connection (a <code>WebSocket</code>, SSE, CDC, a message bus, a plain fetch), and the router owns everything once a message has arrived</strong>; see <a href="#datarouter-websocket-example">a live WebSocket feed</a> for the worked, runnable integration.</p>
5605
5756
  <pre><code>import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
@@ -5630,12 +5781,12 @@ router.load(snapshot); <span class="cmt">// every viewer
5630
5781
  <table>
5631
5782
  <thead><tr><th>Member</th><th>Description</th></tr></thead>
5632
5783
  <tbody>
5633
- <tr><td class="sig">createDataRouter({ key, rowKey?, overlap?, onUnrouted?, seq?, dedupe?, batch?, coalesce?, time?, now?, onWrite?, onConflict?, config? })</td><td class="desc">Create a router. <code>key</code> is the partition (property or <code>fn(row)</code>); <code>rowKey</code> the default identity within a grid; <code>overlap</code> fans a record to all matching routes; <code>onUnrouted</code> is a sink for unmatched records. <strong>v3:</strong> <code>seq</code> (a version field or <code>fn(row)</code>) turns on ordered de-duplication, <code>dedupe: false</code> opts out; <code>batch</code> (interval ms or <code>{ intervalMs }</code>) / <code>coalesce: true</code> buffer a high-frequency <code>push</code>. <strong>v4:</strong> <code>time</code> (a timestamp field or <code>fn(row)</code>) and an injectable <code>now</code> clock drive time-domain scrubbing. <strong>v8:</strong> <code>onWrite</code>/<code>onConflict</code> are the router-global write-back callbacks. <strong>v5:</strong> <code>config</code> is a declarative routing spec, desugared through <code>configure</code>.</td></tr>
5784
+ <tr><td class="sig">createDataRouter({ key?, rowKey?, overlap?, onUnrouted?, selectionDebounce?, metricsInterval?, seq?, dedupe?, batch?, coalesce?, time?, now?, onWrite?, onConflict?, config? })</td><td class="desc">Create a router. <code>key</code> is the partition (property or <code>fn(row)</code>) &mdash; optional, since a router whose routes all use <code>fn(row)</code> predicates never reads it; <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 (it receives the row on <code>load</code> and <code>query</code>, and the whole delta on <code>apply</code>). <code>selectionDebounce</code> is the debounce in ms for cross-grid selection refilters (default 16; <code>0</code> refilters synchronously). <strong>v10:</strong> <code>metricsInterval</code> is the ms between periodic <code>on('metrics')</code> emits (default 1000; <code>0</code> disables the timer). <strong>v3:</strong> <code>seq</code> (a version field or <code>fn(row)</code>) turns on ordered de-duplication, <code>dedupe: false</code> opts out; <code>batch</code> (interval ms or <code>{ intervalMs }</code>) / <code>coalesce: true</code> buffer a high-frequency <code>push</code>. <strong>v4:</strong> <code>time</code> (a timestamp field or <code>fn(row)</code>) and an injectable <code>now</code> clock drive time-domain scrubbing. <strong>v8:</strong> <code>onWrite</code>/<code>onConflict</code> are the router-global write-back callbacks. <strong>v5:</strong> <code>config</code> is a declarative routing spec, desugared through <code>configure</code>.</td></tr>
5634
5785
  <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>
5635
5786
  <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>
5636
5787
  <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>
5637
5788
  <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>
5638
- <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>
5789
+ <tr><td class="sig">unrouted</td><td class="desc">How many records matched no route. Reset to zero by <code>load</code> and by <code>query</code>, then running for deltas &mdash; so read it straight after the call you care about, not at the end of a session.</td></tr>
5639
5790
  <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>
5640
5791
  <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>
5641
5792
  <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>
@@ -5650,8 +5801,9 @@ router.load(snapshot); <span class="cmt">// every viewer
5650
5801
  <tr><td class="sig">attach(grid, predicate, { writable, onWrite?, onConflict? })</td><td class="desc"><strong>v8 (BACKLOG-0000912):</strong> make a route <strong>writable</strong> &mdash; the router captures the grid's committed edits off its public edit surface (<code>grid.on('cell:changed')</code> &rarr; <code>grid.edit.setCells</code>) and routes them to <code>onWrite(change, { route, source })</code> (per-route here, or the router-global <code>onWrite</code>), reverting the cell on reject and re-entering an accepted write as a normal delta. <code>onConflict(change, { serverRow })</code> surfaces a last-write-wins conflict. A derived (<code>rollup</code>/<code>transform</code>) route cannot be writable &mdash; its edits are reverted and warned.</td></tr>
5651
5802
  <tr><td class="sig">attach(grid, predicate, { where })</td><td class="desc"><strong>v7 (BACKLOG-0000914):</strong> a route-level <code>where</code> &mdash; a filter-wire condition <code>{ col, op, value }</code> or an <code>and</code>/<code>or</code>/<code>not</code> group &mdash; used only by query-slice routing (<code>query()</code>): the router pushes it <em>down</em> to the engine where the adapter allows and finishes the residual client-side. Distinct from <code>filter</code> (a <code>fn(row)</code> that only ever runs in the browser).</td></tr>
5652
5803
  <tr><td class="sig">attach(grid, predicate, { label, backpressure })</td><td class="desc"><strong>v10/v13:</strong> a human <code>label</code> for the route (shown in <code>metrics()</code> and the devtools panel), and a per-route <strong>backpressure</strong> policy that throttles / coalesces / samples how that route's viewer is refreshed under load &mdash; <em>without</em> touching the keyed store or any other route. <code>backpressure: { maxHz, minInterval?, sample?, maxLag? }</code>: <code>maxLag</code> (a backlog depth) sets when it engages (below it, changes pass straight through); <code>maxHz</code>/<code>minInterval</code> cap the refresh rate; <code>sample</code> (an integer &gt; 1) thins intermediate refreshes. A trailing flush always lands the latest state (deletes included), so the viewer converges and is never left stale.</td></tr>
5804
+ <tr><td class="sig">flushBackpressure()</td><td class="desc"><strong>v13 (BACKLOG-0000962):</strong> refresh every backpressured route to the latest state now. A route with a <code>backpressure</code> policy holds its viewer refresh until its rate limit or sample count allows one, so a test &mdash; or a teardown &mdash; can observe a route that has not caught up yet; this forces the deferred flush for every route at once and gives you a deterministic point. A no-op for routes without a policy or with nothing pending, and it never touches the keyed store: the rows were always current, only the refresh was held.</td></tr>
5653
5805
  <tr><td class="sig">query(adapter, request?)</td><td class="desc"><strong>v7 (BACKLOG-0000914):</strong> source the router from a DFQL/DuckDB (or any pushdown) adapter. Runs <code>adapter.execute</code>, partitions the result across the routes and drives the grids by the same keyed diff <code>load()</code> uses; a route's <code>where</code> is planned against the adapter's capabilities (pushed down where allowed, residual finished client-side). Composes with per-route <code>transform</code>/<code>filter</code>/<code>sort</code>/<code>rollup</code> and links/graph. Async &mdash; resolves once every slice is fetched and applied.</td></tr>
5654
- <tr><td class="sig">lastQueryPlan()</td><td class="desc"><strong>v7:</strong> the pushed/residual split of the last <code>query()</code>, per fetch &mdash; whether a filter reached the engine and what work was left client-side. <code>null</code> before any query. Provenance, so a slow slice is diagnosed rather than guessed.</td></tr>
5806
+ <tr><td class="sig">lastQueryPlan()</td><td class="desc"><strong>v7:</strong> the pushed/residual split of the last <code>query()</code>, per fetch &mdash; whether a filter reached the engine and what work was left client-side. <code>null</code> before any query. Each entry is <code>{ route, pushedFilter, residual }</code> for a <code>where</code> route &mdash; <code>route</code> the grid, <code>pushedFilter</code> whether its filter reached the engine, <code>residual</code> the work finished client-side &mdash; or <code>{ base: true, pushedFilter, residual }</code> for the single base fetch that fed every route without a <code>where</code>. Provenance, so a slow slice is diagnosed rather than guessed.</td></tr>
5655
5807
  <tr><td class="sig">buffer({ window?, max? })</td><td class="desc"><strong>v4 (BACKLOG-0000911):</strong> turn on time-travel buffering &mdash; record the ordered, de-duplicated stream into a <strong>bounded</strong> ring (a time <code>window</code> in ms and/or a <code>max</code> delta count; eviction folds the oldest into a moving base, so memory never grows unbounded; a default cap applies if you name neither). Seeded from the current world, so it can be turned on at any time. Opt-in and off by default.</td></tr>
5656
5808
  <tr><td class="sig">scrubTo(target, { by? })</td><td class="desc"><strong>v4:</strong> scrub the grids to a past point &mdash; the base snapshot plus the buffered deltas up to <code>target</code> (a seq when the router has one, else a timestamp; <code>{ by: 'seq' | 'time' }</code> chooses). Pushed by keyed diff, so each view keeps scroll and selection and only changed rows repaint. Live deltas keep arriving into the buffer but do not disturb the view.</td></tr>
5657
5809
  <tr><td class="sig">replay(from, to, { speed?, by? })</td><td class="desc"><strong>v4:</strong> walk an incident &mdash; scrub to <code>from</code>, then apply each buffered delta in <code>(from, to]</code> in order, one per <code>speed</code> ms (default <code>0</code>). Returns a promise resolving when the range finishes (or is superseded); the router stays parked at <code>to</code> until <code>live()</code>.</td></tr>
@@ -5664,7 +5816,7 @@ router.load(snapshot); <span class="cmt">// every viewer
5664
5816
  <tr><td class="sig">removeSource(ref) / sources()</td><td class="desc"><strong>v9:</strong> drop exactly the rows a feed contributed (by source id or handle) from every route and unregister it; and list the registered source ids.</td></tr>
5665
5817
  <tr><td class="sig">metrics()</td><td class="desc"><strong>v10 (BACKLOG-0000932):</strong> a cheap point-in-time observability snapshot &mdash; per-route row counts and throughput (rows/sec since the previous read), per-source rates and totals (fan-in), and the global <code>unrouted</code> / <code>dropped</code> / <code>buffered</code> / <code>lag</code> figures. Throughput is sampled over the interval since the last <code>metrics()</code> call or emit. Each entry in <code>routes[]</code> also carries its <code>label</code> and, when the route declares a backpressure policy, a <code>backpressure: { pending, coalesced }</code> object &mdash; <code>pending</code> is the held backlog since the last flush (the route's lag) and <code>coalesced</code> the cumulative change-events it has absorbed into deferred refreshes (<code>null</code> when the route has no policy).</td></tr>
5666
5818
  <tr><td class="sig">on('metrics', handler)</td><td class="desc"><strong>v10:</strong> subscribe to the periodic <code>metrics</code> emit (the <code>metricsInterval</code> ms, default 1000; <code>0</code> disables it). The timer runs only while at least one listener is registered and stops when the last is removed. Returns an unsubscribe function.</td></tr>
5667
- <tr><td class="sig">mountDevtools(el, { interval? })</td><td class="desc"><strong>v10:</strong> mount an opt-in, DOM-touching live panel (in the module's own <code>devtools.js</code>, so the core stays DOM-free) that renders <code>metrics()</code> into <code>el</code> and refreshes on each emit. Returns a controller with <code>destroy()</code>. Off unless called.</td></tr>
5819
+ <tr><td class="sig">mountDevtools(el)</td><td class="desc"><strong>v10:</strong> mount an opt-in, DOM-touching live panel (in the module's own <code>devtools.js</code>, so the core stays DOM-free) that renders <code>metrics()</code> into <code>el</code> and re-renders on each <code>on('metrics')</code> emit &mdash; so the panel's cadence is the router's <code>metricsInterval</code>, not a setting of its own. Returns a controller with <code>refresh()</code>, which forces an immediate re-render, and <code>destroy()</code>, which unsubscribes and removes the panel from the DOM. The same panel is available without a router: <code>mountRouterDevtools(router, el)</code> is a named export of <code>modules/data-router/devtools.js</code>, and <code>mountDevtools</code> is a one-line wrapper over it. Off unless called.</td></tr>
5668
5820
  <tr><td class="sig">persist({ key?, debounce?, storage?, indexedDB?, dbName?, storeName? })</td><td class="desc"><strong>v12 (BACKLOG-0000961):</strong> turn on durable persistence &mdash; snapshot the keyed store and the time-travel ring to a durable async key/value store so an offline reload or a browser refresh resumes exactly where it left off. The default backend is <strong>IndexedDB</strong> (native, no dependency), opened lazily and guarded so private-mode or blocked storage degrades to in-memory with a one-time warning rather than throwing. Writes a coalesced snapshot after each <code>load</code>/<code>apply</code> (debounced by <code>debounce</code> ms, default 250; <code>0</code> is eager). Pass <code>storage</code> &mdash; any object with async <code>get(key)</code>/<code>set(key, value)</code> &mdash; to use another backend (a server, a test double). Opt-in and off by default.</td></tr>
5669
5821
  <tr><td class="sig">restore()</td><td class="desc"><strong>v12:</strong> resume from the durable snapshot. Read the last persisted state and apply it &mdash; <code>load</code> the live head through the ordinary keyed diff (so grids attached before this call repaint only what differs), restore the resume checkpoint and, when the snapshot carried a time-travel ring, restore buffering and the ring so <code>scrubTo</code>/<code>replay</code>/<code>live</code> work straight after a reload. Call it once, after attaching the grids. <code>async</code>; resolves <code>true</code> when a snapshot was found and applied, <code>false</code> when persistence is off/degraded or nothing was stored.</td></tr>
5670
5822
  <tr><td class="sig">flushPersist() / persisting</td><td class="desc"><strong>v12:</strong> flush any pending durable write now (<code>async</code>; cancels the debounce and resolves once the write settles &mdash; for a <code>beforeunload</code> handler, a deterministic checkpoint, or a test), and whether durable persistence is on and not degraded to in-memory.</td></tr>
@@ -5673,13 +5825,14 @@ router.load(snapshot); <span class="cmt">// every viewer
5673
5825
  </tbody>
5674
5826
  </table>
5675
5827
  </div>
5828
+ <p class="section-note"><strong>Four things worth knowing before you build on this.</strong> <code>attachDefault</code> keeps <em>one</em> sink: calling it twice replaces the first, silently, along with whatever slice it held &mdash; attach the sink once, at setup. An <code>alert</code> has no removal: <code>detach</code> drops routes, links and graph edges but leaves alerts running against their own partition, so an alert added at setup keeps firing until <code>destroy()</code>, even after every grid is gone. <code>detach</code> does take a <code>subscribe</code> handler as well as a grid &mdash; pass the same function you handed <code>subscribe</code> and the subscription goes with it. And a <code>join</code> accepts three spellings the examples below do not use: <code>on</code> for <code>localKey</code>, <code>fromKey</code> for <code>foreignKey</code>, and <code>select</code> for <code>fields</code>; they are equivalent, and a spec the router cannot honour degrades to plain fan-in with a one-time warning rather than throwing.</p>
5676
5829
  <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>
5677
5830
  <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>
5678
5831
  <h3 id="datarouter-v2-example">Cross-grid selection filtering, executed</h3>
5679
5832
  <p class="section-note">A customers grid and an orders grid off one feed; selecting customers filters the orders
5680
5833
  grid to their regions through the keyed-diff path, and deselecting restores the full set. Run headless
5681
5834
  on every build.</p>
5682
- <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');
5835
+ <pre data-run="js" data-expect="3 | 2 | 3 | 3" data-covers="export:createDataRouter config:key config:selectionDebounce"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5683
5836
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5684
5837
 
5685
5838
  <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region', type: 'text' }];
@@ -5744,7 +5897,7 @@ g.destroy(); router.destroy();
5744
5897
  <span class="kw">return</span> out.join(' | ');</code></pre>
5745
5898
 
5746
5899
  <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>
5747
- <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');
5900
+ <pre data-run="js" data-expect="2 | amer | 115" data-covers="export:createDataRouter config:rollup"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5748
5901
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5749
5902
 
5750
5903
  <span class="kw">const</span> cols = [{ id: 'region', field: 'region' }, { id: 'total', field: 'total', type: 'number' }];
@@ -5772,7 +5925,7 @@ chart.destroy(); router.destroy();
5772
5925
  <span class="kw">return</span> [groups, top, emeaTotal].join(' | ');</code></pre>
5773
5926
 
5774
5927
  <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>
5775
- <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');
5928
+ <pre data-run="js" data-expect="o1,o3 | l1,l3,l4" data-covers="export:createDataRouter config:relate"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5776
5929
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5777
5930
 
5778
5931
  <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' }];
@@ -5797,11 +5950,11 @@ customers.destroy(); orders.destroy(); lines.destroy(); router.destroy();
5797
5950
  <span class="kw">return</span> out.join(' | ');</code></pre>
5798
5951
 
5799
5952
  <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>). When the host's own connection drops and it reconnects, 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. The router does not detect or recover from the drop itself; see <a href="#datarouter-websocket-example">a live WebSocket feed</a> for the worked reconnect example. <code>seenThrough(mark)</code> primes the checkpoint from a persisted one.</p>
5800
- <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');
5953
+ <pre data-run="js" data-expect="30 | 1 | 4" data-covers="export:createDataRouter config:seq config:dedupe"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5801
5954
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5802
5955
 
5803
5956
  <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
5804
- <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v' });
5957
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
5805
5958
  router.attach(g, () =&gt; true);
5806
5959
  router.load([]);
5807
5960
 
@@ -5823,12 +5976,12 @@ g.destroy(); router.destroy();
5823
5976
  dropped by the <code>seq</code> checkpoint while a genuinely new one lands. Swapping in a real
5824
5977
  <code>WebSocket</code> is a one-line constructor change — see <a href="#mocksocket">the mock socket</a>.
5825
5978
  Run headless on every build.</p>
5826
- <pre data-run="js" data-expect="A@3 | 3 | A@4 | 2" data-covers="export:createDataRouter export:MockWebSocket"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5979
+ <pre data-run="js" data-expect="A@3 | 3 | A@4 | 2" data-covers="export:createDataRouter config:seq config:dedupe export:MockWebSocket"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5827
5980
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5828
5981
  <span class="kw">const</span> { MockWebSocket } = <span class="kw">await</span> import('../packages/modules/mock-socket/index.js');
5829
5982
 
5830
5983
  <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'label', field: 'label' }] });
5831
- <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v' });
5984
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', seq: 'v', dedupe: true });
5832
5985
  router.attach(g, () =&gt; <span class="kw">true</span>);
5833
5986
 
5834
5987
  <span class="cmt">// THE INTEGRATION: a snapshot message loads, a delta message applies. This is</span>
@@ -5850,7 +6003,7 @@ router.attach(g, () =&gt; <span class="kw">true</span>);
5850
6003
  }
5851
6004
  <span class="kw">const</span> socketA = <span class="kw">new</span> MockWebSocket({ feed: feedA(), rate: 5, jitter: 0, snapshotDelay: 5 });
5852
6005
  wireRouter(router, socketA);
5853
- <span class="kw">await</span> wait(30);
6006
+ <span class="kw">await</span> wait(80);
5854
6007
  <span class="kw">const</span> beforeDrop = g.rows.value('a', 'label'); <span class="cmt">// A@3</span>
5855
6008
  <span class="kw">const</span> resumeFrom = router.lastSeq(); <span class="cmt">// 3 — the resume cursor</span>
5856
6009
  socketA.close();
@@ -5866,7 +6019,7 @@ socketA.close();
5866
6019
  <span class="kw">const</span> droppedBefore = router.dropped;
5867
6020
  <span class="kw">const</span> socketB = <span class="kw">new</span> MockWebSocket({ feed: feedB(), rate: 5, jitter: 0, snapshotDelay: 5 });
5868
6021
  wireRouter(router, socketB);
5869
- <span class="kw">await</span> wait(30);
6022
+ <span class="kw">await</span> wait(80);
5870
6023
  <span class="kw">const</span> afterReconnect = g.rows.value('a', 'label'); <span class="cmt">// A@4 — only the new delta advanced it</span>
5871
6024
  <span class="kw">const</span> replaysDropped = router.dropped - droppedBefore; <span class="cmt">// 2 — both replays dropped</span>
5872
6025
  socketB.close();
@@ -5913,7 +6066,7 @@ orders.destroy(); invoices.destroy(); rest.destroy(); router.destroy();
5913
6066
  <h3 id="datarouter-v4-example">Scrub and return to live, executed</h3>
5914
6067
  <p class="section-note">A versioned feed buffered into a bounded ring; the view scrubs to a past seq, reads the
5915
6068
  reconstructed value, then returns to the live head. Run headless on every build.</p>
5916
- <pre data-run="js" data-expect="30 | 10 | true | 30" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6069
+ <pre data-run="js" data-expect="30 | 10 | true | 30" data-covers="export:createDataRouter config:buffer config:seq"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5917
6070
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5918
6071
 
5919
6072
  <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: [{ id: 'id', field: 'id' }, { id: 'n', field: 'n', type: 'number' }] });
@@ -5974,7 +6127,7 @@ big.destroy(); small.destroy(); router.destroy();
5974
6127
  <h3 id="datarouter-v9-example">Fan-in from two feeds, executed</h3>
5975
6128
  <p class="section-note">Two feeds with a colliding raw id, namespaced per source so they merge without clobbering;
5976
6129
  removing one feed drops exactly its rows. Run headless on every build.</p>
5977
- <pre data-run="js" data-expect="3 | crm,erp | 2" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6130
+ <pre data-run="js" data-expect="3 | crm,erp | 2 | 2" data-covers="export:createDataRouter config:id config:key config:load config:size config:remove"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
5978
6131
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
5979
6132
 
5980
6133
  <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'n', field: 'n', type: 'number' }];
@@ -5989,12 +6142,13 @@ crm.load([{ id: '1', type: 'order', n: 10 }, { id: '2', type: 'order', n: 20 }])
5989
6142
  erp.load([{ id: '1', type: 'order', n: 99 }]); <span class="cmt">// same raw id '1' — merged, not clobbered</span>
5990
6143
  <span class="kw">const</span> merged = g.rows.count(); <span class="cmt">// 3</span>
5991
6144
  <span class="kw">const</span> ids = router.sources().join(','); <span class="cmt">// crm,erp</span>
6145
+ <span class="kw">const</span> held = crm.size; <span class="cmt">// 2 — what this one feed holds live</span>
5992
6146
 
5993
- router.removeSource('erp'); <span class="cmt">// drops exactly erp's row</span>
6147
+ erp.remove(); <span class="cmt">// drops exactly erp's row; the handle knows its own feed</span>
5994
6148
  <span class="kw">const</span> afterRemove = g.rows.count(); <span class="cmt">// 2</span>
5995
6149
 
5996
6150
  g.destroy(); router.destroy();
5997
- <span class="kw">return</span> [merged, ids, afterRemove].join(' | ');</code></pre>
6151
+ <span class="kw">return</span> [merged, ids, held, afterRemove].join(' | ');</code></pre>
5998
6152
 
5999
6153
  <p><strong>Fan-in JOIN / enrichment (v11, BACKLOG-0000957).</strong> Fan-in above <em>merges</em> feeds side by side; a <code>join</code> spec goes further and <em>enriches</em> one feed's rows with fields looked up from <em>another</em> registered source &mdash; e.g. an <code>orders</code> feed enriched with <code>name</code>/<code>tier</code> from a <code>customers</code> source keyed by <code>customerId</code>. Declared per source: <code>addSource('orders', { join: { from: 'customers', localKey: 'customerId', fields: ['name', 'tier'], missing: 'hold' } })</code>. <code>localKey</code> is the field on the enriched (left) row holding the foreign key (a field name or <code>fn(row)</code>); <code>foreignKey</code> is the field matched on the lookup row (defaults to <code>localKey</code>'s name); <code>fields</code> is what to pull &mdash; an array, a <code>{ src: dest }</code> rename map, or <code>select(lookupRow, leftRow) =&gt; object</code>. No second store is built: the lookup source <em>is</em> an ordinary fan-in source, and the join probes its existing keyed store by an index of join-key&nbsp;&rarr;&nbsp;store-id. <code>missing</code> chooses what happens when the lookup is absent or late: <code>hold</code> withholds the row from viewers until its lookup arrives, <code>passthrough</code> (the default) lets it flow unenriched, and <code>null</code> flows it with the pulled fields set to <code>null</code>. Enriched rows reach viewers through the ordinary keyed-diff path. <strong>Late lookups re-enrich:</strong> when a lookup row arrives, changes, or is deleted, every already-seated left row that references it is re-enriched and re-emitted &mdash; a held row is released, a <code>null</code>/<code>passthrough</code> row gains its fields, and a row whose lookup vanished is nulled/stripped (or, under <code>hold</code>, withheld again). Enrichment always recomputes from the untouched base row, so it is idempotent.</p>
6000
6154
  <p><strong>Durable resume and backpressure (v12/v13, BACKLOG-0000961 / BACKLOG-0000962).</strong> <code>persist({ key })</code> turns on durability: the router snapshots its keyed store and time-travel ring to a durable async store (IndexedDB by default, or any <code>{ get, set }</code> you pass as <code>storage</code>) after each <code>load</code>/<code>apply</code>, and <code>await router.restore()</code> &mdash; called once after the grids are attached &mdash; rehydrates them through the ordinary keyed diff, so an offline reload or a browser refresh resumes exactly where it left off (blocked/private storage degrades to in-memory with a one-time warning, never a throw). Independently, a route can declare <strong>backpressure</strong> &mdash; <code>attach(grid, type, { label, backpressure: { maxHz } })</code> &mdash; to cap how often its viewer repaints under load without slowing the store or any sibling route: <code>maxHz</code>/<code>minInterval</code> rate-limit the refresh, <code>sample</code> thins intermediate ones, and <code>maxLag</code> sets the backlog depth at which throttling engages; a trailing flush always lands the latest state so the viewer converges. What it cost is observable: <code>router.metrics().routes[].backpressure</code> is <code>{ pending, coalesced }</code> &mdash; the held backlog and the cumulative change-events folded into deferred refreshes (<code>null</code> for a route with no policy).</p>
@@ -6028,15 +6182,15 @@ g.destroy(); router.destroy();
6028
6182
  <h3 id="datarouter-v10-example">A metrics snapshot, executed</h3>
6029
6183
  <p class="section-note">A snapshot fanned to a route and a sink; the metrics read reports the route's row count and the
6030
6184
  unrouted total, and <code>on('metrics')</code> returns an unsubscribe. Run headless on every build.</p>
6031
- <pre data-run="js" data-expect="2 | 1 | function" data-covers="export:createDataRouter"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6185
+ <pre data-run="js" data-expect="2 | 1 | function" data-covers="export:createDataRouter config:metricsInterval"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6032
6186
  <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6033
6187
 
6034
6188
  <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
6035
6189
  <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
6036
- <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id' });
6190
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', metricsInterval: 0 }); <span class="cmt">// 0: no periodic emit; read on demand</span>
6037
6191
  router.attach(g, 'order');
6038
6192
 
6039
- <span class="kw">const</span> off = router.on('metrics', () =&gt; {}); <span class="cmt">// register; starts the timer, returns unsubscribe</span>
6193
+ <span class="kw">const</span> off = router.on('metrics', () =&gt; {}); <span class="cmt">// register; returns unsubscribe (no timer at interval 0)</span>
6040
6194
  router.load([
6041
6195
  { id: 'o1', type: 'order' },
6042
6196
  { id: 'o2', type: 'order' },
@@ -6045,11 +6199,192 @@ router.load([
6045
6199
  <span class="kw">const</span> m = router.metrics();
6046
6200
  <span class="kw">const</span> rows = m.routes[0].rows; <span class="cmt">// 2</span>
6047
6201
  <span class="kw">const</span> unrouted = m.unrouted; <span class="cmt">// 1</span>
6048
- off(); <span class="cmt">// stops the timer (last listener gone)</span>
6202
+ off(); <span class="cmt">// last listener gone</span>
6049
6203
 
6050
6204
  g.destroy(); router.destroy();
6051
6205
  <span class="kw">return</span> [rows, unrouted, typeof off].join(' | ');</code></pre>
6052
6206
 
6207
+
6208
+ <h3 id="datarouter-options-examples">The remaining options, each executed</h3>
6209
+ <p class="section-note">One short example per option family the eight above do not reach: overlap and the unrouted sink, coalesced pushes, write-back, the declarative graph, durable resume, backpressure, and time-domain scrubbing. Each is run headless on every build.</p>
6210
+ <h3 id="datarouter-overlap-example">Fan one value to two viewers, and log the strays, executed</h3>
6211
+ <p class="section-note">With <code>overlap</code> a record goes to <em>every</em> route it matches, so a grid and a second viewer can share one partition; <code>onUnrouted</code> receives what matched none, which is the right sink when a stray record is a bug to log rather than a row to show. Run headless on every build.</p>
6212
+ <pre data-run="js" data-expect="1 | 1 | x1 | 1" data-covers="export:createDataRouter config:overlap config:onUnrouted"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6213
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6214
+
6215
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
6216
+ <span class="kw">const</span> g1 = createHeadlessGrid({ rowKey: 'id', columns: cols });
6217
+ <span class="kw">const</span> g2 = createHeadlessGrid({ rowKey: 'id', columns: cols });
6218
+ <span class="kw">const</span> strays = [];
6219
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', overlap: true, onUnrouted: (row) =&gt; strays.push(row.id) });
6220
+ router.attach(g1, 'order');
6221
+ router.attach(g2, 'order'); // a second viewer on the same value: allowed because overlap is on
6222
+ router.load([{ id: 'o1', type: 'order' }, { id: 'x1', type: 'ticket' }]);
6223
+ <span class="kw">const</span> out = [g1.rows.count(), g2.rows.count(), strays.join(','), router.unrouted];
6224
+ g1.destroy(); g2.destroy(); router.destroy();
6225
+ <span class="kw">return</span> out.join(' | ');</code></pre>
6226
+ <h3 id="datarouter-coalesce-example">Coalesce a burst into one repaint, executed</h3>
6227
+ <p class="section-note">A high-frequency feed goes through <code>push</code>; with <code>coalesce</code> (or a <code>batch</code> interval) rapid updates to one key fold into a single <code>apply</code>, and <code>flushStream</code> gives a deterministic point. Run headless on every build.</p>
6228
+ <pre data-run="js" data-expect="0 | 1 | 3" data-covers="export:createDataRouter config:push config:coalesce config:batch"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6229
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6230
+
6231
+ <span class="cmt">// A hand-rolled viewer, grid-shaped: the router accepts anything with rows.apply.</span>
6232
+ <span class="kw">const</span> rows = <span class="kw">new</span> Map(); <span class="kw">let</span> applies = 0;
6233
+ <span class="kw">const</span> g = { rows: { apply(change) {
6234
+ applies += 1;
6235
+ <span class="kw">for</span> (<span class="kw">const</span> row <span class="kw">of</span> [...(change.add || []), ...(change.update || [])]) rows.set(row.id, row);
6236
+ } } };
6237
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', coalesce: true, batch: { intervalMs: 50 } });
6238
+ router.attach(g, () =&gt; true);
6239
+ router.push({ op: 'upsert', row: { id: 'a', n: 1 } });
6240
+ router.push({ op: 'upsert', row: { id: 'a', n: 2 } });
6241
+ router.push({ op: 'upsert', row: { id: 'a', n: 3 } });
6242
+ <span class="kw">const</span> before = applies; // 0: nothing applied inside the batch window
6243
+ router.flushStream(); // one apply, carrying the latest value
6244
+ <span class="kw">const</span> after = applies;
6245
+ <span class="kw">const</span> n = rows.get('a').n;
6246
+ router.destroy();
6247
+ <span class="kw">return</span> [before, after, n].join(' | ');</code></pre>
6248
+ <h3 id="datarouter-writeback-example">A writable route, a conflict, executed</h3>
6249
+ <p class="section-note">A route attached <code>writable</code> captures the grid&rsquo;s committed edits and routes them to <code>onWrite</code>; when the server answers with a conflict, <code>onConflict</code> fires with the server row and the optimistic value stands (last-write-wins, no merge engine). Run headless on every build.</p>
6250
+ <pre data-run="js" data-expect="1 | 7 | 99" data-covers="export:createDataRouter config:writable config:onWrite config:onConflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6251
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6252
+
6253
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'amt', field: 'amt', type: 'number', edit: true }];
6254
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
6255
+ <span class="kw">const</span> conflicts = [];
6256
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', onConflict: (change, ctx) =&gt; conflicts.push(ctx.serverRow.amt) });
6257
+ router.attach(g, 'a', { writable: true, onWrite: () =&gt; ({ conflict: { id: 'r1', type: 'a', amt: 7 } }) });
6258
+ router.load([{ id: 'r1', type: 'a', amt: 10 }]);
6259
+ g.edit.setCells([{ key: 'r1', colId: 'amt', value: 99 }]); // the user's edit, routed to onWrite
6260
+ <span class="kw">const</span> out = [conflicts.length, conflicts[0], g.rows.byKey('r1').data.amt];
6261
+ g.destroy(); router.destroy();
6262
+ <span class="kw">return</span> out.join(' | ');</code></pre>
6263
+ <h3 id="datarouter-configure-example">The routing graph as one declarative spec, executed</h3>
6264
+ <p class="section-note">The same routes and links the imperative calls would make, as data: <code>routes</code> attach a grid <code>when</code> a value matches, <code>links</code> filter one grid by another&rsquo;s selection. Run headless on every build.</p>
6265
+ <pre data-run="js" data-expect="2 | 1" data-covers="export:createDataRouter config:routes config:links"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6266
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6267
+
6268
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }, { id: 'region', field: 'region' }];
6269
+ <span class="kw">const</span> customers = createHeadlessGrid({ rowKey: 'id', columns: cols, selection: 'multiple' });
6270
+ <span class="kw">const</span> orders = createHeadlessGrid({ rowKey: 'id', columns: cols });
6271
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', selectionDebounce: 0 });
6272
+ router.configure({
6273
+ routes: [{ grid: customers, when: 'customer' }, { grid: orders, when: 'order' }],
6274
+ links: [{ from: customers, to: orders, on: { from: 'region', to: 'region' } }],
6275
+ });
6276
+ router.load([
6277
+ { id: 'c1', type: 'customer', region: 'emea' },
6278
+ { id: 'o1', type: 'order', region: 'emea' },
6279
+ { id: 'o2', type: 'order', region: 'amer' },
6280
+ ]);
6281
+ <span class="kw">const</span> all = orders.rows.count(); // 2: no selection shows the whole partition
6282
+ customers.selection.set(['c1']);
6283
+ router.flush();
6284
+ <span class="kw">const</span> linked = orders.rows.count(); // 1: only emea orders
6285
+ customers.destroy(); orders.destroy(); router.destroy();
6286
+ <span class="kw">return</span> [all, linked].join(' | ');</code></pre>
6287
+ <h3 id="datarouter-persist-example">Durable resume through a store of your own, executed</h3>
6288
+ <p class="section-note">The router snapshots to IndexedDB by default &mdash; <code>dbName</code> and <code>storeName</code> say where, and <code>indexedDB</code> lets you hand it a factory (here a tiny fake, so the example is deterministic) &mdash; or to any <code>storage</code> with async <code>get</code>/<code>set</code>. A second router over the same store <code>restore</code>s what the first one wrote. Run headless on every build.</p>
6289
+ <pre data-run="js" data-expect="demo | router | true | 2 | true | 1" data-covers="export:createDataRouter config:storage config:indexedDB config:dbName config:storeName"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6290
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6291
+
6292
+ <span class="cmt">// A fake IDBFactory: one database, one object store, put/get in a transaction.</span>
6293
+ <span class="kw">const</span> dbs = <span class="kw">new</span> Map(); <span class="kw">const</span> opened = [];
6294
+ <span class="kw">const</span> req = () =&gt; ({ onsuccess: null, onerror: null, onupgradeneeded: null, result: undefined, error: null });
6295
+ <span class="kw">const</span> fakeIndexedDB = { open(name) {
6296
+ <span class="kw">const</span> r = req(); opened.push(name);
6297
+ queueMicrotask(() =&gt; {
6298
+ <span class="kw">const</span> fresh = !dbs.has(name); <span class="kw">if</span> (fresh) dbs.set(name, <span class="kw">new</span> Map());
6299
+ <span class="kw">const</span> stores = dbs.get(name);
6300
+ r.result = {
6301
+ objectStoreNames: { contains: (n) =&gt; stores.has(n) },
6302
+ ['createObjectStore'](n) { stores.set(n, <span class="kw">new</span> Map()); <span class="kw">return</span> {}; },
6303
+ transaction(n) {
6304
+ <span class="kw">const</span> store = stores.get(Array.isArray(n) ? n[0] : n); <span class="kw">const</span> tx = { oncomplete: null, onerror: null, onabort: null }; <span class="kw">const</span> ops = [];
6305
+ tx.objectStore = () =&gt; ({
6306
+ put(v, k) { <span class="kw">const</span> q = req(); ops.push(() =&gt; { store.set(k, v); <span class="kw">if</span> (q.onsuccess) q.onsuccess({ target: q }); }); <span class="kw">return</span> q; },
6307
+ get(k) { <span class="kw">const</span> q = req(); ops.push(() =&gt; { q.result = store.get(k); <span class="kw">if</span> (q.onsuccess) q.onsuccess({ target: q }); }); <span class="kw">return</span> q; },
6308
+ });
6309
+ queueMicrotask(() =&gt; { <span class="kw">for</span> (<span class="kw">const</span> op <span class="kw">of</span> ops) op(); <span class="kw">if</span> (tx.oncomplete) tx.oncomplete(); });
6310
+ <span class="kw">return</span> tx;
6311
+ },
6312
+ close() {},
6313
+ };
6314
+ <span class="kw">if</span> (fresh &amp;&amp; r.onupgradeneeded) r.onupgradeneeded({ target: r });
6315
+ <span class="kw">if</span> (r.onsuccess) r.onsuccess({ target: r });
6316
+ });
6317
+ <span class="kw">return</span> r;
6318
+ } };
6319
+
6320
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'type', field: 'type' }];
6321
+ <span class="kw">const</span> a = createDataRouter({ key: 'type', rowKey: 'id' });
6322
+ <span class="kw">const</span> ga = createHeadlessGrid({ rowKey: 'id', columns: cols });
6323
+ a.attach(ga, 'order');
6324
+ a.persist({ indexedDB: fakeIndexedDB, dbName: 'demo', storeName: 'router', debounce: 0 });
6325
+ a.load([{ id: 'o1', type: 'order' }, { id: 'o2', type: 'order' }]);
6326
+ <span class="kw">await</span> a.flushPersist();
6327
+
6328
+ <span class="kw">const</span> b = createDataRouter({ key: 'type', rowKey: 'id' }); // "after the reload"
6329
+ <span class="kw">const</span> gb = createHeadlessGrid({ rowKey: 'id', columns: cols });
6330
+ b.attach(gb, 'order');
6331
+ b.persist({ indexedDB: fakeIndexedDB, dbName: 'demo', storeName: 'router' });
6332
+ <span class="kw">const</span> found = <span class="kw">await</span> b.restore();
6333
+ <span class="kw">const</span> store = [...dbs.get('demo').keys()][0];
6334
+
6335
+ <span class="cmt">// Or skip IndexedDB entirely: any async get/set pair is a store.</span>
6336
+ <span class="kw">const</span> mem = <span class="kw">new</span> Map();
6337
+ <span class="kw">const</span> c = createDataRouter({ key: 'type', rowKey: 'id' });
6338
+ c.attach(createHeadlessGrid({ rowKey: 'id', columns: cols }), 'order');
6339
+ c.persist({ storage: { get: <span class="kw">async</span> (k) =&gt; mem.get(k), set: <span class="kw">async</span> (k, v) =&gt; { mem.set(k, v); } }, debounce: 0 });
6340
+ c.load([{ id: 'o9', type: 'order' }]);
6341
+ <span class="kw">await</span> c.flushPersist();
6342
+ <span class="kw">const</span> out = [opened[0], store, found, gb.rows.count(), b.persisting, mem.size];
6343
+ ga.destroy(); gb.destroy(); a.destroy(); b.destroy(); c.destroy();
6344
+ <span class="kw">return</span> out.join(' | ');</code></pre>
6345
+ <h3 id="datarouter-backpressure-example">Throttle one viewer under load, executed</h3>
6346
+ <p class="section-note">A <code>backpressure</code> policy on one route caps how often <em>that</em> viewer is refreshed without slowing the store or any sibling; a burst inside the window is held to one leading refresh, and <code>flushBackpressure</code> lands the coalesced latest state. Run headless on every build.</p>
6347
+ <pre data-run="js" data-expect="1 | 2 | 9 | 3" data-covers="export:createDataRouter config:backpressure"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6348
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6349
+
6350
+ <span class="kw">let</span> clock = 0;
6351
+ <span class="kw">const</span> router = createDataRouter({ key: 'type', rowKey: 'id', now: () =&gt; clock });
6352
+ <span class="kw">const</span> view = { refreshes: 0, rows: <span class="kw">new</span> Map() };
6353
+ router.subscribe('x', (change) =&gt; {
6354
+ view.refreshes += 1;
6355
+ <span class="kw">for</span> (<span class="kw">const</span> row <span class="kw">of</span> [...change.add, ...change.update]) view.rows.set(row.id, row);
6356
+ <span class="kw">for</span> (<span class="kw">const</span> key <span class="kw">of</span> change.remove) view.rows.delete(key);
6357
+ }, { backpressure: { maxHz: 100 } }); // at most one refresh per 10 ms
6358
+ <span class="kw">const</span> up = (id, n) =&gt; ({ op: 'upsert', row: { id, type: 'x', n } });
6359
+ router.apply([up('a', 1)]); // leading edge: refreshes at once
6360
+ router.apply([up('b', 2)]); // inside the window: held
6361
+ router.apply([up('c', 3)]);
6362
+ router.apply([up('a', 9)]); // still held; a's latest value wins
6363
+ <span class="kw">const</span> held = view.refreshes; // 1
6364
+ clock = 10;
6365
+ router.flushBackpressure(); // window open: one trailing refresh
6366
+ <span class="kw">const</span> out = [held, view.refreshes, view.rows.get('a').n, view.rows.size];
6367
+ router.destroy();
6368
+ <span class="kw">return</span> out.join(' | ');</code></pre>
6369
+ <h3 id="datarouter-time-example">Scrub by wall-clock time, executed</h3>
6370
+ <p class="section-note">When rows carry a timestamp, <code>time</code> names it and the buffer works in the time domain: <code>scrubTo</code> takes a moment rather than a seq, and <code>live</code> returns to the head. Run headless on every build.</p>
6371
+ <pre data-run="js" data-expect="1 | 3" data-covers="export:createDataRouter config:time config:buffer"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
6372
+ <span class="kw">const</span> { createDataRouter } = <span class="kw">await</span> import('../packages/modules/data-router/index.js');
6373
+
6374
+ <span class="kw">const</span> cols = [{ id: 'id', field: 'id' }, { id: 'ts', field: 'ts', type: 'number' }];
6375
+ <span class="kw">const</span> g = createHeadlessGrid({ rowKey: 'id', columns: cols });
6376
+ <span class="kw">const</span> router = createDataRouter({ rowKey: 'id', time: 'ts' });
6377
+ router.attach(g, () =&gt; true);
6378
+ router.buffer({ window: 10000 });
6379
+ router.apply([{ op: 'upsert', row: { id: 'a', ts: 100 } }]);
6380
+ router.apply([{ op: 'upsert', row: { id: 'b', ts: 200 } }]);
6381
+ router.apply([{ op: 'upsert', row: { id: 'c', ts: 300 } }]);
6382
+ router.scrubTo(150, { by: 'time' }); // what the grid showed at t=150
6383
+ <span class="kw">const</span> then = g.rows.count(); // 1
6384
+ router.live();
6385
+ <span class="kw">const</span> now = g.rows.count(); // 3
6386
+ g.destroy(); router.destroy();
6387
+ <span class="kw">return</span> [then, now].join(' | ');</code></pre>
6053
6388
  <h2 id="ganttmodule">The Gantt module</h2>
6054
6389
  <p><code>modules/gantt</code> is a separate, opt-in project-planning module &mdash; its own bundle, imported only when you want it, changing nothing in the grid core. It turns a task list into a real schedule: a <strong>CPM (Critical Path Method) engine</strong> computes each task's early/late start and finish, its slack (total float), and the zero-float <strong>critical path</strong>, recomputing on every edit. <code>computeSchedule(tasks, deps)</code> is the pure engine; <code>createGantt(opts)</code> is a controller that holds the model, recomputes on <code>setTasks</code>/<code>setDependencies</code>/<code>applyEdit</code>, and emits <code>schedule</code> (or <code>error</code>). Dependencies are the four standard link types &mdash; <code>LINK_TYPES</code> is <code>['FS','SS','FF','SF']</code> &mdash; each with optional lag/lead. A <strong>milestone</strong> is a zero-duration task scheduled as a point; a <strong>summary</strong> task (any task named as another's <code>parent</code>) is derived from its children (start = earliest child, end = latest child, duration-weighted progress) and is not scheduled itself. Bad input never throws or loops: a dependency cycle is refused and reported with a code from <code>SCHEDULE_ERROR</code>, and <code>findViolations</code> flags any task placed earlier than its predecessors allow. <code>toISODate</code> converts an engine day-number back to a calendar date for display.</p>
6055
6390
  <p><strong>Feed it the rows you already have.</strong> <code>fields</code> names your own task properties for the scheduler &mdash; <code>{ id: 'taskId', start: 'startDate', name: 'jobName', duration: 'dur' }</code>, each a field name or a reader <code>(row) =&gt; value</code> &mdash; so a task list arrives as it is rather than being renamed first. The vocabulary is <code>id</code>, <code>name</code>, <code>start</code>, <code>end</code>, <code>duration</code>, <code>milestone</code>, <code>percentComplete</code>, <code>parent</code>, <code>baselineStart</code>, <code>baselineEnd</code>, <code>constraint</code> and <code>constraintDate</code>; anything you leave unmapped reads its canonical name, so an existing plan is unaffected. <code>rowKey</code> reaches the scheduler too, so a row carrying <code>taskId</code> and no <code>id</code> is identified by it &mdash; a task's own <code>id</code> still wins where it has one. It is a <strong>read</strong> mapping: <code>applyEdit</code> and <code>level()</code> write the canonical property, so each says so plainly rather than writing to a field the schedule is not read from, and <code>assignee</code>/<code>cost</code>/<code>actualCost</code> belong to the resource and earned-value layers rather than to this list.</p>
@@ -6496,6 +6831,52 @@ const kpi = createKPI(document.querySelector('#kpis'), {
6496
6831
  </table>
6497
6832
  </div>
6498
6833
  <p><strong>Interaction is light and host-driven.</strong> A tile emits <code>tile:click</code> (also from the keyboard) carrying the tile model, so a host can drill down or, in a demo, filter a routed grid &mdash; the wiring lives in the host, not the module. This is deliberately not a dashboard layout engine (that is the parked dashboard generator) and charting beyond a minimal sparkline belongs to the charts module.</p>
6834
+ <h3 id="kpi-clock">The clock tile: the device clock, not an aggregate</h3>
6835
+ <p><code>{ kind: 'clock', label, timeZone?, locale?, seconds?, date? }</code> in <code>tiles</code> renders a tile that shows the current date and time instead of a figure &mdash; the date on one line, the time on the next, e.g. <code>Mon, 21 Apr 2025</code> over <code>14:32:18</code> &mdash; styled like any other tile, so a panel of zone clocks beside a panel of figures reads as one visual system with no host CSS.</p>
6836
+ <pre><code>const kpi = createKPI(document.querySelector('#kpis'), {
6837
+ tiles: [
6838
+ { kind: 'clock', label: 'London', timeZone: 'Europe/London' },
6839
+ { kind: 'clock', label: 'New York', timeZone: 'America/New_York', locale: 'en-US' },
6840
+ { id: 'open', label: 'Open deals', aggregation: 'count', filter: (r) =&gt; r.stage !== 'won' },
6841
+ ],
6842
+ });</code></pre>
6843
+ <p><strong>Where the time comes from.</strong> The device clock, read every second in exact alignment with the second boundary &mdash; not a fixed <code>setInterval(1000)</code>, which drifts &mdash; so every clock tile on a panel ticks in the same repaint. One timer serves the <em>whole panel</em>, not one per tile: a panel of three zone clocks runs one shared interval, not three. The timer stops when the panel is <code>destroy()</code>ed or the document goes into the background (<code>document.hidden</code>) and resumes correctly &mdash; catching up immediately, then re-aligning &mdash; when the tab returns. A panel with no clock tile starts no timer at all.</p>
6844
+ <p><strong>Zone and format.</strong> <code>timeZone</code> is any IANA zone name; omitted, the tile shows the viewer's local time. A name <code>Intl.DateTimeFormat</code> does not recognise is reported through the usual <code>[lattice]</code> diagnostics warning, by name, and the tile falls back to local time rather than rendering nothing. The date and time are formatted for <code>locale</code> &mdash; the tile's own, else the panel's <code>locale</code>, else the browser's default &mdash; entirely through <code>Intl.DateTimeFormat</code>: a 24-hour clock where the locale uses one, 12-hour with an AM/PM marker where it does not, because that is the locale's own convention rather than a second option to set. <code>seconds: false</code> drops the seconds from the time line; <code>date: false</code> drops the date line entirely. Every formatter is built once, at tile resolution, and reused on every tick.</p>
6845
+ <p><strong>Inert everywhere a stat tile measures.</strong> A clock tile takes none of a stat tile's measurement options &mdash; <code>aggregation</code>, <code>field</code>, <code>format</code>, <code>thresholds</code>, <code>bands</code>, <code>target</code>, <code>baseline</code>, <code>sparkline</code> &mdash; because a tile that measures nothing has nothing for them to apply to. Supplying any of them is reported as a configuration warning by name and ignored: the tile still renders the clock, nothing else. It contributes nothing to the panel's totals, to a parent's rolled-up status in a <a href="#kpi-tree">tree</a> (its own <code>status</code> is always <code>null</code>, never <code>unknown</code> &mdash; a clock tile is never &ldquo;not measured&rdquo;, it always has a reading) and nothing to what a host reads out of <code>onChange</code> beyond its own text. It otherwise behaves exactly like any other tile: it sits in <code>tiles()</code>/<code>tile(id)</code> and a <a href="#kpi-tree">tree</a> alongside stat tiles, responds to <code>columns</code>, fires <code>tile:click</code>/<code>tile:dblclick</code>/<code>tile:contextmenu</code>, and its accessible name (<code>aria-label</code>) carries the same date and time text the two visible lines show.</p>
6846
+ <h3 id="kpi-clock-example">A clock tile, two zones, executed</h3>
6847
+ <p class="section-note">Structural assertions only &mdash; the clock reads the real device clock, so a doc example run on every build cannot pin a literal
6848
+ time without freezing it. The <a href="#kpi-clock">formatting itself</a> is pinned for two locales and two zones with a fixed clock
6849
+ in the test suite. Run headless on every build.</p>
6850
+ <pre data-run="js" data-expect="clock clock null null null | true true | clock" data-covers="export:createKPI"><code><span class="kw">const</span> { createKPI } = <span class="kw">await</span> import('../packages/modules/kpi/index.js');
6851
+
6852
+ <span class="kw">const</span> kpi = createKPI(null, {
6853
+ tiles: [
6854
+ { kind: 'clock', id: 'london', label: 'London', timeZone: 'Europe/London', locale: 'en-GB' },
6855
+ <span class="cmt">// aggregation/thresholds are measurement options a clock tile refuses (warned, ignored):</span>
6856
+ { kind: 'clock', id: 'ny', label: 'New York', timeZone: 'America/New_York', locale: 'en-US',
6857
+ aggregation: 'sum', thresholds: { warn: 1, critical: 2 } },
6858
+ ],
6859
+ });
6860
+
6861
+ <span class="kw">const</span> london = kpi.tile('london');
6862
+ <span class="kw">const</span> ny = kpi.tile('ny');
6863
+
6864
+ <span class="cmt">// kind, status and bar are the same three neutral values on every clock tile,</span>
6865
+ <span class="cmt">// whatever was supplied for the ignored options above (String(), because</span>
6866
+ <span class="cmt">// Array#join renders null as '' rather than 'null'):</span>
6867
+ <span class="kw">const</span> shapes = [london.kind, ny.kind, String(london.status), String(ny.status), String(ny.bar)].join(' '); <span class="cmt">// clock clock null null null</span>
6868
+
6869
+ <span class="cmt">// The date and time lines hold the shape the formatting spec promises:</span>
6870
+ <span class="kw">const</span> shaped = [
6871
+ /^[A-Za-z]{3}, \d{2} [A-Za-z]{3,4} \d{4}$/.test(london.clock.date),
6872
+ /^\d{2}:\d{2}:\d{2}(\s?[AP]M)?$/.test(ny.clock.time),
6873
+ ].join(' '); <span class="cmt">// true true</span>
6874
+
6875
+ <span class="cmt">// The ignored `aggregation: 'sum'` never took effect:</span>
6876
+ <span class="kw">const</span> ignoredAgg = ny.aggregation; <span class="cmt">// clock</span>
6877
+
6878
+ kpi.destroy();
6879
+ <span class="kw">return</span> [shapes, shaped, ignoredAgg].join(' | ');</code></pre>
6499
6880
  <h3 id="kpi-tree">The KPI tree: top-level items that expand to the indicators beneath them</h3>
6500
6881
  <p>Set <code>tree</code> and the panel becomes a <strong>rail</strong> instead of a grid of tiles: a small number of top-level items, each expanding to the indicators underneath it, with the parent telling you at a glance whether anything below needs attention. <code>Compute</code> expands to <code>psi</code> and <code>cpu</code>; collapsed, it still shows you that one of them is in breach.</p>
6501
6882
  <pre><code>const kpi = createKPI(document.querySelector('#rail'), {
@@ -6692,7 +7073,7 @@ grid.destroy();
6692
7073
  <span class="kw">return</span> `${by['risk.atRisk'].display} at risk | SPI ${by['risk.spi'].display} | ${by['risk.sla.breaches'].display} breaches`;</code></pre>
6693
7074
 
6694
7075
  <h2 id="tabs">The tabbed grid</h2>
6695
- <p><code>modules/tabs</code> is an opt-in top-of-grid tab strip where <strong>each tab is its own full, independently-configured grid instance</strong> &mdash; "configure each tab as per a normal grid" rather than one grid whose state is swapped. That is a deliberate rejection of the cheaper alternative: <code>grid.state.get()</code>/<code>.apply()</code> only repositions, hides, resizes and sorts <strong>existing</strong> columns by id (no field, type, editor or row data), so a state-swap only works when every tab shares one column schema and one source &mdash; strictly less than the ask. A tab may instead declare <code>from: '&lt;tabId&gt;'</code> plus a narrowing (<code>where</code>, <code>group</code>, <code>join</code>, &hellip;), and the module wires a <code>source: { mode: 'derived', from: &lt;the parent tab's live grid&gt;, &hellip; }</code> for it &mdash; the shipped derived-source mechanism, not a new config-inheritance one. <code>createGrid</code> is <strong>injected</strong> (the same pattern the React/Vue/Svelte adapters use), so the module imports no engine code regardless of how it is loaded — its own minified ESM build (<code>tabs.esm.min.js</code>) is ~65KB gzipped, the same size class as the KPI and framework-adapter modules (~61&ndash;66KB), rather than the ~700KB a module that inlines the whole engine (the web component, htmx) ships.</p>
7076
+ <p><code>modules/tabs</code> is an opt-in top-of-grid tab strip where <strong>each tab is its own full, independently-configured grid instance</strong> &mdash; "configure each tab as per a normal grid" rather than one grid whose state is swapped. That is a deliberate rejection of the cheaper alternative: <code>grid.state.get()</code>/<code>.apply()</code> only repositions, hides, resizes and sorts <strong>existing</strong> columns by id (no field, type, editor or row data), so a state-swap only works when every tab shares one column schema and one source &mdash; strictly less than the ask. A tab may instead declare <code>from: '&lt;tabId&gt;'</code> plus a narrowing (<code>where</code>, <code>group</code>, <code>join</code>, &hellip;), and the module wires a <code>source: { mode: 'derived', from: &lt;the parent tab's live grid&gt;, &hellip; }</code> for it &mdash; the shipped derived-source mechanism, not a new config-inheritance one. <code>createGrid</code> is <strong>injected</strong> (the same pattern the React/Vue/Svelte adapters use), so the module imports no engine code regardless of how it is loaded — its own minified ESM build (<code>tabs.esm.min.js</code>) is about 12KB gzipped &mdash; the module and nothing else. A module bundle carries its own code plus a small shared runtime, not the engine: the framework adapters are 3&ndash;11KB, the KPI module about 50KB, while a module that inlines the whole engine (the web component, htmx) ships at roughly 760KB.</p>
6696
7077
  <pre><code>import { createGrid } from '@toclocoinc/lattice-grid';
6697
7078
  import { createTabs } from '@toclocoinc/lattice-grid/modules/tabs';
6698
7079
 
@@ -6834,7 +7215,7 @@ tabs.destroy();
6834
7215
 
6835
7216
  <h2 id="layout">The dashboard layout</h2>
6836
7217
  <p><code>modules/layout</code> is an opt-in <strong>reconfigurable dashboard surface</strong>: a cell grid inside an element, and a set of windows placed on it that a user can move, resize and close &mdash; by drag <em>or</em> by keyboard. It is the thing a customer would otherwise reach for GridStack or react-grid-layout to get, which means a second dependency, a second sizing model, and a seam where the viewers in it do not resize properly.</p>
6837
- <p><strong>It is payload-agnostic, and that is the whole design.</strong> A window body is a <code>div</code> with an <code>id</code>. The module creates it, sizes it, and never reads or writes its contents &mdash; it does not import <code>createGrid</code>, does not know what a payload is, and never calls into one. What it does instead is emit <code>window:resized</code> with the measured content box, which is the contract. That rule is what keeps its own code to <strong>12,890 bytes gzipped</strong> (measured: a 77,190-byte bundle over a 62,206-byte fixed floor, of which 2,094 bytes are the two shared module helpers) and what makes it usable for a payload we have not written yet.</p>
7218
+ <p><strong>It is payload-agnostic, and that is the whole design.</strong> A window body is a <code>div</code> with an <code>id</code>. The module creates it, sizes it, and never reads or writes its contents &mdash; it does not import <code>createGrid</code>, does not know what a payload is, and never calls into one. What it does instead is emit <code>window:resized</code> with the measured content box, which is the contract. That rule is what keeps the whole module to <strong>about 17,500 bytes gzipped</strong> (measured on the built bundle: its own code over a shared module runtime of roughly 2KB) and what makes it usable for a payload we have not written yet.</p>
6838
7219
  <pre><code>import { createLayout } from '@toclocoinc/lattice-grid/modules/layout';
6839
7220
 
6840
7221
  const layout = createLayout(document.querySelector('#dash'), {
@@ -8500,6 +8881,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8500
8881
  <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>
8501
8882
  <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>
8502
8883
  <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>
8884
+ <tr><td class="name">region</td><td class="type">'start' | 'centre' | 'end'</td><td class="desc">Which columns the mark belongs to: a pinned region holds still while the grid scrolls sideways, the centre moves with it. Set from where a stroke began; omitted (the centre) for every mark that is not over a pinned column, so a mark saved before this existed reads unchanged. <small>(optional)</small></td></tr>
8503
8885
  </tbody>
8504
8886
  </table>
8505
8887
  </div>
@@ -8774,6 +9156,20 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8774
9156
  </tbody>
8775
9157
  </table>
8776
9158
  </div>
9159
+ <h3 id="type-ChartNode">ChartNode</h3>
9160
+ <p class="section-note">One node of a `network` chart, as the host declares it. `x` and `y` are fractions of the plot, 0 to 1, measured from its top-left. A node giving both is **pinned** there and takes no part in the force simulation; the rest are laid out around it, deterministically. Giving only one of the two is not a position and the node is laid out.</p>
9161
+ <div class="table-wrap">
9162
+ <table>
9163
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9164
+ <tbody>
9165
+ <tr><td class="name">id</td><td class="type">string</td><td class="desc">Matches a value in the `source` or `target` column.</td></tr>
9166
+ <tr><td class="name">label</td><td class="type">string</td><td class="desc">Drawn beneath the node. The id is used when this is absent. <small>(optional)</small></td></tr>
9167
+ <tr><td class="name">icon</td><td class="type">string</td><td class="desc">A name in the grid's icon registry, drawn inside the node's disc. <small>(optional)</small></td></tr>
9168
+ <tr><td class="name">x</td><td class="type">number</td><td class="desc">Where to pin it, as a fraction of the plot's width. <small>(optional)</small></td></tr>
9169
+ <tr><td class="name">y</td><td class="type">number</td><td class="desc">Where to pin it, as a fraction of the plot's height. <small>(optional)</small></td></tr>
9170
+ </tbody>
9171
+ </table>
9172
+ </div>
8777
9173
  <h3 id="type-ChartSpec">ChartSpec</h3>
8778
9174
  <p class="section-note">What a chart draws and how. `grid` and `container` are required; everything else describes the chart. A chart reads the grid's *filtered* rows, so it follows the grid without being told to.</p>
8779
9175
  <div class="table-wrap">
@@ -8812,6 +9208,9 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8812
9208
  <tr><td class="name">diverging</td><td class="type">boolean</td><td class="desc">A diverging colour ramp, for heatmap and geomap. <small>(optional)</small></td></tr>
8813
9209
  <tr><td class="name">shapes</td><td class="type">unknown</td><td class="desc">Country outlines, for a geomap drawing countries rather than continents. Either GeoJSON, an object of code to SVG path data, or a geometry {@link GeoPack} imported from an optional `modules/geo-*` package (BACKLOG-0001321) — as the pack itself, or as `{ pack: id }` once its module has been imported and registered. <small>(optional)</small></td></tr>
8814
9210
  <tr><td class="name">codeProperty</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
9211
+ <tr><td class="name">lon</td><td class="type">string</td><td class="desc">The longitude column, for the types that place a row by where it is rather than by a code: `markermap`, `bubblemap` and `hexmap`. Degrees east, -180 to 180; a row outside that, or with no reading, is left off the map and counted. <small>(optional)</small></td></tr>
9212
+ <tr><td class="name">lat</td><td class="type">string</td><td class="desc">The latitude column, beside {@link ChartSpec.lon}. Degrees north, -90 to 90, on the same terms. <small>(optional)</small></td></tr>
9213
+ <tr><td class="name">value</td><td class="type">string</td><td class="desc">The measure a `markermap` writes beside each dot and colours it by. Its text is the column's own formatted cell text and its colour is whatever the column's conditional-formatting rules give that value, so a map and the table beside it say the same thing about the same number. <small>(optional)</small></td></tr>
8815
9214
  <tr><td class="name">layer</td><td class="type">string</td><td class="desc">Which layer of a multi-layer geometry pack to draw — the UK pack, for instance, ships `regions`, `local-authorities` and `constituencies` together (BACKLOG-0001321). Ignored for a single-layer pack. <small>(optional)</small></td></tr>
8816
9215
  <tr><td class="name">projection</td><td class="type">'equalEarth' | 'robinson' | 'mercator' | 'equirectangular' | 'albers'</td><td class="desc">The map projection a geomap draws through (BACKLOG-0001321): `'equalEarth'` (the default for a world), `'robinson'`, `'mercator'`, `'equirectangular'`, `'albers'`, `'transverseMercator'`, or a projection function of the caller's own `(lon: number, lat: number) =&gt; [number, number]`. Left unset, a geometry pack draws through the projection it declares. <small>(optional)</small></td></tr>
8817
9216
  <tr><td class="name">projectionOptions</td><td class="type">{ parallels?: [number, number]; centre?: [number, number] }</td><td class="desc">Parameters for the projections that take them: `parallels` and `centre` for `albers`, `centre` for `transverseMercator` (BACKLOG-0001321). <small>(optional)</small></td></tr>
@@ -8838,6 +9237,9 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8838
9237
  <tr><td class="name">method</td><td class="type">'pearson' | 'spearman' | 'kendall'</td><td class="desc"><small>(optional)</small></td></tr>
8839
9238
  <tr><td class="name">values</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
8840
9239
  <tr><td class="name">iterations</td><td class="type">number</td><td class="desc">Network layouts: how many relaxation passes to run. <small>(optional)</small></td></tr>
9240
+ <tr><td class="name">nodes</td><td class="type">ChartNode[]</td><td class="desc">The nodes of a `network`, named by the host rather than inferred from the rows: an icon per device, a label, and a position the layout must honour. A node listed here that appears in no row is still drawn. A node in the rows that is not listed here takes the chart's `icon` default and its own id as its label. <small>(optional)</small></td></tr>
9241
+ <tr><td class="name">icon</td><td class="type">string</td><td class="desc">The default glyph for a `network` node that names none of its own: any name in the grid's icon registry (see {@link Grid.icons}). Unset, a node with no icon is a plain disc. <small>(optional)</small></td></tr>
9242
+ <tr><td class="name">linkWidth</td><td class="type">number</td><td class="desc">A `network` link's stroke width in pixels, fixed. Unset, width follows the link's value as a share of the heaviest link, as it always has. <small>(optional)</small></td></tr>
8841
9243
  <tr><td class="name">spec</td><td class="type">{ lower?: number; upper?: number; target?: number }</td><td class="desc">Control and capability charts: a tolerance overriding the column's own `spec`, how many leading readings fix the control limits, which rule set the violations are judged against, and the level for the capability interval. <small>(optional)</small></td></tr>
8842
9244
  <tr><td class="name">baseline</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
8843
9245
  <tr><td class="name">rules</td><td class="type">'westernElectric' | 'nelson'</td><td class="desc"><small>(optional)</small></td></tr>
@@ -9476,18 +9878,75 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9476
9878
  <table>
9477
9879
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9478
9880
  <tbody>
9479
- <tr><td class="name">attach</td><td class="type">(grid: unknown, predicate: RoutePredicate, opts?: RouteOptions): DataRouter</td><td class="desc">Attach a grid behind a predicate; `opts` may reshape/filter/sort the route (v3).</td></tr>
9480
- <tr><td class="name">attachDefault</td><td class="type">(grid: unknown, opts?: RouteOptions): DataRouter</td><td class="desc">Attach the "rest" sink for records no explicit route matched.</td></tr>
9481
- <tr><td class="name">detach</td><td class="type">(grid: unknown): DataRouter</td><td class="desc">Detach a grid; the host still owns and destroys it.</td></tr>
9482
- <tr><td class="name">load</td><td class="type">(snapshot: RouterRecord[]): RouteDiff[]</td><td class="desc">Apply a full snapshot as a keyed diff per grid; returns per-route counts.</td></tr>
9483
- <tr><td class="name">apply</td><td class="type">(deltas: { op: 'upsert' | 'delete'; row: RouterRecord }[]): void</td><td class="desc">Apply incremental deltas, routed and applied in place by `rowKey`.</td></tr>
9881
+ <tr><td class="name">attach</td><td class="type">(grid: unknown, predicate: RoutePredicate, opts?: RouteOptions): DataRouter</td><td class="desc">Attach a grid behind a predicate; `opts` may reshape, filter, sort, summarise or throttle the route.</td></tr>
9882
+ <tr><td class="name">attachDefault</td><td class="type">(grid: unknown, opts?: RouteOptions): DataRouter</td><td class="desc">Attach the "rest" sink for records no explicit route matched. A second call replaces the first.</td></tr>
9883
+ <tr><td class="name">subscribe</td><td class="type">(predicate: RoutePredicate, handler: (change: RouterChange) =&gt; void, opts?: RouteOptions): DataRouter</td><td class="desc">Route a partition slice to any non-grid view (v5): the handler receives the same keyed diff a grid would.</td></tr>
9884
+ <tr><td class="name">alert</td><td class="type">(predicate: RoutePredicate, condition: (rows: RouterRecord[]) =&gt; unknown, handler: (signal: unknown, rows: RouterRecord[]) =&gt; void, opts?: AlertOptions): DataRouter</td><td class="desc">Watch a slice and emit on a rising edge of `condition` rather than render (v5). Removed only by `destroy`.</td></tr>
9885
+ <tr><td class="name">configure</td><td class="type">(spec?: RouterConfig): DataRouter</td><td class="desc">Take the whole routing graph as one declarative spec (v5); desugars to the calls above and composes with them.</td></tr>
9484
9886
  <tr><td class="name">link</td><td class="type">(source: unknown, target: unknown, relation: SelectionRelation): DataRouter</td><td class="desc">Link a source grid's selection to what a target grid receives (v2, BACKLOG-0000880): the target shows the subset of its partition the `relation` admits, re-pushed through the keyed-diff path. No selection shows the full partition; changes are debounced.</td></tr>
9887
+ <tr><td class="name">relate</td><td class="type">(edges: RouterEdge[]): DataRouter</td><td class="desc">Declare a relationship graph (v3): multi-hop, several-into-one and mutual edges — the scalable form of `link`.</td></tr>
9485
9888
  <tr><td class="name">flush</td><td class="type">(): DataRouter</td><td class="desc">Apply any debounced selection refilter synchronously (for tests/determinism).</td></tr>
9486
- <tr><td class="name">unrouted</td><td class="type">number</td><td class="desc">How many records matched no route. <small>(read-only)</small></td></tr>
9889
+ <tr><td class="name">detach</td><td class="type">(grid: unknown): DataRouter</td><td class="desc">Detach a grid — or a `subscribe` handler — and drop any link it is part of; the host still owns and destroys it.</td></tr>
9890
+ <tr><td class="name">load</td><td class="type">(snapshot: RouterRecord[]): RouteDiff[]</td><td class="desc">Apply a full snapshot as a keyed diff per grid; returns per-route counts. Resets `unrouted`.</td></tr>
9891
+ <tr><td class="name">apply</td><td class="type">(deltas: RouterDelta[]): void</td><td class="desc">Apply incremental deltas, routed and applied in place by `rowKey`; ordered and de-duplicated when `seq` is on.</td></tr>
9892
+ <tr><td class="name">push</td><td class="type">(delta: RouterDelta | RouterDelta[]): DataRouter</td><td class="desc">Enqueue deltas for batched or coalesced application (v3); applies at once when no batching mode is on.</td></tr>
9893
+ <tr><td class="name">flushStream</td><td class="type">(): DataRouter</td><td class="desc">Apply the buffered deltas now as a single `apply` (v3) — a deterministic point, and for tests.</td></tr>
9894
+ <tr><td class="name">flushBackpressure</td><td class="type">(): DataRouter</td><td class="desc">Refresh every backpressured route to the latest state now (v13); a no-op with nothing pending.</td></tr>
9895
+ <tr><td class="name">addSource</td><td class="type">(feed: string | RouterSourceOptions, opts?: RouterSourceOptions): RouterSourceHandle</td><td class="desc">Register a source feed for fan-in (v9): its rows are normalised and namespaced into the one keyed store.</td></tr>
9896
+ <tr><td class="name">removeSource</td><td class="type">(ref: string | RouterSourceHandle): DataRouter</td><td class="desc">Remove a source feed by id or handle (v9): delete exactly its rows from every route, then unregister it.</td></tr>
9897
+ <tr><td class="name">sources</td><td class="type">(): string[]</td><td class="desc">The registered source ids (v9).</td></tr>
9898
+ <tr><td class="name">metrics</td><td class="type">(): RouterMetrics</td><td class="desc">A cheap point-in-time snapshot of the router's runtime (v10); throughput is measured since the previous read.</td></tr>
9899
+ <tr><td class="name">on</td><td class="type">(event: 'metrics', handler: (snapshot: RouterMetrics) =&gt; void): () =&gt; void</td><td class="desc">Subscribe to the periodic `metrics` emit (v10) — the only event; the timer runs only while a listener is registered. Returns the unsubscribe.</td></tr>
9900
+ <tr><td class="name">mountDevtools</td><td class="type">(el: unknown): RouterDevtoolsPanel</td><td class="desc">Mount the live devtools panel into `el` (v10); it re-renders on each `metrics` emit.</td></tr>
9901
+ <tr><td class="name">unrouted</td><td class="type">number</td><td class="desc">How many records matched no route since the last `load` or `query`, running for deltas. <small>(read-only)</small></td></tr>
9902
+ <tr><td class="name">dropped</td><td class="type">number</td><td class="desc">How many stale or duplicate deltas the dedupe gate dropped since creation (v3). <small>(read-only)</small></td></tr>
9903
+ <tr><td class="name">lastSeq</td><td class="type">(): number | undefined</td><td class="desc">The highest seq applied — the resume point to request the feed from after a dropped socket (v3).</td></tr>
9904
+ <tr><td class="name">checkpoint</td><td class="type">(): Map&lt;string, number&gt;</td><td class="desc">A copy of the per-record resume checkpoint: record identity → last applied seq (v3).</td></tr>
9905
+ <tr><td class="name">seenThrough</td><td class="type">(mark: Map&lt;string, number&gt; | Record&lt;string, number&gt;): DataRouter</td><td class="desc">Prime the resume checkpoint from a persisted one, so replayed deltas at or below those seqs are dropped (v3).</td></tr>
9906
+ <tr><td class="name">persist</td><td class="type">(opts?: RouterPersistOptions): DataRouter</td><td class="desc">Turn on durable persistence of the router's state (v12).</td></tr>
9907
+ <tr><td class="name">restore</td><td class="type">(): Promise&lt;boolean&gt;</td><td class="desc">Resume from the durable snapshot (v12); resolves true when one was found and applied.</td></tr>
9908
+ <tr><td class="name">flushPersist</td><td class="type">(): Promise&lt;DataRouter&gt;</td><td class="desc">Flush any pending durable write now (v12); resolves once it has settled.</td></tr>
9909
+ <tr><td class="name">persisting</td><td class="type">boolean</td><td class="desc">Whether durable persistence is on and not degraded to in-memory (v12). <small>(read-only)</small></td></tr>
9910
+ <tr><td class="name">buffer</td><td class="type">(opts?: { window?: number; max?: number }): DataRouter</td><td class="desc">Record the stream into a bounded ring for time travel (v4): a time `window` in ms and/or a `max` delta count.</td></tr>
9911
+ <tr><td class="name">scrubTo</td><td class="type">(target: number, opts?: { by?: 'seq' | 'time' }): DataRouter</td><td class="desc">Scrub the attached grids to a past seq or timestamp (v4).</td></tr>
9912
+ <tr><td class="name">replay</td><td class="type">(from: number, to: number, opts?: { speed?: number; by?: 'seq' | 'time' }): Promise&lt;void&gt;</td><td class="desc">Replay a buffered range step by step (v4); resolves when it completes or is superseded.</td></tr>
9913
+ <tr><td class="name">pause</td><td class="type">(): DataRouter</td><td class="desc">Pause an in-flight replay at the current step (v4); a no-op when nothing is replaying.</td></tr>
9914
+ <tr><td class="name">resume</td><td class="type">(): DataRouter</td><td class="desc">Resume a paused replay from where it stopped (v4); a no-op when not paused.</td></tr>
9915
+ <tr><td class="name">live</td><td class="type">(): DataRouter</td><td class="desc">Return to live (v4): rebuild the head from the base plus every buffered delta.</td></tr>
9916
+ <tr><td class="name">traveling</td><td class="type">boolean</td><td class="desc">Whether the grids are currently showing a reconstructed past (v4). <small>(read-only)</small></td></tr>
9917
+ <tr><td class="name">buffered</td><td class="type">number</td><td class="desc">How many deltas the bounded buffer currently holds (v4). <small>(read-only)</small></td></tr>
9918
+ <tr><td class="name">broadcast</td><td class="type">(opts: { channel: string }): DataRouter</td><td class="desc">Mirror the ordered, de-duplicated deltas to other tabs over a BroadcastChannel (v6), with no echo loop.</td></tr>
9919
+ <tr><td class="name">broadcasting</td><td class="type">boolean</td><td class="desc">Whether the router is mirroring to a BroadcastChannel (v6). <small>(read-only)</small></td></tr>
9920
+ <tr><td class="name">query</td><td class="type">(adapter: RouterQueryAdapter, request?: Record&lt;string, unknown&gt;): Promise&lt;DataRouter&gt;</td><td class="desc">Source the router from a pushdown adapter (v7): each `where` route is planned against the adapter's capabilities.</td></tr>
9921
+ <tr><td class="name">lastQueryPlan</td><td class="type">(): RouterQueryPlanEntry[] | null</td><td class="desc">The pushed/residual split of the last `query()` (v7), per fetch, or null before any.</td></tr>
9487
9922
  <tr><td class="name">destroy</td><td class="type">(): void</td><td class="desc">Detach every grid and drop every link (the host destroys the grids themselves).</td></tr>
9488
9923
  </tbody>
9489
9924
  </table>
9490
9925
  </div>
9926
+ <h3 id="type-DataRouterOptions">DataRouterOptions</h3>
9927
+ <p class="section-note">Options for `createDataRouter`. `key` is the partition property or `fn(row)`; optional, since a router whose routes all use `fn(row)` predicates never reads it. `rowKey` is the identity within a grid; `overlap` fans a record to every matching route (default: first match wins); `onUnrouted` receives what matched no route — the row on `load` and `query`, the whole delta on `apply`; `selectionDebounce` is the ms debounce for cross-grid selection refilters (default 16; `0` is synchronous). `seq` names the per-record version that orders and de-duplicates a feed (v3), `dedupe` (default on with `seq`) drops stale and duplicate deltas; `batch` (ms, or `{ intervalMs }`) and `coalesce` buffer a high-frequency feed for `push`; `time` reads a row's timestamp for time-domain scrubbing and `now` overrides the clock (v4); `config` is a declarative routing graph applied at construction (v5); `onWrite` and `onConflict` are the defaults for every writable route (v8); `metricsInterval` is the ms between `metrics` emits (default 1000; `0` disables the timer) (v10).</p>
9928
+ <div class="table-wrap">
9929
+ <table>
9930
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
9931
+ <tbody>
9932
+ <tr><td class="name">key</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
9933
+ <tr><td class="name">rowKey</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
9934
+ <tr><td class="name">overlap</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
9935
+ <tr><td class="name">onUnrouted</td><td class="type">(item: RouterRecord | RouterDelta) =&gt; void</td><td class="desc"><small>(optional)</small></td></tr>
9936
+ <tr><td class="name">selectionDebounce</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
9937
+ <tr><td class="name">seq</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
9938
+ <tr><td class="name">dedupe</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
9939
+ <tr><td class="name">batch</td><td class="type">number | { intervalMs: number }</td><td class="desc"><small>(optional)</small></td></tr>
9940
+ <tr><td class="name">coalesce</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
9941
+ <tr><td class="name">time</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
9942
+ <tr><td class="name">now</td><td class="type">() =&gt; number</td><td class="desc"><small>(optional)</small></td></tr>
9943
+ <tr><td class="name">config</td><td class="type">RouterConfig</td><td class="desc"><small>(optional)</small></td></tr>
9944
+ <tr><td class="name">onWrite</td><td class="type">(change: RouterWrite, ctx: { route: unknown; source: unknown }) =&gt; unknown</td><td class="desc"><small>(optional)</small></td></tr>
9945
+ <tr><td class="name">onConflict</td><td class="type">(change: RouterWrite, ctx: { serverRow: RouterRecord }) =&gt; void</td><td class="desc"><small>(optional)</small></td></tr>
9946
+ <tr><td class="name">metricsInterval</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
9947
+ </tbody>
9948
+ </table>
9949
+ </div>
9491
9950
  <h3 id="type-DatasetColumnDifference">DatasetColumnDifference</h3>
9492
9951
  <div class="table-wrap">
9493
9952
  <table>
@@ -10598,6 +11057,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10598
11057
  <tr><td class="name">setPinnedRows</td><td class="type">(rows: unknown[], opts?: { edge?: 'top' | 'bottom' }): void</td><td class="desc">Pin rows above or below the scrolling body. The rows render through the ordinary column pipeline but are not part of the data: not counted, sorted, filtered, grouped, selectable or exported. Pass a new array rather than mutating the one you passed before: array identity is how the grid knows the pinned rows have changed.</td></tr>
10599
11058
  <tr><td class="name">getPinnedRows</td><td class="type">(opts?: { edge?: 'top' | 'bottom' }): unknown[]</td><td class="desc">The objects currently pinned at one edge, as a copy.</td></tr>
10600
11059
  <tr><td class="name">form</td><td class="type">RowFormApi</td><td class="desc">The row form. Declines when `rowForm` is not configured. <small>(read-only)</small></td></tr>
11060
+ <tr><td class="name">icons</td><td class="type">IconRegistryApi</td><td class="desc">The grid's icon registry, read-only. The same sprite set `registerIcon` writes to and every cell paints from, reachable from the grid instance so that code outside the grid bundle — an optional module drawing its own glyph, a network chart putting a `router` on a node — draws from the one registry rather than a second, empty copy of it. Register with `registerIcon` or `config.icons`, as before. <small>(read-only)</small></td></tr>
10601
11061
  <tr><td class="name">getVersion</td><td class="type">(): string</td><td class="desc">The library version.</td></tr>
10602
11062
  <tr><td class="name">destroy</td><td class="type">(): void</td><td class="desc">Release everything: listeners, timers, workers and the DOM the grid made.</td></tr>
10603
11063
  </tbody>
@@ -10925,6 +11385,29 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10925
11385
  </tbody>
10926
11386
  </table>
10927
11387
  </div>
11388
+ <h3 id="type-IconGlyph">IconGlyph</h3>
11389
+ <p class="section-note">One sprite: its view box, its path data, and how it is painted.</p>
11390
+ <div class="table-wrap">
11391
+ <table>
11392
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11393
+ <tbody>
11394
+ <tr><td class="name">viewBox</td><td class="type">string</td><td class="desc"></td></tr>
11395
+ <tr><td class="name">paths</td><td class="type">string[]</td><td class="desc"></td></tr>
11396
+ <tr><td class="name">paint</td><td class="type">'stroke' | 'fill'</td><td class="desc"></td></tr>
11397
+ </tbody>
11398
+ </table>
11399
+ </div>
11400
+ <h3 id="type-IconRegistryApi">IconRegistryApi</h3>
11401
+ <p class="section-note">Read access to the grid's icon sprite set (see {@link Grid.icons}).</p>
11402
+ <div class="table-wrap">
11403
+ <table>
11404
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11405
+ <tbody>
11406
+ <tr><td class="name">get</td><td class="type">(name: string): IconGlyph | null</td><td class="desc">One glyph, as a copy, or null when the name is not registered.</td></tr>
11407
+ <tr><td class="name">names</td><td class="type">(): string[]</td><td class="desc">Every registered name, in registration order.</td></tr>
11408
+ </tbody>
11409
+ </table>
11410
+ </div>
10928
11411
  <h3 id="type-IconSetSpec">IconSetSpec</h3>
10929
11412
  <p class="section-note">An icon set (BACKLOG-0000955): a glyph placed beside the value by the band it falls in. Drawn as a `background-image` with padding, so it too needs no extra element and stays a plain style value. `set` names a built-in — `'arrows'`, `'trafficLights'` or `'ratings'` (see {@link ICON_SETS}) — or supply your own ordered `icons` (SVG documents, data URIs or `url(...)` values). Bands are split at `thresholds` (ascending, one fewer than the icons); without them the column's distribution is cut into equal-count bands. `reverse` flips the order so a high value can read as red.</p>
10930
11413
  <div class="table-wrap">
@@ -11391,6 +11874,22 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
11391
11874
  </tbody>
11392
11875
  </table>
11393
11876
  </div>
11877
+ <h3 id="type-KPIClockTile">KPIClockTile</h3>
11878
+ <p class="section-note">A clock tile: the device clock, not an aggregate (BACKLOG-0001640) — the date on one line and the time on the next, ticking once a second from one shared panel timer. It takes none of a stat tile's measurement options (`aggregation`, `field`, `format`, `thresholds`, `bands`, `target`, `baseline`, `sparkline`): supplying any of them is reported as a configuration warning by name and ignored, because a tile that measures nothing has nothing for them to apply to.</p>
11879
+ <div class="table-wrap">
11880
+ <table>
11881
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11882
+ <tbody>
11883
+ <tr><td class="name">kind</td><td class="type">'clock'</td><td class="desc">Discriminates a clock tile from an aggregate stat tile.</td></tr>
11884
+ <tr><td class="name">id</td><td class="type">string</td><td class="desc">A stable identity for the tile (defaults to the label, then the index). <small>(optional)</small></td></tr>
11885
+ <tr><td class="name">label</td><td class="type">string</td><td class="desc">The tile's accessible label (e.g. the city or zone it names). <small>(optional)</small></td></tr>
11886
+ <tr><td class="name">timeZone</td><td class="type">string</td><td class="desc">Any IANA zone name (`'Europe/London'`). Omitted, the tile shows the viewer's local time. A name `Intl.DateTimeFormat` does not recognise is reported through the usual diagnostics warning and the tile falls back to local time rather than rendering nothing. <small>(optional)</small></td></tr>
11887
+ <tr><td class="name">locale</td><td class="type">string</td><td class="desc">The locale the date and time are formatted in — the tile's own, else the panel's `KPIConfig.locale`, else the browser's default. A 24-hour clock or a 12-hour one with an AM/PM marker follows from the locale itself (`Intl.DateTimeFormat`'s own convention), never a separate option. <small>(optional)</small></td></tr>
11888
+ <tr><td class="name">seconds</td><td class="type">boolean</td><td class="desc">Show the seconds on the time line. Default `true`. <small>(optional)</small></td></tr>
11889
+ <tr><td class="name">date</td><td class="type">boolean</td><td class="desc">Show the date line at all. Default `true`. <small>(optional)</small></td></tr>
11890
+ </tbody>
11891
+ </table>
11892
+ </div>
11394
11893
  <h3 id="type-KPIConfig">KPIConfig</h3>
11395
11894
  <p class="section-note">KPI panel configuration.</p>
11396
11895
  <div class="table-wrap">
@@ -11405,6 +11904,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
11405
11904
  <tr><td class="name">columns</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
11406
11905
  <tr><td class="name">ariaLabel</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
11407
11906
  <tr><td class="name">nullText</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
11907
+ <tr><td class="name">locale</td><td class="type">string</td><td class="desc">The default locale a clock tile formats in when the tile itself declares none (BACKLOG-0001640); falls back to the browser's default. No effect on a stat tile, which takes its own `format.locale`. <small>(optional)</small></td></tr>
11408
11908
  <tr><td class="name">tree</td><td class="type">KPITreeConfig | false</td><td class="desc">Arrange the tiles as a hierarchy; `false` keeps the panel flat. <small>(optional)</small></td></tr>
11409
11909
  <tr><td class="name">messages</td><td class="type">{ t(key: string, params?: Record&lt;string, unknown&gt;): string }</td><td class="desc">The catalogue the panel's own text is read from. A panel routinely has no grid to borrow one off — two of its three input modes have none — so this is the first-class way to translate it. A grid's own `messages` satisfies the shape; a key it does not carry falls back to English. <small>(optional)</small></td></tr>
11410
11910
  <tr><td class="name">onTileClick</td><td class="type">(event: KPIEvent) =&gt; void</td><td class="desc"><small>(optional)</small></td></tr>
@@ -11475,24 +11975,13 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
11475
11975
  </tbody>
11476
11976
  </table>
11477
11977
  </div>
11478
- <h3 id="type-KPIThresholds">KPIThresholds</h3>
11479
- <p class="section-note">A semantic threshold: two cut points and a direction. `higherIsBetter` (the default) makes a value at/above `warn` good, at/above `critical` a warning, below it critical; `lowerIsBetter` mirrors it. Colour is a host concern.</p>
11480
- <div class="table-wrap">
11481
- <table>
11482
- <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11483
- <tbody>
11484
- <tr><td class="name">warn</td><td class="type">number</td><td class="desc"></td></tr>
11485
- <tr><td class="name">critical</td><td class="type">number</td><td class="desc"></td></tr>
11486
- <tr><td class="name">direction</td><td class="type">'higherIsBetter' | 'lowerIsBetter'</td><td class="desc"><small>(optional)</small></td></tr>
11487
- </tbody>
11488
- </table>
11489
- </div>
11490
- <h3 id="type-KPITile">KPITile</h3>
11491
- <p class="section-note">One tile: an aggregate over the routed rows, with optional filter, format, threshold and trend.</p>
11978
+ <h3 id="type-KPIStatTile">KPIStatTile</h3>
11979
+ <p class="section-note">An aggregate stat tile: the routed rows reduced to one number, with optional filter, format, threshold and trend.</p>
11492
11980
  <div class="table-wrap">
11493
11981
  <table>
11494
11982
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
11495
11983
  <tbody>
11984
+ <tr><td class="name">kind</td><td class="type">'stat'</td><td class="desc">Absent, or `'stat'`: the default tile kind. <small>(optional)</small></td></tr>
11496
11985
  <tr><td class="name">id</td><td class="type">string</td><td class="desc">A stable identity for the tile (defaults to the label, then the index). <small>(optional)</small></td></tr>
11497
11986
  <tr><td class="name">label</td><td class="type">string</td><td class="desc">The tile's accessible label. <small>(optional)</small></td></tr>
11498
11987
  <tr><td class="name">aggregation</td><td class="type">KPIAggregation | ((rows: KPIRow[], tile: object) =&gt; unknown)</td><td class="desc">The aggregation kind, or a reducer `(rows, tile) =&gt; value` for a custom tile. <small>(optional)</small></td></tr>
@@ -11508,6 +11997,18 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
11508
11997
  </tbody>
11509
11998
  </table>
11510
11999
  </div>
12000
+ <h3 id="type-KPIThresholds">KPIThresholds</h3>
12001
+ <p class="section-note">A semantic threshold: two cut points and a direction. `higherIsBetter` (the default) makes a value at/above `warn` good, at/above `critical` a warning, below it critical; `lowerIsBetter` mirrors it. Colour is a host concern.</p>
12002
+ <div class="table-wrap">
12003
+ <table>
12004
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
12005
+ <tbody>
12006
+ <tr><td class="name">warn</td><td class="type">number</td><td class="desc"></td></tr>
12007
+ <tr><td class="name">critical</td><td class="type">number</td><td class="desc"></td></tr>
12008
+ <tr><td class="name">direction</td><td class="type">'higherIsBetter' | 'lowerIsBetter'</td><td class="desc"><small>(optional)</small></td></tr>
12009
+ </tbody>
12010
+ </table>
12011
+ </div>
11511
12012
  <h3 id="type-KPITileModel">KPITileModel</h3>
11512
12013
  <p class="section-note">A computed tile, as it appears in the model.</p>
11513
12014
  <div class="table-wrap">
@@ -11516,10 +12017,12 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
11516
12017
  <tbody>
11517
12018
  <tr><td class="name">id</td><td class="type">string</td><td class="desc"></td></tr>
11518
12019
  <tr><td class="name">label</td><td class="type">string</td><td class="desc"></td></tr>
12020
+ <tr><td class="name">kind</td><td class="type">'stat' | 'clock'</td><td class="desc">`'stat'` for an aggregate tile, `'clock'` for a clock tile (BACKLOG-0001640).</td></tr>
11519
12021
  <tr><td class="name">aggregation</td><td class="type">string</td><td class="desc"></td></tr>
11520
12022
  <tr><td class="name">field</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
11521
- <tr><td class="name">value</td><td class="type">unknown</td><td class="desc"></td></tr>
11522
- <tr><td class="name">formatted</td><td class="type">string</td><td class="desc"></td></tr>
12023
+ <tr><td class="name">value</td><td class="type">unknown</td><td class="desc">For a clock tile, the read instant as epoch milliseconds.</td></tr>
12024
+ <tr><td class="name">formatted</td><td class="type">string</td><td class="desc">For a clock tile, the date and time text joined by a space (the same text `clock.date` and `clock.time` carry separately).</td></tr>
12025
+ <tr><td class="name">clock</td><td class="type">{ date: string | null; time: string }</td><td class="desc">Present only on a clock tile: the date and time lines rendered separately. `date` is `null` when the tile was given `date: false`. <small>(optional)</small></td></tr>
11523
12026
  <tr><td class="name">status</td><td class="type">'good' | 'warn' | 'critical' | 'unknown' | null</td><td class="desc">The tile's semantic band, or `unknown` when the tile measured nothing. `unknown` is decided from data presence before any threshold is consulted: an aggregation over nothing returns the identity of its operation (`sum` and `count` return 0), and 0 is a number a threshold grades, so without it an empty panel would report as a healthy one. Two things make a tile `unknown`: the panel holds no rows at all, or the tile's `field` names no column on the bound grid, so it never read a cell to reduce over. A tile whose `filter` matches none of the rows the panel *does* hold is neither — it has measured a real zero and is banded normally. `null` means the tile has no thresholds or bands configured.</td></tr>
11524
12027
  <tr><td class="name">target</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
11525
12028
  <tr><td class="name">baseline</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
@@ -12589,15 +13092,133 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
12589
13092
  </table>
12590
13093
  </div>
12591
13094
  <h3 id="type-RouteOptions">RouteOptions</h3>
12592
- <p class="section-note">Per-route reshaping options (v3, BACKLOG-0000887): `transform` maps/renames/ derives each row before the grid sees it; `filter` gives the grid only the rows it admits; `sort` (a comparator or `{ key, dir }`) orders what the grid receives. `rowKey` overrides the router default. All optional.</p>
13095
+ <p class="section-note">Per-route options, shared by `attach`, `attachDefault` and `subscribe`. `rowKey` overrides the router default for this route. `transform` reshapes each row before the viewer sees it; `filter` admits a subset; `sort` orders what the viewer receives; `rollup` summarises the slice (v3). A `transform` or `rollup` route is derived and cannot be `writable`. `where` is read only by `query()` (v7). `writable` routes the grid's committed edits to `onWrite`, reverting on reject, with `onConflict` for a last- write-wins conflict (v8); both default to the router's own. `label` names the route in metrics and the devtools panel; `backpressure` throttles its refresh under load (v13).</p>
12593
13096
  <div class="table-wrap">
12594
13097
  <table>
12595
13098
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
12596
13099
  <tbody>
12597
- <tr><td class="name">rowKey</td><td class="type">(string | ((row: RouterRecord) =&gt; unknown))</td><td class="desc"><small>(optional)</small></td></tr>
13100
+ <tr><td class="name">rowKey</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
12598
13101
  <tr><td class="name">transform</td><td class="type">(row: RouterRecord) =&gt; RouterRecord</td><td class="desc"><small>(optional)</small></td></tr>
12599
13102
  <tr><td class="name">filter</td><td class="type">(row: RouterRecord) =&gt; boolean</td><td class="desc"><small>(optional)</small></td></tr>
12600
- <tr><td class="name">sort</td><td class="type">(((a: RouterRecord, b: RouterRecord) =&gt; number) | { key: string; dir?: 'asc' | 'desc' })</td><td class="desc"><small>(optional)</small></td></tr>
13103
+ <tr><td class="name">sort</td><td class="type">RouteSort</td><td class="desc"><small>(optional)</small></td></tr>
13104
+ <tr><td class="name">rollup</td><td class="type">RouteRollup</td><td class="desc"><small>(optional)</small></td></tr>
13105
+ <tr><td class="name">where</td><td class="type">RouteWhere</td><td class="desc"><small>(optional)</small></td></tr>
13106
+ <tr><td class="name">writable</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
13107
+ <tr><td class="name">onWrite</td><td class="type">(change: RouterWrite, ctx: { route: unknown; source: unknown }) =&gt; unknown</td><td class="desc"><small>(optional)</small></td></tr>
13108
+ <tr><td class="name">onConflict</td><td class="type">(change: RouterWrite, ctx: { serverRow: RouterRecord }) =&gt; void</td><td class="desc"><small>(optional)</small></td></tr>
13109
+ <tr><td class="name">label</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
13110
+ <tr><td class="name">backpressure</td><td class="type">RouteBackpressure</td><td class="desc"><small>(optional)</small></td></tr>
13111
+ </tbody>
13112
+ </table>
13113
+ </div>
13114
+ <h3 id="type-RouterConfig">RouterConfig</h3>
13115
+ <p class="section-note">A declarative routing graph (v5, BACKLOG-0000910): the same routes, links, relationship edges and buffer the imperative calls would make, as one data spec. Desugars to those calls and composes with them.</p>
13116
+ <div class="table-wrap">
13117
+ <table>
13118
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13119
+ <tbody>
13120
+ <tr><td class="name">routes</td><td class="type">Record&lt;string, unknown&gt;[]</td><td class="desc"><small>(optional)</small></td></tr>
13121
+ <tr><td class="name">links</td><td class="type">{ from: unknown; to: unknown; on?: SelectionRelation; relation?: SelectionRelation }[]</td><td class="desc"><small>(optional)</small></td></tr>
13122
+ <tr><td class="name">relate</td><td class="type">RouterEdge[]</td><td class="desc"><small>(optional)</small></td></tr>
13123
+ <tr><td class="name">buffer</td><td class="type">{ window?: number; max?: number }</td><td class="desc"><small>(optional)</small></td></tr>
13124
+ </tbody>
13125
+ </table>
13126
+ </div>
13127
+ <h3 id="type-RouterJoin">RouterJoin</h3>
13128
+ <p class="section-note">A fan-in source's lookup join (v11): `from` is the lookup source's id; `localKey` (alias `on`) reads the joining value off this source's row; `foreignKey` (alias `fromKey`) reads it off the lookup row, defaulting to a string `localKey`; `fields` (alias `select`) picks the lookup fields to carry — a list, a rename map, or a function of both rows; `missing` says what to do while the lookup row has not arrived: `hold` the row back, `passthrough` it unjoined, or fill the fields with `null`.</p>
13129
+ <div class="table-wrap">
13130
+ <table>
13131
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13132
+ <tbody>
13133
+ <tr><td class="name">from</td><td class="type">string</td><td class="desc"></td></tr>
13134
+ <tr><td class="name">localKey</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
13135
+ <tr><td class="name">on</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
13136
+ <tr><td class="name">foreignKey</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
13137
+ <tr><td class="name">fromKey</td><td class="type">RouterKey</td><td class="desc"><small>(optional)</small></td></tr>
13138
+ <tr><td class="name">fields</td><td class="type">string[] | Record&lt;string, string&gt; | ((lookupRow: RouterRecord | null, leftRow: RouterRecord) =&gt; RouterRecord)</td><td class="desc"><small>(optional)</small></td></tr>
13139
+ <tr><td class="name">select</td><td class="type">string[] | Record&lt;string, string&gt; | ((lookupRow: RouterRecord | null, leftRow: RouterRecord) =&gt; RouterRecord)</td><td class="desc"><small>(optional)</small></td></tr>
13140
+ <tr><td class="name">missing</td><td class="type">'hold' | 'passthrough' | 'null'</td><td class="desc"><small>(optional)</small></td></tr>
13141
+ </tbody>
13142
+ </table>
13143
+ </div>
13144
+ <h3 id="type-RouterMetrics">RouterMetrics</h3>
13145
+ <p class="section-note">A `metrics()` snapshot (v10): per-route and per-source counts and throughput (rows/sec since the previous read), and the global unrouted, dropped (duplicate), buffered and lag figures.</p>
13146
+ <div class="table-wrap">
13147
+ <table>
13148
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13149
+ <tbody>
13150
+ <tr><td class="name">routes</td><td class="type">RouterRouteMetrics[]</td><td class="desc"></td></tr>
13151
+ <tr><td class="name">sources</td><td class="type">RouterSourceMetrics[]</td><td class="desc"></td></tr>
13152
+ <tr><td class="name">unrouted</td><td class="type">number</td><td class="desc"></td></tr>
13153
+ <tr><td class="name">dropped</td><td class="type">number</td><td class="desc"></td></tr>
13154
+ <tr><td class="name">buffered</td><td class="type">number</td><td class="desc"></td></tr>
13155
+ <tr><td class="name">lag</td><td class="type">number</td><td class="desc"></td></tr>
13156
+ <tr><td class="name">throughput</td><td class="type">number</td><td class="desc"></td></tr>
13157
+ </tbody>
13158
+ </table>
13159
+ </div>
13160
+ <h3 id="type-RouteRollup">RouteRollup</h3>
13161
+ <p class="section-note">A rollup route's summary spec (v3): one summary row per `groupBy` group, each `aggregate` a named reducer over the group's rows or a `{ op, field }` shorthand (`sum`, `avg`, `min`, `max`, `count`).</p>
13162
+ <div class="table-wrap">
13163
+ <table>
13164
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13165
+ <tbody>
13166
+ <tr><td class="name">groupBy</td><td class="type">RouterKey | RouterKey[]</td><td class="desc"></td></tr>
13167
+ <tr><td class="name">aggregate</td><td class="type">Record&lt;string, ((rows: RouterRecord[]) =&gt; unknown) | { op: string; field?: string }&gt;</td><td class="desc"><small>(optional)</small></td></tr>
13168
+ </tbody>
13169
+ </table>
13170
+ </div>
13171
+ <h3 id="type-RouterPersistOptions">RouterPersistOptions</h3>
13172
+ <p class="section-note">Durable persistence options (v12): `key` names the snapshot, `debounce` (ms) coalesces writes, `storage` is a `{ get, set }` pair of your own, or `indexedDB` / `dbName` / `storeName` select the browser store.</p>
13173
+ <div class="table-wrap">
13174
+ <table>
13175
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13176
+ <tbody>
13177
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
13178
+ <tr><td class="name">debounce</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
13179
+ <tr><td class="name">storage</td><td class="type">{ get: (key: string) =&gt; Promise&lt;unknown&gt;; set: (key: string, value: unknown) =&gt; Promise&lt;void&gt; }</td><td class="desc"><small>(optional)</small></td></tr>
13180
+ <tr><td class="name">indexedDB</td><td class="type">unknown</td><td class="desc"><small>(optional)</small></td></tr>
13181
+ <tr><td class="name">dbName</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
13182
+ <tr><td class="name">storeName</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
13183
+ </tbody>
13184
+ </table>
13185
+ </div>
13186
+ <h3 id="type-RouterQueryAdapter">RouterQueryAdapter</h3>
13187
+ <p class="section-note">A pushdown adapter `query()` can source the router from (v7): anything with an `execute(query, request)` returning rows, and optional `capabilities` the planner consults to decide what it may push down.</p>
13188
+ <div class="table-wrap">
13189
+ <table>
13190
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13191
+ <tbody>
13192
+ <tr><td class="name">capabilities</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc"><small>(optional)</small></td></tr>
13193
+ <tr><td class="name">execute</td><td class="type">(query: Record&lt;string, unknown&gt;, request?: Record&lt;string, unknown&gt;) =&gt; Promise&lt;{ rows: RouterRecord[]; total?: number }&gt;</td><td class="desc"></td></tr>
13194
+ </tbody>
13195
+ </table>
13196
+ </div>
13197
+ <h3 id="type-RouterRouteMetrics">RouterRouteMetrics</h3>
13198
+ <p class="section-note">One route's figures in a `metrics()` snapshot (v10).</p>
13199
+ <div class="table-wrap">
13200
+ <table>
13201
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13202
+ <tbody>
13203
+ <tr><td class="name">label</td><td class="type">string | null</td><td class="desc"></td></tr>
13204
+ <tr><td class="name">rows</td><td class="type">number</td><td class="desc"></td></tr>
13205
+ <tr><td class="name">shown</td><td class="type">number</td><td class="desc"></td></tr>
13206
+ <tr><td class="name">throughput</td><td class="type">number</td><td class="desc"></td></tr>
13207
+ </tbody>
13208
+ </table>
13209
+ </div>
13210
+ <h3 id="type-RouterSourceHandle">RouterSourceHandle</h3>
13211
+ <p class="section-note">The handle `addSource` returns for one feed (v9). Its `load` is a per-source snapshot — a keyed diff over this feed's rows only, other feeds untouched; `apply` and `push` take this feed's deltas through the router's ordinary and batched paths; `remove` deletes exactly the rows it holds and unregisters it, returning the router.</p>
13212
+ <div class="table-wrap">
13213
+ <table>
13214
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
13215
+ <tbody>
13216
+ <tr><td class="name">id</td><td class="type">string</td><td class="desc">The source id. <small>(read-only)</small></td></tr>
13217
+ <tr><td class="name">size</td><td class="type">number</td><td class="desc">How many rows this source currently holds live. <small>(read-only)</small></td></tr>
13218
+ <tr><td class="name">load</td><td class="type">(rows: RouterRecord[]): RouterSourceHandle</td><td class="desc">Apply a per-source snapshot: upsert its current rows, delete the ones it no longer has.</td></tr>
13219
+ <tr><td class="name">apply</td><td class="type">(deltas: RouterDelta[]): RouterSourceHandle</td><td class="desc">Apply per-source deltas through the router's ordinary apply path.</td></tr>
13220
+ <tr><td class="name">push</td><td class="type">(delta: RouterDelta | RouterDelta[]): RouterSourceHandle</td><td class="desc">Enqueue per-source deltas through the router's stream path (batching honoured).</td></tr>
13221
+ <tr><td class="name">remove</td><td class="type">(): DataRouter</td><td class="desc">Remove this source: delete exactly the rows it holds from every route, then unregister it.</td></tr>
12601
13222
  </tbody>
12602
13223
  </table>
12603
13224
  </div>
@@ -13444,7 +14065,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
13444
14065
  <!-- END GENERATED TYPE REFERENCE -->
13445
14066
 
13446
14067
  <footer>
13447
- Lattice Grid 1.63.2 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
14068
+ Lattice Grid 1.64.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
13448
14069
  This document describes the behaviour of the shipped library. Where this guide and the code
13449
14070
  disagree, the code wins: please <a href="https://www.latticegrid.dev">tell us</a>.
13450
14071
  </footer>