@toclocoinc/lattice-grid 1.51.0 → 1.52.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 (74) hide show
  1. package/README.md +1 -1
  2. package/docs/API.html +181 -4
  3. package/docs/api-detail.html +119 -4
  4. package/lattice-grid.d.ts +251 -5
  5. package/lattice-grid.esm.min.js +245 -8
  6. package/lattice-grid.min.cjs +245 -8
  7. package/lattice-grid.min.js +245 -8
  8. package/modules/ai.esm.min.js +4 -4
  9. package/modules/ai.min.cjs +4 -4
  10. package/modules/ai.min.js +4 -4
  11. package/modules/angular.esm.min.js +2 -2
  12. package/modules/angular.min.cjs +2 -2
  13. package/modules/angular.min.js +2 -2
  14. package/modules/chart-alluvial.esm.min.js +1 -1
  15. package/modules/chart-arc.esm.min.js +1 -1
  16. package/modules/chart-bubblemap.esm.min.js +1 -1
  17. package/modules/chart-bump.esm.min.js +1 -1
  18. package/modules/chart-calendar.esm.min.js +1 -1
  19. package/modules/chart-decomposition.esm.min.js +1 -1
  20. package/modules/chart-diverging.esm.min.js +1 -1
  21. package/modules/chart-dumbbell.esm.min.js +1 -1
  22. package/modules/chart-fan.esm.min.js +1 -1
  23. package/modules/chart-hexbin.esm.min.js +1 -1
  24. package/modules/chart-hexmap.esm.min.js +1 -1
  25. package/modules/chart-icicle.esm.min.js +1 -1
  26. package/modules/chart-parallel.esm.min.js +1 -1
  27. package/modules/chart-ridgeline.esm.min.js +1 -1
  28. package/modules/chart-roc.esm.min.js +1 -1
  29. package/modules/chart-slope.esm.min.js +1 -1
  30. package/modules/chart-splom.esm.min.js +1 -1
  31. package/modules/chart-waffle.esm.min.js +1 -1
  32. package/modules/charts.esm.min.js +181 -87
  33. package/modules/charts.min.cjs +181 -87
  34. package/modules/charts.min.js +181 -87
  35. package/modules/data-router.esm.min.js +4 -4
  36. package/modules/data-router.min.cjs +4 -4
  37. package/modules/data-router.min.js +4 -4
  38. package/modules/devtools.esm.min.js +2 -2
  39. package/modules/devtools.min.cjs +2 -2
  40. package/modules/devtools.min.js +2 -2
  41. package/modules/dhtmlx-compat.esm.min.js +4 -4
  42. package/modules/dhtmlx-compat.min.cjs +4 -4
  43. package/modules/dhtmlx-compat.min.js +4 -4
  44. package/modules/gantt.esm.min.js +300 -62
  45. package/modules/gantt.min.cjs +300 -62
  46. package/modules/gantt.min.js +300 -62
  47. package/modules/htmx.esm.min.js +245 -8
  48. package/modules/htmx.min.cjs +245 -8
  49. package/modules/htmx.min.js +245 -8
  50. package/modules/kanban.esm.min.js +4 -4
  51. package/modules/kanban.min.cjs +4 -4
  52. package/modules/kanban.min.js +4 -4
  53. package/modules/kpi.esm.min.js +734 -40
  54. package/modules/kpi.min.cjs +734 -40
  55. package/modules/kpi.min.js +734 -40
  56. package/modules/mock-socket.esm.min.js +2 -2
  57. package/modules/mock-socket.min.cjs +2 -2
  58. package/modules/mock-socket.min.js +2 -2
  59. package/modules/react.esm.min.js +2 -2
  60. package/modules/react.min.cjs +2 -2
  61. package/modules/react.min.js +2 -2
  62. package/modules/svelte.esm.min.js +2 -2
  63. package/modules/svelte.min.cjs +2 -2
  64. package/modules/svelte.min.js +2 -2
  65. package/modules/tabs.esm.min.js +88 -14
  66. package/modules/tabs.min.cjs +88 -14
  67. package/modules/tabs.min.js +88 -14
  68. package/modules/vue.esm.min.js +2 -2
  69. package/modules/vue.min.cjs +2 -2
  70. package/modules/vue.min.js +2 -2
  71. package/modules/webcomponent.esm.min.js +245 -8
  72. package/modules/webcomponent.min.cjs +245 -8
  73. package/modules/webcomponent.min.js +245 -8
  74. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  dependencies, no build step required. Optional adapters for React, Vue, Svelte
5
5
  and Web Components ship alongside it.
6
6
 
7
- Version 1.51.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.52.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -903,6 +903,25 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
903
903
 
904
904
  <h2 id="column">Column definition</h2>
905
905
  <p class="section-note">Everything is optional. A column with only <code>field</code> infers its type from sampled data and takes every default from there.</p>
906
+ <p><strong>Inferring a <code>Date</code>.</strong> Inference walks
907
+ <code>boolean</code>, <code>number</code>, <code>date</code>, <code>dateString</code>,
908
+ <code>datetime</code>, <code>text</code>, <code>object</code> and takes the first type that
909
+ matches every sampled value. A <code>Date</code> whose <em>local</em> wall clock reads exactly
910
+ <code>00:00:00.000</code> infers as <code>date</code> and stores <code>YYYY-MM-DD</code>;
911
+ a <code>Date</code> carrying any time of day infers as <code>datetime</code> and stores
912
+ <code>YYYY-MM-DDTHH:mm</code> (with seconds when they are non-zero), because a
913
+ <code>date</code> column would discard the clock on ingest and nothing downstream could
914
+ recover it. <strong>This is a heuristic with one stated blind spot:</strong> a genuine
915
+ timestamp that lands on exactly local midnight — a nightly batch stamped
916
+ <code>00:00:00.000</code> — is indistinguishable from a date-only value and is still inferred
917
+ as <code>date</code>, so its time of day is still discarded. Sub-second resolution is never
918
+ retained by <code>datetime</code>. <strong>If you group by such a column, declare it:</strong> an
919
+ undeclared <code>Date</code> column groups by the instant, which is one group per row, where a
920
+ <code>type: 'date'</code> column groups into day buckets. Date filters are unaffected either way
921
+ — they compare on the day and return the same rows. Declare <code>type</code> and none of this applies:
922
+ <code>'date'</code> truncates on purpose, <code>'datetime'</code> keeps a wall clock, and
923
+ <code>'timestamp'</code> keeps the instant to the millisecond. Strings are unaffected — an ISO
924
+ string with or without a time component still infers as <code>date</code>.</p>
906
925
 
907
926
  <div class="table-wrap">
908
927
  <table>
@@ -2825,6 +2844,62 @@ grid.destroy();
2825
2844
  </tbody>
2826
2845
  </table>
2827
2846
  </div>
2847
+ <h3 id="derived-statistics">The relational statistics, as rows</h3>
2848
+ <p class="section-note">
2849
+ A single-column statistic already has a route: <code>select</code> reduces a group with any
2850
+ kernel the totals row uses, and that table is a superset of the statistics one, so
2851
+ <code>select: { p95: { of: 'amount', fn: 'p95' } }</code> works, along with
2852
+ <code>median</code>, <code>stddev</code>, <code>gini</code> and the rest.
2853
+ <code>statistics</code> is for what <code>select</code> structurally cannot reach: the
2854
+ figures needing two or more columns, or a second grid. Like <code>profile</code> it is a
2855
+ terminal producer &mdash; it replaces the pipeline rather than joining it, and the two cannot
2856
+ be used together. Every row carries <code>n</code>, the rows the figure covered. Full detail,
2857
+ including the measured re-derive cost of each producer, is in
2858
+ <a href="api-detail.html#derived-statistics">the detail reference</a>.
2859
+ </p>
2860
+ <pre data-run="js" data-expect="t/w -1; pairs 3; metrics 14; compared 2 + 1 unmatched" data-covers="config:statistics"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
2861
+
2862
+ <span class="kw">const</span> readings = Array.from({ length: 20 }, (_, i) =&gt; ({ id: i, t: i, v: 100 + i * 3, w: 50 - i }));
2863
+ <span class="kw">const</span> plant = createHeadlessGrid({ rowKey: 'id',
2864
+ columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
2865
+ rows: readings });
2866
+ plant.rows.count();
2867
+
2868
+ <span class="cmt">// One row per column PAIR: { a, b, coefficient, n }. `w` runs exactly against `t`.</span>
2869
+ <span class="kw">const</span> pairs = createHeadlessGrid({ rowKey: '__key',
2870
+ columns: [{ field: 'a' }, { field: 'b' }, { field: 'coefficient', type: 'number' }, { field: 'n', type: 'number' }],
2871
+ source: { mode: 'derived', from: plant, refresh: 'live',
2872
+ statistics: { fn: 'correlation', columns: ['t', 'v', 'w'] } } });
2873
+ <span class="kw">let</span> tw = <span class="kw">null</span>;
2874
+ pairs.rows.forEach((row) =&gt; {
2875
+ <span class="kw">if</span> (pairs.rows.value(row.key, 'a') === 't' &amp;&amp; pairs.rows.value(row.key, 'b') === 'w') {
2876
+ tw = pairs.rows.value(row.key, 'coefficient');
2877
+ }
2878
+ });
2879
+
2880
+ <span class="cmt">// One row per METRIC of the series summary, not one per point.</span>
2881
+ <span class="kw">const</span> series = createHeadlessGrid({ rowKey: '__key',
2882
+ columns: [{ field: 'metric' }, { field: 'value', type: 'number' }],
2883
+ source: { mode: 'derived', from: plant, refresh: 'live',
2884
+ statistics: { fn: 'series', of: 'v', by: 't' } } });
2885
+
2886
+ <span class="cmt">// One row per compared column, against a second grid. The peer is watched.</span>
2887
+ <span class="cmt">// `t` is on this grid only, so it is reported as unmatched rather than dropped.</span>
2888
+ <span class="kw">const</span> peer = createHeadlessGrid({ rowKey: 'id',
2889
+ columns: [{ field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
2890
+ rows: readings.map((r) =&gt; ({ id: r.id, v: r.v * 2, w: r.w })) });
2891
+ peer.rows.count();
2892
+ <span class="kw">const</span> compared = createHeadlessGrid({ rowKey: '__key',
2893
+ columns: [{ field: 'column' }, { field: 'magnitude', type: 'number' }],
2894
+ source: { mode: 'derived', from: plant, refresh: 'live',
2895
+ statistics: { fn: 'datasetVsDataset', with: peer, columns: ['v', 'w'] } } });
2896
+
2897
+ <span class="kw">let</span> shared = 0, unmatched = 0;
2898
+ compared.rows.forEach((row) =&gt; {
2899
+ <span class="kw">if</span> (compared.rows.value(row.key, 'magnitude') === <span class="kw">null</span>) unmatched += 1; <span class="kw">else</span> shared += 1;
2900
+ });
2901
+
2902
+ <span class="kw">return</span> `t/w ${tw}; pairs ${pairs.rows.count()}; metrics ${series.rows.count()}; compared ${shared} + ${unmatched} unmatched`;</code></pre>
2828
2903
  <h3 id="derived-union">Union sources: combining several grids into one</h3>
2829
2904
  <p class="section-note">
2830
2905
  "Worst performers across two datasets" is easy when the two datasets share a key: a
@@ -5332,7 +5407,7 @@ return [p.pv, p.ev, p.ac, p.sv, p.cv, p.spi.toFixed(2), p.cpi.toFixed(2), direct
5332
5407
 
5333
5408
  const gantt = createGantt({ tasks, dependencies });
5334
5409
  gantt.mount(document.querySelector('#plan'), {
5335
- today: 20340, // a day-number; draws the today line
5410
+ today: '2026-03-06', // a day-number, ISO date or Date; draws the today line
5336
5411
  nonWorking: 'weekends', // shade Saturdays and Sundays
5337
5412
  label: 'percent', // bar label: 'name' | 'percent' | 'dates' | (task) =&gt; string
5338
5413
  dateAxis: true, // axis ticks as calendar dates
@@ -5340,11 +5415,25 @@ gantt.mount(document.querySelector('#plan'), {
5340
5415
  scrollToToday: true, // scroll so the today line is in view
5341
5416
  groupBy: 'assignee', // swimlanes by a task property or (task) =&gt; key
5342
5417
  rowHeight: 26,
5343
- width: 720,
5418
+ width: 'container', // the default: fill the container, and keep following it
5419
+ });</code></pre>
5420
+ <p><strong>Sizing (BACKLOG-0001079).</strong> <code>width</code> defaults to <code>'container'</code>: the view measures the box it was mounted into and redraws itself whenever that box changes, so a plan in a tab, a drawer, an accordion, a responsive panel or a split pane fits without the host writing a <code>ResizeObserver</code> of its own. A container with no box &mdash; a hidden tab, or an element that has not been laid out yet &mdash; is not treated as a container of zero width: the view holds a 720px fallback and adopts the real width the moment there is one. Pass a <strong>number</strong> to take the decision yourself; a numeric <code>width</code> is honoured exactly, installs no observer, and keeps the eight-tick axis it always had &mdash; only a container-sized plot thins its tick labels to the width it was given, because only a container-sized plot can be somewhere it had not been before. <code>zoom</code> and a numeric <code>width</code> are mutually exclusive: <strong>zoom wins</strong> &mdash; it fixes the pixels-per-day and lets the plot scroll past the container &mdash; and passing both now warns rather than discarding the <code>width</code> in silence.</p>
5421
+ <p><strong>The project anchor (BACKLOG-0001079).</strong> A <code>mount</code> option, not a <code>mountSplit</code> one: the joined split view below takes neither <code>projectEpoch</code> nor a date-valued <code>today</code>, and its weekend shading is unanchored. The engine's time line is whole days since the Unix epoch, so a plan written as day offsets (<code>0, 4, 9&hellip;</code>) legitimately renders as January 1970 &mdash; day 0 <em>is</em> 1970-01-01, and the module cannot tell an offset from a real epoch day, so it cannot warn about it. <code>projectEpoch</code> says which calendar date plan day 0 stands for. It is <strong>display-only</strong>: axis ticks, bar labels, tooltips, screen-reader text and the built-in weekend shading move with it, and nothing the scheduler, <code>getState</code>, the CSV or the MSPDI export produces does &mdash; every <code>es</code>/<code>ef</code> you read back is still the number you supplied. A host-supplied <code>nonWorking</code> function keeps receiving raw plan days, since it was written against your day numbers. Use <code>projectStart</code> instead when you want the model itself to be on calendar dates.</p>
5422
+ <pre><code>// A relative plan: offsets in the data, real dates on the screen.
5423
+ gantt.mount(el, {
5424
+ projectEpoch: '2026-03-02', // plan day 0 is this Monday
5425
+ nonWorking: 'weekends', // so days 5-6 are the first weekend
5426
+ today: '2026-03-06', // converted into plan space through the anchor
5344
5427
  });
5345
5428
  gantt.view.scrollToToday(); // also callable on demand
5346
5429
  gantt.applyEdit({ id: 'design', duration: 7 }); // the view redraws automatically
5347
5430
  gantt.unmount();</code></pre>
5431
+ <p><strong>Dependency arrows (BACKLOG-0001072).</strong> A link is routed by geometry. When the successor's anchor is at or beyond the predecessor's, it goes <strong>straight down and then in</strong> &mdash; a plain zero-lag finish-to-start link, where the two anchors share an x, is a clean vertical. When the anchor is <em>behind</em> the predecessor's &mdash; a negative lag (a lead), or two overlapping tasks &mdash; "down then in" does not exist, so the link takes a deliberate detour: out on the predecessor's own side, along a lane between the two rows, down, and in. Either way the final segment runs in the direction the arrowhead points, which is what stops a link doubling back on itself. The anchors themselves differ per link type &mdash; FS finish&rarr;start, SS start&rarr;start, FF finish&rarr;finish, SF start&rarr;finish &mdash; and so does the side the arrow arrives on, so an FF or SF link comes in from the right of the successor's finish rather than running through the bar to get there.</p>
5432
+ <p><strong>Link shorthand.</strong> A dependency's <code>type</code> accepts the MS Project string form as well as the structured one: <code>'FS+2'</code>, <code>'SS-1'</code>. It normalises to <code>{ type: 'FS', lag: 2 }</code> on the way in, so <code>gantt.dependencies</code>, the scheduler, the lag label and the MSPDI export all see the one canonical form. A shorthand lag alongside an explicit <code>lag</code> that disagrees warns; the explicit field wins.</p>
5433
+ <pre><code>createGantt({ tasks, dependencies: [
5434
+ { from: 'design', to: 'build', type: 'FS+2' }, // same as { type: 'FS', lag: 2 }
5435
+ { from: 'build', to: 'test', type: 'SS-1' }, // a lead
5436
+ ] });</code></pre>
5348
5437
  <p>Bars are draggable: drag the body to move a task, drag the right edge to resize it. When a <code>grid</code> and a <code>columns</code> map are given, each drag writes the new dates back through the grid's public edit surface (<code>grid.edit.setCells</code>) and reconciles a reverted or conflicted write; <code>autoSchedule: true</code> cascades dependents. A task placed earlier than its predecessors allow is flagged (<code>findViolations</code>), not silently moved.</p>
5349
5438
  <pre><code>import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';
5350
5439
 
@@ -5628,13 +5717,63 @@ const kpi = createKPI(document.querySelector('#kpis'), {
5628
5717
  <tr><td class="sig">rows.apply({ add, update, remove })</td><td class="desc">The keyed-diff consumer contract a grid shares, so the panel is a drop-in Data Router target and updates each tile incrementally. Also <code>rows.forEach</code> and <code>rows.count</code>.</td></tr>
5629
5718
  <tr><td class="sig">tiles() / tile(id) / value(id)</td><td class="desc">Every computed tile model, one tile by id (<code>value</code>, <code>formatted</code>, <code>status</code>, <code>delta</code>, <code>deltaPercent</code>, <code>count</code>, <code>sparkline</code>), or a tile's raw value. <code>status</code> is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>unknown</code> (the panel holds no rows) or <code>null</code> (no thresholds configured); <code>value</code> is <code>null</code> whenever the tile measured nothing.</td></tr>
5630
5719
  <tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or re-seed and recompute.</td></tr>
5631
- <tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">Serialise and restore the panel's row set, so a headless panel round-trips.</td></tr>
5632
- <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>tile:click</code>, <code>tile:dblclick</code>, <code>tile:contextmenu</code>, and <code>change</code> (after every update).</td></tr>
5720
+ <tr><td class="sig">nodes() / node(key) / visibleNodes()</td><td class="desc">The hierarchy, when <code>tree</code> resolves one: the top-level nodes with their children, one node by key at any depth, or just the nodes on screen. Each node carries <code>label</code>, <code>level</code>, <code>tile</code> (null on a synthesised level), <code>status</code>, <code>rollup</code> (the worst severity at or below it, never <code>unknown</code>), <code>unknown</code> (how many below it measured nothing) and <code>items</code>. Empty on a flat panel, where <code>kpi.tree</code> is <code>false</code>.</td></tr>
5721
+ <tr><td class="sig">expand(key) / collapse(key) / toggle(key)</td><td class="desc">Open or close a branch. A key for a branch the panel does not (yet) hold is retained rather than dropped, so a delta that later introduces it finds it already open.</td></tr>
5722
+ <tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">Serialise and restore the panel's row set, so a headless panel round-trips, plus <code>expanded</code> (the open branch keys) on a hierarchical panel. A snapshot with no <code>expanded</code> key leaves expansion alone rather than resetting it.</td></tr>
5723
+ <tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>tile:click</code>, <code>tile:dblclick</code>, <code>tile:contextmenu</code>, <code>node:toggle</code> (a branch opened or closed), and <code>change</code> (after every update).</td></tr>
5633
5724
  <tr><td class="sig">destroy()</td><td class="desc">Empty the element and drop the listeners. The host still owns any bound grid.</td></tr>
5634
5725
  </tbody>
5635
5726
  </table>
5636
5727
  </div>
5637
5728
  <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>
5729
+ <h3 id="kpi-tree">The KPI tree: top-level items that expand to the indicators beneath them</h3>
5730
+ <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>
5731
+ <pre><code>const kpi = createKPI(document.querySelector('#rail'), {
5732
+ rows, rowKey: 'id',
5733
+ tiles: [
5734
+ { id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu',
5735
+ thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
5736
+ { id: 'compute.memory', label: 'memory', aggregation: 'avg', field: 'mem',
5737
+ thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
5738
+ { id: 'network.latency', label: 'latency', aggregation: 'max', field: 'latency',
5739
+ thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
5740
+ ],
5741
+ tree: {}, <span class="cmt">// the dotted ids are the hierarchy</span>
5742
+ });
5743
+
5744
+ kpi.nodes()[0].rollup; <span class="cmt">// 'critical' — even with the branch shut</span>
5745
+ kpi.expand(kpi.nodes()[0].key); <span class="cmt">// open it</span></code></pre>
5746
+ <p><strong>Where the shape comes from &mdash; two sources, in this order.</strong> <em>Declared:</em> <code>tree: { path }</code> or <code>tree: { parentKey }</code> over the <em>tile specs</em>, the same two shapes the grid's <a href="#tree-data">tree data</a> and the tree-select editor already take, so a hierarchy you have configured once needs no second vocabulary. A <code>path</code> is the tile's <em>own</em> place, its own segment last &mdash; <code>['System','Compute','cpu']</code>, exactly as <code>['EMEA','UK','Colchester']</code> is Colchester's path and not its parent's &mdash; and levels no tile represents are synthesised, so <code>System</code> and <code>Compute</code> appear without a tile of their own. <em>Derived:</em> with neither declared, the tile ids are split on <code>separator</code> (default <code>.</code>), so <code>system.compute.cpu</code> files itself. A panel whose ids carry no separator is flat and renders exactly as it always did; <code>tree: false</code> keeps it flat whatever the ids look like. A tile's <code>field</code> is never a source &mdash; a dot there already means a nested object property, and overloading it would make <code>field: 'cpu.util'</code> ambiguous.</p>
5747
+ <p><strong>No value rolls up; severity does.</strong> A parent shows no aggregated number. That is not a simplification: the running accumulators expose <code>add</code>/<code>remove</code>/<code>value</code> and no merge, so <code>avg</code>, <code>countDistinct</code> and a <code>custom</code> reducer cannot be composed from their children without rescanning, and a per-aggregation exception list would be a number that is right for a sum and wrong for an average. A parent that has a tile of its own still shows <em>that tile's</em> reading. What does roll up is the status: <code>rollup</code> is the worst severity at or below the node, across as many levels as you have, and it is what a collapsed branch reports.</p>
5748
+ <p><strong>Nothing measured is not good news, and it does not win the roll-up either.</strong> A leaf that measured nothing is <code>unknown</code> (see above), and <code>unknown</code> is deliberately excluded from <code>rollup</code>: ranking &ldquo;not measured&rdquo; as the worst thing beneath a parent would hide a real warning under it. It is surfaced separately instead &mdash; <code>node.unknown</code> counts the descendants that measured nothing, and the node renders it in words (&ldquo;2 unknown&rdquo;) and puts it in its accessible name. So neither way of being wrong is available: silence cannot read as green, and it cannot bury an amber.</p>
5749
+ <p><strong>Accessible by construction.</strong> The rail is a real <code>role="tree"</code> with the APG keyboard model &mdash; <kbd>&rarr;</kbd> opens a closed branch and otherwise steps into it, <kbd>&larr;</kbd> closes an open one and otherwise steps out to its parent, <kbd>&uarr;</kbd>/<kbd>&darr;</kbd> walk what is visible, <kbd>Home</kbd>/<kbd>End</kbd> jump to its ends, <kbd>Enter</kbd>/<kbd>Space</kbd> activate &mdash; with a roving tabindex, and <code>aria-level</code>, <code>aria-posinset</code> and <code>aria-setsize</code> on every node, because a reader cannot count what a collapsed branch has left out of the DOM. Status carries a <strong>shape</strong> as well as a colour (a filled circle, a triangle, a square, a hollow circle), not one dot in three colours, and a parent's rolled-up status is <em>in its accessible name</em>: &ldquo;Compute, 6 items, worst status critical&rdquo;, announced as one string. Every phrase is a catalogue key: pass <code>messages</code> (any <code>{ t(key, params) }</code>, including a grid's own) to translate the panel, and a key your catalogue lacks falls back to English rather than printing the key.</p>
5750
+ <p><strong>A collapsed branch costs data, not paint.</strong> Every node's status is computed whether or not you can see it &mdash; that is what makes the rail worth having &mdash; while a collapsed branch contributes no DOM at all. It is the same division the grid's grouping already makes between its totals walk and its display walk. Expansion is patched in place, keyed on the node, so a live routed feed does not throw a keyboard user off the node they are standing on.</p>
5751
+ <h3 id="kpi-tree-example">A collapsed parent reports the red leaf underneath it, executed</h3>
5752
+ <p class="section-note">Two subsystems from dotted tile ids, one of them in breach, with nothing expanded; then the same
5753
+ rail with no data at all. Run headless on every build.</p>
5754
+ <pre data-run="js" data-expect="compute false critical | cpu critical | null 2" data-covers="export:createKPI"><code><span class="kw">const</span> { createKPI } = <span class="kw">await</span> import('../packages/modules/kpi/index.js');
5755
+
5756
+ <span class="kw">const</span> fewerIsBetter = { warn: 70, critical: 90, direction: 'lowerIsBetter' };
5757
+ <span class="kw">const</span> tiles = [
5758
+ { id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: fewerIsBetter },
5759
+ { id: 'compute.memory', label: 'memory', aggregation: 'avg', field: 'mem', thresholds: fewerIsBetter },
5760
+ { id: 'network.latency', label: 'latency', aggregation: 'max', field: 'latency',
5761
+ thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
5762
+ ];
5763
+
5764
+ <span class="cmt">// No `tree` block: the dots in the tile ids are the hierarchy.</span>
5765
+ <span class="kw">const</span> kpi = createKPI(null, { rows: [{ id: 'h1', cpu: 94, mem: 40, latency: 12 }], rowKey: 'id', tiles });
5766
+
5767
+ <span class="kw">const</span> compute = kpi.nodes()[0];
5768
+ <span class="cmt">// Nobody has opened it, and it reports the breach anyway.</span>
5769
+ <span class="kw">const</span> shut = [compute.label, compute.expanded, compute.rollup].join(' '); <span class="cmt">// compute false critical</span>
5770
+ <span class="kw">const</span> leaf = [compute.children[0].label, compute.children[0].status].join(' '); <span class="cmt">// cpu critical</span>
5771
+
5772
+ <span class="cmt">// Nothing delivered: the parent reports the silence rather than a false green.</span>
5773
+ <span class="kw">const</span> quiet = createKPI(null, { rows: [], rowKey: 'id', tiles });
5774
+ <span class="kw">const</span> silent = String(quiet.nodes()[0].rollup) + ' ' + quiet.nodes()[0].unknown; <span class="cmt">// null 2</span>
5775
+
5776
+ <span class="kw">return</span> [shut, leaf, silent].join(' | ');</code></pre>
5638
5777
  <h3 id="kpi-live-example">Live, driven by a Data Router alongside a grid, executed</h3>
5639
5778
  <p class="section-note">One feed fans out (<code>overlap</code>) to a KPI panel through the same keyed-diff contract a grid uses:
5640
5779
  a snapshot seeds the tiles, then a delta removes the current max and the min/max rescans. Run headless on every build.</p>
@@ -8044,6 +8183,30 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8044
8183
  </tbody>
8045
8184
  </table>
8046
8185
  </div>
8186
+ <h3 id="type-DerivedCorrelation">DerivedCorrelation</h3>
8187
+ <p class="section-note">Pearson's correlation across N columns, pairwise. Rows, `orient: 'pairs'` (the default): one per unordered pair, `{ a, b, coefficient, n }` — the long form, because that is what a grid sorts, filters and charts well, and "the three most correlated pairs" is then a sort and a `limit` on the derived grid. Only the upper triangle is emitted: r is symmetric, so `(a,b)` and `(b,a)` are one finding, and a column against itself is 1 by definition. Rows, `orient: 'matrix'`: one per column, carrying a field per other column plus `column` and `n` — the classic square, for a heat map. The diagonal is 1 and both triangles are filled.</p>
8188
+ <div class="table-wrap">
8189
+ <table>
8190
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8191
+ <tbody>
8192
+ <tr><td class="name">fn</td><td class="type">'correlation'</td><td class="desc"></td></tr>
8193
+ <tr><td class="name">columns</td><td class="type">string[]</td><td class="desc">The columns to correlate pairwise. At least two, or the source is refused.</td></tr>
8194
+ <tr><td class="name">orient</td><td class="type">'pairs' | 'matrix'</td><td class="desc">`pairs` (default) for one row per pair; `matrix` for the square. <small>(optional)</small></td></tr>
8195
+ </tbody>
8196
+ </table>
8197
+ </div>
8198
+ <h3 id="type-DerivedDatasetComparison">DerivedDatasetComparison</h3>
8199
+ <p class="section-note">How this grid differs from another, ranked by effect size, as rows: `{ column, measure, magnitude, distance, direction, nA, nB, reliable, unmatched }`, largest difference first. The two-grid shape: one grid is the data, a second *is* the analysis of it. Both sides are read over their filtered rows, and the peer is watched — an edit or a filter on it re-derives the comparison, because a comparison whose other side has moved is wrong rather than merely late. A column present on only one side cannot be compared. It is still reported, as a row with a null `magnitude` and `unmatched` set to `'A'` or `'B'`, so a reader sees that it was skipped and why rather than finding it absent.</p>
8200
+ <div class="table-wrap">
8201
+ <table>
8202
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8203
+ <tbody>
8204
+ <tr><td class="name">fn</td><td class="type">'datasetVsDataset'</td><td class="desc"></td></tr>
8205
+ <tr><td class="name">with</td><td class="type">Grid</td><td class="desc">The second grid to compare this one against.</td></tr>
8206
+ <tr><td class="name">columns</td><td class="type">string[]</td><td class="desc">Restrict the comparison to these columns. All shared columns by default. <small>(optional)</small></td></tr>
8207
+ </tbody>
8208
+ </table>
8209
+ </div>
8047
8210
  <h3 id="type-DerivedJoin">DerivedJoin</h3>
8048
8211
  <div class="table-wrap">
8049
8212
  <table>
@@ -8069,6 +8232,19 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8069
8232
  </tbody>
8070
8233
  </table>
8071
8234
  </div>
8235
+ <h3 id="type-DerivedSeries">DerivedSeries</h3>
8236
+ <p class="section-note">A `grid.statistics.series` summary, as one row per metric: `{ metric, value, n }`. One row per *metric*, not per point: `series` returns a `SeriesStats` summary object — `n`, `first`, `last`, `change`, `changePercent`, `volatility`, `annualisedVolatility`, `growth`, `maxDrawdown`, `maxDrawdownFrom`, `maxDrawdownTo`, `autocorrelation`, `upDays`, `downDays` — and not a value per row. The shape is deliberately the one `profile`'s `orient: 'metrics'` already emits rather than a third convention for the same idea.</p>
8237
+ <div class="table-wrap">
8238
+ <table>
8239
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
8240
+ <tbody>
8241
+ <tr><td class="name">fn</td><td class="type">'series'</td><td class="desc"></td></tr>
8242
+ <tr><td class="name">of</td><td class="type">string</td><td class="desc">The column to summarise.</td></tr>
8243
+ <tr><td class="name">by</td><td class="type">string</td><td class="desc">The column that orders it. Required and never guessed.</td></tr>
8244
+ <tr><td class="name">periodsPerYear</td><td class="type">number</td><td class="desc">Annualise volatility and growth against this many periods per year. <small>(optional)</small></td></tr>
8245
+ </tbody>
8246
+ </table>
8247
+ </div>
8072
8248
  <h3 id="type-DerivedSourceConfig">DerivedSourceConfig</h3>
8073
8249
  <p class="section-note">A grid whose rows are derived from another grid: aggregated, unnested, filtered, ranked or profiled. Read-only: write to the source instead.</p>
8074
8250
  <div class="table-wrap">
@@ -8090,6 +8266,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
8090
8266
  <tr><td class="name">cumulative</td><td class="type">{ of: string; upTo: number }</td><td class="desc">Keep rows until their running share of the total reaches `upTo`, 0 to 1. <small>(optional)</small></td></tr>
8091
8267
  <tr><td class="name">profile</td><td class="type">string | string[]</td><td class="desc">One row per column, with the statistics as columns. Replaces the pipeline. <small>(optional)</small></td></tr>
8092
8268
  <tr><td class="name">orient</td><td class="type">'columns' | 'metrics'</td><td class="desc">With `profile`, emit one row per statistic instead of one per column. <small>(optional)</small></td></tr>
8269
+ <tr><td class="name">statistics</td><td class="type">DerivedStatistics</td><td class="desc">Project a **relational** statistic into rows (BACKLOG-0001046): the figures that need two or more columns, or a second grid, and so cannot be reached through `select`. Every *single-column* statistic already has a route and this is not it — the derived `select` reduces a group by any kernel the totals row uses, and that table is a superset of the statistics one, so `select: { p95: { of: 'amount', fn: 'p95' } }` (or `gini`, `stddev`, `median`, `trimmedMean`, …) works today. Reach for `statistics` only when the answer is a correlation, a series summary or a comparison against another dataset. **A terminal producer, like `profile`, not a pipeline stage.** A correlation is one row per column *pair*, a series summary one row per *metric*, a comparison one row per compared *column* — none of which is one row per group, so there is no position in `unnest → where → bucket → groupBy → select → sort → limit` for it to occupy. It replaces the pipeline, and those keys are ignored with a warning naming them (BACKLOG-0001092) rather than silently discarded. Sort, filter or limit the derived grid itself instead, or chain a second derived grid whose `from` is this one. **`profile` and `statistics` are mutually exclusive** and declaring both is refused, by name, when the source is built. **Not supported alongside a union `from`** — a relational statistic reduces one grid's own columns and a union has no single set of them; also refused by name. **Cost.** Like every terminal producer this never patches incrementally: a change on the parent re-derives the whole thing. `correlation` additionally scans the rows once *per pair*, so N columns cost N·(N−1)/2 passes. Use `refresh` (`'idle'` is the default; `'manual'` or a debounce in ms for an expensive analysis over a live feed) — see `docs/api-detail.html` for the measured figures. Every row carries `n`, the rows the figure covered, because a derived statistic travels into an export or a chart without its grid and "r = 0.98 over eleven rows" is a different claim from the same number over eleven thousand. It does NOT carry a windowed/approximate flag: whether a source held fewer rows than matched its filters is decided from the source's own counters, which a derived source cannot reach, so that signal stays where it already works - the `stat.windowed:*` console warning the parent grid emits. <small>(optional)</small></td></tr>
8093
8270
  <tr><td class="name">refresh</td><td class="type">'live' | 'idle' | 'manual' | number</td><td class="desc">When to re-derive. `idle` by default: coalesced to a frame. <small>(optional)</small></td></tr>
8094
8271
  <tr><td class="name">crossFilter</td><td class="type">boolean | string | { col?: string }</td><td class="desc">Let this grid filter the grid it derives from. `true` cross-filters through whatever it groups by; a string names a different source column. <small>(optional)</small></td></tr>
8095
8272
  </tbody>
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.51.0</p>
440
+ <p class="rail__sub">Developer guide · v1.52.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -3396,11 +3396,126 @@ createGrid(right, {
3396
3396
  (<code>idle</code>, or a debounce), and prefer fewer, larger sources over many small ones where
3397
3397
  the shape of your data allows it.</p>
3398
3398
  <p><strong>Not supported alongside a union.</strong> <code>crossFilter</code> has no single
3399
- target once there is more than one parent; <code>profile</code> reduces one grid's own columns.
3400
- Both are refused with a warning rather than guessed at, and the top-level <code>follow</code> is
3401
- ignored in favour of each source's own.</p>
3399
+ target once there is more than one parent; <code>profile</code> and <code>statistics</code>
3400
+ reduce one grid's own columns. All three are refused with a warning rather than guessed at, and
3401
+ the top-level <code>follow</code> is ignored in favour of each source's own.</p>
3402
3402
  </div>
3403
3403
 
3404
+ <h2 id="derived-statistics">The relational statistics, as rows</h2>
3405
+ <p class="lead-in">
3406
+ One grid is the data; a second <em>is</em> the analysis of it. <code>statistics</code> projects
3407
+ the figures that need two or more columns &mdash; or a second grid &mdash; into rows you can
3408
+ sort, filter, chart and export like any others.
3409
+ </p>
3410
+ <p><strong>You probably do not need it for a single-column statistic.</strong> Those already have
3411
+ a route: a derived <code>select</code> reduces a group with any kernel the totals row uses, and
3412
+ that table is a superset of the statistics one. <code>select: { p95: { of: 'amount', fn: 'p95'
3413
+ } }</code> works today, and so do <code>median</code>, <code>stddev</code>, <code>gini</code>,
3414
+ <code>iqr</code>, <code>entropy</code>, <code>trimmedMean</code> and the rest.
3415
+ <code>statistics</code> is for what <code>select</code> structurally cannot reach.</p>
3416
+ <div class="example">
3417
+ <p class="example__label">Which columns move together</p>
3418
+ <pre><code>source: {
3419
+ mode: <span class="str">'derived'</span>,
3420
+ from: trades,
3421
+ statistics: { fn: <span class="str">'correlation'</span>, columns: [<span class="str">'price'</span>, <span class="str">'volume'</span>, <span class="str">'spread'</span>] },
3422
+ <span class="cmt">// -&gt; one row per PAIR: { a, b, coefficient, n }</span>
3423
+ }</code></pre>
3424
+ </div>
3425
+ <p><strong>Three statistics in this release.</strong> Each has one row shape, and the shape is the
3426
+ contract:</p>
3427
+ <ul>
3428
+ <li><code>{ fn: 'correlation', columns, orient? }</code> &mdash; Pearson's r across N columns.
3429
+ <code>orient: 'pairs'</code> (the default) gives <strong>one row per unordered pair</strong>,
3430
+ <code>{ a, b, coefficient, n }</code>. Long form by default because that is what
3431
+ a grid sorts, filters and charts well &mdash; "the three most correlated pairs" is then a
3432
+ sort and a <code>limit</code> on the derived grid. Only the upper triangle is emitted: r is
3433
+ symmetric, so <code>(a,b)</code> and <code>(b,a)</code> are one finding, and a column against
3434
+ itself is 1 by definition. <code>orient: 'matrix'</code> gives the classic square instead,
3435
+ one row per column with a field per other column, for a heat map.</li>
3436
+ <li><code>{ fn: 'series', of, by, periodsPerYear? }</code> &mdash; <strong>one row per
3437
+ metric</strong>, <code>{ metric, value, n }</code>. Note <em>per metric</em>, not
3438
+ per point: <code>grid.statistics.series</code> returns a summary &mdash; <code>n</code>,
3439
+ <code>first</code>, <code>last</code>, <code>change</code>, <code>changePercent</code>,
3440
+ <code>volatility</code>, <code>annualisedVolatility</code>, <code>growth</code>,
3441
+ <code>maxDrawdown</code>, <code>maxDrawdownFrom</code>, <code>maxDrawdownTo</code>,
3442
+ <code>autocorrelation</code>, <code>upDays</code>, <code>downDays</code> &mdash; and not a
3443
+ value per row. The shape is the one <code>profile</code>'s <code>orient: 'metrics'</code>
3444
+ already emits, deliberately, rather than a third convention for the same idea.
3445
+ <code>by</code> is required and never guessed, because kernels see rows in arrival order and
3446
+ that is not the grid's sort.</li>
3447
+ <li><code>{ fn: 'datasetVsDataset', with, columns? }</code> &mdash; <strong>one row per compared
3448
+ column</strong>, largest difference first: <code>{ column, measure, magnitude, distance,
3449
+ direction, nA, nB, reliable, unmatched }</code>. Both sides are read over their
3450
+ <em>filtered</em> rows, and <strong>the peer is watched</strong> &mdash; an edit or a filter
3451
+ on it re-derives the comparison, because a comparison whose other side has moved is wrong
3452
+ rather than merely late. A column present on only one side cannot be compared and is still
3453
+ reported, with a null <code>magnitude</code> and <code>unmatched</code> set to
3454
+ <code>'A'</code> or <code>'B'</code>, so you see that it was skipped and why.</li>
3455
+ </ul>
3456
+ <p><strong>Every row says how much it saw.</strong> <code>n</code> is the rows the figure
3457
+ covered, and it is on the row because a derived statistic travels: a coefficient exported to
3458
+ CSV or bound to a chart has left every bit of its context behind, and &ldquo;r = 0.98 over
3459
+ eleven rows&rdquo; is a different claim from the same number over eleven thousand.</p>
3460
+ <p><strong>What the row does <em>not</em> tell you: whether the source was windowed.</strong> A
3461
+ statistic over a source holding fewer rows than match its filters is computed on the loaded
3462
+ window rather than the whole set. The grid detects that from the <em>source's</em> own
3463
+ counters and says so in a console warning
3464
+ (<code>[lattice] correlation on &hellip; computed over N of M matching rows</code>) &mdash; and
3465
+ that remains the signal to watch. A derived source cannot reach those counters: a grid's public
3466
+ <code>rows.matchCount()</code> reports the <em>loaded</em> matches, so on a bounded stream
3467
+ evicted to 200 of 2,000 rows it returns 200 and agrees exactly with <code>rows.count()</code>.
3468
+ Rather than ship a flag that could never be true, no such flag is emitted; <code>n</code> says
3469
+ what the figure actually saw and nothing more is claimed.</p>
3470
+ <p><strong>A terminal producer, not a pipeline stage.</strong> A correlation is one row per pair,
3471
+ a series summary one row per metric, a comparison one row per column &mdash; none of which is
3472
+ one row per group, so there is no position in
3473
+ <code>unnest &rarr; where &rarr; bucket &rarr; groupBy &rarr; select &rarr; sort &rarr;
3474
+ limit</code> for <code>statistics</code> to occupy. It replaces the pipeline, exactly as
3475
+ <code>profile</code> does. Those keys are now <strong>ignored with a warning that names
3476
+ them</strong> rather than discarded in silence, for both producers. Sort, filter or limit the
3477
+ derived grid itself, or chain a second derived grid whose <code>from</code> is this one.
3478
+ <code>profile</code> and <code>statistics</code> are mutually exclusive and declaring both is
3479
+ refused, by name, when the source is built.</p>
3480
+
3481
+ <h3 id="derived-producer-cost">What a terminal producer costs</h3>
3482
+ <p><strong>No terminal producer patches incrementally &mdash; know this before you point one at a
3483
+ live feed.</strong> The grouped pipeline maintains its grouping across changes: an edit that
3484
+ names the rows it touched re-reduces only the groups those rows belong to. A producer has no
3485
+ grouping to maintain, so <em>every</em> change on the parent re-derives its whole output. This
3486
+ has always been true of <code>profile</code> and was not previously written down; it is written
3487
+ down here now, and it applies to <code>statistics</code> in the same way.</p>
3488
+ <p>Measured on a synthetic 200,000-row grid, five changes after a warm-up, one derived grid
3489
+ attached (machine-dependent &mdash; run <code>node bench/derived-producers.mjs</code> against
3490
+ your own shape):</p>
3491
+ <table>
3492
+ <thead><tr><th>Derived shape</th><th>Per change at 200k rows</th></tr></thead>
3493
+ <tbody>
3494
+ <tr><td>grouped, no <code>where</code> &mdash; <em>the patching pipeline</em></td><td>~1&nbsp;ms</td></tr>
3495
+ <tr><td><code>profile</code>, one column</td><td>~20&nbsp;ms</td></tr>
3496
+ <tr><td><code>statistics</code> <code>correlation</code>, 2 columns (1 pair)</td><td>~5&nbsp;ms</td></tr>
3497
+ <tr><td><code>statistics</code> <code>correlation</code>, 6 columns (15 pairs)</td><td>~32&nbsp;ms</td></tr>
3498
+ <tr><td><code>statistics</code> <code>series</code></td><td>~23&nbsp;ms</td></tr>
3499
+ <tr><td><code>statistics</code> <code>datasetVsDataset</code>, two grids</td><td>~36&nbsp;ms</td></tr>
3500
+ <tr><td><code>where</code>, no <code>groupBy</code> &mdash; <em>a pipeline shape that also never patches</em></td><td>~700&nbsp;ms</td></tr>
3501
+ </tbody>
3502
+ </table>
3503
+ <p><strong>Correlation is quadratic in its column count.</strong> It scans the rows once per pair,
3504
+ so N columns cost N&middot;(N&minus;1)/2 passes: six columns is fifteen passes, twenty columns is
3505
+ a hundred and ninety. Correlate the columns you mean rather than every numeric column you
3506
+ have.</p>
3507
+ <p><strong>The costs of several panels add up.</strong> Every derived grid attached to a parent
3508
+ re-derives on the same change, so three analysis panels over one grid cost the sum of the
3509
+ three, not the largest. This is BACKLOG-0001044's known gap &mdash; a hidden derived grid still
3510
+ does full read and compute work &mdash; with a larger constant behind it; a hidden analysis tab
3511
+ recomputing a correlation matrix on every tick is exactly that cost.</p>
3512
+ <p><strong>The escape hatch is <code>refresh</code>, and it already exists.</strong>
3513
+ <code>'idle'</code> is the default and coalesces a burst of changes into one derivation on the
3514
+ next frame; a <strong>number</strong> is a debounce in milliseconds; <code>'live'</code>
3515
+ derives on every change and is the one to avoid for an expensive analysis over a ticking feed;
3516
+ <code>'manual'</code> stops automatic derivation entirely, leaving the host to drive the
3517
+ source. Below a few tens of thousands of rows none of this matters.</p>
3518
+
3404
3519
  <h2 id="cross-filter">Cross-filtering</h2>
3405
3520
  <p class="lead-in">
3406
3521
  A derived panel can filter the grid it summarises. Click a region in the summary and the