@toclocoinc/lattice-grid 1.10.0 → 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.10.0 · [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
 
package/docs/API.html CHANGED
@@ -2138,6 +2138,7 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'tria
2138
2138
  <tr><td class="name">from</td><td class="type">Grid</td><td class="desc">Required. The grid to read.</td></tr>
2139
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
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>
2141
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>
2142
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>
2143
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>
@@ -2148,6 +2149,7 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'tria
2148
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>
2149
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>
2150
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>
2151
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>
2152
2154
  </tbody>
2153
2155
  </table>
@@ -2168,14 +2170,142 @@ grid.licence.state(); <span class="cmt">// 'licensed' | 'localhost' | 'tria
2168
2170
  the values under it changing.
2169
2171
  </p>
2170
2172
  <p class="section-note">
2171
- <strong>Cost.</strong> Each refresh re-derives from the source in full, so the work is linear in
2172
- the number of rows read and largely independent of what is reduced &mdash; roughly 650&nbsp;ms
2173
- per 200,000 rows, whether the selection is one sum or four statistics. For a large source under
2174
- a live feed, prefer the default <code>idle</code> or a debounce, and narrow with
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
2175
2188
  <code>follow: 'filtered'</code> so the derivation reads what the user is looking at rather than
2176
2189
  the whole table.
2177
2190
  </p>
2178
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>
2207
+ </tbody>
2208
+ </table>
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>
2308
+
2179
2309
  <h3>The remote request</h3>
2180
2310
  <p class="section-note">Your <code>fetch</code> receives one object and returns <code>{ rows, total }</code>.</p>
2181
2311
  <div class="table-wrap">
@@ -2220,7 +2350,7 @@ off(); <span class="cmt">// on() returns i
2220
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>
2221
2351
  <tr><td class="name">destroy</td><td class="type">{}</td><td class="desc"><code>grid.destroy()</code> has run.</td></tr>
2222
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>
2223
- <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>
2224
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>
2225
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>
2226
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>
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.10.0 — type declarations
2
+ * Lattice Grid 1.11.0 — type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -784,6 +784,12 @@ export interface DerivedSourceConfig {
784
784
 
785
785
  /** An array property to expand, one row per element, before anything else. */
786
786
  unnest?: string;
787
+ /**
788
+ * Match each row against a second grid on a shared key, and bring some of its
789
+ * fields across. Runs after `unnest` and before `where`, so a condition — and
790
+ * a grouping, and a total — can read a field the join produced.
791
+ */
792
+ join?: DerivedJoin;
787
793
  /** A row predicate, applied before grouping. */
788
794
  where?: (row: unknown) => boolean;
789
795
  /** Round a date column down to a period, and group on that. */
@@ -808,6 +814,42 @@ export interface DerivedSourceConfig {
808
814
 
809
815
  /** When to re-derive. `idle` by default — coalesced to a frame. */
810
816
  refresh?: 'live' | 'idle' | 'manual' | number;
817
+
818
+ /**
819
+ * Let this grid filter the grid it derives from. `true` cross-filters through
820
+ * whatever it groups by; a string names a different source column.
821
+ */
822
+ crossFilter?: boolean | string | { col?: string };
823
+ }
824
+
825
+ export interface DerivedJoin {
826
+ /** The grid holding the other side. */
827
+ with: Grid;
828
+ /** The shared key: one field name when both sides use it, or one each. */
829
+ on: string | { left?: string; right?: string };
830
+ /** `inner` keeps only rows that matched; `left` keeps them all. */
831
+ type?: 'inner' | 'left';
832
+ /** Which of the partner's fields to bring across. All of them by default. */
833
+ select?: string[];
834
+ /** Rename the brought-across fields, when both sides have one worth keeping. */
835
+ prefix?: string;
836
+ /** Which of the partner's rows to read. `all` by default. */
837
+ follow?: 'all' | 'filtered';
838
+ }
839
+
840
+ export interface CrossFilter {
841
+ /** Whether this grid can cross-filter a source. */
842
+ enabled(): boolean;
843
+ /** The source column the filter is pushed onto. */
844
+ column(): string | null;
845
+ /** The keys currently filtering the source. */
846
+ get(): string[];
847
+ /** Filter the source to these derived rows. */
848
+ set(keys: string | string[] | null): void;
849
+ /** Add or remove one key, for click-to-filter. */
850
+ toggle(key: string): void;
851
+ /** Take this grid's filter off its source. */
852
+ clear(): void;
811
853
  }
812
854
 
813
855
  export type SourceConfig =
@@ -1878,6 +1920,11 @@ export interface RowsApi {
1878
1920
  forEach(fn: (row: Row, index: number) => void): void;
1879
1921
  /** Every row in the data, before any filter. Leaf rows, in physical order. */
1880
1922
  forEachAll(fn: (row: Row, index: number) => void): void;
1923
+ /**
1924
+ * Visit the rows surviving every filter except one column's own — the
1925
+ * faceting question, asked of the rows.
1926
+ */
1927
+ forEachExcept(colId: string, fn: (row: Row, index: number) => void): void;
1881
1928
  value(key: string, colId: string): unknown;
1882
1929
  text(key: string, colId: string): string;
1883
1930
  values(key: string): Record<string, unknown>;
@@ -2775,6 +2822,8 @@ export interface Grid {
2775
2822
  readonly presentation: PresentationApi;
2776
2823
  readonly updates: UpdatesApi;
2777
2824
  readonly timeline: TimelineApi;
2825
+ /** Cross-filtering — a derived grid filtering the grid it derives from. */
2826
+ readonly crossFilter: CrossFilter;
2778
2827
  readonly facets: FacetsApi;
2779
2828
  readonly detail: DetailApi;
2780
2829
  readonly comments: CommentsApi;