Cells

Flash Cells

A grid fed by a socket or a poll changes its numbers faster than anyone can spot which one moved. Flash cells marks a cell for a moment when its value changes, so the grid says what moved.

Basic Usage

Register flashCells() and hand the grid a new data array whenever the rows change. Every visible column is watched unless you narrow it. A marked cell takes a translucent wash of colour that fades: one colour for a number that rose, another for one that fell, and a third for anything else that changed.

Nothing yet.

Symbol
Price
Volume
Status
ALFA
120
3,200
open
BRVO
86
1,500
open
CHRL
240
800
open
DLTA
54
9,400
halted
ECHO
310
620
open
FXTR
18
21,000
open
6 rows
<script lang="ts">
  import { createDataGrid, DataGrid, flashCells } from '@sv5ui/datagrid';

  const grid = createDataGrid<Quote>({
    columns,
    data: quotes,
    getRowId: (quote) => quote.symbol,
    // The factory does not infer the row type, so name it. A mark lasts
    // 700ms unless told otherwise; this page keeps it longer so it can be seen.
    features: [flashCells<Quote>({ duration: 1500 })]
  });

  // A new array is what the feature sees. Rows that did not change can be
  // the same objects; the array has to be a different one.
  socket.onmessage = (event) => {
    const update = JSON.parse(event.data);
    grid.data = grid.data.map((quote) =>
      quote.symbol === update.symbol ? { ...quote, ...update } : quote
    );
  };
</script>

<DataGrid {grid} />

It Marks Only What It Witnessed

A cell is marked when the feature held its previous value and the new one is different. A cell whose previous value it never held is never marked. That one rule covers every awkward case at once: the first paint, a row scrolled into view for the first time, a row that scrolled away and came back, and a row that is simply new. In each the feature has no evidence of a change, and marking on no evidence would point at cells nobody touched.

The two buttons that do nothing are the ones worth pressing. A new array holding the same values marks nothing, because nothing changed. A row mutated in place marks nothing either, for a different reason: the grid is still holding the same array, so it has not been told anything happened, and the number on screen does not even update. The stale value surfaces with the next new array, and is marked then, as a change the feature has only just seen.

// Marked: a cell on screen whose value is different from the one this
// feature last saw there.

// Not marked, because nothing was witnessed changing:
//   the first paint
//   a row scrolled into view for the first time
//   a row that scrolled away and came back
//   a row that is new

// Not marked, because nothing changed:
grid.data = [...grid.data];             // a new array, the same values

// Not seen at all:
grid.data[0].price = 12;                // mutated in place
grid.data = grid.data;                  // the same array

Values are compared with Object.is, so a column whose value is an object counts as changed whenever the object is a new one. The rule also bounds the cost. Only the cells on screen are remembered, because a cell nobody can see has nothing to say, so the memory is the size of the window rather than of the data.

Which Columns, and Whether to Read a Direction

columns narrows what is watched, which is worth doing when only a few columns carry live values: a column nobody is watching costs a comparison per rendered row. The grid below takes the same feed as the one above, watches Price and Status only, and marks every change the same way. Press Tick again and compare the two: Volume moves here without a mark.

Symbol
Price
Volume
Status
ALFA
120
3,200
open
BRVO
86
1,500
open
CHRL
240
800
open
DLTA
54
9,400
halted
ECHO
310
620
open
FXTR
18
21,000
open
6 rows
import { flashCells, getPivot, isPivotColumn } from '@sv5ui/datagrid';

// The grid above: two columns watched, every change marked the same way.
flashCells<Quote>({ columns: ['price', 'status'], direction: false, duration: 1500 });

// Every visible column, which is the default.
flashCells<Quote>();

// A list of ids, for columns you declared.
flashCells<Quote>({ columns: ['price'] });

// A predicate, for the ones you did not. A pivot generates its column
// ids from the data, so they cannot be written down ahead of time.
flashCells<Sale>({ columns: (column) => isPivotColumn(column.id) });

// Narrowed to the first measure of a crossing:
flashCells<Sale>({
  columns: (column) => getPivot(grid)?.crossingMeasure(column.id) === 0
});

A list of ids covers columns you declared. A predicate covers the ones you did not, which is the only way to reach the columns a pivot generates. A direction is read only when both the old and the new value are numbers, so the first number to arrive where there was none is a change without one.

Each Mark on Its Own Clock

duration is how long a mark stays, 700 milliseconds unless you set it, and it is counted from that cell's own change. A cell that changed a second ago lets go while its neighbours keep moving, which is what keeps a mark meaning "just now" on a grid that never stops. Start the stream in the first grid and watch: the marks come and go cell by cell rather than as one block.

The mark fades over that time instead of blinking out, so a change from half a second ago reads as older than one that has just landed. The fade is CSS, so nothing re-renders for it. Behind it there is still a single timer, set to the earliest mark due to expire and set again for the next, so a thousand cells changing at once cost one timer.

flashCells<Quote>({
  // How long a mark stays, in milliseconds, counted from its own change.
  duration: 1500,

  // false marks every change the same way, without a direction.
  direction: false
});

// Reading it back:
import { getFlashCells } from '@sv5ui/datagrid';

const flash = getFlashCells(grid);
flash?.duration; // 1500
flash?.marks;    // what is marked right now, keyed by cell
flash?.muted;    // true while the reader asks for reduced motion

A Wash, Not a Border

A border is what the grid already says selection and focus with: a ring on the focused cell, an edge around a range, a dashed edge on a cut. A flash is a tint laid over the whole cell instead, so the three cannot be mistaken for each other. It sits above a colour scale or a data bar and lets both show through.

The wash itself is drawn by the feature. The fade comes from @sv5ui/datagrid/theme.css, the stylesheet Installation has you import. An app that left it out still marks what moved; the mark holds at full strength and then goes.

Deciding the Look Yourself

appearance is handed each mark and answers how that cell should look. It receives what the value was, what it became and how long ago, so a mark can answer to the size of a move rather than only its direction. Return undefined and the cell keeps the default wash. Its argument is a FlashContext, which is the name to annotate with when the function lives outside the options.

The grid below watches Price only. A move of five or more fills the cell; a smaller one keeps the wash. Tick a few times to see both.

Symbol
Price
Volume
Status
ALFA
120
3,200
open
BRVO
86
1,500
open
CHRL
240
800
open
DLTA
54
9,400
halted
ECHO
310
620
open
FXTR
18
21,000
open
6 rows
import { flashCells, type FlashContext } from '@sv5ui/datagrid';

flashCells<Quote>({
  columns: ['price'],
  duration: 1500,

  // Handed what changed: way, before, after, at (when it was seen) and
  // age (milliseconds since). Answer a decoration to take the cell over,
  // or undefined to leave it the default wash.
  appearance: ({ way, before, after }) => {
    const jump = Math.abs(Number(after) - Number(before));
    if (!Number.isFinite(jump) || jump < 5) return undefined;

    return {
      class: 'font-semibold',
      style: {
        'background-color': way === 'up' ? 'var(--color-success)' : 'var(--color-error)',
        color: 'white'
      }
    };
  }
});

// Written on its own, the function names what it takes.
const dimWhenOld = ({ age }: FlashContext) =>
  age > 1000 ? { class: 'opacity-60' } : undefined;

flashCells<Quote>({ appearance: dimWhenOld });

What it returns replaces the default for that cell entirely, the fade included, so a look of your own is as animated as you make it.

Restyling Every Mark

When the look should change everywhere rather than cell by cell, there are two cheaper ways in. The flashCell slot adds classes to every marked cell, for one grid through ui or for the whole app, like every other slot on Theming.

<script lang="ts">
  import { defineDataGridConfig } from '@sv5ui/datagrid';

  // The flashCell slot for every grid in the app. Its classes are added to
  // each marked cell, beside the one that carries the fade.
  defineDataGridConfig({ slots: { flashCell: 'font-semibold' } });
</script>

<!-- Or for one grid. -->
<DataGrid {grid} ui={{ flashCell: 'font-semibold' }} />

And a marked cell carries two custom properties, which the fade reads. Override the animation in a stylesheet of your own and the colour and the duration still arrive per cell.

/* A marked cell carries the class dg-flash and two custom properties:

     --dg-flash-tint   the colour of this mark
     --dg-flash-ms     how long it lasts

   The fade reads both, so a stylesheet of your own can say it another way.
   This one swaps the wash for a bar along the bottom of the cell. */
@keyframes flash-bar {
  from {
    box-shadow: inset 0 -3px 0 var(--dg-flash-tint);
  }
  to {
    box-shadow: inset 0 -3px 0 transparent;
  }
}

.dg-flash {
  animation: flash-bar var(--dg-flash-ms, 700ms) ease-out forwards;
}

@media (prefers-reduced-motion: reduce) {
  .dg-flash {
    animation: none;
  }
}

Reduced Motion

A reader whose system asks for reduced motion gets the mark without the fade. The wash appears, holds for the duration, and goes. Leaving out the animation is what was asked for; leaving out the information was not.

A mark is still colour and nothing else, so treat it as a hint. If a value matters enough that missing it is a problem, say it somewhere a reader who cannot tell the tints apart will find it.

Options

flashCells(options). All four are optional.

OptionDescription
columnsWhich columns to watch. A list of ids, or a predicate for columns whose ids are not known ahead of time
durationHow long a cell stays marked, in milliseconds, counted from its own change. The mark fades over this time
directionMark a rise and a fall differently when both the old and the new value are numbers
appearanceDecides the look of one marked cell. Receives way, before, after, at and age; undefined leaves that cell the default

FlashWay

What happened to a cell.

ValueWhen
'up'Both values are numbers and the new one is larger. Washed in the success colour
'down'Both values are numbers and the new one is smaller. Washed in the error colour
'changed'Anything else: text, a boolean, a number arriving where there was none, or any change at all with direction off. Washed in the primary colour

A Mark

FlashContext is what appearance receives; FlashMark, the same without age, is what marks holds for each marked cell. Both are exported, so a function written outside the options can name its argument.

FieldDescription
way'up', 'down' or 'changed'
beforeThe value the feature was holding
afterThe value it found
atDate.now() at the moment the change was seen
ageMilliseconds since then. Passed to appearance only; a mark held in marks does not carry it

Reading It Back

What getFlashCells(grid) exposes. The feature adds nothing to grid.api.

MemberWhat it answers
marksWhat is marked right now: a record from cell key to its way, before, after and at
durationThe duration in effect
directionalWhether a direction is being read
mutedWhether the reader has asked for reduced motion, in which case a mark does not fade