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
| Keys | Does |
|---|---|
| Escape | Leave, 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 + 0 | Back 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
| Keys | Does |
|---|---|
| → ↓ Space PageDown | Next view. |
| ← ↑ PageUp | Previous view. |
| Home / End | First or last. |
| R | Put 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 changed | What a saved view does |
|---|---|
| A column was added | It 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 yet | Declare 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 removed | The 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 renamed | That 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.