# Examples

URL: https://schedgrid.fabio.cv/examples

One grid, drawn twice from the same frame — once as DOM, once as canvas.

Both panes below share a single `createGrid` instance over 10,000 resources
and two weeks at 15-minute resolution — 10,000 × 1,345 cells, 3,000 items.
Neither renderer knows about the other; they only read the frame the engine
hands them, which is why scrolling one moves the other.

That is the whole point of a headless engine: the frame is plain numbers, so
two renderers with nothing in common can draw the same state.

## DOM [#dom]

Absolutely positioned inside a box the size of `frame.total`, scrolled
natively by the browser. Pinned rows and columns are the one exception: they
are translated by the current scroll so they stay put.

A live pane: the frame drawn as absolutely positioned DOM nodes in a natively scrolled container.

Only what the frame lists is in the document. At this size that is a few
hundred nodes against 13.45 million cells, and the count above changes as you
scroll.

## Canvas [#canvas]

The same frame drawn with `CanvasRenderingContext2D`. There is no native
scrolling here: wheel events move `grid.setScroll`, which the pane above
follows through its binder — scroll either one and both track.

A live pane: the same frame drawn to a canvas, scrolled by wheel events rather than natively.

A canvas has no scroll container, so it subtracts `frame.scroll` once and
draws everything in screen space. Pinned entries are the exception in the
other direction: on the pinned axis their `start` is already a screen
coordinate, so it is used unchanged.

```ts
function draw(ctx, frame, viewport, colors) {
  const { scroll } = frame;
  ctx.clearRect(0, 0, viewport.width, viewport.height);

  const bodyRows = frame.rows.filter((r) => !r.pinned);
  const bodyColumns = frame.columns.filter((c) => !c.pinned);
  const pinnedRows = frame.rows.filter((r) => r.pinned);
  const pinnedColumns = frame.columns.filter((c) => c.pinned);

  // Gridlines. Half-pixel centres keep a 1px line off the seam between two
  // device pixels.
  ctx.strokeStyle = colors.line;
  for (const row of bodyRows) {
    const y = Math.round(row.start + row.size - scroll.y) + 0.5;
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(viewport.width, y);
    ctx.stroke();
  }
  for (const col of bodyColumns) {
    const x = Math.round(col.start - scroll.x) + 0.5;
    ctx.beginPath();
    ctx.moveTo(x, 0);
    ctx.lineTo(x, viewport.height);
    ctx.stroke();
  }

  // Items. `x` and `y` are content space and already account for lanes.
  for (const it of frame.items) {
    ctx.beginPath();
    ctx.roundRect(it.x - scroll.x, it.y - scroll.y, it.width, it.height, 4);
    ctx.fill();
  }

  // Pinned label column and header row: `start` is already screen space on
  // the pinned axis, only the other axis is scrolled.
  for (const col of pinnedColumns) {
    for (const row of bodyRows) {
      ctx.fillRect(col.start, row.start - scroll.y, col.size, row.size);
    }
  }
  for (const row of pinnedRows) {
    for (const col of bodyColumns) {
      ctx.fillRect(col.start - scroll.x, row.start, col.size, row.size);
    }
  }
}
```

Wheel events are the only input the canvas needs, and hit-testing is one
call — `viewportPoint` converts a pointer event into the space `cellAt`
expects:

```ts
const onWheel = (e: WheelEvent) => {
  const max = grid.maxScroll();
  const { scroll } = grid.getState();
  grid.setScroll({
    x: Math.min(Math.max(scroll.x + e.deltaX, 0), max.x),
    y: Math.min(Math.max(scroll.y + e.deltaY, 0), max.y),
  });
};

const onPointerMove = (e: PointerEvent) => {
  const hit = grid.cellAt(viewportPoint(canvas, e));
  // { row, column } or null outside the content
};
```

The full source of both renderers, the shared data and the theme-token
palette is in
[`apps/docs/components/examples`](https://github.com/naso/schedgrid/tree/main/apps/docs/components/examples).