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.