@toclocoinc/lattice-grid 1.17.0 → 1.18.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.
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.17.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.18.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -913,7 +913,9 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
913
913
  <tr><td class="name">filter</td><td class="type">ColumnFilterSpec | boolean | FilterName</td><td class="desc"></td></tr>
914
914
  <tr><td class="name">group</td><td class="type">object | boolean</td><td class="desc"><code>{ enabled, index, explode }</code>.</td></tr>
915
915
  <tr><td class="name">pivot</td><td class="type">object | boolean</td><td class="desc"><code>{ enabled, index }</code>.</td></tr>
916
- <tr><td class="name">total</td><td class="type">TotalName | TotalFn</td><td class="desc">One property drives the group row, the tree node, the pivot cell and the grand total.</td></tr>
916
+ <tr><td class="name">total</td><td class="type">TotalName | TotalFn</td><td class="desc">One property drives the group row, the tree node, the pivot cell and the grand total. Split it per scope with <code>groupTotal</code> / <code>grandTotal</code> when the subtotals and the grand total should reduce differently.</td></tr>
917
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for group subtotals — group footers, tree-node rollups and pivot cells — where it should differ from the grand total. Overrides <code>total</code> for those scopes only; omitted, <code>total</code> applies.</td></tr>
918
+ <tr><td class="name">grandTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for the pinned grand-total row, where it should differ from the subtotals. Overrides <code>total</code> for the grand total only; omitted, <code>total</code> applies.</td></tr>
917
919
  <tr><td class="name">layout</td><td class="type">ColumnLayoutSpec | number</td><td class="desc">A bare number is the width.</td></tr>
918
920
  <tr><td class="name">header</td><td class="type">ColumnHeaderSpec | string</td><td class="desc"></td></tr>
919
921
  <tr><td class="name">export</td><td class="type">ColumnExportSpec</td><td class="desc"><code>{ lookup: 'label' | 'value' | 'columns', csv, excel }</code>.</td></tr>
@@ -1073,7 +1075,7 @@ grid.overlay.hide();</code></pre>
1073
1075
  <tr><td class="sig">group(ids)</td><td class="type">void</td><td class="desc">Set the row-group columns, in order.</td></tr>
1074
1076
  <tr><td class="sig">pivot(ids)</td><td class="type">void</td><td class="desc"></td></tr>
1075
1077
  <tr><td class="sig">totals(ids)</td><td class="type">void</td><td class="desc">Which columns carry an aggregation.</td></tr>
1076
- <tr><td class="sig">setTotal(id, fn)</td><td class="type">void</td><td class="desc">Change one column's aggregation. <code>null</code> stops totalling it. A named total the column's type says is meaningless is refused (§9.4), the same way it is at configuration.</td></tr>
1078
+ <tr><td class="sig">setTotal(id, fn, opts?)</td><td class="type">void</td><td class="desc">Change one column's aggregation. <code>null</code> stops totalling it. A named total the column's type says is meaningless is refused (§9.4), the same way it is at configuration. With no <code>opts</code>, <code>fn</code> becomes the shared <code>total</code> and clears any group/grand overrides; pass <code>{ scope: 'group' }</code> or <code>{ scope: 'grand' }</code> to set the group subtotals and the grand total independently (a scope with no override follows <code>total</code>).</td></tr>
1077
1079
  <tr><td class="sig">aggregates(id)</td><td class="type">TotalName[]</td><td class="desc">The aggregate names meaningful for a column, honouring its type's declaration — what the aggregate chooser offers.</td></tr>
1078
1080
  <tr><td class="sig">distinct(id)</td><td class="type">unknown[]</td><td class="desc">Distinct values, read from the dictionary rather than by scanning rows.</td></tr>
1079
1081
  <tr><td class="sig">state()</td><td class="type">ColumnState[]</td><td class="desc">Serialisable column state.</td></tr>
@@ -2850,6 +2852,53 @@ app.get('/api/orders', async (req, res) =&gt; {
2850
2852
  <span class="kw">const</span> held = block.total; <span class="cmt">// 100: the whole set is held, not the 10-row window</span>
2851
2853
  <span class="kw">return</span> all.reduce((s, r) =&gt; s + r.amount, 0) / held; <span class="cmt">// 49.5, the true whole-dataset mean</span></code></pre>
2852
2854
 
2855
+ <h4 id="pushdown-allowpartialresults">Refusing a partial result: <code>allowPartialResults</code></h4>
2856
+ <p class="section-note">
2857
+ When a query has residual work — a filter, sort or quick search the engine could not push — the
2858
+ source asks the adapter for the <em>whole</em> matching set, applies the residual here, and pages
2859
+ from what it holds. If the adapter instead returns a <strong>page</strong> of that result (it
2860
+ paged when told not to), the client-side filter or sort runs over the wrong rows: the rows that
2861
+ belong on page one may be in the fraction that was never fetched, so a page is presented as the
2862
+ full filtered set. That is a wrong answer, not a slow one.
2863
+ </p>
2864
+ <p class="section-note">
2865
+ By default the source <strong>refuses</strong> such a shortfall — it throws, and the remote
2866
+ source surfaces a <code>source:error</code> with no rows shown, rather than filter a fraction
2867
+ and lie. The fix is to make the adapter follow the engine's own paging before returning, or hold
2868
+ the data in memory. <code>allowPartialResults: true</code> is the knowing escape hatch: a caller
2869
+ who accepts the permissive behaviour — an adapter that genuinely cannot page and a result small
2870
+ enough not to matter, or a diagnostic run — keeps the old warn-once-and-proceed path. It is
2871
+ off by default, because a silent wrong answer is the one thing the design refuses. It does not
2872
+ affect the <code>fullDataset</code> memory guard, nor the no-residual short-return warning.
2873
+ </p>
2874
+ <div class="table-wrap">
2875
+ <table>
2876
+ <thead><tr><th>Key</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
2877
+ <tbody>
2878
+ <tr><td class="name">allowPartialResults</td><td class="type">boolean</td><td class="desc"><code>false</code></td><td class="desc">Accept a partial/paged result to a whole-set request that residual work will filter over, keeping the warn-once-and-proceed behaviour instead of refusing. Off by default: the shortfall is thrown.</td></tr>
2879
+ </tbody>
2880
+ </table>
2881
+ </div>
2882
+ <pre data-run="js" data-expect="refused; opted in: 1 rows" data-covers="config:allowPartialResults"><code><span class="cmt">// An eq-only engine: the `gt` filter is residual and runs in the browser. The</span>
2883
+ <span class="cmt">// adapter reports 40 matching but returns only 1 row — a page shown as the whole.</span>
2884
+ <span class="kw">const</span> { createPushdownSource } = <span class="kw">await</span> import('../packages/core/src/source/pushdown.js');
2885
+ <span class="kw">const</span> adapter = {
2886
+ name: 'shortfall',
2887
+ capabilities: { filter: 'tree', operators: ['eq'] },
2888
+ execute: <span class="kw">async</span> () =&gt; ({ rows: [{ id: 1, a: 5 }], total: 40 }),
2889
+ };
2890
+ <span class="kw">const</span> req = { range: { start: 0, end: 10 }, filters: { col: 'a', op: 'gt', value: 1 }, sort: [], quick: '' };
2891
+
2892
+ <span class="cmt">// Default: refused. Filtering 1 of 40 rows would return the wrong rows.</span>
2893
+ <span class="kw">const</span> strict = createPushdownSource({ adapter });
2894
+ <span class="kw">let</span> refused = false;
2895
+ <span class="kw">try</span> { <span class="kw">await</span> strict.fetch(req); } <span class="kw">catch</span> (e) { refused = /returned 1 of 40|refused/.test(e.message); }
2896
+
2897
+ <span class="cmt">// Knowing opt-in: warns once and proceeds with the fraction.</span>
2898
+ <span class="kw">const</span> lax = createPushdownSource({ adapter, allowPartialResults: true });
2899
+ <span class="kw">const</span> block = <span class="kw">await</span> lax.fetch(req);
2900
+ <span class="kw">return</span> refused ? `refused; opted in: ${block.rows.length} rows` : 'not refused';</code></pre>
2901
+
2853
2902
  <h4 id="pushdown-aggregates">Pushing statistics down: <code>aggregates</code></h4>
2854
2903
  <p class="section-note">
2855
2904
  A DuckDB-class engine can compute a median or a standard deviation over the whole matching set
@@ -2921,6 +2970,16 @@ app.get('/api/orders', async (req, res) =&gt; {
2921
2970
  <tr><td class="name">harmean</td><td class="type">IDENTICAL</td><td class="desc"><code>(count(col) / sum(1.0 / col))</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2922
2971
  <tr><td class="name">entropy</td><td class="type">IDENTICAL</td><td class="desc"><code>entropy(col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2923
2972
  <tr><td class="name">correlation</td><td class="type">IDENTICAL</td><td class="desc"><code>corr(weight, col)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2973
+ <tr><td class="name">hhi</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_transform(map_values(histogram(col)), lambda v: (v::DOUBLE/count(col))*(v::DOUBLE/count(col)))) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2974
+ <tr><td class="name">evenness</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL WHEN count(DISTINCT col)&lt;2 THEN 1.0 ELSE entropy(col)/log2(count(DISTINCT col)) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2975
+ <tr><td class="name">top3Share</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,3))::DOUBLE/count(col) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2976
+ <tr><td class="name">top10Share</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN count(col)=0 THEN NULL ELSE list_sum(list_slice(list_sort(map_values(histogram(col)),'DESC'),1,10))::DOUBLE/count(col) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2977
+ <tr><td class="name">gini</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN list_min(list(col) FILTER (WHERE isfinite(col)))&lt;0 THEN NULL WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL WHEN list_sum(list(col) FILTER (WHERE isfinite(col)))=0 THEN 0 ELSE 2.0*list_sum(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v, i: i*v))/(len(list(col) FILTER (WHERE isfinite(col)))*list_sum(list(col) FILTER (WHERE isfinite(col))))-(len(list(col) FILTER (WHERE isfinite(col)))+1.0)/len(list(col) FILTER (WHERE isfinite(col))) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2978
+ <tr><td class="name">trimmedMean</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_slice(list_sort(list(col) FILTER (WHERE isfinite(col))), floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1, len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT)) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2979
+ <tr><td class="name">winsorizedMean</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN len(list(col) FILTER (WHERE isfinite(col)))=0 THEN NULL ELSE list_avg(list_transform(list_sort(list(col) FILTER (WHERE isfinite(col))), lambda v: least(list_sort(list(col) FILTER (WHERE isfinite(col)))[len(list(col) FILTER (WHERE isfinite(col)))-floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT], greatest(list_sort(list(col) FILTER (WHERE isfinite(col)))[floor(len(list(col) FILTER (WHERE isfinite(col)))*0.1)::BIGINT+1], v)))) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2980
+ <tr><td class="name">robustOutliers</td><td class="type">IDENTICAL</td><td class="desc"><code>(SELECT CASE WHEN len(d.a)=0 THEN NULL WHEN d.mad=0 THEN NULL ELSE len(list_filter(d.a, lambda v: abs(0.6745*(v-d.med)/d.mad)&gt;3.5)) END FROM (SELECT xs AS a, list_median(xs) AS med, list_median(list_transform(xs, lambda w: abs(w-list_median(xs)))) AS mad FROM (SELECT list(col) FILTER (WHERE isfinite(col)) AS xs)) d)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2981
+ <tr><td class="name">jarqueBera</td><td class="type">IDENTICAL</td><td class="desc"><code>CASE WHEN len(list(col) FILTER (WHERE isfinite(col)))&lt;8 THEN NULL WHEN list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2)))=0 THEN NULL ELSE (len(list(col) FILTER (WHERE isfinite(col)))/6.0)*(power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),3)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),1.5),2)+power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),4)))/power(list_avg(list_transform(list(col) FILTER (WHERE isfinite(col)), lambda v: power(v-list_avg(list(col) FILTER (WHERE isfinite(col))),2))),2)-3,2)/4.0) END</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2982
+ <tr><td class="name">weightedAvg</td><td class="type">IDENTICAL</td><td class="desc"><code>sum(col*weight) FILTER (WHERE isfinite(col) AND isfinite(weight))/nullif(sum(weight) FILTER (WHERE isfinite(col) AND isfinite(weight)),0)</code></td><td class="desc">Pushes to DuckDB with the same result.</td></tr>
2924
2983
  <tr><td class="name">mode</td><td class="type">MAY-DIFFER</td><td class="desc"><code>mode(col)</code></td><td class="desc">DuckDB returns a modal value even for an all-distinct column; the grid returns null. Tie-breaking can also differ, and on a text column an empty string counts as a value. Under pushdown you will see a value.</td></tr>
2925
2984
  <tr><td class="name">weightedQuantile</td><td class="type">FALLBACK</td><td class="desc">&mdash;</td><td class="desc">No SQL equivalent for the grid's weighted-quantile midpoint convention; always computed client-side (needs a full-dataset pull for a correct figure over a remote source).</td></tr>
2926
2985
  </tbody>
@@ -3318,6 +3377,20 @@ createGrid(el, {
3318
3377
  <p>A unit given <code>{ auto: false }</code> stays off the <code>display: 'auto'</code> ladder while remaining accepted on input and available as an explicit <code>display</code>. That is how imperial units sit beside metric ones without an auto readout jumping between the two.</p>
3319
3378
  <div class="note"><p><strong>The stored value is always a plain number in the column's own unit.</strong> Sorting, filtering, grouping, totals and the pivot all read that number and never the text, which is why <code>250mm</code> sorts below <code>1.5 cm</code> correctly rather than <code>1</code> sorting before <code>9</code>. <code>registerUnitSystem</code> is global and throws on a duplicate name, so register each system once at startup rather than inside a component that may mount twice.</p></div>
3320
3379
 
3380
+ <h3 id="compound-units">Compound display: <code>5 ft 11 in</code>, <code>1 h 23 m</code></h3>
3381
+ <p><code>compound</code> renders one stored number across an ordered subset of the system's units. It is <strong>display and parse only</strong>: the value stays a single base-unit number, so sort, filter, group and total are the same arithmetic they always were. The order is free &mdash; the units are sorted largest to smallest &mdash; and the smallest one carries any remainder. Parsing sums the parts, so a paste of <code>5 ft 11 in</code> round-trips, and a single <code>71 in</code> or a bare <code>6</code> still work.</p>
3382
+ <pre data-run="js" data-expect="5 ft 11 in | 1.8034 | true" data-covers="config:compound"><code><span class="kw">const</span> { formatUnit, parseUnit } = <span class="kw">await</span> import('../packages/core/src/columns/types/unit.js');
3383
+
3384
+ <span class="cmt">// A height column stored in metres, displayed as feet and inches.</span>
3385
+ <span class="kw">const</span> cfg = { system: 'length', unit: 'm', compound: ['ft', 'in'], locale: 'en-GB' };
3386
+
3387
+ <span class="kw">const</span> shown = formatUnit(1.8034, cfg); <span class="cmt">// across the two units</span>
3388
+ <span class="kw">const</span> stored = parseUnit('5 ft 11 in', cfg); <span class="cmt">// summed back to metres</span>
3389
+ <span class="kw">const</span> stable = formatUnit(parseUnit(shown, cfg), cfg) === shown; <span class="cmt">// round-trips</span>
3390
+
3391
+ <span class="kw">return</span> `${shown} | ${stored} | ${stable}`;</code></pre>
3392
+ <div class="note"><p>The compound units need not include the stored <code>unit</code>, and any unit of the system is accepted on input: <code>71 in</code> pasted into a feet-and-inches column is still 71 inches. Excel export and <code>display: 'auto'</code> share a rule here &mdash; a column of mixed-scale text is not summable in a spreadsheet, so the export uses the raw base number on the configured unit. The <strong>compound cell editor</strong> (mid-value keystrokes, roll-over between feet and inches, caret behaviour at a boundary) is a separate, later piece; this is the read-and-paste half.</p></div>
3393
+
3321
3394
  <h2 id="stat">The statistic block</h2>
3322
3395
  <p>
3323
3396
  <code>createStat</code> draws the tile a dashboard opens with: a label, a value, its change
@@ -4427,6 +4500,7 @@ grid.destroy();
4427
4500
  <tr><td class="name">class</td><td class="type">'identical' | 'may-differ' | 'fallback'</td><td class="desc">How the engine result relates to the grid kernel.</td></tr>
4428
4501
  <tr><td class="name">reason</td><td class="type">string</td><td class="desc">Why it is client-side, when it is (config, fallback, or the guard). <small>(optional)</small></td></tr>
4429
4502
  <tr><td class="name">weight</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
4503
+ <tr><td class="name">params</td><td class="type">Record&lt;string, unknown&gt;</td><td class="desc">Parameters the statistic takes, carried through so an adapter emits the matching SQL (e.g. a trim share). <small>(optional)</small></td></tr>
4430
4504
  </tbody>
4431
4505
  </table>
4432
4506
  </div>
@@ -4717,6 +4791,8 @@ grid.destroy();
4717
4791
  <tr><td class="name">group</td><td class="type">{ enabled?: boolean; index?: number; explode?: boolean } | boolean</td><td class="desc">Row grouping by this column. `index` fixes its place among several; `explode` gives a multi-value cell one group per value rather than one group for the combination. <small>(optional)</small></td></tr>
4718
4792
  <tr><td class="name">pivot</td><td class="type">{ enabled?: boolean; index?: number } | boolean</td><td class="desc">Use this column as a pivot dimension, and where it sits among several. <small>(optional)</small></td></tr>
4719
4793
  <tr><td class="name">total</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction shown in the totals row and in group footers. <small>(optional)</small></td></tr>
4794
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for group subtotals — group footers, tree-node rollups and pivot cells — where it should differ from the grand total. Overrides `total` for those scopes only; when omitted the column's `total` applies to both. Lets a column average within each group while the grand total sums, for example (BACKLOG-0000726). <small>(optional)</small></td></tr>
4795
+ <tr><td class="name">grandTotal</td><td class="type">TotalName | TotalFn</td><td class="desc">The reduction for the pinned grand-total row, where it should differ from the group subtotals. Overrides `total` for the grand total only; when omitted the column's `total` applies (BACKLOG-0000726). <small>(optional)</small></td></tr>
4720
4796
  <tr><td class="name">shadow</td><td class="type">ShadowKind | {</td><td class="desc">A value the grid maintains about this column's own history, rather than a field in the data. `{of: 'price', kind: 'delta'}`, or the bare kind to shadow the column it sits beside. <small>(optional)</small></td></tr>
4721
4797
  <tr><td class="name">running</td><td class="type">'total' | 'percent' | 'delta'</td><td class="desc">A running total down the grid **as it is currently ordered**. The one derived value that depends on the display order: sort differently and every value changes. That is why it is not a shadow kind: every shadow reads the same however the rows are arranged. <small>(optional)</small></td></tr>
4722
4798
  <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>
@@ -4914,7 +4990,7 @@ grid.destroy();
4914
4990
  <table>
4915
4991
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
4916
4992
  <tbody>
4917
- <tr><td class="name">setTotal</td><td class="type">(id: string, fn: TotalName | TotalFn | null): void</td><td class="desc">Set or clear a column's totals-row reduction.</td></tr>
4993
+ <tr><td class="name">setTotal</td><td class="type">(</td><td class="desc">Set or clear a column's totals-row reduction. With no `scope`, `fn` becomes the column's single `total`, applied to both group subtotals and the grand total, and any independent group/grand overrides are cleared — the same one-property behaviour as before (BACKLOG-0000726). Pass `scope: 'group'` or `scope: 'grand'` to set just that scope's reduction independently, leaving the other and the base `total` untouched; the scope that has no override falls back to `total`.</td></tr>
4918
4994
  <tr><td class="name">aggregates</td><td class="type">(id: string): TotalName[]</td><td class="desc">The aggregate names meaningful for a column, honouring its type's `totals.supported` declaration (§9.4). What the aggregate chooser offers.</td></tr>
4919
4995
  <tr><td class="name">distinct</td><td class="type">(id: string): unknown[]</td><td class="desc">Every distinct value in a column, from the dictionary where there is one.</td></tr>
4920
4996
  <tr><td class="name">get</td><td class="type">(id: string): ResolvedColumn | undefined</td><td class="desc"></td></tr>
@@ -4966,6 +5042,8 @@ grid.destroy();
4966
5042
  <tr><td class="name">groupIndex</td><td class="type">number | null</td><td class="desc"><small>(optional)</small></td></tr>
4967
5043
  <tr><td class="name">pivotIndex</td><td class="type">number | null</td><td class="desc"><small>(optional)</small></td></tr>
4968
5044
  <tr><td class="name">total</td><td class="type">TotalName | null</td><td class="desc"><small>(optional)</small></td></tr>
5045
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | null</td><td class="desc">The group-subtotal override, when one differs from `total`. <small>(optional)</small></td></tr>
5046
+ <tr><td class="name">grandTotal</td><td class="type">TotalName | null</td><td class="desc">The grand-total override, when one differs from `total`. <small>(optional)</small></td></tr>
4969
5047
  </tbody>
4970
5048
  </table>
4971
5049
  </div>
@@ -6309,7 +6387,7 @@ grid.destroy();
6309
6387
  </table>
6310
6388
  </div>
6311
6389
  <h3 id="type-PushdownAggregatesConfig">PushdownAggregatesConfig</h3>
6312
- <p class="section-note">Design-time aggregate-pushdown policy for a pushdown source (BACKLOG-0000730 Part B, ungrouped). The developer chooses, at grid setup before render, whether each statistic is computed by the engine (fast, over the matching set) or client-side (the grid's exact definition, needs a full-dataset pull). It is fixed for the life of the grid, never a runtime toggle, and never surfaced to an end user. Absent, every aggregate is computed client-side — today's behaviour, so no existing caller regresses. `engine-if-identical` is the recommended setting for a windowed DuckDB source: it pushes only the statistics whose engine result is verified identical to the grid kernel, keeping the documented MAY-DIFFER stats (e.g. `mode`) client-side. The engine is used only when the filter is fully pushed; a residual filter forces every aggregate client-side, so an engine figure and a client figure never mix in one result set.</p>
6390
+ <p class="section-note">Design-time aggregate-pushdown policy for a pushdown source (BACKLOG-0000730 Part B). The developer chooses, at grid setup before render, whether each statistic is computed by the engine (fast, over the matching set) or client-side (the grid's exact definition, needs a full-dataset pull). It is fixed for the life of the grid, never a runtime toggle, and never surfaced to an end user. Absent, every aggregate is computed client-side — today's behaviour, so no existing caller regresses. `engine-if-identical` is the recommended setting for a windowed DuckDB source: it pushes only the statistics whose engine result is verified identical to the grid kernel, keeping the documented MAY-DIFFER stats (e.g. `mode`) client-side. The engine is used only when the filter is fully pushed; a residual filter forces every aggregate client-side, so an engine figure and a client figure never mix in one result set.</p>
6313
6391
  <div class="table-wrap">
6314
6392
  <table>
6315
6393
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
@@ -6372,6 +6450,7 @@ grid.destroy();
6372
6450
  <tr><td class="name">pageSize</td><td class="type">number</td><td class="desc"><small>(optional)</small></td></tr>
6373
6451
  <tr><td class="name">fullDataset</td><td class="type">PushdownFullDatasetConfig</td><td class="desc">Opt-in full-dataset pull. Off unless `fullDataset.enabled` is set. See {@link PushdownFullDatasetConfig}. <small>(optional)</small></td></tr>
6374
6452
  <tr><td class="name">aggregates</td><td class="type">PushdownAggregatesConfig</td><td class="desc">Design-time aggregate-pushdown policy. Absent = client-side (today's behaviour). See {@link PushdownAggregatesConfig}. <small>(optional)</small></td></tr>
6453
+ <tr><td class="name">allowPartialResults</td><td class="type">boolean</td><td class="desc">Accept a partial/paged result to a whole-set request when residual work (a filter, sort or quick search) will run over it client-side. Off by default: such a shortfall is refused with a thrown error, because filtering or sorting a fraction of the result presents the wrong rows as the whole filtered set — a wrong answer, not a slow one. Set `true` only when you knowingly accept that risk (e.g. an adapter that cannot page and a result small enough not to matter); the old warn-once-and-proceed behaviour is then kept. It never changes the fullDataset memory-guard or the no-residual short-return warning. <small>(optional)</small></td></tr>
6375
6454
  </tbody>
6376
6455
  </table>
6377
6456
  </div>
@@ -6536,6 +6615,8 @@ grid.destroy();
6536
6615
  <tr><td class="name">group</td><td class="type">{ enabled: boolean; index: number; explode: boolean }</td><td class="desc"></td></tr>
6537
6616
  <tr><td class="name">pivot</td><td class="type">{ enabled: boolean; index: number }</td><td class="desc"></td></tr>
6538
6617
  <tr><td class="name">total</td><td class="type">TotalName | TotalFn | null</td><td class="desc"></td></tr>
6618
+ <tr><td class="name">groupTotal</td><td class="type">TotalName | TotalFn | null</td><td class="desc">The group-subtotal override, or null when group subtotals follow `total` (BACKLOG-0000726).</td></tr>
6619
+ <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>
6539
6620
  <tr><td class="name">layout</td><td class="type">ColumnLayoutSpec</td><td class="desc"></td></tr>
6540
6621
  <tr><td class="name">header</td><td class="type">ColumnHeaderSpec</td><td class="desc"></td></tr>
6541
6622
  <tr><td class="name">export</td><td class="type">ColumnExportSpec</td><td class="desc"></td></tr>
@@ -6955,6 +7036,7 @@ grid.destroy();
6955
7036
  <tr><td class="name">group</td><td class="type">boolean</td><td class="desc"><small>(optional)</small></td></tr>
6956
7037
  <tr><td class="name">space</td><td class="type">string</td><td class="desc"><small>(optional)</small></td></tr>
6957
7038
  <tr><td class="name">placement</td><td class="type">'suffix' | 'prefix'</td><td class="desc"><small>(optional)</small></td></tr>
7039
+ <tr><td class="name">compound</td><td class="type">string[]</td><td class="desc">Render one stored number across an ordered subset of the system's units, e.g. `['ft', 'in']` for `5 ft 11 in`. Display and parse only: the stored value stays a single base-unit number, so sort, filter and total are unchanged. Parsing sums the parts. <small>(optional)</small></td></tr>
6958
7040
  </tbody>
6959
7041
  </table>
6960
7042
  </div>
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.17.0</p>
440
+ <p class="rail__sub">Developer guide · v1.18.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -1368,6 +1368,17 @@ dataTypes: {
1368
1368
  is <code>1.00 MB</code> rather than <code>1,000 kB</code>.
1369
1369
  </p>
1370
1370
 
1371
+ <p class="lead-in">
1372
+ <code>compound: ['ft', 'in']</code> renders one stored number across an ordered subset of the
1373
+ system's units &mdash; a length as <code>5 ft 11 in</code>, a duration as <code>1 h 23 m</code>.
1374
+ It is display and parse only: the stored value stays a single base-unit number, so sorting,
1375
+ filtering, grouping and totals are the same arithmetic as any other unit column. The units are
1376
+ sorted largest to smallest, the smallest carries the remainder, and parsing sums the parts, so
1377
+ the display round-trips through a paste. The mid-value <em>editor</em> &mdash; keystroke
1378
+ roll-over between feet and inches, caret behaviour at a rung boundary &mdash; is a separate,
1379
+ later piece of work; this is the read-and-paste half.
1380
+ </p>
1381
+
1371
1382
  <div class="why">
1372
1383
  <p><strong>Angles wrap, so their mean is replaced.</strong> The average of 359° and 1° is 0°,
1373
1384
  and the arithmetic answer (180°) is a confident, plausible number pointing in exactly the
@@ -1961,12 +1972,14 @@ grid.destroy();
1961
1972
  </p>
1962
1973
 
1963
1974
  <p class="lead-in">
1964
- On a memory source the grand total is <strong>maintained incrementally</strong>: a cell
1965
- update moves the running value by the difference rather than re-reducing the column, so a
1966
- totals row costs the same on a million rows as on a thousand. This applies to
1967
- <code>sum</code>, <code>avg</code>, <code>countValues</code>, <code>min</code> and
1968
- <code>max</code> on numeric columns. Everything else re-reduces, and so does the
1969
- incremental path itself whenever it cannot reach the right answer:
1975
+ On a memory source totals are <strong>maintained incrementally</strong>: a cell update moves
1976
+ the running value by the difference rather than re-reducing the column, so a totals row costs
1977
+ the same on a million rows as on a thousand. This applies to both the grand total and each
1978
+ group subtotal, for <code>sum</code>, <code>avg</code>, <code>countValues</code>,
1979
+ <code>min</code> and <code>max</code> on numeric columns. A cell edit that moves a row between
1980
+ groups is subtracted from its old group and added to its new one; only the affected groups are
1981
+ touched. Everything else re-reduces, and so does the incremental path itself whenever it cannot
1982
+ reach the right answer:
1970
1983
  </p>
1971
1984
  <div class="table-wrap">
1972
1985
  <table>
@@ -1979,7 +1992,7 @@ grid.destroy();
1979
1992
  <tr><td class="name">Adding or removing rows</td><td class="desc">Re-reduced once, then incremental again.</td></tr>
1980
1993
  <tr><td class="name">Filtering, sorting or grouping</td><td class="desc">Re-reduced once, because the rows contributing to the total have changed.</td></tr>
1981
1994
  <tr><td class="name">Totals above ~1e15</td><td class="desc">Re-reduced. Past that magnitude a small change no longer moves a 64-bit float, and a running total would silently stop tracking the data.</td></tr>
1982
- <tr><td class="name">Group totals</td><td class="desc">Re-reduced per group on every change. Only the grand total is incremental.</td></tr>
1995
+ <tr><td class="name">Group subtotals</td><td class="desc">Maintained incrementally the same way the grand total is: an in-group edit applies the difference, and a cross-group move subtracts from the old group and adds to the new. A <code>min</code>/<code>max</code> move that leaves a group's current extreme reseeds only that group. A custom group total, or a statistical reduction, keeps the full per-group pass.</td></tr>
1983
1996
  </tbody>
1984
1997
  </table>
1985
1998
  </div>
@@ -2073,10 +2086,12 @@ pivot: { groupTotals: 'after', totalsLabel: 'All regions' }</code></pre>
2073
2086
  <pre><code>totalOnlyChangedColumns: <span class="kw">true</span></code></pre>
2074
2087
  </div>
2075
2088
  <p class="lead-in">
2076
- Group totals re-reduce every totalled column on every change, including columns the change
2077
- did not touch. Switching this on reduces only the columns whose values actually moved, and
2078
- an update that rewrites a field with the value it already held reduces nothing at all,
2079
- which is what a feed resending unchanged fields looks like.
2089
+ By default the total stage considers every totalled column on every change, even columns the
2090
+ change did not touch. Switching this on considers only the columns whose values actually moved,
2091
+ and an update that rewrites a field with the value it already held reduces nothing at all,
2092
+ which is what a feed resending unchanged fields looks like. This narrows <em>which</em> columns
2093
+ are looked at; the incremental grand total and group subtotals described above narrow
2094
+ <em>how</em> each one is brought up to date, and the two compound.
2080
2095
  </p>
2081
2096
  <p class="lead-in">
2082
2097
  On a million rows in seven groups, a single-cell update with four totalled columns:
@@ -2547,6 +2562,49 @@ grid.destroy();
2547
2562
  <span class="kw">return</span> offered.includes('sum') ? <span class="num">6</span> : <span class="num">0</span>; <span class="cmt">// sum is offered on a number column</span></code></pre>
2548
2563
  </div>
2549
2564
 
2565
+ <div class="why">
2566
+ <p><strong>The group subtotals and the grand total can reduce differently.</strong> By default
2567
+ one <code>total</code> drives both, and that is unchanged. When a column needs, say, an
2568
+ <em>average per group</em> under a <em>sum of everything</em>, set the two independently with
2569
+ the <code>scope</code> option: <code>setTotal(id, 'avg', { scope: 'group' })</code> and
2570
+ <code>setTotal(id, 'sum', { scope: 'grand' })</code>. A scope with no override falls back to
2571
+ <code>total</code>, and passing no <code>scope</code> sets the shared <code>total</code> and
2572
+ clears both overrides — so the one-property behaviour is exactly what it was. The same split is
2573
+ declarable on a column as <code>groupTotal</code> / <code>grandTotal</code>, and it persists in
2574
+ saved views alongside <code>total</code>.</p>
2575
+ </div>
2576
+ <div class="example">
2577
+ <p class="example__label">Group average under a grand sum, executed</p>
2578
+ <pre data-run="js" data-expect="avg groups under sum grand" data-covers="method:columns config:columns"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
2579
+
2580
+ <span class="kw">const</span> grid = createHeadlessGrid({
2581
+ columns: [{ field: 'amount', type: 'number', total: 'sum' }, { field: 'region' }],
2582
+ rows: [
2583
+ { id: '1', amount: 2, region: 'N' }, { id: '2', amount: 6, region: 'N' },
2584
+ { id: '3', amount: 4, region: 'S' }, { id: '4', amount: 8, region: 'S' },
2585
+ ],
2586
+ rowKey: 'id',
2587
+ grandTotalRow: <span class="kw">true</span>,
2588
+ });
2589
+ grid.columns.group(['region']);
2590
+
2591
+ <span class="cmt">// Group subtotals average within each region; the grand total sums everything.</span>
2592
+ grid.columns.setTotal('amount', 'avg', { scope: 'group' });
2593
+ grid.columns.setTotal('amount', 'sum', { scope: 'grand' });
2594
+
2595
+ <span class="cmt">// The first group subtotal (region N: (2+6)/2 = 4) and the grand row (sum = 20).</span>
2596
+ <span class="kw">let</span> groupSubtotal = <span class="kw">null</span>;
2597
+ <span class="kw">for</span> (<span class="kw">let</span> i = <span class="num">0</span>; i &lt; grid.rows.count(); i++) {
2598
+ <span class="kw">const</span> r = grid.rows.get(i);
2599
+ <span class="kw">if</span> (r &amp;&amp; r.group &amp;&amp; !r.grandTotal &amp;&amp; groupSubtotal === <span class="kw">null</span>) groupSubtotal = r.totals.amount;
2600
+ }
2601
+ <span class="kw">const</span> grand = grid.rows.get(grid.rows.count() - <span class="num">1</span>).totals.amount;
2602
+
2603
+ grid.destroy();
2604
+ <span class="kw">return</span> (groupSubtotal === <span class="num">4</span> &amp;&amp; grand === <span class="num">20</span>)
2605
+ ? 'avg groups under sum grand' : `group ${groupSubtotal}, grand ${grand}`;</code></pre>
2606
+ </div>
2607
+
2550
2608
  <h2 id="sticky-group-headings">Sticky group headings</h2>
2551
2609
  <p class="lead-in">
2552
2610
  Scrolling inside a group keeps that group's headings pinned above the rows, so the rows on
@@ -2826,6 +2884,33 @@ createGrid(el, {
2826
2884
  cannot compute, this is the only route to a correct whole-dataset statistic.</p>
2827
2885
  </div>
2828
2886
 
2887
+ <h3 id="allowpartialresults">Refusing a partial result over residual work</h3>
2888
+ <p class="lead-in">
2889
+ When residual work has to run in the browser, the source asks the adapter for the whole matching
2890
+ set and pages from what it holds. An adapter that answers with a <em>page</em> of that result —
2891
+ it paged when told not to — leaves the client-side filter or sort running over the wrong rows:
2892
+ the ones that belong on page one may be in the fraction never fetched. A page presented as the
2893
+ full filtered set is a wrong answer, not a slow one.
2894
+ </p>
2895
+ <div class="example">
2896
+ <p class="example__label">Refused by default; opt in only when you knowingly accept it</p>
2897
+ <pre><code><span class="cmt">// Default: a shortfall under residual work throws, surfaced as source:error.</span>
2898
+ <span class="kw">const</span> strict = createPushdownSource({ adapter });
2899
+
2900
+ <span class="cmt">// Knowing escape hatch: keep the old warn-once-and-proceed behaviour.</span>
2901
+ <span class="kw">const</span> lax = createPushdownSource({ adapter, allowPartialResults: <span class="kw">true</span> });</code></pre>
2902
+ </div>
2903
+ <div class="why">
2904
+ <p><strong>Off by default, refused loudly.</strong> Filtering or sorting a fraction of the result
2905
+ does not lose rows quietly, it returns the <em>wrong</em> rows, so the default throws rather than
2906
+ warns — <code>nothing fails silently</code> (§8) means a wrong answer is never preferred to a
2907
+ visible failure. The fix is an adapter that follows the engine's own paging, or holding the data
2908
+ in memory. <code>allowPartialResults: true</code> is the deliberate opt-out — a caller who
2909
+ accepts the permissive behaviour keeps the warn-once path — and it is never the default. It does
2910
+ not touch the <code>fullDataset</code> memory guard or the no-residual short-return warning, both
2911
+ of which stand regardless.</p>
2912
+ </div>
2913
+
2829
2914
  <h3 id="aggregates">Pushing statistics down to the engine</h3>
2830
2915
  <p class="lead-in">
2831
2916
  A DuckDB-class engine computes a median or a standard deviation over the whole matching set far
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.17.0, type declarations
2
+ * Lattice Grid 1.18.0, type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -610,6 +610,20 @@ export interface Column {
610
610
  pivot?: { enabled?: boolean; index?: number } | boolean;
611
611
  /** The reduction shown in the totals row and in group footers. */
612
612
  total?: TotalName | TotalFn;
613
+ /**
614
+ * The reduction for group subtotals — group footers, tree-node rollups and
615
+ * pivot cells — where it should differ from the grand total. Overrides
616
+ * `total` for those scopes only; when omitted the column's `total` applies to
617
+ * both. Lets a column average within each group while the grand total sums,
618
+ * for example (BACKLOG-0000726).
619
+ */
620
+ groupTotal?: TotalName | TotalFn;
621
+ /**
622
+ * The reduction for the pinned grand-total row, where it should differ from
623
+ * the group subtotals. Overrides `total` for the grand total only; when
624
+ * omitted the column's `total` applies (BACKLOG-0000726).
625
+ */
626
+ grandTotal?: TotalName | TotalFn;
613
627
  /**
614
628
  * A value the grid maintains about this column's own history, rather than a
615
629
  * field in the data. `{of: 'price', kind: 'delta'}`, or the bare kind to
@@ -688,6 +702,16 @@ export interface ResolvedColumn {
688
702
  group: { enabled: boolean; index: number; explode: boolean };
689
703
  pivot: { enabled: boolean; index: number };
690
704
  total: TotalName | TotalFn | null;
705
+ /**
706
+ * The group-subtotal override, or null when group subtotals follow `total`
707
+ * (BACKLOG-0000726).
708
+ */
709
+ groupTotal: TotalName | TotalFn | null;
710
+ /**
711
+ * The grand-total override, or null when the grand total follows `total`
712
+ * (BACKLOG-0000726).
713
+ */
714
+ grandTotal: TotalName | TotalFn | null;
691
715
  layout: ColumnLayoutSpec;
692
716
  header: ColumnHeaderSpec;
693
717
  export: ColumnExportSpec;
@@ -1805,6 +1829,10 @@ export interface ColumnState {
1805
1829
  groupIndex?: number | null;
1806
1830
  pivotIndex?: number | null;
1807
1831
  total?: TotalName | null;
1832
+ /** The group-subtotal override, when one differs from `total`. */
1833
+ groupTotal?: TotalName | null;
1834
+ /** The grand-total override, when one differs from `total`. */
1835
+ grandTotal?: TotalName | null;
1808
1836
  }
1809
1837
 
1810
1838
  export interface GridState {
@@ -1991,7 +2019,7 @@ export type AggregateMode = 'engine' | 'client' | 'engine-if-identical';
1991
2019
 
1992
2020
  /**
1993
2021
  * Design-time aggregate-pushdown policy for a pushdown source
1994
- * (BACKLOG-0000730 Part B, ungrouped). The developer chooses, at grid setup
2022
+ * (BACKLOG-0000730 Part B). The developer chooses, at grid setup
1995
2023
  * before render, whether each statistic is computed by the engine (fast, over
1996
2024
  * the matching set) or client-side (the grid's exact definition, needs a
1997
2025
  * full-dataset pull). It is fixed for the life of the grid, never a runtime
@@ -2046,6 +2074,9 @@ export interface AggregateProvenance {
2046
2074
  /** Why it is client-side, when it is (config, fallback, or the guard). */
2047
2075
  reason?: string;
2048
2076
  weight?: string;
2077
+ /** Parameters the statistic takes, carried through so an adapter emits the
2078
+ * matching SQL (e.g. a trim share). */
2079
+ params?: Record<string, unknown>;
2049
2080
  }
2050
2081
 
2051
2082
  export interface PushdownSourceConfig {
@@ -2063,6 +2094,18 @@ export interface PushdownSourceConfig {
2063
2094
  * behaviour). See {@link PushdownAggregatesConfig}.
2064
2095
  */
2065
2096
  aggregates?: PushdownAggregatesConfig;
2097
+ /**
2098
+ * Accept a partial/paged result to a whole-set request when residual work
2099
+ * (a filter, sort or quick search) will run over it client-side. Off by
2100
+ * default: such a shortfall is refused with a thrown error, because filtering
2101
+ * or sorting a fraction of the result presents the wrong rows as the whole
2102
+ * filtered set — a wrong answer, not a slow one. Set `true` only when you
2103
+ * knowingly accept that risk (e.g. an adapter that cannot page and a result
2104
+ * small enough not to matter); the old warn-once-and-proceed behaviour is
2105
+ * then kept. It never changes the fullDataset memory-guard or the
2106
+ * no-residual short-return warning.
2107
+ */
2108
+ allowPartialResults?: boolean;
2066
2109
  }
2067
2110
 
2068
2111
  export interface StatisticsApi {
@@ -2451,8 +2494,21 @@ export interface RowsApi {
2451
2494
  }
2452
2495
 
2453
2496
  export interface ColumnsApi {
2454
- /** Set or clear a column's totals-row reduction. */
2455
- setTotal(id: string, fn: TotalName | TotalFn | null): void;
2497
+ /**
2498
+ * Set or clear a column's totals-row reduction.
2499
+ *
2500
+ * With no `scope`, `fn` becomes the column's single `total`, applied to both
2501
+ * group subtotals and the grand total, and any independent group/grand
2502
+ * overrides are cleared — the same one-property behaviour as before
2503
+ * (BACKLOG-0000726). Pass `scope: 'group'` or `scope: 'grand'` to set just
2504
+ * that scope's reduction independently, leaving the other and the base
2505
+ * `total` untouched; the scope that has no override falls back to `total`.
2506
+ */
2507
+ setTotal(
2508
+ id: string,
2509
+ fn: TotalName | TotalFn | null,
2510
+ opts?: { scope?: 'group' | 'grand' },
2511
+ ): void;
2456
2512
  /**
2457
2513
  * The aggregate names meaningful for a column, honouring its type's
2458
2514
  * `totals.supported` declaration (§9.4). What the aggregate chooser offers.
@@ -3474,6 +3530,11 @@ export interface UnitConfig {
3474
3530
  group?: boolean;
3475
3531
  space?: string;
3476
3532
  placement?: 'suffix' | 'prefix';
3533
+ /** Render one stored number across an ordered subset of the system's units,
3534
+ * e.g. `['ft', 'in']` for `5 ft 11 in`. Display and parse only: the stored
3535
+ * value stays a single base-unit number, so sort, filter and total are
3536
+ * unchanged. Parsing sums the parts. */
3537
+ compound?: string[];
3477
3538
  }
3478
3539
 
3479
3540
  export function defineUnit(