@toclocoinc/lattice-grid 1.7.0 → 1.8.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.7.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.8.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
@@ -27,6 +27,98 @@ runtime, not a CDN, not a font, not an icon sprite.
27
27
 
28
28
  ---
29
29
 
30
+ ## What it does
31
+
32
+ A short tour. The [API reference](docs/API.html) has the whole of it; this is
33
+ enough to know whether the grid covers what you need.
34
+
35
+ ### Data and scale
36
+
37
+ - **Virtualised rows and columns.** Hundreds of thousands of rows on a typed-array
38
+ column store, with dictionary encoding and presence bitsets. Row height can be
39
+ fixed, per-row, or measured from content.
40
+ - **Sort, filter, group, pivot and aggregate**, each as an independent stage
41
+ over the same data. Multi-column sort, a filter grammar with typed operators,
42
+ row grouping to any depth, full pivoting, and a totals row that reduces by any
43
+ of the built-in kernels or one of your own.
44
+ - **Live data.** `rows.apply({add, update, remove})` patches in place: the grid
45
+ re-queries the stages a change actually touched and repaints the cells that
46
+ moved. A feed can be paused and resumed with the queue held.
47
+ - **Any source.** Rows in memory, or a source you write — server-side paging,
48
+ infinite scroll, streaming, or an async provider. Sorting and filtering can be
49
+ handed to the server or left to the grid.
50
+
51
+ ### Working with the data
52
+
53
+ - **Editing.** Cell, row and form editing, with over twenty editors — text,
54
+ number, date, time, select, multi-select, colour, rating, slider, segmented,
55
+ code, password, icon picker and more. Validation, async commits, optimistic
56
+ updates with rollback, and a full undo history.
57
+ - **Selection and ranges.** Cell, row, column and rectangular range selection,
58
+ with clipboard behaviour that round-trips through Excel.
59
+ - **Fill, copy and paste** across a range, including formulas.
60
+ - **Formulas.** A closed, safe expression language — no `eval`, no host access —
61
+ with maths, text, logic, date and statistical functions, evaluated against
62
+ other columns.
63
+ - **Export.** CSV with fields sanitised against formula injection, real `.xlsx`
64
+ written without a ZIP dependency, the clipboard as TSV with a matching paste
65
+ parser, and print. All of them take the filters, sort and grouping the user is
66
+ looking at, or the whole set.
67
+
68
+ ### Seeing the data
69
+
70
+ - **Charts.** `modules/charts` draws thirty chart types from the grid's own
71
+ data — line, bar, area, scatter, pie, donut, sunburst, treemap, radar, gauge,
72
+ funnel, heatmap, histogram, box plot, candlestick, combo, geomap, sankey,
73
+ chord, network, stream, violin, gantt and more. They follow the grid's
74
+ filters, and clicking a mark can filter it in turn.
75
+ - **Statistics.** `grid.statistics` profiles a column in one pass — count,
76
+ missing, distinct, five-number summary, standard deviation, outliers and a
77
+ histogram — and answers correlations and weighted averages. Twenty-eight
78
+ reduction kernels are available to the totals row, and you can register your
79
+ own.
80
+ - **Shadow columns.** Values the grid maintains about itself: how many times a
81
+ row has changed, what a value was when the page loaded, how fast it is moving,
82
+ its rank, percentile or share of the total. Real columns — sortable,
83
+ filterable, exportable, saved into a view.
84
+ - **Conditional formatting** as runtime state a user can change, with rules that
85
+ either name a threshold or describe the data: the top decile, the outliers,
86
+ two deviations above the mean.
87
+ - **In-cell charts**, sparklines, data bars, progress, ratings and pills.
88
+ - **Header histograms** that double as a filter.
89
+
90
+ ### The interface
91
+
92
+ - **Tool panels** for columns, filters, views, quick filter, formatting and
93
+ statistics, docked or as an icon rail.
94
+ - **Saved views** — the whole grid state as a named, shareable object, stored
95
+ on your server or in the browser.
96
+ - **Column menu, context menu and status bar**, each extensible with your own
97
+ items.
98
+ - **Pinned columns and rows**, column groups, resize, reorder, autosize, and a
99
+ density control.
100
+ - **Master–detail rows**, tree data, and full-width rows.
101
+ - **Presence** — live cursors, selections and edit locks for collaborative use,
102
+ carrying intent and never values.
103
+ - **Comments** threaded on cells, and an annotation layer for presenting.
104
+ - **Full-screen mode**, print, and image capture.
105
+
106
+ ### Quality of the thing itself
107
+
108
+ - **Accessible.** Keyboard operable throughout, ARIA grid semantics, a live
109
+ region for announcements, honours reduced motion, forced colours and large
110
+ target sizes. The devtools module runs the accessibility checks in place.
111
+ - **Internationalised.** Eighteen complete locales, right-to-left layout, and
112
+ locale-aware number, date and currency formatting throughout.
113
+ - **Themeable** through CSS custom properties, with light and dark built in and
114
+ a reset that keeps a host page's stylesheet out.
115
+ - **Typed.** Complete TypeScript declarations, checked against the runtime on
116
+ every build.
117
+ - **Zero runtime dependencies.** Nothing is fetched at runtime — not a CDN, not
118
+ a font, not an icon sprite.
119
+
120
+ ---
121
+
30
122
  ## Quick start
31
123
 
32
124
  ### No build step
package/docs/API.html CHANGED
@@ -360,7 +360,7 @@
360
360
  <div class="shell">
361
361
  <aside class="rail">
362
362
  <p class="rail__brand">Lattice Grid</p>
363
- <p class="rail__sub">API reference · v1.7.0</p>
363
+ <p class="rail__sub">API reference · v1.7.1</p>
364
364
  <nav>
365
365
  <div class="rail__group">
366
366
  <span class="rail__label">Start</span>
@@ -397,6 +397,7 @@
397
397
  <a href="#presentation">grid.presentation</a>
398
398
  <a href="#redaction">grid.redaction</a>
399
399
  <a href="#formatting">grid.formatting</a>
400
+ <a href="#statistics">grid.statistics</a>
400
401
  <a href="#highlight">grid.highlight</a>
401
402
  <a href="#views">grid.views</a>
402
403
  <a href="#diff">grid.diff</a>
@@ -437,7 +438,7 @@
437
438
  </header>
438
439
 
439
440
  <p class="chips">
440
- <span class="chip">Version 1.7.0</span>
441
+ <span class="chip">Version 1.7.1</span>
441
442
  <span class="chip">Zero dependencies</span>
442
443
  <span class="chip"><a href="api-detail.html">Developer guide &rarr;</a></span>
443
444
  </p>
@@ -455,8 +456,8 @@
455
456
 
456
457
  <p>Or straight from jsDelivr — no npm install, no bundler, no local copy at all:</p>
457
458
 
458
- <pre><code>&lt;link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.0/lattice-grid.min.css"&gt;
459
- &lt;script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.0/lattice-grid.min.js"&gt;&lt;/script&gt;
459
+ <pre><code>&lt;link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.1/lattice-grid.min.css"&gt;
460
+ &lt;script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.1/lattice-grid.min.js"&gt;&lt;/script&gt;
460
461
 
461
462
  &lt;script&gt;
462
463
  <span class="kw">const</span> grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
@@ -467,7 +468,7 @@
467
468
  <pre><code><span class="cmt">// With a renderer, in a browser. Any of:</span>
468
469
  <span class="kw">import</span> { createGrid } <span class="kw">from</span> './dist/lattice-grid.esm.js';
469
470
  <span class="cmt">// import { createGrid } from '@toclocoinc/lattice-grid';</span>
470
- <span class="cmt">// import { createGrid } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.0/lattice-grid.esm.min.js';</span>
471
+ <span class="cmt">// import { createGrid } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.1/lattice-grid.esm.min.js';</span>
471
472
  <span class="kw">const</span> grid = createGrid(document.getElementById('grid'), config);
472
473
 
473
474
  <span class="cmt">// Headless: the same API without a renderer. Data, filters, sort,</span>
@@ -479,7 +480,7 @@
479
480
 
480
481
  <div class="note"><p>jsDelivr mirrors every version published to npm at
481
482
  <code>cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@&lt;version&gt;/&lt;file&gt;</code>. Pin an exact
482
- version for anything shipped — <code>@1.7.0</code>, not <code>@latest</code> — so a release does
483
+ version for anything shipped — <code>@1.7.1</code>, not <code>@latest</code> — so a release does
483
484
  not change what a page already in production loads. The same convention reaches any module:
484
485
  <code>.../modules/htmx.esm.min.js</code>, <code>.../modules/react.esm.min.js</code>, and so on.</p></div>
485
486
 
@@ -793,7 +794,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
793
794
  <tbody>
794
795
  <tr><td class="name">statusBar</td><td class="type">boolean | { panels }</td><td class="desc">Composable panels along the bottom. Default set: <code>rowCount</code>, <code>selectedCount</code>, <code>aggregation</code>, <code>comments</code>, <code>updates</code>, <code>progress</code>. Each is silent when it has nothing to report.</td></tr>
795
796
  <tr><td class="name">maximise</td><td class="type">boolean</td><td class="dflt">true</td><td class="desc"><code>false</code> removes the rail button and <code>grid.maximise</code>, for an application with its own full-screen mode.</td></tr>
796
- <tr><td class="name">toolPanel</td><td class="type">boolean | object</td><td class="desc">Side dock. <code>panels</code>: <code>columns</code>, <code>filters</code>, <code>views</code>, <code>quick</code>, <code>formatting</code>. <code>side: 'left'</code> makes it the icon rail, which also turns on <code>actions</code> (<code>undo</code>, <code>redo</code>, <code>pause</code>, <code>restore</code>, <code>maximise</code>, then the export group: <code>export</code>, <code>excel</code>, <code>clipboard</code>, <code>print</code> — nine in all, and an array takes these names rather than the button labels) and <code>icons</code>. An explicit array <em>replaces</em> that list rather than extending it; a bare <code>'-'</code> in it renders a divider between groups. <code>exportName</code> names the CSV.</td></tr>
797
+ <tr><td class="name">toolPanel</td><td class="type">boolean | object</td><td class="desc">Side dock. <code>panels</code>: <code>columns</code>, <code>filters</code>, <code>views</code>, <code>quick</code>, <code>formatting</code>, <code>statistics</code>. <code>side: 'left'</code> makes it the icon rail, which also turns on <code>actions</code> (<code>undo</code>, <code>redo</code>, <code>pause</code>, <code>restore</code>, <code>maximise</code>, then the export group: <code>export</code>, <code>excel</code>, <code>clipboard</code>, <code>print</code> — nine in all, and an array takes these names rather than the button labels) and <code>icons</code>. An explicit array <em>replaces</em> that list rather than extending it; a bare <code>'-'</code> in it renders a divider between groups. <code>exportName</code> names the CSV.</td></tr>
797
798
  <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>
798
799
  <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>
799
800
  <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>
@@ -930,7 +931,7 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
930
931
  <table>
931
932
  <thead><tr><th>Member</th><th>Returns</th><th>Description</th></tr></thead>
932
933
  <tbody>
933
- <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.7.0'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
934
+ <tr><td class="sig">getVersion()</td><td class="type">string</td><td class="desc">The version this grid came from, e.g. <code>'1.7.1'</code>. Also on the module as <code>getVersion()</code>, for when you have no grid to hand.</td></tr>
934
935
  <tr><td class="sig">get(key)</td><td class="type">unknown</td><td class="desc">Read any configuration key.</td></tr>
935
936
  <tr><td class="sig">set(key, value)</td><td class="type">void</td><td class="desc">Write one key. Every key is live; nothing needs a rebuild.</td></tr>
936
937
  <tr><td class="sig">setAll(values)</td><td class="type">void</td><td class="desc">Write several in one pass. Emits one <code>config:changed</code> for the batch, not one per key.</td></tr>
@@ -1563,6 +1564,89 @@ grid.formatting.clear('margin'); // or clear() for everything</
1563
1564
  </div>
1564
1565
  <div class="note"><p>A rule held here must be JSON: <code>style</code> may not be a function, because the rules are serialised into views and undo slices. Config-time <code>cell.style</code> still accepts one. Group rows are not formatted, matching the way decoration is dropped for them.</p></div>
1565
1566
 
1567
+ <h3>Rules that describe the data, not a threshold</h3>
1568
+ <p><code>gt: 100</code> needs somebody to know that 100 is the interesting number. Often nobody does — the interesting cells are <em>the top decile</em>, or <em>the outliers</em>, and where those fall is a property of the data rather than of the rule. These operators say that directly, and the grid works out the threshold from the column itself, over the filtered rows.</p>
1569
+ <pre><code>grid.formatting.add('margin', { when: { op: 'outlier' }, style: { background: '#fbeceb' } });
1570
+ grid.formatting.add('qty', { when: { op: 'topPercent', value: 10 }, style: { bold: true } });
1571
+ grid.formatting.add('score', { scale: { from: 'quantile', colours: ['#f8f9fa', '#1a6bc7'] } });
1572
+
1573
+ grid.formatting.distribution('margin'); // { n, min, max, mean, stddev, median, q1, q3, iqr }
1574
+ grid.formatting.restat(); // re-derive every threshold from the data as it stands</code></pre>
1575
+ <div class="table-wrap">
1576
+ <table>
1577
+ <thead><tr><th>Operator</th><th><code>value</code></th><th>Marks</th></tr></thead>
1578
+ <tbody>
1579
+ <tr><td class="sig">topPercent</td><td class="type">10 or 0.1</td><td class="desc">The top tenth of the column. Written either way; both mean the same thing.</td></tr>
1580
+ <tr><td class="sig">bottomPercent</td><td class="type">10 or 0.1</td><td class="desc">The bottom tenth.</td></tr>
1581
+ <tr><td class="sig">topN</td><td class="type">5</td><td class="desc">The five largest, ties included — three rows sharing second place in a top three all take the colour.</td></tr>
1582
+ <tr><td class="sig">bottomN</td><td class="type">5</td><td class="desc">The five smallest.</td></tr>
1583
+ <tr><td class="sig">aboveMean / belowMean</td><td class="type">—</td><td class="desc">Either side of the mean.</td></tr>
1584
+ <tr><td class="sig">aboveMedian / belowMedian</td><td class="type">—</td><td class="desc">Either side of the median, which is the one to reach for on a skewed column.</td></tr>
1585
+ <tr><td class="sig">zAbove / zBelow</td><td class="type">2</td><td class="desc">That many standard deviations from the mean. A column with no spread marks nothing rather than everything.</td></tr>
1586
+ <tr><td class="sig">outlier</td><td class="type">1.5</td><td class="desc">Outside Tukey's fences at that many IQRs — the same definition a box plot draws, so the marked cells are the ones its whiskers exclude.</td></tr>
1587
+ </tbody>
1588
+ </table>
1589
+ </div>
1590
+ <p>A colour scale can take its bounds the same way, with <code>from</code> in place of <code>min</code> and <code>max</code>: <code>'minmax'</code> spans the data, <code>'quantile'</code> spans <code>low</code> to <code>high</code> (5th to 95th percentile by default), <code>'stddev'</code> spans <code>deviations</code> either side of the mean. The quantile form is the better default on real data — one mistyped order of magnitude otherwise compresses every real value into the first swatch.</p>
1591
+ <div class="note"><p>Thresholds are <strong>pinned</strong> when the rules compile and do not move on their own. That is deliberate: a boundary that re-derived itself as rows were filtered would repaint cells whose values had not changed, and nobody comparing two screenshots could tell which of the two things had moved. <code>grid.formatting.restat()</code> is how you move it, and a "recalculate" control is the natural place to put it.</p></div>
1592
+
1593
+ <h2 id="statistics">grid.statistics</h2>
1594
+ <p>What the grid knows about its own numbers, and about how they have changed since the page loaded. Every figure is computed over the <em>filtered</em> rows, through the same column handles the totals row uses — so a median here and a median in the footer are the same number, by the same definition (R type 7).</p>
1595
+ <pre><code>grid.statistics.profile('margin');
1596
+ // { column, rows, present, missing, distinct, min, max, mean, median,
1597
+ // q1, q3, iqr, stddev, outliers, histogram: [{ from, to, count }, …] }
1598
+
1599
+ grid.statistics.reduce('margin', 'p95'); // any registered kernel
1600
+ grid.statistics.correlation('spend', 'revenue'); // Pearson's r, clamped to [-1, 1]
1601
+ grid.statistics.weightedAverage('price', 'qty');
1602
+
1603
+ grid.statistics.shadow('price', 'delta', 'R42'); // one row's shadow value
1604
+ grid.statistics.rebase('price'); // "mark all" — today's values become the baseline
1605
+ grid.statistics.tracking(); // { columns, rows, forgotten }</code></pre>
1606
+ <p>The <code>statistics</code> tool panel is the end-user half of <code>profile()</code>: a column picker, the twelve figures and a histogram of the column's shape, all following the filters. Add it with <code>toolPanel: { panels: ['columns', 'statistics'] }</code>.</p>
1607
+
1608
+ <h3>Shadow columns</h3>
1609
+ <p>A shadow column is declared against another column and maintained by the grid. It has no field in the data and it is not a pure computed column either, because its value depends on what happened <em>before</em>. It is a real column throughout: sortable, filterable, totalled, grouped, exported, saved into a view — which is what makes "show me every circuit repriced more than twice this session, most-changed first" one gesture rather than a report.</p>
1610
+ <pre><code>columns: [
1611
+ { field: 'price', type: 'number' },
1612
+ { id: 'moved', title: 'Change', shadow: { of: 'price', kind: 'delta' } },
1613
+ { id: 'churn', title: 'Updates', shadow: { of: 'price', kind: 'updates' } },
1614
+ { id: 'run', title: 'Streak', shadow: 'streak' }, // shorthand: shadows the column beside it
1615
+ ]</code></pre>
1616
+ <div class="table-wrap">
1617
+ <table>
1618
+ <thead><tr><th>Kind</th><th>Value</th></tr></thead>
1619
+ <tbody>
1620
+ <tr><td class="sig">updates</td><td class="desc">How many times the row's value has changed. Arrival is not a change, so a freshly loaded grid reads zero rather than one.</td></tr>
1621
+ <tr><td class="sig">updatedAt</td><td class="desc">When it last changed, as a <code>Date</code>.</td></tr>
1622
+ <tr><td class="sig">sinceUpdate</td><td class="desc">Milliseconds since it last changed.</td></tr>
1623
+ <tr><td class="sig">delta</td><td class="desc">Current value minus the baseline.</td></tr>
1624
+ <tr><td class="sig">deltaPercent</td><td class="desc">The same as a percentage. A change from nothing has no percentage and reads null rather than infinity.</td></tr>
1625
+ <tr><td class="sig">rate</td><td class="desc">Change per second, from the last two readings.</td></tr>
1626
+ <tr><td class="sig">history</td><td class="desc">The recent readings, oldest first. <code>depth</code> sets how many; the default is 20.</td></tr>
1627
+ <tr><td class="sig">firstValue</td><td class="desc">The baseline itself.</td></tr>
1628
+ <tr><td class="sig">streak</td><td class="desc">Consecutive moves in one direction, signed. It resets on a turn, because "seven rises" means something and "seven changes" does not.</td></tr>
1629
+ </tbody>
1630
+ </table>
1631
+ </div>
1632
+ <p>A second family answers where the row sits among the others rather than what it did before. They share the same declaration and the same state — the tracker already holds every row's current value <em>and</em> its baseline, which is exactly what a rank and a rank <em>change</em> need.</p>
1633
+ <div class="table-wrap">
1634
+ <table>
1635
+ <thead><tr><th>Kind</th><th>Value</th></tr></thead>
1636
+ <tbody>
1637
+ <tr><td class="sig">rank</td><td class="desc">Competition rank, largest first: ties share the better rank and the next value skips, so two firsts are followed by a third.</td></tr>
1638
+ <tr><td class="sig">rankAsc</td><td class="desc">The same ranking read from the other end.</td></tr>
1639
+ <tr><td class="sig">rankChange</td><td class="desc">Places climbed since the baseline. Positive means climbed, even though the rank number itself falls — this is the "top movers" column.</td></tr>
1640
+ <tr><td class="sig">percentile</td><td class="desc">The share of rows at or below this one, 0 to 100.</td></tr>
1641
+ <tr><td class="sig">quartile</td><td class="desc">1 to 4, agreeing with <code>percentile</code>: the 60th percentile is in the third quartile.</td></tr>
1642
+ <tr><td class="sig">zScore</td><td class="desc">Deviations from the mean. A column with no spread reads null rather than zero.</td></tr>
1643
+ <tr><td class="sig">shareOfTotal</td><td class="desc">The value over the column's total, as a percentage. A total of zero — a column of offsetting positions — reads null rather than a division by it.</td></tr>
1644
+ </tbody>
1645
+ </table>
1646
+ </div>
1647
+ <div class="note"><p>Positional kinds rank over every <em>tracked</em> row, not over the filtered set: a rank that changed as you filtered would make "the top ten movers" depend on what happened to be on screen, and the column would disagree with itself between two views of the same data. For the filtered answer, use <code>grid.statistics.profile()</code> or a distribution formatting rule.</p></div>
1648
+ <div class="note"><p>Shadow state is keyed by row key, never by index — after any sort an index-keyed history would report one row's past against another row's present, and the wrong number would be <em>sortable</em>. Memory is capped at 200,000 tracked rows per column; past that the oldest are dropped and <code>tracking().forgotten</code> says how many, rather than a smaller number being reported as though it were the truth.</p></div>
1649
+
1566
1650
  <h2 id="highlight">grid.highlight</h2>
1567
1651
  <p>One mechanism for two jobs: the flash a changed cell makes, and a marker you paint deliberately. A target is a cell (<code>{key, colId}</code>), a row (<code>{key}</code>, or a bare row key) or a column (<code>{colId}</code>). Cell beats row beats column, so a specific highlight is never hidden by a broad one laid over it.</p>
1568
1652
  <pre><code>createGrid(el, {
@@ -2110,10 +2194,13 @@ grid.filters.quickState(); <span class="cmt">// { text: 'acme london', mode: '
2110
2194
  <tr><td class="sig">Rounding</td><td><code>ROUND</code>, <code>ROUNDUP</code>, <code>ROUNDDOWN</code>, <code>FLOOR</code>, <code>CEILING</code></td></tr>
2111
2195
  <tr><td class="sig">Logic</td><td><code>IF</code>, <code>AND</code>, <code>OR</code>, <code>NOT</code>, <code>COALESCE</code></td></tr>
2112
2196
  <tr><td class="sig">Text</td><td><code>CONCAT</code>, <code>LEN</code>, <code>UPPER</code>, <code>LOWER</code>, <code>TRIM</code>, <code>LEFT</code>, <code>RIGHT</code></td></tr>
2197
+ <tr><td class="sig">Statistics</td><td><code>MEDIAN</code>, <code>PERCENTILE</code>, <code>QUARTILE1</code>, <code>QUARTILE3</code>, <code>IQR</code>, <code>STDEV</code>, <code>STDEVP</code>, <code>VAR</code>, <code>VARP</code>, <code>COUNTDISTINCT</code></td></tr>
2113
2198
  </tbody>
2114
2199
  </table>
2115
2200
  </div>
2116
2201
 
2202
+ <div class="note"><p>The statistical functions use R type 7 quantiles — the same definition as the totals row, <code>grid.statistics</code> and the distribution formatting rules, so the four never disagree about what a median is. <code>PERCENTILE</code> reads <code>90</code> and <code>0.9</code> as the same request. Over an empty set they return a number rather than null, because a formula is arithmetic and has to keep composing.</p></div>
2203
+
2117
2204
  <p>Operators <code>+ - * / ^</code> with parentheses, comparison for <code>IF</code>, and postfix
2118
2205
  <code>%</code>. <code>^</code> is right-associative and unary minus binds tighter than it, so
2119
2206
  <code>-2^2</code> is 4 — Excel's answer rather than mathematics'.</p>
@@ -2332,7 +2419,7 @@ createGrid(el, {
2332
2419
  </div>
2333
2420
 
2334
2421
  <footer>
2335
- Lattice Grid 1.7.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
2422
+ Lattice Grid 1.7.1 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
2336
2423
  This document describes the behaviour of the shipped library. Where this guide and the code
2337
2424
  disagree, the code wins — please <a href="https://www.latticegrid.dev">tell us</a>.
2338
2425
  </footer>
@@ -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.7.0</p>
440
+ <p class="rail__sub">Developer guide · v1.7.1</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -537,7 +537,7 @@
537
537
  <a href="API.html">reference tables</a> are the shorter version for when you already know.
538
538
  </p>
539
539
  <p class="chips">
540
- <span class="chip">Version 1.7.0</span>
540
+ <span class="chip">Version 1.7.1</span>
541
541
  <span class="chip">Zero dependencies</span>
542
542
  <span class="chip">No build step</span>
543
543
  </p>
@@ -652,8 +652,8 @@ createGrid(element, { direction: 'rtl' }); <span class="cmt">// or say so out
652
652
 
653
653
  <div class="example">
654
654
  <p class="example__label">jsDelivr — no npm install, no bundler</p>
655
- <pre><code>&lt;link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.0/lattice-grid.min.css"&gt;
656
- &lt;script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.0/lattice-grid.min.js"&gt;&lt;/script&gt;
655
+ <pre><code>&lt;link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.1/lattice-grid.min.css"&gt;
656
+ &lt;script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.7.1/lattice-grid.min.js"&gt;&lt;/script&gt;
657
657
 
658
658
  &lt;script&gt;
659
659
  <span class="kw">const</span> grid = LatticeGrid.createGrid(document.getElementById('grid'), config);
@@ -679,7 +679,7 @@ createGrid(element, { direction: 'rtl' }); <span class="cmt">// or say so out
679
679
  <div class="note">
680
680
  <p><strong>jsDelivr mirrors every version published to npm</strong> at
681
681
  <code>cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@&lt;version&gt;/&lt;file&gt;</code> — pin an
682
- exact version, e.g. <code>@1.7.0</code> rather than <code>@latest</code>, so a later release
682
+ exact version, e.g. <code>@1.7.1</code> rather than <code>@latest</code>, so a later release
683
683
  does not change what a page already in production loads. The same convention reaches a
684
684
  module: <code>.../modules/htmx.esm.min.js</code>, <code>.../modules/dhtmlx-compat.esm.min.js</code>,
685
685
  and so on. Type declarations resolve automatically through npm's own <code>types</code> field;
@@ -1168,7 +1168,7 @@ off(); <span class="cmt">// every subscrip
1168
1168
  </p>
1169
1169
  <div class="example">
1170
1170
  <p class="example__label">Which version am I running?</p>
1171
- <pre><code>grid.getVersion(); <span class="cmt">// '1.7.0'</span>
1171
+ <pre><code>grid.getVersion(); <span class="cmt">// '1.7.1'</span>
1172
1172
  LatticeGrid.getVersion(); <span class="cmt">// the same, when you have no grid to hand</span></code></pre>
1173
1173
  </div>
1174
1174
  <p class="lead-in">
@@ -1652,9 +1652,10 @@ grid.set('theme', <span class="kw">null</span>); <span class="cmt">// back to
1652
1652
  <div class="why">
1653
1653
  <p><strong>Why this is needed at all.</strong> A grid is mounted inside somebody else's
1654
1654
  stylesheet. A rule as ordinary as <code>section { padding: 5.5rem 0 }</code> — a marketing
1655
- page, a CMS theme, a Tailwind preflight — used to reach inside and put 88px of padding on every
1656
- filter row in the tool panel, because the grid builds those rows as <code>&lt;section&gt;</code>
1657
- elements and its own rules said nothing about padding there.</p>
1655
+ page, a CMS theme, a Tailwind preflight — matches by tag name, and the grid builds parts of
1656
+ its own interface from those tags: the tool panel's filter rows are
1657
+ <code>&lt;section&gt;</code> elements. Without the reset, 5.5rem of somebody else's padding
1658
+ lands on every one of them.</p>
1658
1659
  <p>The reset uses no <code>!important</code>. It is specificity (0,1,1) and every rule that
1659
1660
  dresses a grid element is (0,2,0) or higher, so the grid's own styling always wins and the
1660
1661
  reset only fills a gap. Yours wins too, on the same terms: a rule aimed at a Lattice class —
@@ -4440,10 +4441,11 @@ r.ok ? r.value : r.error; <span class="cmt">// 42</span></code></pre>
4440
4441
  </p>
4441
4442
  <div class="why">
4442
4443
  <p><strong>Bare arithmetic is deliberately not a formula.</strong> <code>2-1</code> is a
4443
- plausible product code and <code>1/2</code> a plausible date. Evaluating either on a guess would
4444
- repeat the bug this replaced, where the number reader stripped the operator and stored
4445
- <code>2*3</code> as <strong>23</strong>. Arithmetic without a leading <code>=</code> is now
4446
- refused outright, so the cell keeps what it had rather than taking a plausible wrong number.</p>
4444
+ plausible product code and <code>1/2</code> a plausible date. A reader that evaluated either on
4445
+ a guess would have to guess wrong sometimes, and the wrong answer is not a visible error but a
4446
+ plausible number — <code>2*3</code> stored as <strong>23</strong> looks like data. Arithmetic
4447
+ without a leading <code>=</code> is refused outright, so the cell keeps what it had rather than
4448
+ taking a number nobody typed.</p>
4447
4449
  </div>
4448
4450
 
4449
4451
  <h2 id="custom-menu">Your own menu items and buttons</h2>
@@ -5124,7 +5126,7 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'trial'<
5124
5126
 
5125
5127
  <footer>
5126
5128
  <p>
5127
- Lattice Grid 1.7.0 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
5129
+ Lattice Grid 1.7.1 · Copyright © 2026 TOCLOCO Inc. All rights reserved.
5128
5130
  Written against the shipped source. Where this guide and the code disagree, the code wins —
5129
5131
  please <a href="https://www.latticegrid.dev">tell us</a>.
5130
5132
  </p>
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.7.0 — type declarations
2
+ * Lattice Grid 1.8.0 — type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -1389,15 +1389,31 @@ export interface StateApplyReport {
1389
1389
  // ---------------------------------------------------------------------------
1390
1390
 
1391
1391
  export interface FormattingCondition {
1392
- op: Operator;
1392
+ /**
1393
+ * A filter operator compared against `value`, or a distribution operator
1394
+ * whose threshold comes from the column itself — `{op: 'topPercent', value: 10}`,
1395
+ * `{op: 'outlier'}`. Distribution thresholds are pinned when the rules
1396
+ * compile; `grid.formatting.restat()` moves them.
1397
+ */
1398
+ op: Operator | DistributionOp;
1393
1399
  value?: unknown;
1394
1400
  value2?: unknown;
1395
1401
  }
1396
1402
 
1397
1403
  export interface FormattingScale {
1398
- min: number;
1399
- max: number;
1404
+ /**
1405
+ * Where the bounds come from when `min` and `max` are not given.
1406
+ * `'minmax'` spans the data, `'quantile'` spans `low` to `high`
1407
+ * (5th to 95th percentile by default), `'stddev'` spans `deviations`
1408
+ * either side of the mean.
1409
+ */
1410
+ from?: 'minmax' | 'quantile' | 'stddev';
1411
+ min?: number;
1412
+ max?: number;
1400
1413
  mid?: number;
1414
+ low?: number;
1415
+ high?: number;
1416
+ deviations?: number;
1401
1417
  colours?: string[];
1402
1418
  }
1403
1419
 
@@ -1421,6 +1437,58 @@ export interface FormattingRule {
1421
1437
  /** A column id, or `'*'` for every column. */
1422
1438
  export type FormattingScope = string;
1423
1439
 
1440
+ export interface StatisticsApi {
1441
+ /** One shadow value for one row, by the column it shadows and the kind. */
1442
+ shadow(colId: string, kind: ShadowKind, rowKey: string): unknown;
1443
+ /** Make the current values the new baseline — "mark all". */
1444
+ rebase(colId?: string): void;
1445
+ /** What the shadow histories are costing. */
1446
+ tracking(): { columns: string[]; rows: number; forgotten: number };
1447
+ /** Reduce a column by a named kernel over the filtered rows. */
1448
+ reduce(colId: string, fn: string): unknown;
1449
+ /** Everything worth knowing about one column, in one pass each. */
1450
+ profile(colId: string): ColumnProfile | null;
1451
+ /** Pearson's correlation between two columns. */
1452
+ correlation(a: string, b: string): number | null;
1453
+ /** A weighted average of one column by another. */
1454
+ weightedAverage(colId: string, weightId: string): number | null;
1455
+ /** The key a row's data resolves to. */
1456
+ keyOf(data: unknown): string | null;
1457
+ /** Which reductions can be maintained against a change, and which rescan. */
1458
+ readonly maintenance: Readonly<Record<string, 'maintained' | 'rescan'>>;
1459
+ }
1460
+
1461
+ export type ShadowKind =
1462
+ | 'updates' | 'updatedAt' | 'sinceUpdate' | 'delta' | 'deltaPercent'
1463
+ | 'rate' | 'history' | 'firstValue' | 'streak'
1464
+ /** Where the row sits among the others, over every tracked row. */
1465
+ | 'rank' | 'rankAsc' | 'rankChange' | 'percentile' | 'quartile'
1466
+ | 'zScore' | 'shareOfTotal';
1467
+
1468
+ export interface ColumnProfile {
1469
+ column: string;
1470
+ rows: number;
1471
+ present: number;
1472
+ missing: number;
1473
+ distinct: number;
1474
+ min: number | null;
1475
+ max: number | null;
1476
+ mean: number | null;
1477
+ median: number | null;
1478
+ q1: number | null;
1479
+ q3: number | null;
1480
+ iqr: number | null;
1481
+ stddev: number | null;
1482
+ outliers: number;
1483
+ histogram: HistogramBin[];
1484
+ }
1485
+
1486
+ export interface HistogramBin {
1487
+ from: number;
1488
+ to: number;
1489
+ count: number;
1490
+ }
1491
+
1424
1492
  export interface FormattingApi {
1425
1493
  list(scope?: FormattingScope): FormattingRule[];
1426
1494
  all(): Record<FormattingScope, FormattingRule[]>;
@@ -1433,6 +1501,29 @@ export interface FormattingApi {
1433
1501
  replaceAll(rules: Record<FormattingScope, FormattingRule[]>): void;
1434
1502
  clear(scope?: FormattingScope): void;
1435
1503
  styleFor(colId: string, value: unknown): CellStyle | null;
1504
+ /** Re-derive the thresholds of distribution rules from the data as it stands. */
1505
+ restat(): void;
1506
+ /** The five numbers a distribution rule resolves against for one column. */
1507
+ distribution(colId: string): ColumnDistribution | null;
1508
+ }
1509
+
1510
+ /** Operators that resolve against the column's own distribution (spec 8.12). */
1511
+ export type DistributionOp =
1512
+ | 'topPercent' | 'bottomPercent' | 'topN' | 'bottomN'
1513
+ | 'aboveMean' | 'belowMean' | 'aboveMedian' | 'belowMedian'
1514
+ | 'zAbove' | 'zBelow' | 'outlier';
1515
+
1516
+ export interface ColumnDistribution {
1517
+ n: number;
1518
+ min: number;
1519
+ max: number;
1520
+ mean: number;
1521
+ stddev: number;
1522
+ median: number;
1523
+ q1: number;
1524
+ q3: number;
1525
+ iqr: number;
1526
+ sorted: number[];
1436
1527
  }
1437
1528
 
1438
1529
  // ---------------------------------------------------------------------------
@@ -2430,6 +2521,7 @@ export interface Grid {
2430
2521
  readonly comments: CommentsApi;
2431
2522
  readonly presence: PresenceApi;
2432
2523
  readonly diagnostics: DiagnosticsApi;
2524
+ readonly statistics: StatisticsApi;
2433
2525
  readonly formatting: FormattingApi;
2434
2526
  readonly maximise?: MaximiseApi;
2435
2527
  /**