@toclocoinc/lattice-grid 1.9.1 → 1.11.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.9.1 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.11.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
@@ -61,7 +61,7 @@ enough to know whether the grid covers what you need.
61
61
 
62
62
  ### Working with the data
63
63
 
64
- - **Editing.** Cell, row and form editing, with over twenty editors — text,
64
+ - **Editing.** Cell, row and form editing, with twenty-three editors — text,
65
65
  number, date, time, select, multi-select, colour, rating, slider, segmented,
66
66
  code, password, icon picker and more. Validation, async commits, optimistic
67
67
  updates with rollback, and a full undo history.
@@ -78,14 +78,14 @@ enough to know whether the grid covers what you need.
78
78
 
79
79
  ### Seeing the data
80
80
 
81
- - **Charts.** `modules/charts` draws thirty chart types from the grid's own
81
+ - **Charts.** `modules/charts` draws thirty-five chart types from the grid's own
82
82
  data — line, bar, area, scatter, pie, donut, sunburst, treemap, radar, gauge,
83
83
  funnel, heatmap, histogram, box plot, candlestick, combo, geomap, sankey,
84
84
  chord, network, stream, violin, gantt and more. They follow the grid's
85
85
  filters, and clicking a mark can filter it in turn.
86
86
  - **Statistics.** `grid.statistics` profiles a column in one pass — count,
87
87
  missing, distinct, five-number summary, standard deviation, outliers and a
88
- histogram — and answers correlations and weighted averages. Twenty-eight
88
+ histogram — and answers correlations and weighted averages. Thirty-eight
89
89
  reduction kernels are available to the totals row, and you can register your
90
90
  own.
91
91
  - **Shadow columns.** Values the grid maintains about itself: how many times a
@@ -119,7 +119,8 @@ enough to know whether the grid covers what you need.
119
119
  - **Accessible.** Keyboard operable throughout, ARIA grid semantics, a live
120
120
  region for announcements, honours reduced motion, forced colours and large
121
121
  target sizes. The devtools module runs the accessibility checks in place.
122
- - **Internationalised.** Eighteen complete locales, right-to-left layout, and
122
+ - **Internationalised.** Twenty-two complete locale catalogues across nineteen
123
+ languages, right-to-left layout, and
123
124
  locale-aware number, date and currency formatting throughout.
124
125
  - **Themeable** through CSS custom properties, with light and dark built in and
125
126
  a reset that keeps a host page's stylesheet out.
package/docs/API.html CHANGED
@@ -768,6 +768,8 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
768
768
  <tr><td class="name">density</td><td class="type">'compact' | 'standard' | 'comfortable' | 'spacious' | number</td><td class="dflt">'compact'</td><td class="desc">One scale that every geometry token derives from — row height, spacing, decoration sizes, and type at a damped rate. Row heights are 23.8 / 28 / 42 / 56px. A number scales 28px, so <code>1.4</code> gives 39.2px for anything between the presets. Virtualisation follows it; an explicit <code>rowHeight</code> overrides it.</td></tr>
769
769
  <tr><td class="name">rowHeight</td><td class="type">number | (row) =&gt; number</td><td class="dflt">28</td><td class="desc">A function enables variable-height rows.</td></tr>
770
770
  <tr><td class="name">headerHeight</td><td class="type">number</td><td class="dflt">32</td><td class="desc">Per header row.</td></tr>
771
+ <tr><td class="name">title</td><td class="type">string</td><td class="dflt">&mdash;</td><td class="desc">A caption drawn above the column headings. Inside the grid rather than an element placed above it, so it scrolls with the grid, sits in the region a screen reader announces, and is kept by image capture and print.</td></tr>
772
+ <tr><td class="name">showHeader</td><td class="type">boolean</td><td class="dflt">true</td><td class="desc">Draw the column headings at all. <code>false</code> removes the row, and removes it from the accessibility tree rather than only from view. Distinct from <code>showColumnFunctions</code>, which keeps the headings and drops only their sort, filter and menu controls.</td></tr>
771
773
  <tr><td class="name">overscan</td><td class="type">number</td><td class="dflt">4</td><td class="desc">Rows rendered beyond the viewport.</td></tr>
772
774
  <tr><td class="name">autoHeight</td><td class="type">boolean | 'visible'</td><td class="dflt">—</td><td class="desc">Size rows to their content: cells wrap instead of ellipsising, and each row takes the height its tallest cell needs. Only rendered rows are measured either way — the difference is that <code>true</code> gives up above 10,000 rows and returns to fixed heights, while <code>'visible'</code> keeps measuring at any size and accepts a scrollbar that shifts as rows are measured on the way past.</td></tr>
773
775
  <tr><td class="name">columnVirtualisationAbove</td><td class="type">number</td><td class="dflt">30</td><td class="desc">Column count above which columns virtualise too.</td></tr>
@@ -1680,6 +1682,62 @@ stats.destroy(); // yours to call: the element belongs to your page</code></pr
1680
1682
  </table>
1681
1683
  </div>
1682
1684
 
1685
+ <h3 id="derived-values">Values the grid maintains for you</h3>
1686
+ <p>
1687
+ Three of the ideas in this section are not standard grid vocabulary, so it is worth saying what
1688
+ they have in common before the detail. <strong>Each of them is a value the grid keeps up to date
1689
+ from data you already have</strong>, declared once rather than maintained by hand.
1690
+ </p>
1691
+ <p>
1692
+ The alternative, in every application that needs one of these, is a parallel structure in the
1693
+ host: a copy of what each row looked like a moment ago, a rank recomputed on every tick, a
1694
+ cumulative total that has to be redone whenever the sort changes, and a dashboard panel running
1695
+ its own query beside the table. That code works for a while and then produces a number nobody
1696
+ can account for, usually because one part of it noticed a filter and another did not.
1697
+ </p>
1698
+ <div class="table-wrap">
1699
+ <table>
1700
+ <thead><tr><th>Concept</th><th>What it is</th><th>Reach for it when</th></tr></thead>
1701
+ <tbody>
1702
+ <tr>
1703
+ <td class="name">Shadow column</td>
1704
+ <td class="desc">An extra column, declared against a real one, holding something the grid works out about it: how it has changed, or where it sits among the others.</td>
1705
+ <td class="desc">You want &ldquo;what was this an hour ago&rdquo;, &ldquo;how many places has it moved&rdquo;, or &ldquo;which decile is it in&rdquo; as a column you can sort and filter on.</td>
1706
+ </tr>
1707
+ <tr>
1708
+ <td class="name">Running column</td>
1709
+ <td class="desc">A cumulative value: the total, or the share of the total, <em>by the time you reach this row</em>.</td>
1710
+ <td class="desc">You want a running balance, a cumulative percentage, or a Pareto curve down the page.</td>
1711
+ </tr>
1712
+ <tr>
1713
+ <td class="name">Derived grid</td>
1714
+ <td class="desc">A whole second grid whose rows are built from the first: grouped, unnested, filtered, ranked or profiled.</td>
1715
+ <td class="desc">You want a top-five panel, a breakdown by region, an exceptions list or a statistics summary <em>beside</em> the table, and it must never disagree with it.</td>
1716
+ </tr>
1717
+ </tbody>
1718
+ </table>
1719
+ </div>
1720
+ <p>
1721
+ <strong>The line between a shadow and a running column is the sort order.</strong> A shadow is a
1722
+ function of the column: a row&rsquo;s own history, or where its value sits among the others.
1723
+ Sort the grid differently and a rank is still the same rank. A running total is the opposite
1724
+ &mdash; it answers &ldquo;how much by the time we reach this row&rdquo;, and <em>by the time</em>
1725
+ is the order the rows are in, so re-sorting changes every value in the column. That is why they
1726
+ are declared separately rather than as two kinds of one thing.
1727
+ </p>
1728
+ <p>
1729
+ <strong>A derived grid is a different scale of the same idea.</strong> A shadow adds a column to
1730
+ the rows you have; a derivation produces different rows altogether &mdash; one per sales person
1731
+ rather than one per sale. Because it is a <em>source</em> rather than a special kind of grid,
1732
+ the result sorts, filters, totals, themes and exports like any other, and can itself be the
1733
+ source of another.
1734
+ </p>
1735
+ <p class="section-note">
1736
+ All three read the rows the grid is currently showing, so a filter applied to the table moves
1737
+ the ranks, the running totals and every derived panel together. That is the property worth
1738
+ having: not that any one of them is clever, but that they cannot disagree.
1739
+ </p>
1740
+
1683
1741
  <h3>Shadow columns</h3>
1684
1742
  <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>
1685
1743
  <pre><code>columns: [
@@ -1720,7 +1778,12 @@ stats.destroy(); // yours to call: the element belongs to your page</code></pr
1720
1778
  </table>
1721
1779
  </div>
1722
1780
  <h3>Running totals</h3>
1723
- <p>A running total is <strong>not</strong> a shadow kind, and the distinction is the design. Every shadow is a function of the column — of a row's own history, or of where its value sits among the others — so it reads the same however the rows are arranged. A running total does not: sort the grid differently and every value changes, because the question is "how much by the time we reach this row", and <em>by the time</em> is the sort order.</p>
1781
+ <p>
1782
+ A running column answers &ldquo;how much by the time we reach this row&rdquo;: a balance
1783
+ that accumulates down the page, or the share of the total accounted for so far. It is the
1784
+ column a Pareto chart is made of, and the one a finance report opens with.
1785
+ </p>
1786
+ <p>It is declared separately from a shadow column, and the sort order is the reason. Every shadow is a function of the column — of a row's own history, or of where its value sits among the others — so it reads the same however the rows are arranged. A running total does not: sort the grid differently and every value changes, because the question is "how much by the time we reach this row", and <em>by the time</em> is the sort order.</p>
1724
1787
  <pre><code>columns: [
1725
1788
  { field: 'amount', type: 'number' },
1726
1789
  { id: 'cum', title: 'Running', running: { of: 'amount', kind: 'total' } },
@@ -2028,9 +2091,220 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'tria
2028
2091
  <tr><td class="sig">paged</td><td class="type">fetch</td><td class="desc">A page at a time from a server that paginates.</td></tr>
2029
2092
  <tr><td class="sig">remote</td><td class="type">fetch</td><td class="desc">Blocks fetched as the viewport reaches them, with sort, filter and grouping pushed to the server.</td></tr>
2030
2093
  <tr><td class="sig">stream</td><td class="type">connect</td><td class="desc">Rows arriving over time. Promotes to memory once complete.</td></tr>
2094
+ <tr><td class="sig">derived</td><td class="type">from</td><td class="desc">Rows built from another grid: grouped, unnested, filtered, ranked or profiled. Read-only, and follows the source.</td></tr>
2095
+ </tbody>
2096
+ </table>
2097
+ </div>
2098
+
2099
+ <h3 id="derived-source">Derived sources: a grid built from another grid</h3>
2100
+ <p class="section-note">
2101
+ Most dashboards put a summary panel beside a table &mdash; the top five sales people, the
2102
+ breakdown by region, the exceptions list. Built by hand, that panel runs its own query, and
2103
+ sooner or later somebody filters the table and the panel does not follow. Everyone who has
2104
+ shipped a dashboard has been in the meeting where two numbers on one screen disagree.
2105
+ </p>
2106
+ <p>
2107
+ A derived grid removes the possibility. It is a second grid whose rows are <em>built from the
2108
+ first</em> &mdash; grouped, unnested, filtered, ranked or profiled &mdash; so the panel is the
2109
+ table, one derivation later, and one filter moves both. It answers the questions a summary
2110
+ panel exists for: the top five sales people, the most-sold SKUs, a statistical profile of
2111
+ whatever the user has filtered to.
2112
+ </p>
2113
+ <p>
2114
+ It is a <em>source</em> rather than a new kind of grid, so everything downstream &mdash; its own sorting and filters,
2115
+ totals, shadow columns, formatting, export, themes &mdash; works on the result and knows
2116
+ nothing about where the rows came from. Charts bind to one as readily as to any grid.
2117
+ </p>
2118
+ <pre><code>source: {
2119
+ mode: 'derived',
2120
+ from: salesGrid,
2121
+ follow: 'filtered',
2122
+
2123
+ groupBy: 'rep',
2124
+ select: { revenue: { of: 'amount', fn: 'sum' }, deals: { fn: 'count' } },
2125
+ sort: [{ col: 'revenue', dir: 'desc' }],
2126
+ limit: 5,
2127
+ }</code></pre>
2128
+ <p class="section-note">
2129
+ The pipeline runs in one order, and the order is the contract:
2130
+ <code>unnest &rarr; where &rarr; bucket &rarr; group &rarr; reduce &rarr; sort &rarr; limit</code>.
2131
+ <code>where</code> sits before grouping deliberately: filtering afterwards is a different
2132
+ question &mdash; which <em>groups</em>, not which <em>rows</em> &mdash; and one key cannot mean both.
2133
+ </p>
2134
+ <div class="table-wrap">
2135
+ <table>
2136
+ <thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
2137
+ <tbody>
2138
+ <tr><td class="name">from</td><td class="type">Grid</td><td class="desc">Required. The grid to read.</td></tr>
2139
+ <tr><td class="name">follow</td><td class="type">'filtered' | 'all' | 'selected' | 'grouped'</td><td class="desc">Which of its rows to read. <code>filtered</code> by default. <code>grouped</code> re-aggregates by whatever dimension the user has grouped the source by, so a panel tracks the reader rather than a dimension fixed when the page was built; with the source ungrouped it falls back to <code>groupBy</code>.</td></tr>
2140
+ <tr><td class="name">unnest</td><td class="type">string</td><td class="desc">Expand an array property, one row per element, keeping the parent's fields. Address the element with a dotted path afterwards &mdash; <code>lines.sku</code> is the element, <code>region</code> is still the parent. A row whose property is absent or empty contributes nothing.</td></tr>
2141
+ <tr><td class="name">join</td><td class="type">{ with, on, type, select, prefix, follow }</td><td class="desc">Match each row against a second grid on a shared key and bring some of its fields across. Runs after <code>unnest</code> and before <code>where</code>, so a condition &mdash; and a grouping, and a total &mdash; can read a field the join produced.</td></tr>
2142
+ <tr><td class="name">where</td><td class="type">(row) =&gt; boolean</td><td class="desc">A row predicate, applied before grouping. With no <code>groupBy</code> the rows pass through as themselves, which is how an exceptions list is built.</td></tr>
2143
+ <tr><td class="name">bucket</td><td class="type">{ of, by }</td><td class="desc">Round a date column down to the start of its period and group on that. <code>by</code> is <code>day</code>, <code>week</code>, <code>month</code>, <code>quarter</code> or <code>year</code>; weeks start on the ISO Monday.</td></tr>
2144
+ <tr><td class="name">groupBy</td><td class="type">string | string[]</td><td class="desc">The dimension, or dimensions, to group by. Omit to pass rows through.</td></tr>
2145
+ <tr><td class="name">select</td><td class="type">Record&lt;string, {of, fn}&gt;</td><td class="desc">The reduced columns, by output id. <code>fn</code> is any key of the totals-row kernels, so <code>median</code>, <code>p95</code>, <code>stddev</code> and <code>gini</code> are available as readily as <code>sum</code>. <code>count</code> needs no <code>of</code>.</td></tr>
2146
+ <tr><td class="name">sort</td><td class="type">{ col, dir }[]</td><td class="desc">Order the derived rows before limiting them. The grid's own user-facing sort is separate and unaffected.</td></tr>
2147
+ <tr><td class="name">limit</td><td class="type">number</td><td class="desc">Keep at most this many rows.</td></tr>
2148
+ <tr><td class="name">limitPer</td><td class="type">string</td><td class="desc">Apply <code>limit</code> within each distinct value of this column rather than overall &mdash; the best three SKUs in <em>each</em> region, which a global limit cannot express.</td></tr>
2149
+ <tr><td class="name">cumulative</td><td class="type">{ of, upTo }</td><td class="desc">Keep rows until their running share of the total reaches <code>upTo</code>, 0 to 1. The Pareto question. The row that crosses the cutoff is kept, because the set has to reach the share.</td></tr>
2150
+ <tr><td class="name">profile</td><td class="type">string | string[]</td><td class="desc">Replaces the pipeline with a transpose: one row per column, with count, present, missing, distinct, min, max, mean, median, quartiles, deviation and outlier count as its columns.</td></tr>
2151
+ <tr><td class="name">orient</td><td class="type">'columns' | 'metrics'</td><td class="desc">With <code>profile</code>, emit one row per statistic instead of one per column &mdash; the shape a dashboard tile wants.</td></tr>
2152
+ <tr><td class="name">crossFilter</td><td class="type">boolean | string</td><td class="desc">Let this grid filter the grid it derives from. <code>true</code> cross-filters through whatever it groups by; a string names a different source column.</td></tr>
2153
+ <tr><td class="name">refresh</td><td class="type">'live' | 'idle' | 'manual' | number</td><td class="desc">When to re-derive. <code>idle</code> by default, coalescing to a frame, because a hundred cell updates in one frame are one derivation. A number debounces by that many milliseconds.</td></tr>
2154
+ </tbody>
2155
+ </table>
2156
+ </div>
2157
+ <p class="section-note">
2158
+ <strong>Read-only.</strong> A derived row is an answer, not a record: there is no write-back for
2159
+ the sum of four hundred rows, so writes are refused with a reason rather than accepted and
2160
+ discarded on the next refresh.
2161
+ </p>
2162
+ <p class="section-note">
2163
+ <strong>They chain.</strong> A derived grid can be the source of another to any depth &mdash; a
2164
+ profile <em>of</em> the top five &mdash; and a change at the root travels the whole chain. A
2165
+ cycle is refused rather than recursed.
2166
+ </p>
2167
+ <p class="section-note">
2168
+ <strong>The key.</strong> <code>rowKey</code> defaults to the derived key and need not be set.
2169
+ It is the group value, which is what makes a live ranking readable: the row moves rather than
2170
+ the values under it changing.
2171
+ </p>
2172
+ <p class="section-note">
2173
+ <strong>Cost.</strong> The first derivation is linear in the rows read and largely independent
2174
+ of what is reduced &mdash; roughly 900&nbsp;ms per 200,000 rows grouped into forty, whether the selection is one sum
2175
+ or four statistics. After that, a change that names the rows it touched is <em>patched</em>
2176
+ rather than re-derived: only the groups those rows entered or left are reduced again, so a live
2177
+ feed costs time proportional to what changed rather than to the table. Five hundred updates
2178
+ against that same source take under 300&nbsp;ms in total, not 300&nbsp;ms each. A joined
2179
+ derivation is maintained the same way from both sides: the lookup is held between derivations
2180
+ rather than rebuilt, a change to the fact table rejoins only the rows that moved, and a change
2181
+ to the lookup rejoins only the rows behind the keys whose match actually changed &mdash; about
2182
+ 2&nbsp;ms per fact update and 1.5&nbsp;ms per lookup edit against a 200,000-row source joined to
2183
+ 2,000 customers, where every one of them previously cost a full derivation of roughly a second.
2184
+ A change that cannot be reasoned about that way &mdash; a new filter, a regrouping, a
2185
+ derivation using <code>unnest</code> or <code>where</code>, or a lookup row arriving for
2186
+ rows an inner join had dropped &mdash; falls back to a full derivation, which is correct but
2187
+ costs the full linear pass. Narrow with
2188
+ <code>follow: 'filtered'</code> so the derivation reads what the user is looking at rather than
2189
+ the whole table.
2190
+ </p>
2191
+
2192
+ <h3 id="change-contract">What a change firing promises</h3>
2193
+ <p class="section-note">
2194
+ Anything maintaining state from <code>rows:changed</code> &mdash; a derived grid, a chart, your
2195
+ own cache &mdash; needs to know whether a firing names the rows that moved or merely says that
2196
+ something did. One <code>rows.apply</code> announces itself more than once: the source reports
2197
+ how many rows moved, the row model reports which ones, and the grid reports that a change
2198
+ happened. Acting on all three does the work three times over.
2199
+ </p>
2200
+ <div class="table-wrap">
2201
+ <table>
2202
+ <thead><tr><th>Field</th><th>Meaning</th></tr></thead>
2203
+ <tbody>
2204
+ <tr><td class="name">identified: true</td><td class="desc"><code>added</code>, <code>updated</code> and <code>removed</code> are arrays naming exactly the rows that moved. Safe to patch from.</td></tr>
2205
+ <tr><td class="name">companion: true</td><td class="desc">A second announcement of a change already reported with identity, or one made before the grid's own view caught up. Ignore it.</td></tr>
2206
+ <tr><td class="name">neither</td><td class="desc">A real change whose extent cannot be named &mdash; rows replaced wholesale, or a row moved, where what changed is the order. Re-read.</td></tr>
2031
2207
  </tbody>
2032
2208
  </table>
2033
2209
  </div>
2210
+ <p class="section-note">
2211
+ <strong>The default is the safe one.</strong> A firing that says nothing is treated as a change
2212
+ of unknown extent, so an emitter added later that forgets the flag costs a rescan rather than
2213
+ silently leaving a total stale. This was previously inferred from the payload's <em>shape</em>
2214
+ &mdash; arrays meant identity, numbers meant a duplicate &mdash; which was wrong for exactly one
2215
+ case: a row <em>move</em>, which carries counts because nothing changed value. Moves were
2216
+ dropped, and every order-dependent derivation below kept its old answer indefinitely.
2217
+ </p>
2218
+
2219
+ <h3 id="derived-join">Joining two grids</h3>
2220
+ <p class="section-note">
2221
+ Two grids each holding their own data, and a third showing where they meet. Orders against
2222
+ customers; shipments against carriers; enrolments against students. The third grid derives from
2223
+ one side and names the other as its <code>join</code> partner.
2224
+ </p>
2225
+ <pre><code>const joined = createGrid(host, {
2226
+ source: {
2227
+ mode: 'derived',
2228
+ from: orders,
2229
+ join: {
2230
+ with: customers,
2231
+ on: { left: 'customerId', right: 'id' },
2232
+ select: ['name', 'tier'],
2233
+ },
2234
+ },
2235
+ columns: [{ field: 'ref' }, { field: 'name' }, { field: 'tier' }, { field: 'amount' }],
2236
+ });</code></pre>
2237
+ <div class="table-wrap">
2238
+ <table>
2239
+ <thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
2240
+ <tbody>
2241
+ <tr><td class="name">with</td><td class="type">Grid</td><td class="desc">Required. The grid holding the other side.</td></tr>
2242
+ <tr><td class="name">on</td><td class="type">string | { left, right }</td><td class="desc">Required. The shared key: one field name when both sides use it, or one each.</td></tr>
2243
+ <tr><td class="name">type</td><td class="type">'inner' | 'left'</td><td class="desc"><code>inner</code> by default, keeping only rows that matched &mdash; which is usually what &ldquo;common data&rdquo; means. <code>left</code> keeps every row and leaves the brought-across fields undefined, the shape you want when the unmatched rows <em>are</em> the finding.</td></tr>
2244
+ <tr><td class="name">select</td><td class="type">string[]</td><td class="desc">Which of the partner's fields to bring across. All of them by default.</td></tr>
2245
+ <tr><td class="name">prefix</td><td class="type">string</td><td class="desc">Rename the brought-across fields, for when both sides have a <code>name</code> worth keeping.</td></tr>
2246
+ <tr><td class="name">follow</td><td class="type">'all' | 'filtered'</td><td class="desc">Which of the partner's rows to read. <code>all</code> by default: a lookup table is normally the whole table, and a customer list filtered to Europe would otherwise silently drop every other order from a grid the reader takes to be all orders.</td></tr>
2247
+ </tbody>
2248
+ </table>
2249
+ </div>
2250
+ <p class="section-note">
2251
+ <strong>The row count does not change.</strong> A key appearing twice on the right keeps the
2252
+ first match rather than emitting a row per pair. SQL would multiply them out; here that would
2253
+ change the row count of a grid the reader thinks of as &ldquo;the orders&rdquo; and quietly
2254
+ double every total taken from it.
2255
+ </p>
2256
+ <p class="section-note">
2257
+ <strong>Both sides are live.</strong> The partner is read at derivation time, not captured when
2258
+ the grid was built, and editing it re-derives &mdash; a corrected tier in the customer grid
2259
+ moves the order into a different band in the joined one.
2260
+ </p>
2261
+
2262
+ <h3 id="cross-filter">Cross-filtering: the path back up</h3>
2263
+ <p class="section-note">
2264
+ Derivation runs one way. A derived grid reads its source and never writes to it, which is what
2265
+ makes a chain of them safe to reason about. Cross-filtering is the single deliberate path back
2266
+ up: clicking a row in a summary panel filters the grid it summarises.
2267
+ </p>
2268
+ <pre><code>const byRep = createGrid(panel, {
2269
+ source: {
2270
+ mode: 'derived', from: main, groupBy: 'rep', refresh: 'live',
2271
+ crossFilter: true,
2272
+ select: { total: { of: 'amount', fn: 'sum' } },
2273
+ },
2274
+ columns: [{ field: 'rep' }, { field: 'total' }],
2275
+ });
2276
+
2277
+ byRep.on('row:click', (e) =&gt; byRep.crossFilter.toggle(e.key));</code></pre>
2278
+ <div class="table-wrap">
2279
+ <table>
2280
+ <thead><tr><th>Member</th><th>Returns</th><th>Description</th></tr></thead>
2281
+ <tbody>
2282
+ <tr><td class="sig">enabled()</td><td class="type">boolean</td><td class="desc">Whether this grid can cross-filter a source. False on a grid that is not derived, or whose source has no <code>crossFilter</code>.</td></tr>
2283
+ <tr><td class="sig">column()</td><td class="type">string | null</td><td class="desc">The source column the filter is pushed onto.</td></tr>
2284
+ <tr><td class="sig">get()</td><td class="type">string[]</td><td class="desc">The keys currently filtering the source.</td></tr>
2285
+ <tr><td class="sig">set(keys)</td><td class="type">void</td><td class="desc">Filter the source to these derived rows. <code>null</code> clears.</td></tr>
2286
+ <tr><td class="sig">toggle(key)</td><td class="type">void</td><td class="desc">Add or remove one key &mdash; what a click handler wants.</td></tr>
2287
+ <tr><td class="sig">clear()</td><td class="type">void</td><td class="desc">Take this grid's filter off its source.</td></tr>
2288
+ </tbody>
2289
+ </table>
2290
+ </div>
2291
+ <p class="section-note">
2292
+ <strong>A panel does not filter itself.</strong> The grid pushing the filter leaves its own
2293
+ condition out when it reads the source back. Without that, clicking one rep would collapse the
2294
+ panel to that single row and strand the reader with nothing else to click. It is the same rule
2295
+ that keeps a header histogram showing every bar after you click one (<a href="#facets">facets</a>),
2296
+ applied between grids instead of within one.
2297
+ </p>
2298
+ <p class="section-note">
2299
+ <strong>Several panels compose.</strong> Each leaves out only its own condition, so two panels
2300
+ over different columns narrow each other while both stay whole &mdash; pick a rep and the region
2301
+ panel shows that rep's regions, pick a region and the rep panel shows that region's reps.
2302
+ </p>
2303
+ <p class="section-note">
2304
+ <strong>It is an ordinary filter.</strong> The condition goes through the source's
2305
+ <code>filters.set</code>, so it undoes, rides in a saved view, and appears in whatever filter UI
2306
+ the grid already has. There is no second filter model beside the real one.
2307
+ </p>
2034
2308
 
2035
2309
  <h3>The remote request</h3>
2036
2310
  <p class="section-note">Your <code>fetch</code> receives one object and returns <code>{ rows, total }</code>.</p>
@@ -2076,7 +2350,7 @@ off(); <span class="cmt">// on() returns i
2076
2350
  <tr><td class="name">render:first</td><td class="type">{}</td><td class="desc">First paint — the number to measure time-to-first-row against.</td></tr>
2077
2351
  <tr><td class="name">destroy</td><td class="type">{}</td><td class="desc"><code>grid.destroy()</code> has run.</td></tr>
2078
2352
  <tr><td class="name">model:changed</td><td class="type">{ reason }</td><td class="desc">Columns, grouping, pivot or another structural change.</td></tr>
2079
- <tr><td class="name">rows:changed</td><td class="type">{ added, updated, removed, plan }</td><td class="desc">The display row set changed. Counts, not row objects, on the streaming path.</td></tr>
2353
+ <tr><td class="name">rows:changed</td><td class="type">{ identified?, companion?, added, updated, removed, plan }</td><td class="desc">The row set changed. See <a href="#change-contract">what a change firing promises</a>: <code>identified</code> means the three arrays name the rows that moved, <code>companion</code> marks a duplicate announcement of a change already made with identity, and a firing with neither is a real change of unknown extent.</td></tr>
2080
2354
  <tr><td class="name">rows:queued</td><td class="type">{ pending }</td><td class="desc">A batched change is waiting for the next frame.</td></tr>
2081
2355
  <tr><td class="name">cell:changed</td><td class="type">{ row, key, colId, value, oldValue, undo }</td><td class="desc">A committed edit reached the data. <code>undo</code> distinguishes a rollback.</td></tr>
2082
2356
  <tr><td class="name">cell:pending</td><td class="type">{ row, key, colId, value, before, id }</td><td class="desc">Applied optimistically, not yet durable. Only with <code>edit.commit</code>.</td></tr>
@@ -2274,8 +2548,61 @@ createGrid(el, {
2274
2548
  <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>
2275
2549
  <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>
2276
2550
 
2551
+ <h2 id="stat">The statistic block</h2>
2552
+ <p>
2553
+ <code>createStat</code> draws the tile a dashboard opens with: a label, a value, its change
2554
+ against a baseline, and a line saying what the comparison was. Two things make it worth using
2555
+ rather than writing. <strong>It reads the grid</strong>, so it cannot disagree with the table
2556
+ beneath it &mdash; a tile saying &pound;4.2M above a table filtered to &pound;1.8M is worse
2557
+ than no tile, and that is what a hand-built tile does the first time somebody adds a filter.
2558
+ And <strong>it formats through the column's own type</strong>: a stat over a
2559
+ <code>seconds</code> column reads <code>42.1 ms</code>, over a money column with an auto ladder
2560
+ <code>&pound;1.2M</code>, with nothing declared.
2561
+ </p>
2562
+ <pre><code>import { createStat } from '@toclocoinc/lattice-grid';
2563
+
2564
+ createStat({
2565
+ grid, container: '#mrr',
2566
+ title: 'Monthly recurring revenue',
2567
+ of: 'mrr', fn: 'sum',
2568
+ baseline: lastMonth,
2569
+ footer: 'vs. last month',
2570
+ });</code></pre>
2571
+ <div class="table-wrap">
2572
+ <table>
2573
+ <thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
2574
+ <tbody>
2575
+ <tr><td class="name">grid</td><td class="type">Grid</td><td class="desc">The grid to read.</td></tr>
2576
+ <tr><td class="name">container</td><td class="type">Element | string</td><td class="desc">Required. An element, or a selector resolved against the grid's document.</td></tr>
2577
+ <tr><td class="name">title</td><td class="type">string</td><td class="desc">The label above the value. Hidden when absent rather than left blank.</td></tr>
2578
+ <tr><td class="name">of</td><td class="type">string</td><td class="desc">The column to reduce. Omit for <code>count</code>.</td></tr>
2579
+ <tr><td class="name">fn</td><td class="type">TotalName</td><td class="desc">Any of the totals-row kernels: <code>sum</code>, <code>avg</code>, <code>median</code>, <code>p95</code>, <code>distinct</code>, <code>gini</code> and the rest. <code>sum</code> by default.</td></tr>
2580
+ <tr><td class="name">show</td><td class="type">string</td><td class="desc">Report this column from the row holding the extreme, rather than the extreme itself &mdash; <code>{ of: 'sales', fn: 'max', show: 'rep' }</code> is the <em>name</em> of the best rep. Needs <code>min</code> or <code>max</code>; no single row holds an average, so any other reduction is refused with a warning.</td></tr>
2581
+ <tr><td class="name">value</td><td class="type">unknown | fn</td><td class="desc">A literal value &mdash; numeric or otherwise &mdash; or a function of the grid, instead of a reduction.</td></tr>
2582
+ <tr><td class="name">footer</td><td class="type">string | fn</td><td class="desc">Text under the value, or a function of it.</td></tr>
2583
+ <tr><td class="name">baseline</td><td class="type">number | fn</td><td class="desc">What the value is compared against. A zero baseline reports the absolute change and no percentage, because &ldquo;up infinity per cent&rdquo; is not a reading anyone can act on.</td></tr>
2584
+ <tr><td class="name">goodWhen</td><td class="type">'up' | 'down' | 'neither'</td><td class="desc">Whether a rise is good news, which decides the colour. <code>up</code> by default. Revenue up is green and error rate up is red; a tile that paints every rise green is misleading on half a dashboard.</td></tr>
2585
+ <tr><td class="name">scope</td><td class="type">'filtered' | 'all' | 'selected'</td><td class="desc">Which rows feed the value. <code>filtered</code> by default; <code>all</code> for a tile that is deliberately a constant, such as the denominator a filtered number is a share of.</td></tr>
2586
+ <tr><td class="name">live</td><td class="type">boolean</td><td class="desc"><code>false</code> stops the tile following the grid. <code>refresh()</code> still works, so a caller can drive it.</td></tr>
2587
+ <tr><td class="name">format</td><td class="type">(value, grid) =&gt; string</td><td class="desc">Override the formatting the column's type would apply.</td></tr>
2588
+ <tr><td class="name">empty</td><td class="type">string</td><td class="desc">Shown when there is no value. An em dash by default.</td></tr>
2589
+ <tr><td class="name">decimals</td><td class="type">number</td><td class="desc">Fraction digits for a value whose reduction changed the unit. 2 by default.</td></tr>
2590
+ </tbody>
2591
+ </table>
2592
+ </div>
2593
+ <p class="section-note">
2594
+ The column's formatter is borrowed only where the reduction leaves the unit alone. A Gini
2595
+ coefficient over a money column is a ratio between 0 and 1, and rendering it as
2596
+ <code>$0.34</code> says it is thirty-four cents; counts, ratios and variances &mdash; which are
2597
+ in units <em>squared</em> &mdash; fall back to a plain number.
2598
+ </p>
2599
+ <p class="section-note">
2600
+ Returns <code>{ element, value, refresh, destroy }</code>. A misconfigured tile returns an inert
2601
+ handle rather than throwing, so a dashboard with one bad tile still renders the other eleven.
2602
+ </p>
2603
+
2277
2604
  <h2 id="chartsmodule">The charts module</h2>
2278
- <p><code>modules/charts</code> draws thirty chart types from the grid's own data. It is optional and imports nothing from the grid &mdash; the grid is handed in &mdash; so the bundle carries the drawing and none of the grid, and a page that never charts never loads it.</p>
2605
+ <p><code>modules/charts</code> draws thirty-five chart types from the grid's own data. It is optional and imports nothing from the grid &mdash; the grid is handed in &mdash; so the bundle carries the drawing and none of the grid, and a page that never charts never loads it.</p>
2279
2606
  <pre><code>import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
2280
2607
 
2281
2608
  const chart = createChart({