@toclocoinc/lattice-grid 1.50.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 +477 -11
  3. package/docs/api-detail.html +348 -1
  4. package/lattice-grid.d.ts +399 -10
  5. package/lattice-grid.esm.min.js +619 -69
  6. package/lattice-grid.min.cjs +619 -69
  7. package/lattice-grid.min.js +619 -69
  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 +7 -5
  36. package/modules/data-router.min.cjs +7 -5
  37. package/modules/data-router.min.js +7 -5
  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 +619 -69
  48. package/modules/htmx.min.cjs +619 -69
  49. package/modules/htmx.min.js +619 -69
  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 +752 -35
  54. package/modules/kpi.min.cjs +752 -35
  55. package/modules/kpi.min.js +752 -35
  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 +94 -12
  66. package/modules/tabs.min.cjs +94 -12
  67. package/modules/tabs.min.js +94 -12
  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 +619 -69
  72. package/modules/webcomponent.min.cjs +619 -69
  73. package/modules/webcomponent.min.js +619 -69
  74. package/package.json +1 -1
package/docs/API.html CHANGED
@@ -861,7 +861,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
861
861
  <tr><td class="name">comments</td><td class="type">object</td><td class="desc">Threaded cell comments: storage, the current author, and whether the indicator shows on an unread thread.</td></tr>
862
862
  <tr><td class="name">presence</td><td class="type">object</td><td class="desc">Live cursors, selections and edit locks. Carries intent and never values; see <code>grid.presence</code>.</td></tr>
863
863
  <tr><td class="name">environment</td><td class="type">function</td><td class="desc">Extra fields for the diagnostics bundle: build number, tenant, region. Called when a bundle is taken, never on the render path.</td></tr>
864
- <tr><td class="name">contextMenu</td><td class="type">boolean | (p) =&gt; MenuItem[]</td><td class="desc">Right-click menu. The function form is <code>(params, defaults) =&gt; items</code>: see <a href="#custom-menu">custom items</a>. <code>false</code> suppresses it: what a read-only grid wants, since the default menu offers Paste, Clear and Fill down.</td></tr>
864
+ <tr><td class="name">contextMenu</td><td class="type">boolean | (p) =&gt; MenuItem[]</td><td class="desc">Right-click menu. The function form is <code>(params, defaults) =&gt; items</code>: see <a href="#custom-menu">custom items</a>. <code>false</code> suppresses it: what a read-only grid wants, since the default menu offers Paste, Clear and Fill down. A <strong>column</strong> takes its own <code>contextMenu</code> (also accepting a bare <code>MenuItem[]</code>), which composes onto this one as a chain and outranks it on suppression.</td></tr>
865
865
  <tr><td class="name">columnMenu</td><td class="type">boolean | (p) =&gt; MenuItem[]</td><td class="desc">The header's 3-dot menu, and a right-click on a column heading. The function form is <code>(params, defaults) =&gt; items</code>, with <code>params</code> carrying <code>colId</code>, <code>column</code> and <code>grid</code>: see <a href="#custom-menu">custom items</a>. <code>false</code> suppresses it.</td></tr>
866
866
  <tr><td class="name">rangeChart</td><td class="type">fn | { onChart } | boolean</td><td class="desc">Off by default. Offers <strong>Chart selection</strong> in the cell menu and binds <kbd>Alt</kbd>+<kbd>F1</kbd> when a selected range has a number to plot. The DOM layer draws no charts, so the handler you give — a function, or <code>{ onChart }</code>, called <code>(grid, range)</code> — is where the page wires in <code>chartRange</code> from <a href="#chart-a-range">the charts module</a>.</td></tr>
867
867
  <tr><td class="name">shortcuts</td><td class="type">boolean</td><td class="dflt">true</td><td class="desc">The <kbd>?</kbd> keyboard shortcut overlay. <code>false</code> suppresses it, for a host that wants <kbd>?</kbd> for itself. See <a href="api-detail.html#keyboard">Keyboard</a>.</td></tr>
@@ -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>
@@ -2806,8 +2825,8 @@ grid.destroy();
2806
2825
  <table>
2807
2826
  <thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
2808
2827
  <tbody>
2809
- <tr><td class="name">from</td><td class="type">Grid</td><td class="desc">Required. The grid to read.</td></tr>
2810
- <tr><td class="name">follow</td><td class="type">'filtered' | 'all' | 'selected' | 'grouped'</td><td class="desc">Which of its rows to read. <code>filtered</code> by default. <code>grouped</code> re-aggregates by whatever dimension the user has grouped the source by, so a panel tracks the reader rather than a dimension fixed when the page was built; with the source ungrouped it falls back to <code>groupBy</code>.</td></tr>
2828
+ <tr><td class="name">from</td><td class="type">Grid | UnionSourceOptions[]</td><td class="desc">Required. The grid to read &mdash; or several to combine into one row set before the rest of the pipeline runs (a <em>union</em>; see below). A bare <code>Grid</code> in the array is shorthand for <code>{ grid }</code>.</td></tr>
2829
+ <tr><td class="name">follow</td><td class="type">'filtered' | 'all' | 'selected' | 'grouped'</td><td class="desc">Which of its rows to read. <code>filtered</code> by default. <code>grouped</code> re-aggregates by whatever dimension the user has grouped the source by, so a panel tracks the reader rather than a dimension fixed when the page was built; with the source ungrouped it falls back to <code>groupBy</code>. Ignored (with a warning) when <code>from</code> is a union array &mdash; each entry has its own <code>follow</code> instead.</td></tr>
2811
2830
  <tr><td class="name">unnest</td><td class="type">string</td><td class="desc">Expand an array property, one row per element, keeping the parent's fields. Address the element with a dotted path afterwards: <code>lines.sku</code> is the element, <code>region</code> is still the parent. A row whose property is absent or empty contributes nothing.</td></tr>
2812
2831
  <tr><td class="name">join</td><td class="type">{ with, on, type, select, prefix, follow }</td><td class="desc">Match each row against a second grid on a shared key and bring some of its fields across. Runs after <code>unnest</code> and before <code>where</code>, so a condition (and a grouping, and a total) can read a field the join produced.</td></tr>
2813
2832
  <tr><td class="name">where</td><td class="type">(row) =&gt; boolean</td><td class="desc">A row predicate, applied before grouping. With no <code>groupBy</code> the rows pass through as themselves, which is how an exceptions list is built.</td></tr>
@@ -2825,6 +2844,183 @@ 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>
2903
+ <h3 id="derived-union">Union sources: combining several grids into one</h3>
2904
+ <p class="section-note">
2905
+ "Worst performers across two datasets" is easy when the two datasets share a key: a
2906
+ <code>join</code> brings the second grid's fields onto the first. It is not expressible at all
2907
+ when they do not: incidents from two regions with no shared identifier, orders from two
2908
+ systems, this quarter and last as one ranked list. <code>from</code> takes an array of sources
2909
+ for exactly this: stack several row sets into one, then rank, group or filter the combined set
2910
+ with the same pipeline a single <code>from</code> already runs.
2911
+ </p>
2912
+ <pre><code>source: {
2913
+ mode: 'derived',
2914
+ from: [
2915
+ { grid: eastIncidents, label: 'east' },
2916
+ { grid: westIncidents, label: 'west' },
2917
+ ],
2918
+ sort: [{ col: 'severity', dir: 'desc' }],
2919
+ limit: 10,
2920
+ }</code></pre>
2921
+ <p class="section-note">
2922
+ Every source is read (each narrowed by its own <code>follow</code>, <code>filtered</code> by
2923
+ default) and concatenated <strong>in declaration order</strong>, deterministic rather than
2924
+ interleaved, before <code>unnest</code>/<code>join</code>/<code>where</code>/<code>bucket</code>/
2925
+ <code>groupBy</code>/<code>select</code>/<code>sort</code>/<code>limit</code>/<code>limitPer</code>/
2926
+ <code>cumulative</code> run once over the result &mdash; so "the worst across both" is one
2927
+ derivation, not a hand-merged array.
2928
+ </p>
2929
+ <div class="table-wrap">
2930
+ <table>
2931
+ <thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
2932
+ <tbody>
2933
+ <tr><td class="name">grid</td><td class="type">Grid</td><td class="desc">Required. This source's grid.</td></tr>
2934
+ <tr><td class="name">label</td><td class="type">string</td><td class="desc">Identifies this source. Carried onto every row as <code>__source</code>, and used to namespace that row's <code>__key</code>. Defaults to the source's position in the array (<code>'0'</code>, <code>'1'</code>, &hellip;).</td></tr>
2935
+ <tr><td class="name">follow</td><td class="type">'filtered' | 'all' | 'selected' | 'grouped'</td><td class="desc">Which of this source's rows to read, independent of every other source's. <code>filtered</code> by default.</td></tr>
2936
+ <tr><td class="name">map</td><td class="type">(row) =&gt; unknown</td><td class="desc">Reshape this source's rows into a common shape before they join the rest &mdash; typically a rename or a projection for a field this source calls something else.</td></tr>
2937
+ </tbody>
2938
+ </table>
2939
+ </div>
2940
+ <p class="section-note">
2941
+ <strong><code>__source</code> is required, not optional.</strong> Every row carries it &mdash;
2942
+ the entry's <code>label</code>, or its declaration index when unlabelled &mdash; because without
2943
+ it a combined list cannot be read, filtered or grouped by where it came from, which is most of
2944
+ the point of stacking several sources. It is an ordinary field to <code>where</code>,
2945
+ <code>groupBy</code> and <code>select</code>, exactly like a column the data itself carries.
2946
+ </p>
2947
+ <p class="section-note">
2948
+ <strong>The union of fields, not the intersection.</strong> A field present on only one source
2949
+ is <code>undefined</code> on rows from the others &mdash; not fabricated, not coerced. Sources
2950
+ are <strong>not</strong> type-reconciled: if two disagree on what a field means, <code>map</code>
2951
+ is where you make them agree, before they combine, not something the union guesses at for you.
2952
+ </p>
2953
+ <p class="section-note">
2954
+ <strong>The key is namespaced.</strong> A derived grid's <code>__key</code> is the source row's
2955
+ own key when nothing is grouped, and two sources sharing the same identifiers would otherwise
2956
+ collide. So it is qualified by the source tag when there is no <code>groupBy</code>. Grouped, the
2957
+ key is the group value exactly as it always has been &mdash; rows from different sources landing
2958
+ in the <em>same</em> group when their group values agree is the point of grouping a union, not a
2959
+ collision to guard against.
2960
+ </p>
2961
+ <p class="section-note">
2962
+ <strong>Not a join.</strong> There is no dedup and no merge-on-key: two sources reporting the
2963
+ same fact both appear as separate rows, and there is no UNION-vs-UNION-ALL distinction to draw.
2964
+ Reach for <code>join</code> when two sides share a key and you want them matched rather than
2965
+ stacked; use <code>groupBy</code> on the combined set when you want them summed together.
2966
+ </p>
2967
+ <p class="section-note">
2968
+ <strong>Empty and failing sources.</strong> A source with no matching rows contributes nothing;
2969
+ the rest of the union still derives. A source that throws while being read (or mapped) is named
2970
+ in a <code>warnOnce</code> and skipped for that pass &mdash; reported, never silently dropped,
2971
+ because a silently missing source would make "worst across both" quietly wrong.
2972
+ </p>
2973
+ <p class="section-note">
2974
+ <strong>A cycle is refused, not recursed.</strong> A source list that includes the grid being
2975
+ derived, directly or through a chain of other derived grids, is refused when the source is
2976
+ built, naming the offending source.
2977
+ </p>
2978
+ <p class="section-note">
2979
+ <strong>Not supported alongside a union.</strong> <code>crossFilter</code> has no single target
2980
+ once there is more than one parent, and <code>profile</code> reduces one grid's own columns, so
2981
+ both are refused with a warning rather than guessed at.
2982
+ </p>
2983
+ <pre data-run="js" data-expect="worst=oom-kill,disk-full; sources=east,east,west,west" data-covers="config:map config:grid"><code>const { createHeadlessGrid } = await import('../packages/core/src/index.js');
2984
+
2985
+ const east = createHeadlessGrid({
2986
+ rowKey: 'id',
2987
+ columns: [{ field: 'title' }, { field: 'severity', type: 'number' }],
2988
+ rows: [{ id: 'e1', title: 'disk-full', severity: 9 }, { id: 'e2', title: 'slow-query', severity: 3 }],
2989
+ });
2990
+ <span class="cmt">// A second, unrelated incident log — its own "rating" field, no shared id with `east` at all.</span>
2991
+ const west = createHeadlessGrid({
2992
+ rowKey: 'id',
2993
+ columns: [{ field: 'name' }, { field: 'rating', type: 'number' }],
2994
+ rows: [{ id: 'w1', name: 'oom-kill', rating: 10 }, { id: 'w2', name: 'stale-cache', rating: 2 }],
2995
+ });
2996
+
2997
+ const worst = createHeadlessGrid({
2998
+ columns: [{ field: 'title' }, { field: 'severity', type: 'number' }, { field: '__source' }],
2999
+ source: {
3000
+ mode: 'derived',
3001
+ from: [
3002
+ { grid: east, label: 'east' },
3003
+ <span class="cmt">// `map` brings west's differently-named fields into the common shape.</span>
3004
+ { grid: west, label: 'west', map: (row) =&gt; ({ title: row.name, severity: row.rating }) },
3005
+ ],
3006
+ sort: [{ col: 'severity', dir: 'desc' }],
3007
+ limit: 2,
3008
+ },
3009
+ });
3010
+
3011
+ const titles = [];
3012
+ worst.rows.forEach((r) =&gt; titles.push(worst.rows.value(r.key, 'title')));
3013
+
3014
+ <span class="cmt">// A per-source breakdown reads `__source` like any other field.</span>
3015
+ const bySource = createHeadlessGrid({
3016
+ columns: [{ field: '__source' }],
3017
+ source: { mode: 'derived', from: [{ grid: east, label: 'east' }, { grid: west, label: 'west' }] },
3018
+ });
3019
+ const sources = [];
3020
+ bySource.rows.forEach((r) =&gt; sources.push(bySource.rows.value(r.key, '__source')));
3021
+
3022
+ worst.destroy(); bySource.destroy(); east.destroy(); west.destroy();
3023
+ return `worst=${titles.join(',')}; sources=${sources.join(',')}`;</code></pre>
2828
3024
  <p class="section-note">
2829
3025
  <strong>Read-only.</strong> A derived row is an answer, not a record: there is no write-back for
2830
3026
  the sum of four hundred rows, so writes are refused with a reason rather than accepted and
@@ -3132,7 +3328,7 @@ createGrid(host, { source, columns: [...] });</code></pre>
3132
3328
  <tr><td class="name">url</td><td class="type">string</td><td class="type">—</td><td class="desc">The entity-set endpoint, e.g. <code>https://api.example.com/Orders</code>. Required.</td></tr>
3133
3329
  <tr><td class="name">headers</td><td class="type">Record&lt;string, string&gt;</td><td class="type">{}</td><td class="desc">Sent on every request, merged over <code>Accept: application/json</code>. This is where a fixed bearer token or an API key goes. See <a href="#adapter-auth">authenticating</a>.</td></tr>
3134
3330
  <tr><td class="name">fetch</td><td class="type">typeof fetch</td><td class="type">the global <code>fetch</code></td><td class="desc">Your own fetch, for a token that expires, a proxy, or a non-browser runtime. The adapter bundles no HTTP client. See <a href="#adapter-auth">authenticating</a>.</td></tr>
3135
- <tr><td class="name">count</td><td class="type">boolean</td><td class="type">true</td><td class="desc">Whether to ask for <code>$count=true</code> and read <code>@odata.count</code>. On by default because the grid sizes its scrollbar from the total; set <code>false</code> for a server that does not support it.</td></tr>
3331
+ <tr><td class="name">count</td><td class="type">boolean</td><td class="type">true</td><td class="desc">Whether to ask for <code>$count=true</code> and read <code>@odata.count</code>. On by default because the grid sizes its scrollbar from the total; set <code>false</code> for a server that does not support it, or to spare a server the count for a grid that never shows one. With it off the adapter reports <em>no total</em> and the grid scrolls open-ended — it does not substitute the page length, which before 1.51 told the grid the entity set was exactly one page long. The count travels inline in the same request, so there is nothing to split out: suppression is the only lever OData offers.</td></tr>
3136
3332
  <tr><td class="name">search</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Whether the server implements <code>$search</code>. Off by default, so quick-filter text stays with the grid until you confirm the endpoint honours it; <code>true</code> pushes it as <code>$search</code>.</td></tr>
3137
3333
  <tr><td class="name">edit</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Opt into write-back. Off keeps the source read-only; <code>true</code> advertises <code>mutate: { update: true, delete: true, append: true, returning: 'row' }</code>, so a committed cell edit is persisted with <code>PATCH</code>, a row delete with <code>DELETE /EntitySet(key)</code>, and an add-row with <code>POST /EntitySet</code> reading the created entity back for its server key.</td></tr>
3138
3334
  <tr><td class="name">key</td><td class="type">string</td><td class="type">the row key</td><td class="desc">The key property every write addresses a row by in its entity-key URL segment, e.g. <code>/Orders(&lt;key&gt;)</code>, and that an add-row is rekeyed to from the created entity. Write-back only.</td></tr>
@@ -3216,6 +3412,7 @@ createGrid(host, { source, columns: [...] });</code></pre>
3216
3412
  <tr><td class="name">connection</td><td class="type">object</td><td class="type">—</td><td class="desc">A live connection exposing <code>query</code>, and ideally <code>prepare</code>. Required. A connection without <code>prepare</code> is used only for unfiltered queries, because interpolating a user's filter into SQL is worse than not filtering.</td></tr>
3217
3413
  <tr><td class="name">from</td><td class="type">string</td><td class="type">—</td><td class="desc">A table, a view, or any FROM expression. Required. <code>read_parquet('s3://bucket/*.parquet')</code> is as valid as a table name.</td></tr>
3218
3414
  <tr><td class="name">fields</td><td class="type">string[]</td><td class="type">everything (<code>SELECT *</code>)</td><td class="desc">The columns to select. Name them to narrow the projection when the grid shows a subset of a wide table.</td></tr>
3415
+ <tr><td class="name">count</td><td class="type">boolean</td><td class="type">true</td><td class="desc">Whether to count the matching set at all. On by default: the total comes from a separate <code>count(*)</code> carrying the same <code>WHERE</code>, dispatched alongside the page query — see <a href="#duckdb-total">how the total is counted</a>. Set <code>false</code> for a grid that never shows a count; then no count statement is issued, <code>capabilities.total</code> is <code>false</code>, and the result carries <em>no total</em>, so the grid scrolls open-ended rather than being told the page length is the whole set. That is a trade: with no total the scrollbar is open-ended and <code>grid.scroll.toRow(n)</code> cannot reach a row past the discovered end. See <a href="#duckdb-total">how the total is counted</a>.</td></tr>
3219
3416
  <tr><td class="name">writable</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Allow write-back against a plain writable table. Off keeps the source read-only, so a <code>from</code> that is a view or an expression can never be mutated by accident. Enables <code>update</code>, <code>delete</code> and <code>append</code>.</td></tr>
3220
3417
  <tr><td class="name">keyField</td><td class="type">string</td><td class="type"><code>id</code></td><td class="desc">The key column an update and a delete target in their <code>WHERE</code>, and that an add-row is rekeyed by. Write-back is refused unless this names a real column, because an <code>UPDATE</code>/<code>DELETE</code> without a unique key could touch more than one row.</td></tr>
3221
3418
  <tr><td class="name">returning</td><td class="type">'row' | 'none'</td><td class="type"><code>row</code></td><td class="desc">The reconcile contract for a successful write. <code>row</code> appends <code>RETURNING *</code> and reconciles server truth (computed columns, triggers); <code>none</code> keeps the optimistic value. An add-row always <code>RETURNING</code>s at least the key column regardless, since it needs that key to rekey the temp row.</td></tr>
@@ -3223,6 +3420,89 @@ createGrid(host, { source, columns: [...] });</code></pre>
3223
3420
  </table>
3224
3421
  </div>
3225
3422
 
3423
+ <p class="section-note" id="duckdb-total">
3424
+ <strong>How the total is counted, and what it costs.</strong> The grid needs the size of the
3425
+ matching set to size its scrollbar. Until 1.51 every page query carried
3426
+ <code>count(*) OVER () AS "__lattice_total"</code>, which looks free: the window is evaluated
3427
+ before <code>LIMIT</code>, so one round trip returns both the window and the size of the set it
3428
+ was cut from. Against a local table it is free. Against a remote Parquet it is the most
3429
+ expensive thing the adapter does — a window function has to see every matching row of the
3430
+ <em>projected</em> columns, so row-group pruning and range reads cannot help and the whole file
3431
+ crosses the wire to produce one page. Measured in Chrome on <code>duckdb-eh.wasm</code> against
3432
+ a 10,000,000-row Parquet of 162,386,227&nbsp;bytes — <strong>162.4&nbsp;MB</strong> decimal,
3433
+ 154.9&nbsp;MiB binary, and every transfer figure on this page is decimal MB so that it can be
3434
+ compared with it directly — on an origin counting bytes actually served, first paint pulled
3435
+ 162.5&nbsp;MB. Slightly <em>more</em> than the file, because the reads overlap.
3436
+ </p>
3437
+ <p class="section-note">
3438
+ The total is now its own statement — <code>SELECT count(*) FROM &lt;from&gt; WHERE …</code>,
3439
+ the same predicate through the same builder with the same typed casts and the same bound
3440
+ values, and no <code>ORDER BY</code> or <code>LIMIT</code>. Read it with
3441
+ <code>adapter.countSqlFor(query)</code>, the counting counterpart of
3442
+ <code>adapter.sqlFor(query)</code>; it returns <code>null</code> when <code>count</code> is
3443
+ <code>false</code>. The two statements are <strong>dispatched in the same tick</strong> —
3444
+ both started before either is awaited — so there is no browser round trip between them.
3445
+ </p>
3446
+ <p class="section-note">
3447
+ <strong>That does not make the count free on the clock, and on DuckDB-Wasm it often is not.</strong>
3448
+ One DuckDB-Wasm connection funnels its statements through a single worker, so the engine still
3449
+ runs the two <em>in sequence</em>; what dispatching together buys there is that the second is
3450
+ already queued the instant the first finishes. Measured on the file below: an unfiltered first
3451
+ paint takes 566&nbsp;ms with the count and 558&nbsp;ms without, so the count costs about
3452
+ <strong>8&nbsp;ms</strong> — genuinely hidden. A filtered query takes 2122&nbsp;ms with the
3453
+ count and 573&nbsp;ms without, so there the count costs about <strong>1550&nbsp;ms</strong> and
3454
+ is not hidden at all. (It is still faster than the 3064&nbsp;ms the old window function took for
3455
+ the same query.) A server-side DuckDB with a thread pool runs the two at once and the
3456
+ distinction goes away.
3457
+ </p>
3458
+ <p class="section-note">
3459
+ <strong>Cheap, not free — and on a filtered query, not even cheap.</strong> An
3460
+ <em>unfiltered</em> <code>count(*)</code> over Parquet is answered from the file's footer
3461
+ metadata and reads no data at all: measured against the 162.4&nbsp;MB file above, first paint
3462
+ costs the same 5.71&nbsp;MB with the count on as with <code>count: false</code> — the count's
3463
+ own share is <strong>0.00&nbsp;MB</strong>. A <em>filtered</em> count still has to evaluate the
3464
+ predicate. It reads the predicate columns rather than the whole projection, and row-group
3465
+ statistics can prune entire groups (a predicate no row group can satisfy is answered from
3466
+ metadata alone — <code>country = 'ZZ'</code> against this file costs 0.12&nbsp;MB, count and
3467
+ all). But a predicate whose columns are spread across every row group has to read them all:
3468
+ <code>country = 'GB' AND risk_score &gt; 70</code> costs 45.2&nbsp;MB with the count and
3469
+ 3.2&nbsp;MB without, so <strong>41.9&nbsp;MB of it is the count</strong>. Against the
3470
+ 191.2&nbsp;MB the old window function cost, that is still a 4× saving — and if your grid never
3471
+ shows a count, <code>count: false</code> makes the same query a 59× one.
3472
+ </p>
3473
+ <p class="section-note">
3474
+ <strong>One case where 1.51 transfers more, not less: a session that eventually reads the whole
3475
+ table anyway.</strong> Every individual query above is cheaper than or equal to its 1.50.0
3476
+ counterpart, but a whole <em>session</em> need not be. 1.50.0 dragged the entire file down on
3477
+ first paint in a handful of large sequential reads, after which everything was cache-warm and
3478
+ every later query cost nothing. 1.51 reads lazily, and lazy range reads over a big Parquet
3479
+ <em>overlap</em> where one eager read did not — so bytes already paid for can be paid for again.
3480
+ Measured over one browser session doing first paint, a selective filter, a full-table
3481
+ <code>ORDER BY</code>, a deep page and two more filters, with the counter reset between each:
3482
+ 1.50.0 transferred <strong>162.5&nbsp;MB</strong> in total and 1.51 transferred
3483
+ <strong>209.8&nbsp;MB</strong>. The user who never sorts the whole table pays 46.7&nbsp;MB
3484
+ instead of 162.5&nbsp;MB and sees a first paint in 566&nbsp;ms instead of 5808&nbsp;ms; the user
3485
+ who does sort the whole table has to read the whole file either way, and now pays some of it
3486
+ twice. A full <code>ORDER BY</code> over an unindexed column with <code>SELECT *</code> is
3487
+ unchanged at 162.5&nbsp;MB before and after — this card neither helps nor hurts it.
3488
+ </p>
3489
+ <p class="section-note">
3490
+ <strong>Turning the count off changes what the grid knows, on purpose.</strong> With
3491
+ <code>count: false</code> the adapter reports no total, and the grid does what it already does
3492
+ for any source of unknown length: it scrolls open-ended and discovers the end when a short page
3493
+ arrives. It does <em>not</em> substitute the page length for the total — a page presented as
3494
+ the whole is a wrong number where a right one goes, and every &ldquo;showing X of Y&rdquo;,
3495
+ scrollbar and row count would be wrong with nothing said.
3496
+ </p>
3497
+ <p class="section-note">
3498
+ So <code>count: false</code> is a trade, not a free win, and here is the part you will notice
3499
+ first: <strong>the grid can only scroll as far as it has discovered</strong>. The scrollbar is
3500
+ open-ended rather than proportional, a &ldquo;showing X of Y&rdquo; readout has no Y, and
3501
+ <code>grid.scroll.toRow(n)</code> cannot jump to a row beyond the discovered end — asking for
3502
+ row 900 of a not-yet-discovered million lands at the furthest row known so far, and reaching the
3503
+ real row 900 means paging to it. Turn the count off for a grid whose users scroll; leave it on
3504
+ for one whose users jump.
3505
+ </p>
3226
3506
  <p class="section-note">
3227
3507
  <strong>Typed binding for timestamp and date columns.</strong> A prepared statement binds a
3228
3508
  filter value with the value's own type, not the column's: the grid sends an instant as an
@@ -3292,6 +3572,19 @@ createGrid(host, { source, columns: [...] });</code></pre>
3292
3572
  <code>operators</code> or <code>capabilities</code> for filter or sort only alongside a
3293
3573
  <code>buildQuery</code> that genuinely emits them, or the grid returns the wrong rows silently.
3294
3574
  </p>
3575
+ <p class="section-note">
3576
+ <strong>If your schema does not expose <code>totalCount</code>.</strong> The adapter reports no
3577
+ total, rather than the number of rows in the page, and the grid scrolls open-ended; the
3578
+ endpoint is named once in a warning so the silence is not mistaken for a working count. Before
3579
+ 1.51 the page length was reported as the total, and that was not only a wrong number on screen
3580
+ — it truncated results. When the grid has residual work to finish it asks for the
3581
+ <em>whole</em> result and the adapter walks <code>offset</code>/<code>limit</code> to get it,
3582
+ stopping when it has as many rows as the total says exist. With the total invented from page
3583
+ one, the walk stopped at page one: a 337-row connection came back as 100 rows, reported as
3584
+ complete, and any client-side filter or sort then ran over that fraction. The walk now stops on
3585
+ a short page or an exhausted cursor, so it returns everything and its count is exact. A schema
3586
+ that does report <code>totalCount</code> was never affected.
3587
+ </p>
3295
3588
  <div class="table-wrap">
3296
3589
  <table>
3297
3590
  <thead><tr><th>Option</th><th>Type</th><th>Default</th><th>Meaning</th></tr></thead>
@@ -3304,6 +3597,7 @@ createGrid(host, { source, columns: [...] });</code></pre>
3304
3597
  <tr><td class="name">selection</td><td class="type">string</td><td class="type">— (uses <code>fields</code>)</td><td class="desc">A raw selection set for nested fields, e.g. <code>'id name address { city }'</code>, overriding <code>fields</code>.</td></tr>
3305
3598
  <tr><td class="name">pagination</td><td class="type">'offset' | 'cursor'</td><td class="type"><code>offset</code></td><td class="desc">The default convention: an <code>offset</code>/<code>limit</code> list, or a Relay <code>cursor</code> connection (<code>first</code>/<code>after</code> with <code>pageInfo</code>). A cursor connection is forward-only, so a deep window is paged forward to and costs round trips proportional to its offset.</td></tr>
3306
3599
  <tr><td class="name">pageSize</td><td class="type">number</td><td class="type">1000</td><td class="desc">The page size for the two forward walks: pulling the whole result (when residual work forces it) and walking a cursor connection to a window.</td></tr>
3600
+ <tr><td class="name">count</td><td class="type">boolean</td><td class="type">true</td><td class="desc">Whether the default query asks for <code>totalCount</code>. A <code>totalCount</code> on a connection is rarely free on the server — it is usually a second <code>COUNT(*)</code> over the same predicate — so set <code>false</code> for a grid that never shows a count: the field is dropped from the selection set, <code>capabilities.total</code> becomes <code>false</code>, and the grid scrolls open-ended. Unlike <code>duckdbAdapter</code> the count is <em>not</em> split into a second operation, because over HTTP that would cost an extra round trip rather than saving one. Ignored when you pass your own <code>buildQuery</code>.</td></tr>
3307
3601
  <tr><td class="name">vars</td><td class="type">Partial&lt;Record&lt;'offset'|'limit'|'first'|'after', string&gt;&gt;</td><td class="type">{ offset:'offset', limit:'limit', first:'first', after:'after' }</td><td class="desc">Renames the pagination variables the adapter drives per page, to match the names your schema's arguments use.</td></tr>
3308
3602
  <tr><td class="name">capabilities</td><td class="type">PushdownCapabilities</td><td class="type">{ range:true, total:true, filter:false, sort:false, quick:false }</td><td class="desc">What your <code>buildQuery</code> actually pushes, merged over the defaults. Declaring a capability the hook does not honour returns the wrong rows silently, so the default declares only the window and the total.</td></tr>
3309
3603
  <tr><td class="name">operators</td><td class="type">string[]</td><td class="type">— (filtering off)</td><td class="desc">The comparisons your <code>buildQuery</code> emits, e.g. <code>['eq','gt','contains']</code>. Setting it turns filtering on as a <code>tree</code>; pair it with a <code>buildQuery</code> that translates the condition tree, or the filter is declared but not applied.</td></tr>
@@ -5113,7 +5407,7 @@ return [p.pv, p.ev, p.ac, p.sv, p.cv, p.spi.toFixed(2), p.cpi.toFixed(2), direct
5113
5407
 
5114
5408
  const gantt = createGantt({ tasks, dependencies });
5115
5409
  gantt.mount(document.querySelector('#plan'), {
5116
- 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
5117
5411
  nonWorking: 'weekends', // shade Saturdays and Sundays
5118
5412
  label: 'percent', // bar label: 'name' | 'percent' | 'dates' | (task) =&gt; string
5119
5413
  dateAxis: true, // axis ticks as calendar dates
@@ -5121,11 +5415,25 @@ gantt.mount(document.querySelector('#plan'), {
5121
5415
  scrollToToday: true, // scroll so the today line is in view
5122
5416
  groupBy: 'assignee', // swimlanes by a task property or (task) =&gt; key
5123
5417
  rowHeight: 26,
5124
- 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
5125
5427
  });
5126
5428
  gantt.view.scrollToToday(); // also callable on demand
5127
5429
  gantt.applyEdit({ id: 'design', duration: 7 }); // the view redraws automatically
5128
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>
5129
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>
5130
5438
  <pre><code>import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';
5131
5439
 
@@ -5397,6 +5705,9 @@ const kpi = createKPI(document.querySelector('#kpis'), {
5397
5705
  onTileClick: ({ tile }) =&gt; drillInto(tile.id),
5398
5706
  });</code></pre>
5399
5707
  <p>Each tile names an <strong>aggregation</strong> (<code>sum</code>, <code>avg</code>, <code>min</code>, <code>max</code>, <code>count</code>, <code>countDistinct</code>, or a <code>custom</code> reducer <code>(rows, tile) =&gt; value</code>), the <strong>field</strong> it reads, and an optional <strong>filter</strong> predicate. Formatting (<code>number</code>/<code>currency</code>/<code>percent</code>/<code>compact</code>, with decimals, currency and locale), a <strong>baseline</strong> for a delta, and semantic <strong>threshold bands</strong> (two cut points with a direction, or an explicit <code>bands</code> list) are all per tile; the band is a semantic name (<code>good</code>/<code>warn</code>/<code>critical</code>), separate from any accent colour. An optional <strong>sparkline</strong> plots a <code>{ x, y }</code> series in sequence order. Each tile is a labelled <code>&lt;figure&gt;</code>, focusable and keyboard-activatable, its value announced; the sparkline transition respects <code>prefers-reduced-motion</code>.</p>
5708
+ <p><strong>A panel with no data says so.</strong> A tile's status is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>null</code> (no thresholds) &mdash; or <code>unknown</code>, which means the panel holds <em>no rows at all</em>. That fourth status is decided from data presence <em>before</em> any threshold is consulted, because an aggregation over nothing returns the identity of its operation (<code>sum</code> and <code>count</code> return 0) and 0 is a number a threshold grades: under <code>lowerIsBetter</code> cut points it grades <code>good</code>, so an empty panel would otherwise read as a healthy one. An <code>unknown</code> tile reports a <code>null</code> value, renders the <code>nullText</code> placeholder rather than a zero, and is marked with a dashed edge and a visible &ldquo;No data&rdquo; caption that also forms part of its accessible name &mdash; the state is never carried by colour alone. Because <code>unknown</code> is an explicit status rather than a severity, anything rolling tiles up can surface &ldquo;not measured&rdquo; instead of inheriting a false green.</p>
5709
+ <p><strong>A measured zero is still a measurement.</strong> A tile whose <code>filter</code> matches none of the rows the panel <em>does</em> hold is a different thing: no open incidents is genuinely good, so it reads <code>0</code> and is graded on its thresholds exactly as before. Only an empty panel is <code>unknown</code>.</p>
5710
+ <p><strong>Staleness is a different question, and a time-bounded source answers it.</strong> <code>unknown</code> is about the absence of rows, not their staleness: a panel over an unbounded store keeps showing the last values it was given when its feed goes quiet, because those rows are still there. Give the underlying source a <a href="#sources">rolling time window</a> (<code>maxAge</code> with <code>ageBy</code>) and its rows age out while the feed is silent, so the panel drains and reports <code>unknown</code> the next time it reads them &mdash; a grid-bound panel when the host calls <code>refresh()</code>, a routed one as the removals reach <code>rows.apply</code>.</p>
5400
5711
  <p><strong>Incremental, not recomputed.</strong> Each tile keeps a running accumulator, so a routed <code>rows.apply</code> delta adjusts only the rows it carries &mdash; an add contributes, a remove reverses, an update reverses the old row and contributes the new one &mdash; rather than re-reading the whole dataset per delta. The two bounded exceptions are honest: an extreme (<code>min</code>/<code>max</code>) removed at its current value triggers a rescan of that tile's own value multiset, and a <code>custom</code> reducer is recomputed over the (filtered) store because an arbitrary function has no inverse.</p>
5401
5712
  <div class="table-wrap">
5402
5713
  <table>
@@ -5404,15 +5715,65 @@ const kpi = createKPI(document.querySelector('#kpis'), {
5404
5715
  <tbody>
5405
5716
  <tr><td class="sig">createKPI(el, config)</td><td class="desc">Create a KPI panel. Pass a DOM element to render into, or <code>null</code> for a headless panel that computes the same tile model without a DOM.</td></tr>
5406
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>
5407
- <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.</td></tr>
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>
5408
5719
  <tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or re-seed and recompute.</td></tr>
5409
- <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>
5410
- <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>
5411
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>
5412
5725
  </tbody>
5413
5726
  </table>
5414
5727
  </div>
5415
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>
5416
5777
  <h3 id="kpi-live-example">Live, driven by a Data Router alongside a grid, executed</h3>
5417
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:
5418
5779
  a snapshot seeds the tiles, then a delta removes the current max and the min/max rescans. Run headless on every build.</p>
@@ -5443,6 +5804,31 @@ router.apply([{ op: 'delete', row: { id: 'd2' } }]); <span class="c
5443
5804
 
5444
5805
  router.destroy();
5445
5806
  <span class="kw">return</span> [seeded, afterRemove, band].join(' | ');</code></pre>
5807
+ <h3 id="kpi-no-data-example">An empty panel is <code>unknown</code>, a measured zero is not, executed</h3>
5808
+ <p class="section-note">The same <code>lowerIsBetter</code> thresholds, three states: nothing delivered yet, rows delivered,
5809
+ and a tile whose filter matches none of the rows the panel holds. Run headless on every build.</p>
5810
+ <pre data-run="js" data-expect="unknown null | good 2 | good 0" data-covers="export:createKPI"><code><span class="kw">const</span> { createKPI } = <span class="kw">await</span> import('../packages/modules/kpi/index.js');
5811
+
5812
+ <span class="kw">const</span> fewerIsBetter = { warn: 10, critical: 25, direction: 'lowerIsBetter' };
5813
+ <span class="kw">const</span> kpi = createKPI(null, {
5814
+ rows: [], rowKey: 'id',
5815
+ tiles: [
5816
+ { id: 'errors', label: 'Errors', aggregation: 'count', thresholds: fewerIsBetter },
5817
+ { id: 'sev1', label: 'Sev-1', aggregation: 'count',
5818
+ filter: (r) =&gt; r.severity === 1, thresholds: fewerIsBetter },
5819
+ ],
5820
+ });
5821
+
5822
+ <span class="cmt">// Nothing has arrived: not a healthy zero, and no number to show.</span>
5823
+ <span class="kw">const</span> empty = kpi.tile('errors').status + ' ' + kpi.tile('errors').value; <span class="cmt">// unknown null</span>
5824
+
5825
+ kpi.rows.apply({ add: [{ id: 'e1', severity: 3 }, { id: 'e2', severity: 2 }] });
5826
+ <span class="kw">const</span> measured = kpi.tile('errors').status + ' ' + kpi.tile('errors').value; <span class="cmt">// good 2</span>
5827
+
5828
+ <span class="cmt">// Its filter matched none of those rows &mdash; but the panel holds rows, so 0 is a reading.</span>
5829
+ <span class="kw">const</span> realZero = kpi.tile('sev1').status + ' ' + kpi.tile('sev1').value; <span class="cmt">// good 0</span>
5830
+
5831
+ <span class="kw">return</span> [empty, measured, realZero].join(' | ');</code></pre>
5446
5832
 
5447
5833
  <h2 id="ai">The AI narrative / insights layer</h2>
5448
5834
  <p><code>modules/ai</code> is an opt-in layer that produces a short, plain-language <strong>narrative</strong> of the grid's <em>computed</em> figures &mdash; a per-KPI / per-chart / per-column &ldquo;Explain&rdquo;, or an insights panel over the current (filtered) view. It is a separate bundle that adds no weight to a page that does not load it, changes nothing in grid core, and pulls in no dependency. <strong>The grid makes no AI call of its own:</strong> <code>createAI</code> never imports a provider SDK, never reads a key, and never makes a network request. It calls one async callback you supply, <code>ask()</code> &mdash; your model, your key, your privacy decision &mdash; exactly the philosophy of the data adapters, where auth and transport are always the caller's.</p>
@@ -5751,6 +6137,33 @@ createGrid(el, {
5751
6137
  Returning an empty array suppresses the menu; returning nothing at all leaves the defaults
5752
6138
  alone, so a missing <code>return</code> cannot silently delete the menu.</p>
5753
6139
 
6140
+ <p>The same option is accepted <strong>on a column definition</strong>, so a column's menu is
6141
+ declared where the column is rather than as one more branch inside a single grid-level callback.
6142
+ It takes the same shapes plus a bare array for the common &ldquo;just these items here&rdquo;
6143
+ case: <code>boolean | MenuItem[] | (params, defaults) =&gt; items</code>.</p>
6144
+ <pre><code>createGrid(el, {
6145
+ columns: [
6146
+ { field: 'owner', contextMenu: [{ name: 'Reassign', action: reassign }] },
6147
+ { field: 'amount', contextMenu: (p, defaults) =&gt; [...defaults, { name: 'Reprice', action: reprice }] },
6148
+ { field: 'nationalId', contextMenu: <span class="kw">false</span> }, <span class="cmt">// no menu on this column, others unaffected</span>
6149
+ ],
6150
+ });</code></pre>
6151
+ <p>The three levels <strong>compose as a chain</strong>: built-in defaults, then the grid-level
6152
+ <code>contextMenu</code>, then the column's &mdash; each handed the previous result as its
6153
+ <code>defaults</code>, so a column adding one item never restates the built-ins. Suppression
6154
+ follows the same order and the more specific level wins: <code>false</code> on a column is a
6155
+ statement about that column alone. <strong>The reverse holds too, and is worth knowing before you
6156
+ rely on grid-level <code>contextMenu: false</code> as a safety property: a column that declares
6157
+ its own <code>contextMenu</code> opens one anyway.</strong> Grid-level <code>false</code> is a
6158
+ default, not a lock &mdash; it is what makes &ldquo;no menu anywhere except here&rdquo;
6159
+ expressible. On a right-click inside a multi-column selection
6160
+ the <strong>clicked</strong> column's menu is the one that opens &mdash; not the intersection,
6161
+ which loses items, and not the union, which offers actions wrong for most of the selection. A
6162
+ group row, pivot group row or full-width row belongs to no column, so the chain has one link
6163
+ fewer and the grid-level menu stands. The keyboard routes (<kbd>Shift</kbd>+<kbd>F10</kbd> and
6164
+ the <kbd>Context&nbsp;Menu</kbd> key) honour the column exactly as the pointer does. See
6165
+ <a href="api-detail.html#per-column-menu">the guide</a> for the full table of combinations.</p>
6166
+
5754
6167
  <p><code>columnMenu</code> takes the same form for the header's menu: both the 3-dot button
5755
6168
  and a right-click on a heading. Its <code>params</code> is
5756
6169
  <code>{ colId, column, grid }</code>. Anything of your own that you put on a column definition
@@ -7152,6 +7565,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7152
7565
  <tr><td class="name">spec</td><td class="type">{ lower?: number; upper?: number; target?: number }</td><td class="desc">The customer's tolerance, for process capability and control charts. Declared here rather than passed to each call so the capability figures, a control chart and any rule marking an out-of-tolerance cell cannot disagree about what the tolerance is. <small>(optional)</small></td></tr>
7153
7566
  <tr><td class="name">layout</td><td class="type">ColumnLayoutSpec | number</td><td class="desc">Width, pinning and flex. A bare number is the width in pixels. <small>(optional)</small></td></tr>
7154
7567
  <tr><td class="name">header</td><td class="type">ColumnHeaderSpec | string</td><td class="desc">The header cell: its text, tooltip, menu and any header chart. <small>(optional)</small></td></tr>
7568
+ <tr><td class="name">contextMenu</td><td class="type">boolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) =&gt; MenuItem[] | void)</td><td class="desc">The cell right-click menu for this column alone (BACKLOG-0001068), in the same shapes the grid-level `contextMenu` takes plus a bare array for the common "just these items here" case. Declared where the column is declared rather than as another branch inside one grid-level callback: the menu logic for a column belongs beside the column it belongs to. It does not replace the grid-level menu — the three levels compose as a chain, built-in defaults then grid-level then this one, each handed the previous result as its `defaults`, so a column adding one item does not have to restate Paste, Clear and Fill down. `false` suppresses the menu on this column and leaves every other column alone: what a sensitive or read-only column wants. The more specific level wins, so a column may also declare a menu on a grid whose `contextMenu` is `false`. <small>(optional)</small></td></tr>
7155
7569
  <tr><td class="name">headerControls</td><td class="type">'hover' | 'always' | 'hidden'</td><td class="desc">When this column's header controls — its sort arrow, filter funnel and menu button — are shown, overriding the grid-level `headerControls` default for this column alone (BACKLOG-0000982). `'hover'` reveals them on hover or focus, `'always'` keeps them visible, `'hidden'` draws none of them and leaves them out of the tab order. Omitted, the column follows the grid default, which is itself `'hover'`. <small>(optional)</small></td></tr>
7156
7570
  <tr><td class="name">verticalAlign</td><td class="type">VAlign</td><td class="desc">Vertical alignment of this column's cell content within the row (BACKLOG-0000989). Overrides the grid-level `verticalAlign` for this column alone; `top`, `middle` or `bottom`. Also accepted as `cell.verticalAlign`, the way `align` is. Omitted, the column follows the grid default. <small>(optional)</small></td></tr>
7157
7571
  <tr><td class="name">export</td><td class="type">ColumnExportSpec</td><td class="desc">How the column leaves the grid, where that differs from how it is shown. <small>(optional)</small></td></tr>
@@ -7769,6 +8183,30 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7769
8183
  </tbody>
7770
8184
  </table>
7771
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>
7772
8210
  <h3 id="type-DerivedJoin">DerivedJoin</h3>
7773
8211
  <div class="table-wrap">
7774
8212
  <table>
@@ -7794,6 +8232,19 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7794
8232
  </tbody>
7795
8233
  </table>
7796
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>
7797
8248
  <h3 id="type-DerivedSourceConfig">DerivedSourceConfig</h3>
7798
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>
7799
8250
  <div class="table-wrap">
@@ -7801,8 +8252,8 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7801
8252
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
7802
8253
  <tbody>
7803
8254
  <tr><td class="name">mode</td><td class="type">'derived'</td><td class="desc"></td></tr>
7804
- <tr><td class="name">from</td><td class="type">Grid</td><td class="desc">The grid to read.</td></tr>
7805
- <tr><td class="name">follow</td><td class="type">'filtered' | 'all' | 'selected' | 'grouped'</td><td class="desc">Which of its rows to read. `filtered` by default. <small>(optional)</small></td></tr>
8255
+ <tr><td class="name">from</td><td class="type">Grid | UnionSourceOptions[]</td><td class="desc">The grid to read, or several to combine into one row set before the rest of the pipeline runs (BACKLOG-0001045). A bare `Grid` is shorthand for a `UnionSourceOptions` with no `label`/`follow`/`map` override, so an existing `from: &lt;grid&gt;` keeps meaning exactly what it always has. Given an array, every source is read (each narrowed by its own `follow`, defaulting to `'filtered'` as a lone `from` does today), concatenated in **declaration order** — deterministic, not interleaved — and only then does `unnest`/`join`/`where`/`bucket`/`groupBy`/`select`/`sort`/`limit`/ `limitPer`/`cumulative` run, over the combined set, so "the worst performers across both" is one derivation rather than a hand-merge. The output carries the **union of the sources' fields**: a field present on only one source is `undefined` on rows from the others. Sources are **not** type-reconciled — if two disagree on what a field means or holds, that is not resolved for you; give each source a `map` to project it into a common shape first. Every row also carries `__source` (the entry's `label`, or its declaration index when unlabelled), which is required — not optional — because without it a combined list cannot be read, filtered or grouped by where it came from; it is an ordinary field to `where`, `groupBy` and `select`. And because the derived key (`__key`) would otherwise collide across sources sharing the same identifiers, it is namespaced by the same source tag when nothing is grouped (a grouped union's `__key` is the group value, exactly as today, and rows from different sources correctly land in the *same* group when their group values agree — that merging is the point of grouping a union, not a collision to guard against). This is **not** a join: there is no dedup or merge-on-key, and it draws no UNION/UNION ALL distinction — overlapping rows from two sources simply both appear. Reach for `join` when two sides share a key and you want them matched rather than stacked. An empty source contributes nothing and the rest still combine; a source that fails to read is named in a `warnOnce` and skipped for that pass rather than silently dropped, because a silently missing source would make "worst across both" quietly wrong. A source list that includes the grid being derived, directly or through a chain, is refused when the source is built (naming the offender) rather than recursed into. `crossFilter` has no single target once there is more than one parent, so it is not supported alongside a union `from` (ignored, with a `warnOnce`, rather than guessing which parent to push onto).</td></tr>
8256
+ <tr><td class="name">follow</td><td class="type">'filtered' | 'all' | 'selected' | 'grouped'</td><td class="desc">Which of its rows to read. `filtered` by default. Ignored — with a `warnOnce` — when `from` is a union array: each entry there carries its own `follow` instead (BACKLOG-0001045). <small>(optional)</small></td></tr>
7806
8257
  <tr><td class="name">unnest</td><td class="type">string</td><td class="desc">An array property to expand, one row per element, before anything else. <small>(optional)</small></td></tr>
7807
8258
  <tr><td class="name">join</td><td class="type">DerivedJoin</td><td class="desc">Match each row against a second grid on a shared key, and bring some of its fields across. Runs after `unnest` and before `where`, so a condition: and a grouping, and a total: can read a field the join produced. <small>(optional)</small></td></tr>
7808
8259
  <tr><td class="name">where</td><td class="type">(row: unknown) =&gt; boolean</td><td class="desc">A row predicate, applied before grouping. <small>(optional)</small></td></tr>
@@ -7815,6 +8266,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
7815
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>
7816
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>
7817
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>
7818
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>
7819
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>
7820
8272
  </tbody>
@@ -9610,6 +10062,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
9610
10062
  <tr><td class="name">grandTotal</td><td class="type">TotalName | TotalFn | null</td><td class="desc">The grand-total override, or null when the grand total follows `total` (BACKLOG-0000726).</td></tr>
9611
10063
  <tr><td class="name">layout</td><td class="type">ColumnLayoutSpec</td><td class="desc"></td></tr>
9612
10064
  <tr><td class="name">header</td><td class="type">ColumnHeaderSpec</td><td class="desc"></td></tr>
10065
+ <tr><td class="name">contextMenu</td><td class="type">boolean | MenuItem[] | ((p: CellMenuParams, defaults: MenuItem[]) =&gt; MenuItem[] | void) | null</td><td class="desc">This column's own cell-menu declaration (BACKLOG-0001068), or null when it makes none and the grid-level menu stands alone. Carried onto the resolved column so a column preset or `columnDefaults` can supply one.</td></tr>
9613
10066
  <tr><td class="name">export</td><td class="type">ColumnExportSpec</td><td class="desc"></td></tr>
9614
10067
  <tr><td class="name">lookup</td><td class="type">LookupSpec | null</td><td class="desc"></td></tr>
9615
10068
  <tr><td class="name">allowGroup</td><td class="type">boolean</td><td class="desc"></td></tr>
@@ -10065,6 +10518,19 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
10065
10518
  </tbody>
10066
10519
  </table>
10067
10520
  </div>
10521
+ <h3 id="type-UnionSourceOptions">UnionSourceOptions</h3>
10522
+ <p class="section-note">One member of a union `from` (BACKLOG-0001045): a grid to combine with the others, plus how to read it and reshape it before it joins the rest. A bare `Grid` in the `from` array is shorthand for `{ grid }` with every other field defaulted.</p>
10523
+ <div class="table-wrap">
10524
+ <table>
10525
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
10526
+ <tbody>
10527
+ <tr><td class="name">grid</td><td class="type">Grid</td><td class="desc">The grid this source reads.</td></tr>
10528
+ <tr><td class="name">label</td><td class="type">string</td><td class="desc">Identifies this source: it is what `__source` carries on every row this source contributes, and what namespaces that row's `__key` so two sources sharing the same identifiers do not collide. Defaults to the source's position in the `from` array (`'0'`, `'1'`, …), as a string. <small>(optional)</small></td></tr>
10529
+ <tr><td class="name">follow</td><td class="type">'filtered' | 'all' | 'selected' | 'grouped'</td><td class="desc">Which of this source's rows to read. `filtered` by default, exactly as a lone `from` follows its grid today — set independently per source, so filtering one narrows only its own contribution. <small>(optional)</small></td></tr>
10530
+ <tr><td class="name">map</td><td class="type">(row: unknown) =&gt; unknown</td><td class="desc">Reshape this source's rows into the common shape before they join the rest — typically a rename or a projection, for a field this source calls something else. Not a type coercion: if a field means something different on two sources, `map` is where you make them agree, because the union itself does not guess. <small>(optional)</small></td></tr>
10531
+ </tbody>
10532
+ </table>
10533
+ </div>
10068
10534
  <h3 id="type-UnitConfig">UnitConfig</h3>
10069
10535
  <p class="section-note">How a column stores, parses and renders a quantity.</p>
10070
10536
  <div class="table-wrap">