# Concepts

URL: https://schedgrid.fabio.cv/docs/concepts

The two coordinate systems, pinned bands, lanes and the frame contract.

## Coordinate systems [#coordinate-systems]

Three spaces, and every number in the library is in one of them.

**Index space.** Rows and columns are integers `0..count-1`. Positions
*inside* a cell are fractions, so `38.47` means "47% into column 38". Items
live here. Anything continuous (time, distance, money) is mapped into
fractional indices by an add-on such as [`schedgrid/time`](/docs/time).

**Content space.** Pixels measured from the top-left of the full, unscrolled
grid. The values in a `Frame` are in content space. A renderer with native
scrolling uses them as-is; a canvas subtracts `frame.scroll` once.

**Viewport space.** Pixels measured from the top-left of the scroll
container — what a pointer event gives you. It differs from content space by
the scroll offset, except across a pinned band, where the two already agree.
Only the hit-testing entry points take it: `grid.indexAt` and `grid.cellAt`.

The engine converts between index and content space through each axis:
`grid.columns.offsetAt(3.5)` is the content-space pixel in the middle of
column 3, and `indexAt` is its inverse.

Content and viewport space are easy to confuse, because for an unscrolled
grid they are the same numbers. Passing content-space coordinates to
`grid.cellAt` is silently wrong the moment the user scrolls;
[`viewportPoint`](/docs/dom#viewportpointelement-event) converts a pointer
event for you, and is the only thing that should be producing these.

## Half-open ranges [#half-open-ranges]

An item is a rectangle in fractional index space. Its `rows` and `columns`
are half-open ranges `[start, end)`, so a one-row event in row 3 spanning
09:37 to 10:30 on a 15-minute axis is:

```ts
{ id: 'standup', rows: [3, 4], columns: [38.47, 42] }
```

Extra fields on an item are preserved. `ItemRect.item` is the same object
you passed in, so a renderer can carry along a title, a colour or anything
else without a lookup.

## Pinned rows and columns [#pinned-rows-and-columns]

Each axis can freeze its first `pinned` indices at the start of the
viewport: a header row, a label column. Those frozen indices are that axis's
**pinned band**. Pinned entries never scroll. Their content offset and their
screen offset are the same number, which is what makes a native-scroll
renderer and a canvas renderer share the arithmetic.

`frame.rows` and `frame.columns` list pinned entries first, then the visible
scrollable ones. Hit-testing with `grid.indexAt` resolves pinned bands
before scroll is applied, so a point over the header row maps to the header
row no matter how far down the user has scrolled.

## Lanes [#lanes]

When items overlap, the engine divides one dimension between them. The
`lanes` option picks which:

* `'rows'` (default): overlapping items are stacked inside the rows they
  occupy, splitting their height. The usual layout when time runs along
  columns.
* `'columns'`: symmetric. Overlapping items split their width. The usual
  layout when time runs along rows, like a day view with hours going down.
* `false`: no lane assignment; every item reports lane 0 of 1.

Each `ItemRect` carries `lane` and `lanes`, and its `x`, `y`, `width` and
`height` already account for the split.

## Overscan [#overscan]

The frame includes a few rows and columns beyond the viewport on each side
so that scrolling a little does not reveal empty space before the next
frame lands. The default is 2 on both axes. Pass a number or
`{ rows, columns }` to tune it.

## The frame contract [#the-frame-contract]

`grid.getFrame()` is memoised. The same object comes back until one of its
inputs changes, and the expensive pieces (axis scales, lane assignment) are
rebuilt only when their own inputs change. Scroll and viewport are compared
by value, so a scroll event that fires without the offset moving does not
invalidate anything.

`subscribe` fires once per change, or once per `batch`. Together these
give a renderer a simple loop: subscribe, read the frame, draw it. React's
`useSyncExternalStore` fits this directly, which is all
[`useFrame`](/docs/react) does.