@toclocoinc/lattice-grid 1.16.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.
@@ -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.16.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
@@ -2554,9 +2612,9 @@ grid.destroy();
2554
2612
  </p>
2555
2613
 
2556
2614
  <div class="example">
2557
- <p class="example__label">On by default; turn it off or change the cap</p>
2558
- <pre><code>createGrid(element, { stickyGroupHeaders: <span class="kw">false</span> }); <span class="cmt">// off</span>
2559
- createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// stack up to three</span></code></pre>
2615
+ <p class="example__label">Off by default; opt in or set the cap</p>
2616
+ <pre><code>createGrid(element, { stickyGroupHeaders: <span class="kw">true</span> }); <span class="cmt">// on, up to two</span>
2617
+ createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// on, up to three</span></code></pre>
2560
2618
  </div>
2561
2619
 
2562
2620
  <div class="why">
@@ -2608,7 +2666,8 @@ createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// stack
2608
2666
  <table>
2609
2667
  <thead><tr><th>Option</th><th>Type</th><th>Description</th></tr></thead>
2610
2668
  <tbody>
2611
- <tr><td class="name">retainSource</td><td class="type">boolean</td><td class="desc">Default <code>true</code>. Set <code>false</code> to keep only the packed columns and reconstruct a plain row object from them on demand. <code>rows.data()</code> then returns freshly reconstructed objects — a new object each call — so <code>row === sourceObject</code> and a custom renderer reading <code>row.sourceObject</code> no longer hold, and equality becomes value-based. Cell values are identical either way, so <code>get()</code>, <code>byKey()</code>, <code>value()</code> and <code>values()</code> are unaffected.</td></tr>
2669
+ <tr><td class="name">retainSource</td><td class="type">boolean</td><td class="desc">Default <code>true</code>. Set <code>false</code> to keep only the packed columns and reconstruct a plain row object from them on demand. <code>rows.data()</code> then returns freshly reconstructed objects — a new object each call — so <code>row === sourceObject</code> and a custom renderer reading <code>row.sourceObject</code> no longer hold, and equality becomes value-based. Cell values are identical either way, so <code>get()</code>, <code>byKey()</code>, <code>value()</code> and <code>values()</code> are unaffected. On its own this drops only the store's reference array, not the objects: the source layer still holds them.</td></tr>
2670
+ <tr><td class="name">dropSourceRows</td><td class="type">boolean</td><td class="desc">Default <code>false</code>. Set <code>true</code> to release the caller's row objects from the <em>source layer</em> and the grid config once the store is built, so the packed columns are the only resident copy. This is where the large reduction lives — roughly an order of magnitude at a million rows — because the caller's objects are the dominant term. Implies <code>retainSource: false</code> (the store must reconstruct), so it carries the same identity tradeoff. An impure computed column — a shadow or a rank/positional column — is never stored, so it cannot be served from the columns: it warns once and drops out rather than answering wrongly, and <code>rows.move()</code> is refused. Do not enable it on a grid that sorts, filters, groups or totals on such a column, or that reorders rows in place.</td></tr>
2612
2671
  </tbody>
2613
2672
  </table>
2614
2673
  </div>
@@ -2618,14 +2677,17 @@ createGrid(element, { stickyGroupHeaders: 3 }); <span class="cmt">// stack
2618
2677
  columns,
2619
2678
  rowKey: 'id',
2620
2679
  rows,
2621
- ingest: { retainSource: <span class="kw">false</span> },
2680
+ <span class="cmt">// Release the caller's objects entirely; the packed columns are the sole copy.</span>
2681
+ ingest: { dropSourceRows: <span class="kw">true</span> },
2622
2682
  });</code></pre>
2623
2683
  </div>
2624
2684
  <div class="why">
2625
- <p>The saving is the store's own copy of the object references, not the objects
2626
- themselves: whoever handed the grid its rows still owns them. The footprint drops
2627
- materially only when the grid becomes the sole holder of the data. Reach for this when
2628
- you can let go of the source array and do not depend on caller identity through
2685
+ <p>With <code>retainSource: false</code> alone the saving is only the store's own copy of the
2686
+ object references, not the objects themselves: the source layer and the grid config still
2687
+ hold the array, so whoever handed the grid its rows keeps them alive. <code>dropSourceRows</code>
2688
+ releases those references too, so once the caller lets go the objects can be collected and the
2689
+ grid becomes the sole holder of the data. Reach for it on a large, read-mostly grid where you
2690
+ can let go of the source array and do not depend on caller identity through
2629
2691
  <code>rows.data()</code>.</p>
2630
2692
  </div>
2631
2693
 
@@ -2746,7 +2808,8 @@ createGrid(el, { columns, rowKey: 'id', source });
2746
2808
  plan.pushed; <span class="cmt">// the query the adapter was given</span>
2747
2809
  plan.residual; <span class="cmt">// { filters, sort, quick } the grid applied after</span>
2748
2810
  plan.unpushed; <span class="cmt">// ['filter'], the parts that stayed behind</span>
2749
- plan.needsAll; <span class="cmt">// true when the whole result had to be fetched</span></code></pre>
2811
+ plan.needsAll; <span class="cmt">// true when the whole result had to be fetched</span>
2812
+ plan.full; <span class="cmt">// true when fullDataset forced it, not just residual work</span></code></pre>
2750
2813
  </div>
2751
2814
  <p>
2752
2815
  The grid also warns once, naming the predicate that could not be pushed, because the fix is
@@ -2788,6 +2851,105 @@ createGrid(el, {
2788
2851
  with no server involved.</p>
2789
2852
  </div>
2790
2853
 
2854
+ <h3 id="fulldataset">Whole-dataset statistics over a remote source</h3>
2855
+ <p class="lead-in">
2856
+ A windowed source reduces a total or statistic over the rows it has loaded, not the whole
2857
+ matching set — a footer median of the 200 rows on screen, which is wrong and looks right.
2858
+ <code>fullDataset</code> makes the source hold the entire matching set client-side so those
2859
+ figures are computed over everything.
2860
+ </p>
2861
+ <div class="example">
2862
+ <p class="example__label">Correct footer figures over a REST or DuckDB source</p>
2863
+ <pre><code><span class="kw">const</span> source = createPushdownSource({
2864
+ adapter: restAdapter({ url: '/api/trades' }),
2865
+ fullDataset: {
2866
+ enabled: <span class="kw">true</span>, <span class="cmt">// hold the whole matching set, once per query</span>
2867
+ maxRows: 1_000_000, <span class="cmt">// refuse (visible error) past this</span>
2868
+ maxBytesEstimate: 512 * 1024 * 1024,
2869
+ },
2870
+ });</code></pre>
2871
+ </div>
2872
+ <div class="why">
2873
+ <p>It is <strong>off by default</strong> and reuses the same whole-result path that residual
2874
+ work already takes: the flag ORs into <code>needsAll</code>, so once the set is held it is
2875
+ ordinary in-memory data and the grid's existing total and statistics kernels reduce over all of
2876
+ it, with no per-stat change. When it is active and within the limits, the whole matching set is
2877
+ covered, so the windowed-statistic warning (BACKLOG-0000731) stays silent — the figure is now
2878
+ honestly whole-dataset.</p>
2879
+ <p><strong>It is refused loudly, never truncated.</strong> A matching set past
2880
+ <code>maxRows</code> or <code>maxBytesEstimate</code> is thrown and surfaced as a
2881
+ <code>source:error</code> with no rows shown, rather than held as a fraction and presented as
2882
+ the whole. A fraction shown as the whole is exactly the silent wrong answer this feature
2883
+ exists to remove, so it is never how the feature fails. For a <code>restAdapter</code>, which
2884
+ cannot compute, this is the only route to a correct whole-dataset statistic.</p>
2885
+ </div>
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
+
2914
+ <h3 id="aggregates">Pushing statistics down to the engine</h3>
2915
+ <p class="lead-in">
2916
+ A DuckDB-class engine computes a median or a standard deviation over the whole matching set far
2917
+ faster than pulling every row here to do it. The <code>aggregates</code> config decides, at
2918
+ grid setup, which statistics the engine computes and which the grid does — a design-time
2919
+ developer choice, fixed for the life of the grid, never a runtime toggle and never shown to an
2920
+ end user.
2921
+ </p>
2922
+ <div class="example">
2923
+ <p class="example__label">Push the verified-identical stats, keep the rest exact</p>
2924
+ <pre><code><span class="kw">const</span> source = createPushdownSource({
2925
+ adapter: duckdbAdapter({ connection: conn, from: <span class="str">'trades'</span> }),
2926
+ aggregates: {
2927
+ <span class="cmt">// 'engine' pushes everything expressible; 'engine-if-identical' pushes only</span>
2928
+ <span class="cmt">// the stats whose engine result is verified identical to the grid kernel;</span>
2929
+ <span class="cmt">// 'client' (the default when absent) computes everything here.</span>
2930
+ <span class="kw">default</span>: <span class="str">'engine-if-identical'</span>,
2931
+ overrides: { mode: <span class="str">'client'</span> }, <span class="cmt">// I want the grid's null-when-distinct mode</span>
2932
+ },
2933
+ });</code></pre>
2934
+ </div>
2935
+ <div class="why">
2936
+ <p>Every statistic is classified <strong>IDENTICAL</strong> (the engine's result equals the
2937
+ grid's own kernel, verified against it on the same data) or <strong>MAY-DIFFER</strong> (the
2938
+ engine computes it by a method that can differ from the grid's definition — <code>mode</code>
2939
+ returns a value where the grid returns null). The classification drives the docs and
2940
+ build-time provenance, <em>not</em> whether a stat is pushed: that is your choice.
2941
+ <code>weightedQuantile</code> is the one genuine fallback, always client-side, because the
2942
+ engine cannot express the grid's midpoint convention. The published table of every stat, its
2943
+ class and its SQL is generated from one map (<code>STAT_PUSHDOWN</code>) so it cannot drift.</p>
2944
+ <p><strong>No mixed provenance.</strong> An engine figure and a client figure never appear in
2945
+ one result set. Aggregates are pushed only when the filter is <em>fully</em> pushed; a residual
2946
+ filter the engine could not apply forces every aggregate client-side, because an engine number
2947
+ computed over a superset beside a client number over the real set would be wrong-but-plausible.
2948
+ <code>source.lastPlan().aggregates</code> reports, per stat, whether the engine or the grid
2949
+ computed it and the class it was assigned — inspection during the build, not a per-figure
2950
+ runtime marker.</p>
2951
+ </div>
2952
+
2791
2953
  <h2 id="derived">Grids built from other grids</h2>
2792
2954
  <p class="lead-in">
2793
2955
  A derived grid takes its rows from another grid rather than from a load: grouped and
@@ -2982,6 +3144,27 @@ columns: [
2982
3144
  scrolling past a rounded corner is cut by it rather than squaring it off.
2983
3145
  </p>
2984
3146
 
3147
+ <div class="example">
3148
+ <p class="example__label">Zebra striping (opt-in)</p>
3149
+ <pre><code>createGrid(element, {
3150
+ columns, rows,
3151
+ stripedRows: <span class="kw">true</span>, <span class="cmt">// shade alternate data rows; off by default</span>
3152
+ });</code></pre>
3153
+ </div>
3154
+
3155
+ <p class="lead-in">
3156
+ <code>stripedRows</code> shades every other data row. It is strictly opt-in and off by default,
3157
+ so a grid that never mentions it looks exactly as it did on upgrade. Parity is decided by each
3158
+ row's <em>logical</em> index rather than its position in the DOM: rows are virtualised and
3159
+ recycled, so a <code>:nth-child</code> rule would repaint the stripe onto whichever row landed
3160
+ in an odd slot after a scroll, and a logical-index stripe keeps a row shaded consistently across
3161
+ a scroll and across the left-pinned, centre and right-pinned segments of the same row. Group
3162
+ headings, group footers and the grand total are structure rather than data, so they are never
3163
+ striped. The stripe uses the theme's <code>--lattice-surface-alt</code> token, which every
3164
+ palette defines, so dark, high-contrast and terminal are correct without any extra rule, and
3165
+ both selection and hover still win over it.
3166
+ </p>
3167
+
2985
3168
  <h2 id="cards">Cards, lists and feeds</h2>
2986
3169
  <p class="lead-in">
2987
3170
  <code>rowTemplate</code> draws each row with a layout of your own instead of dividing it into