Lattice Grid Buy a licence

developer guide

Presenting, saved views and history

Presentation mode for a room, full-screen maximise, the time scrubber, saved views, undo and redo across the whole grid, and the left tool rail.

Developer guide › Presenting, saved views and history

Presentation mode

Renders the grid for a room: full-screen, application chrome hidden, everything enlarged. The data stays live and queryable throughout, so a question from the audience is answered by filtering in front of them rather than promised as a follow-up.

Starting and stopping

grid.presentation.start();                  // 1.5x by default
grid.presentation.start({ scale: 2 });
grid.presentation.start({ chrome: ['statusBar'] });   // keep some chrome

grid.presentation.nudge(1);                 // live, or Ctrl/Cmd +
grid.presentation.setScale(1.8);
grid.presentation.stop();                   // or Escape
KeysDoes
EscapeLeave, restoring the grid exactly as it was.
Ctrl/Cmd + = / -Enlarge or reduce live, a laptop on a call and a projector at the back of a room are different problems.
Ctrl/Cmd + 0Back to the default enlargement.

The scale multiplies your density, it does not replace it. A grid built at spacious presented at 1.5x is still recognisably that grid, half as big again, which is what makes a presentation look like the product rather than like a different one. Virtualisation follows the enlargement, so rows are positioned at the size they are drawn.

Full-screen is the maximiser, not a second implementation. A grid the user had already maximised stays maximised when the presentation ends: leaving it would be undoing something the presentation did not do. Chrome hidden on entry is recorded and put back, so an element the host had already hidden is not revealed on exit.

Events are presentation:started, presentation:ended, presentation:scale and presentation:changed: colon-separated like every other grid event rather than the camelCase the original brief used, so a host subscribing to them does not have to remember which family a name belongs to.

Views are the slides

grid.presentation.start({ views: ['escalations', 'at-risk', 'margin-watch'] });
grid.presentation.step(1);          // or an arrow key, space, Page Down
grid.presentation.goTo(0);          // or Home / End
grid.presentation.reset();          // or R: back to the view as saved
KeysDoes
→ ↓ Space PageDownNext view.
← ↑ PageUpPrevious view.
Home / EndFirst or last.
RPut the current view back as saved, discarding anything sorted or filtered since arriving at it.

They are the saved views you already have. A view captures the column set, order, widths, filters, sorts, grouping and density; stepping applies each through the ordinary views.apply, as a single undo entry. Nothing about presenting changes what a view means.

The stepping keys only bind when there is a sequence, and never while something is being typed into. Without a deck those keys belong to the grid, a presenter with no slides still expects Page Down to scroll, and a quick filter answering a question from the room must not advance the deck on the space bar.

Stepping past either end sits there. It does not wrap: a presenter who sees the first slide again thinks the deck has restarted.

Transitions are a cross-fade, not continuous row motion. Rows are pooled and virtualised, so an element holding a row before a view change may hold a different row after it: only rows visible in both states could be animated between positions, and half a movement draws the eye to whichever rows happened to survive rather than to the change itself. prefers-reduced-motion removes it; a projected fade is far larger than one on a laptop, so someone who asked for less motion meant it.

Spotlight, redaction and unattended cycling

// light one row across two columns; everything else recedes
grid.presentation.setSpotlight({ keys: ['R42'], colIds: ['margin', 'utilisation'] });
grid.presentation.setSpotlight(null);        // after the point is made

// a wall display cycling saved views with nobody at the keyboard
grid.presentation.start({ views: [...], autoAdvance: 15000 });

// keep some chrome
grid.presentation.start({ chrome: ['statusBar'] });

Spotlight dims what it is not on, rather than lighting what it is. Rows and columns combine as an intersection, so naming both lights the cells where they meet. The dimming stops at 0.28 rather than going further: the audience has to see that there is more data and roughly what shape it is, or the spotlight reads as a filter and the room believes the other rows are gone. It is opacity alone, so a dimmed sparkline keeps its colours instead of flattening to grey.

A spotlight does not survive a view change. It belongs to the point being made, not to the deck: carried forward, it leaves the audience looking at a lit row that no longer means anything.

Redaction travels in views and undo. It is part of grid state, so a saved view carries its own masking and a view that redacts salary redacts it every time it is shown. Toggling is a tracked action, so it undoes like any other change.

Auto-advance wraps, unlike a keypress. An unattended display that stopped on the last view would show one screen for the rest of the day.

Escape ends the presentation, not just full screen. A presentation runs full screen with its chrome hidden, so leaving full screen without ending it would drop a chrome-less enlarged grid back into the page with no control left to turn it off. The maximiser stays the only listener on the key and the presentation follows it, which keeps one Escape doing one thing: an open editor or menu still closes first. A grid built with maximise: false binds the key directly instead.

Capturing a still

const blob = await grid.capture({ scale: 2 });
await grid.capture({ scale: 3, fileName: 'q3-margins.png' });   // and save it

grid.on('presentation:captured', (e) => {
  console.log(e.width, e.height, e.bytes, e.mimeType);   // 1800 600 41030 'image/png'
});

Mounting the bar elsewhere needs the grid's class. Every rule that styles the prompt bar is scoped under .lattice, and every colour token is declared there, so a bar mounted into your own chrome through ai.element arrives unstyled. Add class="lattice" to the container, and the same data-theme the grid carries, if you have set one, and it picks up the theme.

It photographs the browser's own rendering. The grid is cloned, every computed style is inlined onto the clone, and the result is wrapped in an SVG foreignObject and drawn to a canvas, so the picture is what the browser drew, not a second renderer's guess at it. That matters here more than usual: every decoration, sparkline and pill the cell layer produces comes out right without being reimplemented.

Virtualisation makes it cheap. Only the rows on screen exist in the DOM, so capturing a million-row grid clones the thirty rows a camera could have seen anyway. A full-screen capture at scale: 2 takes around a second.

Cross-origin images are refused before the work starts. They taint the canvas, and a tainted canvas fails at the very last step with a SecurityError that names nothing, so the check runs first and the error names the offending URL. Serve the image same-origin, inline it as a data: URL, or hide the column.

Two further limits, both inherent to the technique: web fonts need embedding to appear (Lattice's default system-ui stack is unaffected), and CSS pseudo-elements are not captured.

Drawing over the grid

grid.annotate.use('pen');                    // pen · arrow · rect · highlight
grid.annotate.use('arrow', { colour: '#e0245e' });
grid.annotate.undo();
grid.annotate.clear();
grid.annotate.use(null);                     // hand the grid back

It never touches data. Nothing in the layer reads a row or writes one. A grid with annotations sorts, filters and exports exactly as one without them.

It is inert unless a tool is chosen. The canvas is not even created until the first use(), and carries pointer-events: none whenever no tool is active, so scrolling, selection and editing pass straight through. A presenter who has finished drawing must not discover the grid has stopped responding.

Marks are anchored to the data, not the screen. They are stored in content coordinates and redrawn with the scroll offset subtracted, so a circle drawn round a cell travels with that cell rather than hanging over whatever scrolled underneath it.

They are transient. Marks annotate a moment, so they are cleared when the presentation ends. A capture taken while they are on screen includes them, the canvas bitmap is carried into the still deliberately, because cloneNode copies a canvas element and not one pixel of what was drawn on it.

No tool shortcuts are bound. The keys a presenter would want are already taken by stepping and by the grid itself, and a shortcut that silently shadows Page Down is worse than one the host chooses. Bind your own to use().

Maximise

A grid usually lives in whatever box the page layout gave it, and that box is usually too small for the job. The left rail's last button fills the browser window with the grid, and clicking it again puts the grid back exactly where it was. Esc also restores.

Your own control, or a keyboard shortcut

// The rail button is on by default. This is the same thing.
grid.maximise.toggle();
grid.maximise.active();   // true while it fills the window

document.addEventListener('keydown', (e) => {
  if (e.key === 'F11' && !e.ctrlKey) { e.preventDefault(); grid.maximise.toggle(); }
});

// Or take the button away, if the application has its own full-screen mode.
createGrid(el, { maximise: false });

One button in two states rather than two buttons: the icon and its label turn round when the grid is maximised, so the control always describes what it is about to do.

The element is moved, not just restyled. A position: fixed element is positioned against the nearest ancestor carrying a transform, filter, contain or will-change, which is to say any card, any animated panel, any sticky app shell, and against the viewport only when there is no such ancestor. A class alone would therefore fill the window on one page and land in a 300px box on the next, and would still be clipped by an overflow: hidden or buried by a stacking context. Reparenting to <body> removes every ancestor that could do any of that.

Coming back is a hidden placeholder left in the element's place, not a remembered parent and index, an index goes stale the moment your application inserts a sibling while the grid is away, and then silently reinserts in the wrong slot. The placeholder also holds the vacated space open at the size the grid had, so the page behind neither reflows nor loses its scroll position while you are looking at the grid.

The geometry is written as inline styles and every displaced property is handed back exactly as it was found, because the element being restyled is yours: most often one with an inline height on it, which is the ordinary way a grid gets sized and which nothing but an inline style can beat. While maximised, the element carries .lat-maximised and <body> carries .lat-maximised-host, as hooks for your own CSS.

Time scrubber

Move the grid back through recent data changes: what did this look like a minute ago, before that number moved.

Scrubbing

grid.timeline.attach();      // start recording; the window fills from here

grid.timeline.seek(5);       // stand five changes back
grid.timeline.step(-1);      // one further back
grid.timeline.at();          // the moment being shown
grid.timeline.toLive();      // return, applying everything stepped over

grid.timeline.detach();

Attaching puts a control on the grid. A slider along the bottom with two readings beside it: how long ago, and the clock time. Relative answers the question actually being asked; absolute is what someone reads out to the person next to them. It moves the grid while the handle is dragged rather than on release, and it turns accent-coloured the moment you are off live, because a grid quietly showing stale data is the failure this control can cause. It removes itself on detach().

It reads the data, not your actions. Undo history records what the user did (sorts, filters, edits) which is rarely the question. This reads the change log: a bounded, timestamped, deliberately unmerged record of everything that arrived, so the intermediate states are all still there to move between.

Nothing is scrubbable before attach(). What a value used to be is not recoverable after the fact (no other part of the grid remembers it) so recording has to be switched on before there is a past to move through. It is off by default because reading a row per key on every change is real cost on a hot feed, and paying it for a scrubber nobody opened would be the wrong default.

Scrubbed back, the grid is not live. Changes keep being recorded but are not applied, because applying them would fight the position being held. Returning to the present applies everything missed.

Value changes reverse; row additions and removals do not. An add would need a removal and a remove would need re-insertion at its old position, and neither is recoverable from what the log holds. A window containing them scrubs over the value changes and leaves the row set alone: stated plainly because the alternative is a scrubber that silently half-works.

What moved is marked. Seeking compares each affected row before and after and marks the cells whose value changed, in --lattice-timeline-changed. Without it a scrub is nearly unreadable: the grid moves, and on a row twelve columns wide the one number you are hunting for goes past unseen.

The mark is held, not flashed. It stays until the next seek clears it. Every other transient signal in the grid fades on a timer, and this one deliberately does not, a scrub is someone hunting for what changed, and a highlight they can miss while reading the other end of the row helps nobody. timeline:seeking fires before any change is applied, so a five-step drag clears once and marks once rather than strobing per entry.

It compares column values, not raw fields. A computed column has no field of its own; diffing the source row would leave it silently unmarked while its number visibly moved. Reading through the column instead costs a little more and marks what the viewer can actually see change, which is the only definition of "changed" that matters here.

The window is bounded by rows, not only by changes. A cap on entries alone does not bound memory, because an entry is not a fixed size: one carrying a single changed cell and one carrying a fifty-thousand-row batch both count as one. So the log holds at most updates.logLimit changes (2000) and updates.logRows rows between them (100,000), dropping oldest-first on whichever it meets. It matters more here than it looks: the log is what keeps superseded row objects alive after the source has swapped in their replacements, so on a feed delivering five thousand rows a batch an entry-only cap retains twenty million of them. The one exception is a single change larger than the whole cap, which is kept: emptying the log would be worse than being briefly over, and it would drop the newest change rather than the oldest. Watch held against heldLimit in grid.updates.stats(); rows is a lifetime total and says nothing about memory.

Charts scrub like anything else. A sparkline column redraws to the series the row held at that point, and its cell is marked with the rest. The exception is the delta renderer: it samples on a wall-clock timer and compares against its own previous sample, so it reads a seek as a genuine movement and draws an arrow for it. Keep it off a grid you intend to scrub.

Saved views

A view is a named grid state: sort, filters, grouping, column order, widths, visibility. There are two kinds and the picker keeps them apart.

Views you ship, and views the user saves

views: {
  saved: [
    { id: 'escalations', name: 'Escalations',
      description: 'Escalated circuits, worst SLA first',
      state: {
        filters: { col: 'statusId', op: 'eq', value: 4 },
        sort: [{ col: 'utilisation', dir: 'desc' }],
      }},
    { id: 'commercial', name: 'Commercial', isDefault: true,
      state: { columns: [{ id: 'notes', hidden: true }] }},
  ],
  allowSave: true,
}

Views in saved are defined views: part of the application, listed under their own heading, and neither renamable nor deletable: refused by the model as well as hidden in the interface. A view flagged isDefault is applied on load. Everything the user saves sits below, with rename, share, make-default and delete.

Applying a view is a destination, not a patch. A view's state names only the sections it cares about, so applying one resets to the grid's starting state first. Without that, clicking "APAC capacity" after "Commercial" would inherit Commercial's hidden columns, the same view giving a different grid depending on what preceded it, which is the one thing a named view must not do.

When the columns change underneath a saved view

A saved view is user data written months ago against a column set that has since moved on. A release adds columns, renames one, drops another; the views people saved must survive it.

What changedWhat a saved view does
A column was addedIt appears, in the state its definition declares, placed after every column the view names. A view is not a whitelist, it says nothing about columns it has never seen, and silence is not an instruction to hide.
A column was added, and should not appear yetDeclare it layout: { hidden: true }. The view does not mention it, so nothing overrides that, and it stays hidden until the user shows it.
A column was removedThe entries naming it are skipped and reported; the rest of the view applies. A sort or grouping on the missing column is dropped rather than left pointing at nothing.
A column was renamedThat is a removal and an addition. The old id is skipped, the new column appears at the end, and any width or pinning the user had set is lost with the old id.

Applying a view never throws and never refuses. It returns a report, { applied, skipped }: naming each thing it could not use and why. Refusing the whole view because one column has gone would lose a layout the user built deliberately, and throwing during a page load would lose the page. So a view degrades to as much of itself as still makes sense, and the host decides whether the user needs telling.

Telling the user their view has aged

const report = grid.state.apply(saved.state);
if (report.skipped.length) {
  // e.g. [{ key: 'columns.legacyRef', reason: 'unknown column' }]
  notify(`This view was saved against an older layout; ${report.skipped.length} setting(s) no longer apply.`);
}

The consequence worth planning for is the first one: a column added in a new release is visible to everyone, including users with a saved view. That is usually what you want (a new field nobody can see is a field nobody uses) but if a release adds several at once, every saved view gains them all at the right-hand end. Ship them hidden if that is not the introduction you want.

Persisting them

The grid makes no network calls. It tells you what happened and you decide what that means.

To a server

grid.on('view:saved',   e => api.post('/views', e.view));
grid.on('view:renamed', e => api.patch(`/views/${e.view.id}`, { name: e.view.name }));
grid.on('view:removed', e => api.delete(`/views/${e.view.id}`));
grid.on('view:default', e => api.patch(`/views/${e.view.id}`, { isDefault: true }));

Each event carries the one view that moved, so you send a single record rather than diffing two lists. Since the grid does not track whether your write landed, catch the failure and call grid.views.reload().

With no backend at all

createGrid(el, {
  views: { local: true, allowSave: true },
});

The other half of the same seam. views.storage above is where a developer plugs in their own backend, a real server, reached over the network. Not every grid has one to plug in, and a picker offering "Save" that quietly does nothing until a backend exists is worse than not offering it. views.local: true is the no-backend answer: saved views live in this browser's own localStorage, under a default key shared by every grid on the origin unless you pass one of your own, views: { local: { key: 'orders-grid-views' } }: to keep two grids' views apart. Given alongside an explicit storage, the explicit adapter always wins and local is silently (well, not silently: it warns once) ignored, so a page cannot end up writing to both without meaning to. The adapter itself is exported as createLocalViewStorage(opts), for anyone who wants it directly, a custom key without the shorthand, or a different Storage-shaped backing such as sessionStorage for views scoped to one tab rather than persisted across visits.

Undo

Undo covers the whole grid, not only edits. Sorts, filters, column moves, grouping, an applied view and a restore all record an entry, and each carries a label written for a button.

Naming the action

grid.history.peek('undo');   // { type: 'sort', label: 'sort by Region' }
grid.history.undo();
grid.history.list();         // the timeline, newest first

A button that says only "Undo" makes the user press it to find out what it does: and pressing it is the thing they were unsure about. "Undo sort by Region" is decided before the click rather than after.

Grouping is by user action, not by internal operation. A multi-cell paste is one entry, not one per cell. An AI plan is one entry however many actions it contains.

Grouping your own changes

grid.history.transaction('apply the quarterly template', () => {
  grid.sort.set([{ col: 'margin', dir: 'desc' }]);
  grid.filters.set({ col: 'region', op: 'eq', value: 'EMEA' });
  grid.columns.hide(['notes', 'mgmtIp']);
});
// one press of undo reverses all three

The left rail

toolPanel: { side: 'left' } docks the panels as an icon rail and turns on the action buttons: undo, redo, export to CSV, export to Excel, copy to the clipboard, print, restore the default view, and maximise. Each is a single click on the thing you came for, and all four export destinations are in the cell context menu as well.

Picking a subset

createGrid(el, {
  toolPanel: {
    side: 'left',
    panels: ['columns', 'filters', 'views', 'quick'],
    // Omit `actions` entirely to take all seven, including any added later.
    actions: ['undo', 'redo', 'export', 'excel', 'clipboard', 'print', 'restore', 'maximise'],
    exportName: 'circuits',
  },
});

An explicit actions array replaces the default rather than extending it. That is what makes it useful (you choose the set and the order) but it also means a config written against an earlier version keeps exactly the buttons it named and silently misses anything added since. If you want whatever the current version offers, leave the key out.

Every action is gated on a predicate rather than always offered, so undo greys out when there is nothing to undo and "restore the default view" greys out when nothing has changed. A rail of seven icons where three do nothing is worse than a rail of four.