@toclocoinc/lattice-grid 1.51.0 → 1.53.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 +2 -1
- package/docs/API.html +259 -5
- package/docs/api-detail.html +121 -5
- package/lattice-grid.d.ts +501 -5
- package/lattice-grid.esm.min.js +288 -17
- package/lattice-grid.min.cjs +288 -17
- package/lattice-grid.min.js +288 -17
- package/modules/ai.esm.min.js +18 -4
- package/modules/ai.min.cjs +18 -4
- package/modules/ai.min.js +18 -4
- package/modules/angular.esm.min.js +2 -2
- package/modules/angular.min.cjs +2 -2
- package/modules/angular.min.js +2 -2
- package/modules/chart-alluvial.esm.min.js +1 -1
- package/modules/chart-arc.esm.min.js +1 -1
- package/modules/chart-bubblemap.esm.min.js +1 -1
- package/modules/chart-bump.esm.min.js +1 -1
- package/modules/chart-calendar.esm.min.js +1 -1
- package/modules/chart-decomposition.esm.min.js +1 -1
- package/modules/chart-diverging.esm.min.js +1 -1
- package/modules/chart-dumbbell.esm.min.js +1 -1
- package/modules/chart-fan.esm.min.js +1 -1
- package/modules/chart-hexbin.esm.min.js +1 -1
- package/modules/chart-hexmap.esm.min.js +1 -1
- package/modules/chart-icicle.esm.min.js +1 -1
- package/modules/chart-parallel.esm.min.js +1 -1
- package/modules/chart-ridgeline.esm.min.js +1 -1
- package/modules/chart-roc.esm.min.js +1 -1
- package/modules/chart-slope.esm.min.js +1 -1
- package/modules/chart-splom.esm.min.js +1 -1
- package/modules/chart-waffle.esm.min.js +1 -1
- package/modules/charts.esm.min.js +255 -83
- package/modules/charts.min.cjs +255 -83
- package/modules/charts.min.js +255 -83
- package/modules/data-router.esm.min.js +4 -4
- package/modules/data-router.min.cjs +4 -4
- package/modules/data-router.min.js +4 -4
- package/modules/devtools.esm.min.js +2 -2
- package/modules/devtools.min.cjs +2 -2
- package/modules/devtools.min.js +2 -2
- package/modules/dhtmlx-compat.esm.min.js +4 -4
- package/modules/dhtmlx-compat.min.cjs +4 -4
- package/modules/dhtmlx-compat.min.js +4 -4
- package/modules/gantt.esm.min.js +359 -62
- package/modules/gantt.min.cjs +359 -62
- package/modules/gantt.min.js +359 -62
- package/modules/htmx.esm.min.js +288 -17
- package/modules/htmx.min.cjs +288 -17
- package/modules/htmx.min.js +288 -17
- package/modules/kanban.esm.min.js +4 -4
- package/modules/kanban.min.cjs +4 -4
- package/modules/kanban.min.js +4 -4
- package/modules/kpi.esm.min.js +886 -41
- package/modules/kpi.min.cjs +886 -41
- package/modules/kpi.min.js +886 -41
- package/modules/layout.esm.min.js +1825 -0
- package/modules/layout.min.cjs +1828 -0
- package/modules/layout.min.js +1828 -0
- package/modules/mock-socket.esm.min.js +2 -2
- package/modules/mock-socket.min.cjs +2 -2
- package/modules/mock-socket.min.js +2 -2
- package/modules/react.esm.min.js +2 -2
- package/modules/react.min.cjs +2 -2
- package/modules/react.min.js +2 -2
- package/modules/svelte.esm.min.js +2 -2
- package/modules/svelte.min.cjs +2 -2
- package/modules/svelte.min.js +2 -2
- package/modules/tabs.esm.min.js +89 -14
- package/modules/tabs.min.cjs +89 -14
- package/modules/tabs.min.js +89 -14
- package/modules/vue.esm.min.js +2 -2
- package/modules/vue.min.cjs +2 -2
- package/modules/vue.min.js +2 -2
- package/modules/webcomponent.esm.min.js +288 -17
- package/modules/webcomponent.min.cjs +288 -17
- package/modules/webcomponent.min.js +288 -17
- package/package.json +1 -1
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
|
+
Version 1.53.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -350,6 +350,7 @@ the UMD build or `.cjs` for CommonJS.
|
|
|
350
350
|
| gantt | `@toclocoinc/lattice-grid/modules/gantt` | `modules/gantt.min.js` | `LatticeGridGantt` | Editable, dependency-aware project plan with a computed critical path (`createGantt`). |
|
|
351
351
|
| kpi | `@toclocoinc/lattice-grid/modules/kpi` | `modules/kpi.min.js` | `LatticeGridKPI` | A grid of stat tiles, each an aggregate over a dataset (`createKPI`). |
|
|
352
352
|
| tabs | `@toclocoinc/lattice-grid/modules/tabs` | `modules/tabs.min.js` | `LatticeGridTabs` | A tab strip where each tab is its own full grid, optionally derived from another (`createTabs`). |
|
|
353
|
+
| layout | `@toclocoinc/lattice-grid/modules/layout` | `modules/layout.min.js` | `LatticeGridLayout` | A reconfigurable dashboard: windows on a cell grid, moved and resized by drag or keyboard (`createLayout`). |
|
|
353
354
|
| ai | `@toclocoinc/lattice-grid/modules/ai` | `modules/ai.min.js` | `LatticeGridAI` | Bring-your-own-model narrative and insights grounded on computed figures (`createAI`). |
|
|
354
355
|
| mock-socket | `@toclocoinc/lattice-grid/modules/mock-socket` | `modules/mock-socket.min.js` | `LatticeGridMockSocket` | A serverless stand-in for a live WebSocket feed (`MockWebSocket`, `opsFeed`). |
|
|
355
356
|
| devtools | `@toclocoinc/lattice-grid/modules/devtools` | `modules/devtools.min.js` | `LatticeGrid` (extends it) | The in-page diagnostic panel, including the accessibility checks (`createDevtools`). |
|
package/docs/API.html
CHANGED
|
@@ -420,6 +420,7 @@
|
|
|
420
420
|
<a href="#quickfilter">Quick filter</a>
|
|
421
421
|
<a href="#units">Units of your own</a>
|
|
422
422
|
<a href="#chartsmodule">The charts module</a>
|
|
423
|
+
<a href="#layout">The dashboard layout</a>
|
|
423
424
|
<a href="#mocksocket">The mock socket</a>
|
|
424
425
|
<a href="#charts">In-cell charts</a>
|
|
425
426
|
<a href="#formulas">Formulas</a>
|
|
@@ -903,6 +904,25 @@ autoInit(document); <span class="cmt">// builds every [data-lattice-grid] under
|
|
|
903
904
|
|
|
904
905
|
<h2 id="column">Column definition</h2>
|
|
905
906
|
<p class="section-note">Everything is optional. A column with only <code>field</code> infers its type from sampled data and takes every default from there.</p>
|
|
907
|
+
<p><strong>Inferring a <code>Date</code>.</strong> Inference walks
|
|
908
|
+
<code>boolean</code>, <code>number</code>, <code>date</code>, <code>dateString</code>,
|
|
909
|
+
<code>datetime</code>, <code>text</code>, <code>object</code> and takes the first type that
|
|
910
|
+
matches every sampled value. A <code>Date</code> whose <em>local</em> wall clock reads exactly
|
|
911
|
+
<code>00:00:00.000</code> infers as <code>date</code> and stores <code>YYYY-MM-DD</code>;
|
|
912
|
+
a <code>Date</code> carrying any time of day infers as <code>datetime</code> and stores
|
|
913
|
+
<code>YYYY-MM-DDTHH:mm</code> (with seconds when they are non-zero), because a
|
|
914
|
+
<code>date</code> column would discard the clock on ingest and nothing downstream could
|
|
915
|
+
recover it. <strong>This is a heuristic with one stated blind spot:</strong> a genuine
|
|
916
|
+
timestamp that lands on exactly local midnight — a nightly batch stamped
|
|
917
|
+
<code>00:00:00.000</code> — is indistinguishable from a date-only value and is still inferred
|
|
918
|
+
as <code>date</code>, so its time of day is still discarded. Sub-second resolution is never
|
|
919
|
+
retained by <code>datetime</code>. <strong>If you group by such a column, declare it:</strong> an
|
|
920
|
+
undeclared <code>Date</code> column groups by the instant, which is one group per row, where a
|
|
921
|
+
<code>type: 'date'</code> column groups into day buckets. Date filters are unaffected either way
|
|
922
|
+
— they compare on the day and return the same rows. Declare <code>type</code> and none of this applies:
|
|
923
|
+
<code>'date'</code> truncates on purpose, <code>'datetime'</code> keeps a wall clock, and
|
|
924
|
+
<code>'timestamp'</code> keeps the instant to the millisecond. Strings are unaffected — an ISO
|
|
925
|
+
string with or without a time component still infers as <code>date</code>.</p>
|
|
906
926
|
|
|
907
927
|
<div class="table-wrap">
|
|
908
928
|
<table>
|
|
@@ -2825,6 +2845,62 @@ grid.destroy();
|
|
|
2825
2845
|
</tbody>
|
|
2826
2846
|
</table>
|
|
2827
2847
|
</div>
|
|
2848
|
+
<h3 id="derived-statistics">The relational statistics, as rows</h3>
|
|
2849
|
+
<p class="section-note">
|
|
2850
|
+
A single-column statistic already has a route: <code>select</code> reduces a group with any
|
|
2851
|
+
kernel the totals row uses, and that table is a superset of the statistics one, so
|
|
2852
|
+
<code>select: { p95: { of: 'amount', fn: 'p95' } }</code> works, along with
|
|
2853
|
+
<code>median</code>, <code>stddev</code>, <code>gini</code> and the rest.
|
|
2854
|
+
<code>statistics</code> is for what <code>select</code> structurally cannot reach: the
|
|
2855
|
+
figures needing two or more columns, or a second grid. Like <code>profile</code> it is a
|
|
2856
|
+
terminal producer — it replaces the pipeline rather than joining it, and the two cannot
|
|
2857
|
+
be used together. Every row carries <code>n</code>, the rows the figure covered. Full detail,
|
|
2858
|
+
including the measured re-derive cost of each producer, is in
|
|
2859
|
+
<a href="api-detail.html#derived-statistics">the detail reference</a>.
|
|
2860
|
+
</p>
|
|
2861
|
+
<pre data-run="js" data-expect="t/w -1; pairs 3; metrics 14; compared 2 + 1 unmatched" data-covers="config:statistics"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
|
|
2862
|
+
|
|
2863
|
+
<span class="kw">const</span> readings = Array.from({ length: 20 }, (_, i) => ({ id: i, t: i, v: 100 + i * 3, w: 50 - i }));
|
|
2864
|
+
<span class="kw">const</span> plant = createHeadlessGrid({ rowKey: 'id',
|
|
2865
|
+
columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
|
|
2866
|
+
rows: readings });
|
|
2867
|
+
plant.rows.count();
|
|
2868
|
+
|
|
2869
|
+
<span class="cmt">// One row per column PAIR: { a, b, coefficient, n }. `w` runs exactly against `t`.</span>
|
|
2870
|
+
<span class="kw">const</span> pairs = createHeadlessGrid({ rowKey: '__key',
|
|
2871
|
+
columns: [{ field: 'a' }, { field: 'b' }, { field: 'coefficient', type: 'number' }, { field: 'n', type: 'number' }],
|
|
2872
|
+
source: { mode: 'derived', from: plant, refresh: 'live',
|
|
2873
|
+
statistics: { fn: 'correlation', columns: ['t', 'v', 'w'] } } });
|
|
2874
|
+
<span class="kw">let</span> tw = <span class="kw">null</span>;
|
|
2875
|
+
pairs.rows.forEach((row) => {
|
|
2876
|
+
<span class="kw">if</span> (pairs.rows.value(row.key, 'a') === 't' && pairs.rows.value(row.key, 'b') === 'w') {
|
|
2877
|
+
tw = pairs.rows.value(row.key, 'coefficient');
|
|
2878
|
+
}
|
|
2879
|
+
});
|
|
2880
|
+
|
|
2881
|
+
<span class="cmt">// One row per METRIC of the series summary, not one per point.</span>
|
|
2882
|
+
<span class="kw">const</span> series = createHeadlessGrid({ rowKey: '__key',
|
|
2883
|
+
columns: [{ field: 'metric' }, { field: 'value', type: 'number' }],
|
|
2884
|
+
source: { mode: 'derived', from: plant, refresh: 'live',
|
|
2885
|
+
statistics: { fn: 'series', of: 'v', by: 't' } } });
|
|
2886
|
+
|
|
2887
|
+
<span class="cmt">// One row per compared column, against a second grid. The peer is watched.</span>
|
|
2888
|
+
<span class="cmt">// `t` is on this grid only, so it is reported as unmatched rather than dropped.</span>
|
|
2889
|
+
<span class="kw">const</span> peer = createHeadlessGrid({ rowKey: 'id',
|
|
2890
|
+
columns: [{ field: 'v', type: 'number' }, { field: 'w', type: 'number' }],
|
|
2891
|
+
rows: readings.map((r) => ({ id: r.id, v: r.v * 2, w: r.w })) });
|
|
2892
|
+
peer.rows.count();
|
|
2893
|
+
<span class="kw">const</span> compared = createHeadlessGrid({ rowKey: '__key',
|
|
2894
|
+
columns: [{ field: 'column' }, { field: 'magnitude', type: 'number' }],
|
|
2895
|
+
source: { mode: 'derived', from: plant, refresh: 'live',
|
|
2896
|
+
statistics: { fn: 'datasetVsDataset', with: peer, columns: ['v', 'w'] } } });
|
|
2897
|
+
|
|
2898
|
+
<span class="kw">let</span> shared = 0, unmatched = 0;
|
|
2899
|
+
compared.rows.forEach((row) => {
|
|
2900
|
+
<span class="kw">if</span> (compared.rows.value(row.key, 'magnitude') === <span class="kw">null</span>) unmatched += 1; <span class="kw">else</span> shared += 1;
|
|
2901
|
+
});
|
|
2902
|
+
|
|
2903
|
+
<span class="kw">return</span> `t/w ${tw}; pairs ${pairs.rows.count()}; metrics ${series.rows.count()}; compared ${shared} + ${unmatched} unmatched`;</code></pre>
|
|
2828
2904
|
<h3 id="derived-union">Union sources: combining several grids into one</h3>
|
|
2829
2905
|
<p class="section-note">
|
|
2830
2906
|
"Worst performers across two datasets" is easy when the two datasets share a key: a
|
|
@@ -5332,7 +5408,7 @@ return [p.pv, p.ev, p.ac, p.sv, p.cv, p.spi.toFixed(2), p.cpi.toFixed(2), direct
|
|
|
5332
5408
|
|
|
5333
5409
|
const gantt = createGantt({ tasks, dependencies });
|
|
5334
5410
|
gantt.mount(document.querySelector('#plan'), {
|
|
5335
|
-
today:
|
|
5411
|
+
today: '2026-03-06', // a day-number, ISO date or Date; draws the today line
|
|
5336
5412
|
nonWorking: 'weekends', // shade Saturdays and Sundays
|
|
5337
5413
|
label: 'percent', // bar label: 'name' | 'percent' | 'dates' | (task) => string
|
|
5338
5414
|
dateAxis: true, // axis ticks as calendar dates
|
|
@@ -5340,11 +5416,25 @@ gantt.mount(document.querySelector('#plan'), {
|
|
|
5340
5416
|
scrollToToday: true, // scroll so the today line is in view
|
|
5341
5417
|
groupBy: 'assignee', // swimlanes by a task property or (task) => key
|
|
5342
5418
|
rowHeight: 26,
|
|
5343
|
-
width:
|
|
5419
|
+
width: 'container', // the default: fill the container, and keep following it
|
|
5420
|
+
});</code></pre>
|
|
5421
|
+
<p><strong>Sizing (BACKLOG-0001079).</strong> <code>width</code> defaults to <code>'container'</code>: the view measures the box it was mounted into and redraws itself whenever that box changes, so a plan in a tab, a drawer, an accordion, a responsive panel or a split pane fits without the host writing a <code>ResizeObserver</code> of its own. A container with no box — a hidden tab, or an element that has not been laid out yet — is not treated as a container of zero width: the view holds a 720px fallback and adopts the real width the moment there is one. Pass a <strong>number</strong> to take the decision yourself; a numeric <code>width</code> is honoured exactly, installs no observer, and keeps the eight-tick axis it always had — only a container-sized plot thins its tick labels to the width it was given, because only a container-sized plot can be somewhere it had not been before. <code>zoom</code> and a numeric <code>width</code> are mutually exclusive: <strong>zoom wins</strong> — it fixes the pixels-per-day and lets the plot scroll past the container — and passing both now warns rather than discarding the <code>width</code> in silence.</p>
|
|
5422
|
+
<p><strong>The project anchor (BACKLOG-0001079).</strong> A <code>mount</code> option, not a <code>mountSplit</code> one: the joined split view below takes neither <code>projectEpoch</code> nor a date-valued <code>today</code>, and its weekend shading is unanchored. The engine's time line is whole days since the Unix epoch, so a plan written as day offsets (<code>0, 4, 9…</code>) legitimately renders as January 1970 — day 0 <em>is</em> 1970-01-01, and the module cannot tell an offset from a real epoch day, so it cannot warn about it. <code>projectEpoch</code> says which calendar date plan day 0 stands for. It is <strong>display-only</strong>: axis ticks, bar labels, tooltips, screen-reader text and the built-in weekend shading move with it, and nothing the scheduler, <code>getState</code>, the CSV or the MSPDI export produces does — every <code>es</code>/<code>ef</code> you read back is still the number you supplied. A host-supplied <code>nonWorking</code> function keeps receiving raw plan days, since it was written against your day numbers. Use <code>projectStart</code> instead when you want the model itself to be on calendar dates.</p>
|
|
5423
|
+
<pre><code>// A relative plan: offsets in the data, real dates on the screen.
|
|
5424
|
+
gantt.mount(el, {
|
|
5425
|
+
projectEpoch: '2026-03-02', // plan day 0 is this Monday
|
|
5426
|
+
nonWorking: 'weekends', // so days 5-6 are the first weekend
|
|
5427
|
+
today: '2026-03-06', // converted into plan space through the anchor
|
|
5344
5428
|
});
|
|
5345
5429
|
gantt.view.scrollToToday(); // also callable on demand
|
|
5346
5430
|
gantt.applyEdit({ id: 'design', duration: 7 }); // the view redraws automatically
|
|
5347
5431
|
gantt.unmount();</code></pre>
|
|
5432
|
+
<p><strong>Dependency arrows (BACKLOG-0001072).</strong> A link is routed by geometry. When the successor's anchor is at or beyond the predecessor's, it goes <strong>straight down and then in</strong> — a plain zero-lag finish-to-start link, where the two anchors share an x, is a clean vertical. When the anchor is <em>behind</em> the predecessor's — a negative lag (a lead), or two overlapping tasks — "down then in" does not exist, so the link takes a deliberate detour: out on the predecessor's own side, along a lane between the two rows, down, and in. Either way the final segment runs in the direction the arrowhead points, which is what stops a link doubling back on itself. The anchors themselves differ per link type — FS finish→start, SS start→start, FF finish→finish, SF start→finish — and so does the side the arrow arrives on, so an FF or SF link comes in from the right of the successor's finish rather than running through the bar to get there.</p>
|
|
5433
|
+
<p><strong>Link shorthand.</strong> A dependency's <code>type</code> accepts the MS Project string form as well as the structured one: <code>'FS+2'</code>, <code>'SS-1'</code>. It normalises to <code>{ type: 'FS', lag: 2 }</code> on the way in, so <code>gantt.dependencies</code>, the scheduler, the lag label and the MSPDI export all see the one canonical form. A shorthand lag alongside an explicit <code>lag</code> that disagrees warns; the explicit field wins.</p>
|
|
5434
|
+
<pre><code>createGantt({ tasks, dependencies: [
|
|
5435
|
+
{ from: 'design', to: 'build', type: 'FS+2' }, // same as { type: 'FS', lag: 2 }
|
|
5436
|
+
{ from: 'build', to: 'test', type: 'SS-1' }, // a lead
|
|
5437
|
+
] });</code></pre>
|
|
5348
5438
|
<p>Bars are draggable: drag the body to move a task, drag the right edge to resize it. When a <code>grid</code> and a <code>columns</code> map are given, each drag writes the new dates back through the grid's public edit surface (<code>grid.edit.setCells</code>) and reconciles a reverted or conflicted write; <code>autoSchedule: true</code> cascades dependents. A task placed earlier than its predecessors allow is flagged (<code>findViolations</code>), not silently moved.</p>
|
|
5349
5439
|
<pre><code>import { createGantt } from '@toclocoinc/lattice-grid/modules/gantt';
|
|
5350
5440
|
|
|
@@ -5626,15 +5716,68 @@ const kpi = createKPI(document.querySelector('#kpis'), {
|
|
|
5626
5716
|
<tbody>
|
|
5627
5717
|
<tr><td class="sig">createKPI(el, config)</td><td class="desc">Create a KPI panel. Pass a DOM element to render into, or <code>null</code> for a headless panel that computes the same tile model without a DOM.</td></tr>
|
|
5628
5718
|
<tr><td class="sig">rows.apply({ add, update, remove })</td><td class="desc">The keyed-diff consumer contract a grid shares, so the panel is a drop-in Data Router target and updates each tile incrementally. Also <code>rows.forEach</code> and <code>rows.count</code>.</td></tr>
|
|
5629
|
-
<tr><td class="sig">tiles() / tile(id) / value(id)</td><td class="desc">Every computed tile model, one tile by id (<code>value</code>, <code>formatted</code>, <code>status</code>, <code>delta</code>, <code>deltaPercent</code>, <code>count</code>, <code>sparkline</code>), or a tile's raw value. <code>status</code> is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>unknown</code> (the panel holds no rows) or <code>null</code> (no thresholds configured); <code>value</code> is <code>null</code> whenever the tile measured nothing.</td></tr>
|
|
5719
|
+
<tr><td class="sig">tiles() / tile(id) / value(id)</td><td class="desc">Every computed tile model, one tile by id (<code>value</code>, <code>formatted</code>, <code>status</code>, <code>delta</code>, <code>deltaPercent</code>, <code>count</code>, <code>sparkline</code>, <code>bar</code>), or a tile's raw value. <code>status</code> is <code>good</code>, <code>warn</code>, <code>critical</code>, <code>unknown</code> (the panel holds no rows) or <code>null</code> (no thresholds configured); <code>value</code> is <code>null</code> whenever the tile measured nothing.</td></tr>
|
|
5630
5720
|
<tr><td class="sig">setRows(rows) / refresh()</td><td class="desc">Replace the source rows, or re-seed and recompute.</td></tr>
|
|
5631
|
-
<tr><td class="sig">
|
|
5632
|
-
<tr><td class="sig">
|
|
5721
|
+
<tr><td class="sig">nodes() / node(key) / visibleNodes()</td><td class="desc">The hierarchy, when <code>tree</code> resolves one: the top-level nodes with their children, one node by key at any depth, or just the nodes on screen. Each node carries <code>label</code>, <code>level</code>, <code>tile</code> (null on a synthesised level), <code>status</code>, <code>rollup</code> (the worst severity at or below it, never <code>unknown</code>), <code>unknown</code> (how many below it measured nothing) and <code>items</code>. Empty on a flat panel, where <code>kpi.tree</code> is <code>false</code>.</td></tr>
|
|
5722
|
+
<tr><td class="sig">expand(key) / collapse(key) / toggle(key)</td><td class="desc">Open or close a branch. A key for a branch the panel does not (yet) hold is retained rather than dropped, so a delta that later introduces it finds it already open.</td></tr>
|
|
5723
|
+
<tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">Serialise and restore the panel's row set, so a headless panel round-trips, plus <code>expanded</code> (the open branch keys) on a hierarchical panel. A snapshot with no <code>expanded</code> key leaves expansion alone rather than resetting it.</td></tr>
|
|
5724
|
+
<tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>tile:click</code>, <code>tile:dblclick</code>, <code>tile:contextmenu</code>, <code>node:toggle</code> (a branch opened or closed), and <code>change</code> (after every update).</td></tr>
|
|
5633
5725
|
<tr><td class="sig">destroy()</td><td class="desc">Empty the element and drop the listeners. The host still owns any bound grid.</td></tr>
|
|
5634
5726
|
</tbody>
|
|
5635
5727
|
</table>
|
|
5636
5728
|
</div>
|
|
5637
5729
|
<p><strong>Interaction is light and host-driven.</strong> A tile emits <code>tile:click</code> (also from the keyboard) carrying the tile model, so a host can drill down or, in a demo, filter a routed grid — the wiring lives in the host, not the module. This is deliberately not a dashboard layout engine (that is the parked dashboard generator) and charting beyond a minimal sparkline belongs to the charts module.</p>
|
|
5730
|
+
<h3 id="kpi-tree">The KPI tree: top-level items that expand to the indicators beneath them</h3>
|
|
5731
|
+
<p>Set <code>tree</code> and the panel becomes a <strong>rail</strong> instead of a grid of tiles: a small number of top-level items, each expanding to the indicators underneath it, with the parent telling you at a glance whether anything below needs attention. <code>Compute</code> expands to <code>psi</code> and <code>cpu</code>; collapsed, it still shows you that one of them is in breach.</p>
|
|
5732
|
+
<pre><code>const kpi = createKPI(document.querySelector('#rail'), {
|
|
5733
|
+
rows, rowKey: 'id',
|
|
5734
|
+
tiles: [
|
|
5735
|
+
{ id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu',
|
|
5736
|
+
thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
|
|
5737
|
+
{ id: 'compute.memory', label: 'memory', aggregation: 'avg', field: 'mem',
|
|
5738
|
+
thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
|
|
5739
|
+
{ id: 'network.latency', label: 'latency', aggregation: 'max', field: 'latency',
|
|
5740
|
+
thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
|
|
5741
|
+
],
|
|
5742
|
+
tree: {}, <span class="cmt">// the dotted ids are the hierarchy</span>
|
|
5743
|
+
});
|
|
5744
|
+
|
|
5745
|
+
kpi.nodes()[0].rollup; <span class="cmt">// 'critical' — even with the branch shut</span>
|
|
5746
|
+
kpi.expand(kpi.nodes()[0].key); <span class="cmt">// open it</span></code></pre>
|
|
5747
|
+
<p><strong>Where the shape comes from — two sources, in this order.</strong> <em>Declared:</em> <code>tree: { path }</code> or <code>tree: { parentKey }</code> over the <em>tile specs</em>, the same two shapes the grid's <a href="#tree-data">tree data</a> and the tree-select editor already take, so a hierarchy you have configured once needs no second vocabulary. A <code>path</code> is the tile's <em>own</em> place, its own segment last — <code>['System','Compute','cpu']</code>, exactly as <code>['EMEA','UK','Colchester']</code> is Colchester's path and not its parent's — and levels no tile represents are synthesised, so <code>System</code> and <code>Compute</code> appear without a tile of their own. <em>Derived:</em> with neither declared, the tile ids are split on <code>separator</code> (default <code>.</code>), so <code>system.compute.cpu</code> files itself. A panel whose ids carry no separator is flat and renders exactly as it always did; <code>tree: false</code> keeps it flat whatever the ids look like. A tile's <code>field</code> is never a source — a dot there already means a nested object property, and overloading it would make <code>field: 'cpu.util'</code> ambiguous.</p>
|
|
5748
|
+
<p><strong>No value rolls up; severity does.</strong> A parent shows no aggregated number. That is not a simplification: the running accumulators expose <code>add</code>/<code>remove</code>/<code>value</code> and no merge, so <code>avg</code>, <code>countDistinct</code> and a <code>custom</code> reducer cannot be composed from their children without rescanning, and a per-aggregation exception list would be a number that is right for a sum and wrong for an average. A parent that has a tile of its own still shows <em>that tile's</em> reading. What does roll up is the status: <code>rollup</code> is the worst severity at or below the node, across as many levels as you have, and it is what a collapsed branch reports.</p>
|
|
5749
|
+
<p><strong>Nothing measured is not good news, and it does not win the roll-up either.</strong> A leaf that measured nothing is <code>unknown</code> (see above), and <code>unknown</code> is deliberately excluded from <code>rollup</code>: ranking “not measured” as the worst thing beneath a parent would hide a real warning under it. It is surfaced separately instead — <code>node.unknown</code> counts the descendants that measured nothing, and the node renders it in words (“2 unknown”) and puts it in its accessible name. So neither way of being wrong is available: silence cannot read as green, and it cannot bury an amber.</p>
|
|
5750
|
+
<p><strong>Accessible by construction.</strong> The rail is a real <code>role="tree"</code> with the APG keyboard model — <kbd>→</kbd> opens a closed branch and otherwise steps into it, <kbd>←</kbd> closes an open one and otherwise steps out to its parent, <kbd>↑</kbd>/<kbd>↓</kbd> walk what is visible, <kbd>Home</kbd>/<kbd>End</kbd> jump to its ends, <kbd>Enter</kbd>/<kbd>Space</kbd> activate — with a roving tabindex, and <code>aria-level</code>, <code>aria-posinset</code> and <code>aria-setsize</code> on every node, because a reader cannot count what a collapsed branch has left out of the DOM. Status carries a <strong>shape</strong> as well as a colour (a filled circle, a triangle, a square, a hollow circle), not one dot in three colours, and a parent's rolled-up status is <em>in its accessible name</em>: “Compute, 6 items, worst status critical”, announced as one string. Every phrase is a catalogue key: pass <code>messages</code> (any <code>{ t(key, params) }</code>, including a grid's own) to translate the panel, and a key your catalogue lacks falls back to English rather than printing the key.</p>
|
|
5751
|
+
<p><strong>Every leaf carries a value <em>and</em> a meter.</strong> A rail exists to be read at a glance, and a column of numbers is not that, so each leaf draws a small fixed-scale bar beside its reading. The scale comes only from what the tile already declares — there is no new configuration key. A <code>bands</code> list states its own ends and is taken at its word; <code>thresholds</code> states two interior cut points and a lone <code>target</code> states one point, so in those the open end is anchored at the <strong>origin</strong>: <code>{ warn: 70, critical: 90 }</code> measures 0–90, and <code>target: 4000</code> measures 0 to the target. The scale is never derived from the data — a bar scaled to the values currently in the panel would mean something different on every refresh — so a tile with no bands, no thresholds and no target gets <strong>no meter at all</strong>, a value and nothing else. The meter is placed by exactly the mapping the grid's own conditional-formatting data bar uses, <code>clamp((x - lo) / span)</code>, so a reading past the top of its scale fills the bar rather than overflowing it, and it is coloured by the leaf's own <code>good</code>/<code>warn</code>/<code>critical</code> status — reinforcing the shape glyph, never replacing it. A leaf that measured nothing draws a <em>dashed empty outline with no fill element</em>, which is deliberately not what a measured zero looks like (a solid track with a fill of no length): those are different facts. Each tile model carries the same numbers as <code>bar</code> (<code>{ lo, hi, percent }</code>, or <code>null</code> when no scale is declared) for a host that would rather draw its own. A row that gets no meter <strong>reserves the width of one</strong>, so the readings stay in a single column whether or not there is a bar beside them — the same reservation a leaf makes on the other side for the <code>+</code>/<code>−</code> control it does not have. A branch draws no meter, because no value rolls up to draw one; and a flat panel of tiles renders exactly the tiles it always did.</p>
|
|
5752
|
+
<p><strong>The panel follows the grid it belongs to, not the page.</strong> Pass <code>grid</code> and the panel takes that grid's <em>resolved</em> type and row rhythm: on a 16px page beside a grid painting its cells at 12.7px, a rail used to draw 16px text. Type reads <code>--lat-kpi-* > --lattice-* > --lat-chrome-* > 13px</code> and row height reads <code>--lat-kpi-row-height > --lat-chrome-row-height > 22px</code>. The <code>--lat-chrome-*</code> rung is mirrored off the mounted grid in JS (<code>modules/shared/chrome.js</code>) because a grid's <code>density</code> lands on the grid's own root, which is a <em>descendant</em> of a panel mounted beside it, and custom properties inherit downward only — no stylesheet can read it. Measured in Chrome: identical type and a 1.00× leaf-row-to-grid-row ratio at <code>compact</code>, <code>comfortable</code> and <code>spacious</code>. It re-mirrors on the grid's <code>config:changed</code>, so <code>grid.set('density', …)</code> moves the panel with it, and <code>destroy()</code> releases everything it wrote. A panel with <strong>no</strong> grid — a plain <code>rows</code> array, or the router-driven panel — mirrors nothing and renders at the module's own defaults, and a host's <code>--lattice-font-size</code> on an ancestor still out-ranks the mirror.</p>
|
|
5753
|
+
<p><strong>A heading says it opens.</strong> Each top-level item carries a <code>+</code> when shut and a <code>−</code> when open, in a small bordered box, on a row with a pointer cursor and a hover state — a disclosure triangle read as decoration rather than as a control. It is purely decoration: <code>aria-expanded</code> on the node is what states the same thing to a screen reader, so the marker is <code>aria-hidden</code> exactly as the status shape is, and the announced name is unchanged. A leaf keeps the marker's width as an empty spacer, so the status shapes and labels stay in one column whether or not the row beside them opens.</p>
|
|
5754
|
+
<p><strong>A collapsed branch costs data, not paint.</strong> Every node's status is computed whether or not you can see it — that is what makes the rail worth having — while a collapsed branch contributes no DOM at all. It is the same division the grid's grouping already makes between its totals walk and its display walk. Expansion is patched in place, keyed on the node, so a live routed feed does not throw a keyboard user off the node they are standing on.</p>
|
|
5755
|
+
<h3 id="kpi-tree-example">A collapsed parent reports the red leaf underneath it, executed</h3>
|
|
5756
|
+
<p class="section-note">Two subsystems from dotted tile ids, one of them in breach, with nothing expanded; then the same
|
|
5757
|
+
rail with no data at all. Run headless on every build.</p>
|
|
5758
|
+
<pre data-run="js" data-expect="compute false critical | cpu critical | null 2" data-covers="export:createKPI"><code><span class="kw">const</span> { createKPI } = <span class="kw">await</span> import('../packages/modules/kpi/index.js');
|
|
5759
|
+
|
|
5760
|
+
<span class="kw">const</span> fewerIsBetter = { warn: 70, critical: 90, direction: 'lowerIsBetter' };
|
|
5761
|
+
<span class="kw">const</span> tiles = [
|
|
5762
|
+
{ id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: fewerIsBetter },
|
|
5763
|
+
{ id: 'compute.memory', label: 'memory', aggregation: 'avg', field: 'mem', thresholds: fewerIsBetter },
|
|
5764
|
+
{ id: 'network.latency', label: 'latency', aggregation: 'max', field: 'latency',
|
|
5765
|
+
thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
|
|
5766
|
+
];
|
|
5767
|
+
|
|
5768
|
+
<span class="cmt">// No `tree` block: the dots in the tile ids are the hierarchy.</span>
|
|
5769
|
+
<span class="kw">const</span> kpi = createKPI(null, { rows: [{ id: 'h1', cpu: 94, mem: 40, latency: 12 }], rowKey: 'id', tiles });
|
|
5770
|
+
|
|
5771
|
+
<span class="kw">const</span> compute = kpi.nodes()[0];
|
|
5772
|
+
<span class="cmt">// Nobody has opened it, and it reports the breach anyway.</span>
|
|
5773
|
+
<span class="kw">const</span> shut = [compute.label, compute.expanded, compute.rollup].join(' '); <span class="cmt">// compute false critical</span>
|
|
5774
|
+
<span class="kw">const</span> leaf = [compute.children[0].label, compute.children[0].status].join(' '); <span class="cmt">// cpu critical</span>
|
|
5775
|
+
|
|
5776
|
+
<span class="cmt">// Nothing delivered: the parent reports the silence rather than a false green.</span>
|
|
5777
|
+
<span class="kw">const</span> quiet = createKPI(null, { rows: [], rowKey: 'id', tiles });
|
|
5778
|
+
<span class="kw">const</span> silent = String(quiet.nodes()[0].rollup) + ' ' + quiet.nodes()[0].unknown; <span class="cmt">// null 2</span>
|
|
5779
|
+
|
|
5780
|
+
<span class="kw">return</span> [shut, leaf, silent].join(' | ');</code></pre>
|
|
5638
5781
|
<h3 id="kpi-live-example">Live, driven by a Data Router alongside a grid, executed</h3>
|
|
5639
5782
|
<p class="section-note">One feed fans out (<code>overlap</code>) to a KPI panel through the same keyed-diff contract a grid uses:
|
|
5640
5783
|
a snapshot seeds the tiles, then a delta removes the current max and the min/max rescans. Run headless on every build.</p>
|
|
@@ -5839,6 +5982,79 @@ tabs.activate('breached'); <span class="cmt">// materialises
|
|
|
5839
5982
|
tabs.destroy();
|
|
5840
5983
|
<span class="kw">return</span> counts.join(','); <span class="cmt">// 4,3,2</span></code></pre>
|
|
5841
5984
|
|
|
5985
|
+
<h2 id="layout">The dashboard layout</h2>
|
|
5986
|
+
<p><code>modules/layout</code> is an opt-in <strong>reconfigurable dashboard surface</strong>: a cell grid inside an element, and a set of windows placed on it that a user can move, resize and close — by drag <em>or</em> by keyboard. It is the thing a customer would otherwise reach for GridStack or react-grid-layout to get, which means a second dependency, a second sizing model, and a seam where the viewers in it do not resize properly.</p>
|
|
5987
|
+
<p><strong>It is payload-agnostic, and that is the whole design.</strong> A window body is a <code>div</code> with an <code>id</code>. The module creates it, sizes it, and never reads or writes its contents — it does not import <code>createGrid</code>, does not know what a payload is, and never calls into one. What it does instead is emit <code>window:resized</code> with the measured content box, which is the contract. That rule is what keeps its own code to <strong>11,202 bytes gzipped</strong> (measured: a 75,502-byte bundle over a 62,206-byte fixed floor, of which 2,094 bytes are the two shared module helpers) and what makes it usable for a payload we have not written yet.</p>
|
|
5988
|
+
<pre><code>import { createLayout } from '@toclocoinc/lattice-grid/modules/layout';
|
|
5989
|
+
|
|
5990
|
+
const layout = createLayout(document.querySelector('#dash'), {
|
|
5991
|
+
columns: 12, rows: 8, gap: 8,
|
|
5992
|
+
overflowX: 'static', overflowY: 'scroll', rowHeight: '160px',
|
|
5993
|
+
windows: [
|
|
5994
|
+
{ id: 'pipeline', title: 'Pipeline', xPos: 1, yPos: 1, xSize: 6, ySize: 4,
|
|
5995
|
+
movable: true, resizable: true, closable: true },
|
|
5996
|
+
{ id: 'trend', title: 'Trend', xPos: 7, yPos: 1, xSize: 6, ySize: 4,
|
|
5997
|
+
movable: true, resizable: true },
|
|
5998
|
+
],
|
|
5999
|
+
onWindowResized: ({ id, width, height }) => redraw(id, width, height),
|
|
6000
|
+
});
|
|
6001
|
+
|
|
6002
|
+
<span class="cmt">// The module made the container; you fill it and you own what is inside it.</span>
|
|
6003
|
+
createGrid(layout.payload('pipeline'), { rowKey: 'id', rows, columns });</code></pre>
|
|
6004
|
+
<p><strong>Two independent overflow axes, not one setting.</strong> <code>overflowX</code> and <code>overflowY</code> are each <code>'static'</code> or <code>'scroll'</code>, because a dashboard that scrolls both ways is ordinary and a single enum cannot express it. The difference is what happens to a track's size. A <strong>static</strong> axis divides the mounted element with <code>minmax(0, 1fr)</code> — never a bare <code>1fr</code>, whose implicit <code>auto</code> minimum lets one stubborn payload drag a track past the container. A <strong>scrolling</strong> axis repeats a <em>fixed</em> track (<code>columnWidth</code> / <code>rowHeight</code>) and the canvas extends past the viewport, which then scrolls. That is the owner's "maintaining their sizing", and it is the difference between this and <code>flex-wrap</code>: measured in a real browser, ten 200px columns in a 600px host paint at 200px each over a 2000px canvas, and <em>shrinking the host to 300px leaves the column at 200px</em> and scrolls further.</p>
|
|
6005
|
+
<p><strong>Spacing takes a real CSS length.</strong> <code>gap</code>, <code>padding</code>, <code>columnWidth</code> and <code>rowHeight</code> each accept a number (pixels), or a string: <code>'200px'</code>, <code>'25%'</code>, <code>'1fr'</code>, <code>'2rem'</code>, <code>'10vh'</code>. Percentages that sum past 100 are allowed to overflow and scroll rather than being silently scaled down, which is the honest outcome. Anything outside that vocabulary — including <code>calc()</code> and <code>var()</code> — is refused by name with one warning and replaced by the default, because the value is written into an inline style.</p>
|
|
6006
|
+
<p><strong>Rearrangement.</strong> <code>compact: 'vertical'</code> (the default) pushes displaced windows down and then pulls everything up into whatever space that left, so a window dropped into empty space falls to the top of its column — <code>window:moved</code> carries both <code>to</code> (where it was asked to go) and <code>landed</code> (where it actually ended up). <code>compact: 'none'</code> keeps every window exactly where it is put. There is no horizontal compactor: pushing sideways has no single obviously-correct direction, and getting it wrong silently rearranges a dashboard a user carefully built.</p>
|
|
6007
|
+
<p><strong>Keyboard, to the same standard as the drag.</strong> Every movable and resizable window carries a focusable handle running the full grab / move / drop / cancel model the kanban board established: <kbd>Space</kbd> or <kbd>Enter</kbd> grabs, the arrow keys move a tentative placement, <kbd>Enter</kbd> drops it through the same <code>beforeWindowMove</code> gate the pointer drag uses, and <kbd>Escape</kbd> cancels. A polite live region announces every step — grabbed, each tentative position with its column and row, dropped, cancelled, and <em>reverted</em> when a handler vetoes the drop — and focus returns to the handle afterwards. A window with <code>chrome: false</code> still gets a handle, because a movable window a keyboard user cannot move is not movable.</p>
|
|
6008
|
+
<p><strong>An “Edit layout” button, without rebuilding the dashboard.</strong> <code>closable</code>, <code>movable</code> and <code>resizable</code> also take a <em>layout-level</em> default, so unlocking a twelve-window dashboard is one setting rather than twenty-four, and <code>setInteractive(true|false|{movable, resizable, closable})</code> changes that default at runtime — unlock, let the user rearrange, lock again and save <code>getLayout()</code>. Nothing is destroyed and nothing is rebuilt, so every grid, chart and board mounted in a window survives the toggle untouched. <strong>The asymmetry is deliberate: you can always take a capability away; you can never grant one where the developer said no.</strong> <code>setInteractive(false)</code> locks every window, including one whose own spec says <code>movable: true</code>, so a dashboard hard-locks in a single call without auditing twelve window specs; <code>setInteractive(true)</code> unlocks only the windows that never opted out, so a masthead declared <code>movable: false</code> stays pinned. Both halves of the enforcement move together — the handles a window renders <em>and</em> the checks the pointer and keyboard paths make, because removing a handle stops a mouse while only the gesture check stops a keyboard user already standing on one. <strong><code>config.movable: false</code> and <code>setInteractive(false)</code> are deliberately not the same thing:</strong> the config states the <em>default</em> for windows that declare nothing — and <code>false</code> is already that default, so it takes nothing away from a window that declared <code>movable: true</code> — while <code>setInteractive(false)</code> is an <em>active lock</em> that pins every window whatever its own spec says. <code>getInteractive()</code> reports all three states rather than two: <code>undefined</code> where no layout-level default is in force, <code>true</code>, or <code>false</code> for a lock. Reporting “unset” as <code>false</code> would read correctly and round-trip wrongly, so <code>setInteractive(getInteractive())</code> is a no-op in every state, and a key carrying <code>undefined</code> means “leave this capability alone”. Interactivity is a <em>mode</em>, not part of the arrangement: <code>getLayout()</code> does not carry it, <code>setLayout()</code> does not read it, and no event fires. <strong>A locked layout is not a read-only dashboard:</strong> the module creates the payload container and never reads or writes its contents, so a grid inside a window is made read-only with the grid's own settings — a dashboard that must not be edited is two decisions, not one.</p>
|
|
6009
|
+
<p><strong>Which payloads re-lay-out on <code>window:resized</code>, honestly.</strong> The grid and the chart each own a <code>ResizeObserver</code> and respond correctly; the Gantt does too since 1.52.0; kanban and KPI do no JS work at all on a resize and need none, because they reflow by CSS construction (a kanban column keeps its 280px and the board starts scrolling). Every one of those is measured in <code>test/layout-browser.test.js</code> rather than asserted. <strong>One exception, named rather than softened:</strong> a grid column declared as a <em>percentage</em> (<code>layout: { width: '50%' }</code>) is resolved against the viewport width once and never re-resolved, so halving the window leaves the column wider than the viewport it sits in (800px host → 783px viewport, 391px column; 400px host → 383px viewport, still a 391px column). That is not caused by this module — it reproduces on a plain grid in a plain resized <code>div</code> — and until it is fixed, size grid columns inside a resizable window in pixels or with <code>flex</code>, not with percentages.</p>
|
|
6010
|
+
<p><strong>Idle cost, measured.</strong> A twelve-window dashboard is <strong>indistinguishable from a page with no layout module on it at all</strong>. Over 8 seconds of real Chrome (<code>bench/layout-idle.mjs</code>), twelve windows with empty payloads, twelve <em>independent</em> live grids in them, and a single lone grid with no layout module all sit in the same few-millisecond band — under a tenth of one percent of a core. They are not separated here because they cannot be: seven runs across two machines land between 3.1ms and 9.0ms and the ordering between them inverts run to run, so a stated delta would be reporting the noise floor. The module adds no timer, no frame loop and no polling, and owns exactly one <code>ResizeObserver</code> for the whole layout rather than one per window. <strong>The one figure that is a result rather than noise is not this module's:</strong> twelve grids <em>derived</em> from one shared parent filtered at 20Hz cost <strong>3,155–3,409ms</strong> over the same 8 seconds — several hundred times the quiet band, and stable across every run — because the engine's repaint listener for a derived source's <code>rows:changed</code> calls the renderer directly and bypasses <code>grid.updates.pause()</code>. The bench reports the size of that gap rather than leaving it inferred.</p>
|
|
6011
|
+
<p><strong>Closing a window does not destroy its payload.</strong> <code>window:closed</code> hands the payload container back; whatever you mounted inside it is yours to destroy. Stated plainly because a leaked grid per closed window is the obvious failure, and this module has no way to know that a <code>div</code> contains something with a <code>destroy()</code>.</p>
|
|
6012
|
+
<p><strong>Not in v1:</strong> horizontal compaction; per-frame drag events; nested layouts; window maximise/minimise; tabbed windows (that is <code>modules/tabs</code>); and <strong>responsive breakpoints — a twelve-window dashboard on a phone is unsolved, and this does not pretend otherwise</strong>. Server-side persistence is the host's, with <code>getLayout()</code>.</p>
|
|
6013
|
+
<div class="table-wrap">
|
|
6014
|
+
<table>
|
|
6015
|
+
<thead><tr><th>Member</th><th>Description</th></tr></thead>
|
|
6016
|
+
<tbody>
|
|
6017
|
+
<tr><td class="sig">createLayout(el, config)</td><td class="desc">Create a dashboard layout. <code>columns</code>/<code>rows</code> (default 12/6) divide the element; <code>overflowX</code>/<code>overflowY</code> are each <code>'static'</code> or <code>'scroll'</code>; <code>columnWidth</code>/<code>rowHeight</code> are the fixed track sizes a scrolling axis uses; <code>gap</code> (8px), <code>padding</code> (5px) and <code>compact</code> (<code>'vertical'</code>) complete it. A second mount on the same element is refused by name.</td></tr>
|
|
6018
|
+
<tr><td class="sig">config.windows[]</td><td class="desc">Each window: <code>id</code> (required, unique), <code>xPos</code>/<code>yPos</code>/<code>xSize</code>/<code>ySize</code> in 1-based cells (auto-placed in the first free cell when omitted), <code>title</code>, <code>chrome</code> (default <code>true</code>), and <code>closable</code>/<code>movable</code>/<code>resizable</code> (all default <code>false</code>, so a dashboard the developer wants fixed is fixed without opting out of anything; each also takes a layout-level default of the same name, which a window's own boolean overrides). <code>padding</code> and <code>payloadId</code> (default <code>`${id}-body`</code>) override per window.</td></tr>
|
|
6019
|
+
<tr><td class="sig">payload(id) / window(id) / windows()</td><td class="desc">The payload container for a window — the <code>div</code> carrying its <code>payloadId</code>, which you fill; a copy of a window's current descriptor; every window id in mount order.</td></tr>
|
|
6020
|
+
<tr><td class="sig">add(spec) / move(id, to) / close(id)</td><td class="desc">Add a window after mount (returns its payload container); move or resize one through the same before-events the drag uses; close one through <code>beforeWindowClose</code>. <code>move</code> and <code>close</code> return <code>true</code>/<code>false</code> synchronously with no handler registered, or a <code>Promise<boolean></code> when a handler deferred.</td></tr>
|
|
6021
|
+
<tr><td class="sig">getLayout() / setLayout(snapshot)</td><td class="desc">The full current arrangement as plain JSON (<code>{columns, rows, windows: [{id, xPos, yPos, xSize, ySize}]}</code>), and its restore. <code>setLayout</code> never throws on garbage, and an entry naming a window that does not exist yet is <em>retained</em> and applied when that window is added.</td></tr>
|
|
6022
|
+
<tr><td class="sig">getState() / setState(snapshot)</td><td class="desc">The versioned persistence pair, following core's and the Gantt's shape: no arguments in, one plain JSON-safe object out, and <code>setState</code> survives whatever is handed to it.</td></tr>
|
|
6023
|
+
<tr><td class="sig">setInteractive(value) / getInteractive()</td><td class="desc">Lock or unlock the whole dashboard at runtime, without destroying it. A boolean sets <code>movable</code>, <code>resizable</code> and <code>closable</code> together; an object sets only the keys it carries, and a key carrying <code>undefined</code> is treated as absent; <code>getInteractive()</code> returns the layout-level values as a copy, three-valued (<code>undefined</code> for unset, <code>true</code>, or <code>false</code> for a lock) so that <code>setInteractive(getInteractive())</code> is a no-op in every state. The config keys of the same name state the <em>default</em>; only this method takes a capability away. Locking always wins and unlocking never overrides an opt-out: <code>setInteractive(false)</code> pins a window that declared <code>movable: true</code>, and <code>setInteractive(true)</code> leaves a window that declared <code>movable: false</code> pinned. No event fires and <code>getLayout()</code> is unchanged — a mode is not an arrangement.</td></tr>
|
|
6024
|
+
<tr><td class="sig">refresh()</td><td class="desc">Re-measure every window and emit <code>window:resized</code> for those that changed. Called automatically; exposed for a host that changed something the module cannot observe, such as revealing an ancestor.</td></tr>
|
|
6025
|
+
<tr><td class="sig">on(name, fn) / off(name, fn)</td><td class="desc">Events: <code>window:moved</code>, <code>window:resized</code>, <code>window:closed</code>, <code>layout:changed</code>; the cancellable <code>beforeWindowMove</code>, <code>beforeWindowResize</code> and <code>beforeWindowClose</code> (call <code>preventDefault(reason?)</code> or return <code>false</code>), each paired with <code>windowMove:cancelled</code>, <code>windowResize:cancelled</code> and <code>windowClose:cancelled</code>. <code>'*'</code> subscribes to every past-tense event and is deliberately never delivered a before-event. Config sugar for all ten. Drag progress is <strong>not</strong> emitted per frame.</td></tr>
|
|
6026
|
+
<tr><td class="sig">destroy()</td><td class="desc">Stop observing, drop every listener including any left by a gesture in flight, and remove the DOM the module built. Whatever you mounted in a payload is yours to destroy.</td></tr>
|
|
6027
|
+
</tbody>
|
|
6028
|
+
</table>
|
|
6029
|
+
</div>
|
|
6030
|
+
<h3 id="layout-live-example">Placement, compaction and a saved arrangement, executed</h3>
|
|
6031
|
+
<p class="section-note"><code>createLayout</code> needs a real host element, the same way <code>createGrid</code> does, so this executed example reaches for the same in-tree DOM test double the suite runs the renderer against headlessly (<code>packages/dom/src/renderer/testdom.js</code>). <code>demo/layout.html</code> is the browser version, with a real grid, chart and KPI rail in three windows that you can drag, resize with the keyboard, close, save and restore.</p>
|
|
6032
|
+
<pre data-run="js" data-expect="a@1,1 b@3,1|a@3,1 b@3,2|a@1,1 b@3,1|a-body" data-covers="export:createLayout"><code><span class="kw">const</span> { createTestDom } = <span class="kw">await</span> import('../packages/dom/src/renderer/testdom.js');
|
|
6033
|
+
<span class="kw">const</span> { root } = createTestDom();
|
|
6034
|
+
<span class="kw">const</span> { createLayout } = <span class="kw">await</span> import('../packages/modules/layout/index.js');
|
|
6035
|
+
|
|
6036
|
+
<span class="kw">const</span> layout = createLayout(root, {
|
|
6037
|
+
columns: 4, rows: 4,
|
|
6038
|
+
windows: [
|
|
6039
|
+
{ id: 'a', title: 'A', xPos: 1, yPos: 1, xSize: 2, ySize: 1 },
|
|
6040
|
+
{ id: 'b', title: 'B', xSize: 2, ySize: 1 }, <span class="cmt">// no coordinates: auto-placed</span>
|
|
6041
|
+
],
|
|
6042
|
+
});
|
|
6043
|
+
<span class="kw">const</span> shape = () => layout.getLayout().windows.map((w) => `${w.id}@${w.xPos},${w.yPos}`).join(' ');
|
|
6044
|
+
|
|
6045
|
+
<span class="kw">const</span> placed = shape(); <span class="cmt">// B landed in the first free cell: 3,1</span>
|
|
6046
|
+
<span class="kw">const</span> saved = JSON.parse(JSON.stringify(layout.getLayout()));
|
|
6047
|
+
|
|
6048
|
+
layout.move('a', { xPos: 3, yPos: 1 }); <span class="cmt">// drop A on top of B</span>
|
|
6049
|
+
<span class="kw">const</span> pushed = shape(); <span class="cmt">// B is pushed down to 3,2</span>
|
|
6050
|
+
|
|
6051
|
+
layout.setLayout(saved); <span class="cmt">// the saved arrangement round-trips</span>
|
|
6052
|
+
<span class="kw">const</span> restored = shape();
|
|
6053
|
+
|
|
6054
|
+
<span class="kw">const</span> payload = layout.payload('a').id; <span class="cmt">// the container you fill: 'a-body'</span>
|
|
6055
|
+
layout.destroy();
|
|
6056
|
+
<span class="kw">return</span> [placed, pushed, restored, payload].join('|');</code></pre>
|
|
6057
|
+
|
|
5842
6058
|
<h2 id="mocksocket">The mock socket</h2>
|
|
5843
6059
|
<p><code>modules/mock-socket</code> is a serverless stand-in for a live <code>WebSocket</code> feed, for building and demonstrating a real-time UI with <strong>no backend</strong>. <code>MockWebSocket</code> presents the same surface as the browser's <code>WebSocket</code> — the same <code>readyState</code> and state constants, the same <code>onopen</code>, <code>onmessage</code>, <code>onclose</code> and <code>onerror</code>, <code>addEventListener</code>, <code>send</code> and <code>close</code> — so the code that reads it does not change when it is swapped for a real one. It fires an initial snapshot the moment it opens, then a stream of deltas on a timer, all from a generator you hand it. It is a dev and test utility: optional, imports nothing from the grid, and is never pulled into the core bundle. It pairs naturally with the data router (one mock stream, partitioned to many grids), but depends on it no more than a real socket does.</p>
|
|
5844
6060
|
<pre><code>import { MockWebSocket, opsFeed } from '@toclocoinc/lattice-grid/modules/mock-socket';
|
|
@@ -8044,6 +8260,30 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
|
|
|
8044
8260
|
</tbody>
|
|
8045
8261
|
</table>
|
|
8046
8262
|
</div>
|
|
8263
|
+
<h3 id="type-DerivedCorrelation">DerivedCorrelation</h3>
|
|
8264
|
+
<p class="section-note">Pearson's correlation across N columns, pairwise. Rows, `orient: 'pairs'` (the default): one per unordered pair, `{ a, b, coefficient, n }` — the long form, because that is what a grid sorts, filters and charts well, and "the three most correlated pairs" is then a sort and a `limit` on the derived grid. Only the upper triangle is emitted: r is symmetric, so `(a,b)` and `(b,a)` are one finding, and a column against itself is 1 by definition. Rows, `orient: 'matrix'`: one per column, carrying a field per other column plus `column` and `n` — the classic square, for a heat map. The diagonal is 1 and both triangles are filled.</p>
|
|
8265
|
+
<div class="table-wrap">
|
|
8266
|
+
<table>
|
|
8267
|
+
<thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
|
|
8268
|
+
<tbody>
|
|
8269
|
+
<tr><td class="name">fn</td><td class="type">'correlation'</td><td class="desc"></td></tr>
|
|
8270
|
+
<tr><td class="name">columns</td><td class="type">string[]</td><td class="desc">The columns to correlate pairwise. At least two, or the source is refused.</td></tr>
|
|
8271
|
+
<tr><td class="name">orient</td><td class="type">'pairs' | 'matrix'</td><td class="desc">`pairs` (default) for one row per pair; `matrix` for the square. <small>(optional)</small></td></tr>
|
|
8272
|
+
</tbody>
|
|
8273
|
+
</table>
|
|
8274
|
+
</div>
|
|
8275
|
+
<h3 id="type-DerivedDatasetComparison">DerivedDatasetComparison</h3>
|
|
8276
|
+
<p class="section-note">How this grid differs from another, ranked by effect size, as rows: `{ column, measure, magnitude, distance, direction, nA, nB, reliable, unmatched }`, largest difference first. The two-grid shape: one grid is the data, a second *is* the analysis of it. Both sides are read over their filtered rows, and the peer is watched — an edit or a filter on it re-derives the comparison, because a comparison whose other side has moved is wrong rather than merely late. A column present on only one side cannot be compared. It is still reported, as a row with a null `magnitude` and `unmatched` set to `'A'` or `'B'`, so a reader sees that it was skipped and why rather than finding it absent.</p>
|
|
8277
|
+
<div class="table-wrap">
|
|
8278
|
+
<table>
|
|
8279
|
+
<thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
|
|
8280
|
+
<tbody>
|
|
8281
|
+
<tr><td class="name">fn</td><td class="type">'datasetVsDataset'</td><td class="desc"></td></tr>
|
|
8282
|
+
<tr><td class="name">with</td><td class="type">Grid</td><td class="desc">The second grid to compare this one against.</td></tr>
|
|
8283
|
+
<tr><td class="name">columns</td><td class="type">string[]</td><td class="desc">Restrict the comparison to these columns. All shared columns by default. <small>(optional)</small></td></tr>
|
|
8284
|
+
</tbody>
|
|
8285
|
+
</table>
|
|
8286
|
+
</div>
|
|
8047
8287
|
<h3 id="type-DerivedJoin">DerivedJoin</h3>
|
|
8048
8288
|
<div class="table-wrap">
|
|
8049
8289
|
<table>
|
|
@@ -8069,6 +8309,19 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
|
|
|
8069
8309
|
</tbody>
|
|
8070
8310
|
</table>
|
|
8071
8311
|
</div>
|
|
8312
|
+
<h3 id="type-DerivedSeries">DerivedSeries</h3>
|
|
8313
|
+
<p class="section-note">A `grid.statistics.series` summary, as one row per metric: `{ metric, value, n }`. One row per *metric*, not per point: `series` returns a `SeriesStats` summary object — `n`, `first`, `last`, `change`, `changePercent`, `volatility`, `annualisedVolatility`, `growth`, `maxDrawdown`, `maxDrawdownFrom`, `maxDrawdownTo`, `autocorrelation`, `upDays`, `downDays` — and not a value per row. The shape is deliberately the one `profile`'s `orient: 'metrics'` already emits rather than a third convention for the same idea.</p>
|
|
8314
|
+
<div class="table-wrap">
|
|
8315
|
+
<table>
|
|
8316
|
+
<thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
|
|
8317
|
+
<tbody>
|
|
8318
|
+
<tr><td class="name">fn</td><td class="type">'series'</td><td class="desc"></td></tr>
|
|
8319
|
+
<tr><td class="name">of</td><td class="type">string</td><td class="desc">The column to summarise.</td></tr>
|
|
8320
|
+
<tr><td class="name">by</td><td class="type">string</td><td class="desc">The column that orders it. Required and never guessed.</td></tr>
|
|
8321
|
+
<tr><td class="name">periodsPerYear</td><td class="type">number</td><td class="desc">Annualise volatility and growth against this many periods per year. <small>(optional)</small></td></tr>
|
|
8322
|
+
</tbody>
|
|
8323
|
+
</table>
|
|
8324
|
+
</div>
|
|
8072
8325
|
<h3 id="type-DerivedSourceConfig">DerivedSourceConfig</h3>
|
|
8073
8326
|
<p class="section-note">A grid whose rows are derived from another grid: aggregated, unnested, filtered, ranked or profiled. Read-only: write to the source instead.</p>
|
|
8074
8327
|
<div class="table-wrap">
|
|
@@ -8090,6 +8343,7 @@ return `next ${p.mean.toFixed(1)}; r2 ${f.r2.toFixed(1)}; band ${p.upper - p.low
|
|
|
8090
8343
|
<tr><td class="name">cumulative</td><td class="type">{ of: string; upTo: number }</td><td class="desc">Keep rows until their running share of the total reaches `upTo`, 0 to 1. <small>(optional)</small></td></tr>
|
|
8091
8344
|
<tr><td class="name">profile</td><td class="type">string | string[]</td><td class="desc">One row per column, with the statistics as columns. Replaces the pipeline. <small>(optional)</small></td></tr>
|
|
8092
8345
|
<tr><td class="name">orient</td><td class="type">'columns' | 'metrics'</td><td class="desc">With `profile`, emit one row per statistic instead of one per column. <small>(optional)</small></td></tr>
|
|
8346
|
+
<tr><td class="name">statistics</td><td class="type">DerivedStatistics</td><td class="desc">Project a **relational** statistic into rows (BACKLOG-0001046): the figures that need two or more columns, or a second grid, and so cannot be reached through `select`. Every *single-column* statistic already has a route and this is not it — the derived `select` reduces a group by any kernel the totals row uses, and that table is a superset of the statistics one, so `select: { p95: { of: 'amount', fn: 'p95' } }` (or `gini`, `stddev`, `median`, `trimmedMean`, …) works today. Reach for `statistics` only when the answer is a correlation, a series summary or a comparison against another dataset. **A terminal producer, like `profile`, not a pipeline stage.** A correlation is one row per column *pair*, a series summary one row per *metric*, a comparison one row per compared *column* — none of which is one row per group, so there is no position in `unnest → where → bucket → groupBy → select → sort → limit` for it to occupy. It replaces the pipeline, and those keys are ignored with a warning naming them (BACKLOG-0001092) rather than silently discarded. Sort, filter or limit the derived grid itself instead, or chain a second derived grid whose `from` is this one. **`profile` and `statistics` are mutually exclusive** and declaring both is refused, by name, when the source is built. **Not supported alongside a union `from`** — a relational statistic reduces one grid's own columns and a union has no single set of them; also refused by name. **Cost.** Like every terminal producer this never patches incrementally: a change on the parent re-derives the whole thing. `correlation` additionally scans the rows once *per pair*, so N columns cost N·(N−1)/2 passes. Use `refresh` (`'idle'` is the default; `'manual'` or a debounce in ms for an expensive analysis over a live feed) — see `docs/api-detail.html` for the measured figures. Every row carries `n`, the rows the figure covered, because a derived statistic travels into an export or a chart without its grid and "r = 0.98 over eleven rows" is a different claim from the same number over eleven thousand. It does NOT carry a windowed/approximate flag: whether a source held fewer rows than matched its filters is decided from the source's own counters, which a derived source cannot reach, so that signal stays where it already works - the `stat.windowed:*` console warning the parent grid emits. <small>(optional)</small></td></tr>
|
|
8093
8347
|
<tr><td class="name">refresh</td><td class="type">'live' | 'idle' | 'manual' | number</td><td class="desc">When to re-derive. `idle` by default: coalesced to a frame. <small>(optional)</small></td></tr>
|
|
8094
8348
|
<tr><td class="name">crossFilter</td><td class="type">boolean | string | { col?: string }</td><td class="desc">Let this grid filter the grid it derives from. `true` cross-filters through whatever it groups by; a string names a different source column. <small>(optional)</small></td></tr>
|
|
8095
8349
|
</tbody>
|