Concepts

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

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.

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 converts a pointer event for you, and is the only thing that should be producing these.

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:

{ 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

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

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

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

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 does.