Lattice Grid Buy a licence

developer guide

Audit Mode and Diff View for a Data Grid

Audit mode shows the grid as a record of change: the previous value beside the current one, and rows that were deleted still visible rather than quietly absent, so a review has something to look at.

Developer guideCollaboration and comments › Audit Mode and Diff View for a Data Grid

Audit mode

Give the grid a prior snapshot and every row reports whether it was added, removed or changed, and which cells moved.

Before and after

diff: { snapshot: lastApprovedVersion },

grid.diff.summary();              // { added, removed, changed, unchanged }
grid.diff.statusOf('CIR-100042'); // 'changed'
grid.diff.changedColumns('CIR-100042');
grid.diff.before('CIR-100042', 'capacity');

Changed rows get a band down the leading edge and changed cells a tint with the prior value on the cell as data-before. The row band and the cell tint are deliberately different devices: "which rows moved" and "what changed in this row" are different questions and one highlight cannot serve both.

A network map: icon nodes, links coloured by their value

A network chart draws the rows as a graph - two columns name the endpoints and a third carries the value on the link. Until 1.64 every node was a plain circle, every edge the same grey, and the layout went wherever the simulation put it, so the picture a network team actually draws - core on top, regions below, an icon per device, a colour per circuit - could not be expressed. Three options change that, and every one of them carries a fact that only the host has.

One nodes list, one rule set, one chart config

createGrid(el, {
  columns: [{ field: 'from' }, { field: 'to' }, { field: 'load', type: 'number' }],
  rows,                                  // one row per circuit
  selection: 'multiple',
  // The rules live on the column. The cells and the links read the same ones.
  formatting: {
    load: [
      { label: 'Healthy',   when: { op: 'lt',  value: 40 }, style: { background: '#107c41' } },
      { label: 'Busy',      when: { op: 'lt',  value: 80 }, style: { background: '#f0b400' } },
      { label: 'Saturated', when: { op: 'gte', value: 80 }, style: { background: '#a4262c' } },
    ],
  },
});

createChart({
  grid, container: '#topology', type: 'network',
  source: 'from', target: 'to', y: { col: 'load', fn: 'sum' },
  selection: true,
  nodes: [
    // x/y are fractions of the plot: both given pins the node there.
    { id: 'core-1', label: 'Core', icon: 'router', x: 0.3, y: 0.15 },
    { id: 'core-2', label: 'Core', icon: 'router', x: 0.7, y: 0.15 },
    { id: 'emea',   label: 'EMEA', icon: 'hub',    x: 0.2, y: 0.8 },
    { id: 'amer',   label: 'AMER', icon: 'hub',    x: 0.5, y: 0.8 },
    { id: 'apac',   label: 'APAC', icon: 'hub',    x: 0.8, y: 0.8 },
  ],
});

The icons come from the grid, not from the chart. icon is a name in the grid’s own sprite registry - a built-in, or one you registered - and the chart reads it through grid.icons, the grid it is bound to. That is deliberate and it is the only route that works: the charts module ships as its own bundle, so an import of the registry there would hand the chart a second, empty copy, and a glyph you registered would be invisible to it. One registry, reached through the one object both sides already share. Unknown names warn once and draw a plain disc rather than nothing.

Pinning steers the layout without replacing it. A node with both x and y is a fixed body: it still pushes its neighbours apart and still pulls on its links, and the integration step skips it. Everything unpinned settles around it by the same deterministic relaxation as before - and pinning one node does not reshuffle the others, because the layout’s seeding draws for every node whether it is pinned or not, precisely so that it cannot. Half a position is not a position: a node with only x is laid out.

Parallel links are not summed. Three rows between the same pair are three lines, offset 4 px apart, symmetric about the pair’s own line, in row order. One line carrying their total would be a number nothing measured - three circuits at 40% do not make one at 120% - and the pointer picks out the line you are over rather than the pair, so each one’s own value is readable. Links are undirected: no arrowheads, and A,B is the same pair as B,A.

There is no chart-level threshold option, on purpose. The colour comes from grid.formatting.styleFor(col, value) - background, then backgroundColor, then color; a gradient is not a colour and is not read. A second place to say “red above 80” is a second place for the chart and the cell to disagree. The legend lists only the rules that fired, with their own labels and swatches, and changing a rule recolours the links on the next frame without the layout re-running, so nothing moves.

Clicking acts on rows, because a link is a row. With selection: true, clicking a link selects its row and clicking a node selects every row it is an end of; the grid’s selection then lights those marks and dims the rest. The types whose marks are aggregates are deliberately untouched: selecting the forty rows behind a bar is not what a click on a bar means.

The glyphs those nodes draw are yours to supply. icons on the grid configuration registers SVG sprites by name, before the first paint, into the same registry the built-in chevrons and sort arrows live in - so a name you register is usable anywhere a glyph name is: a column’s icon decoration, a rail action’s icon, a network node’s icon. Registering a built-in name overrides it, which is how the expander chevron becomes your own mark. A sprite is a view box and its path data; one filled path on the house 16×16 box is the common case, so a bare path string is read as exactly that. grid.icons reads the registry back.

Registering your own glyphs, executed

const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { root } = createTestDom();
const { createGrid } = await import('../packages/dom/src/index.js');

const grid = createGrid(root, {
  rowKey: 'id',
  rows: [{ id: 'core-1', load: 41 }],
  columns: [{ field: 'id' }, { field: 'load', type: 'number' }],
  // Your own sprites, by name, alongside the built-in set.
  icons: {
    router: { viewBox: '0 0 16 16', paths: ['M2 6h12v6H2Z', 'M5 6V3h6v3'], paint: 'stroke' },
    hub: 'M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Z',      // one filled path is enough
  },
});

// Read back what the registry holds: the normalised glyph, whichever form registered it.
const router = grid.icons.get('router');
const hub = grid.icons.get('hub');
grid.destroy();

return `router ${router.paths.length} ${router.paint} | hub ${hub.paths.length} ${hub.paint}`;

Showing what was deleted

A deletion is a change too

diff: { snapshot: lastApprovedVersion, removedRows: 'pinned' }   // or 'data'

The row is gone from the data and still in the snapshot. That is the only place it exists, and it is what these rows are built from, so they carry the values they had when the snapshot was taken, not any current ones.

One option answers both questions: whether to show it, and whether it counts. 'pinned' puts it beneath the rows, struck through and dimmed, outside the row set, so rows.count(), exports and selection all pass over it. 'data' appends it to the set instead, and all three include it. Omitted, nothing is shown and the grid is the one you already had.

Neither sorts or filters it among the live rows. A removed row's values are yesterday's; ordering them among today's presents two data sets as one, and lets a filter written for current values decide the fate of historical ones. Neither permits an edit either, a write aimed at a removed row is refused and returns 0, rather than being counted as applied against a record that is not there.