# schedgrid/core

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

createGrid, the engine. No platform dependencies.

```ts
import { createGrid } from 'schedgrid/core';
```

`createGrid` holds the state, rebuilds the expensive pieces only when their
own inputs change and the cheap frame whenever anything does. Nothing in
this module touches a platform API. It compiles without the DOM lib.

## createGrid(options) [#creategridoptions]

**GridOptions**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `rows` | `AxisOptions` | yes |  |
| `columns` | `AxisOptions` | yes |  |
| `overscan` | `number \| { rows: number; columns: number; } \| undefined` | no | Extra rows/columns laid out beyond the viewport on each side. Default 2. |
| `lanes` | `LaneMode \| undefined` | no |  |
| `items` | `T[] \| undefined` | no |  |
| `viewport` | `Size \| undefined` | no | Starting viewport. Whatever measures the element overwrites it. |
| `scroll` | `Point \| undefined` | no | Starting scroll offset, in content space. A bound scroll element is moved to this rather than reporting its own, so it survives `bindScrollElement`. |

`rows` and `columns` each describe one axis:

**AxisOptions**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `count` | `number` | yes | Number of rows or columns. |
| `size` | `SizeSpec` | yes | Pixel size of each row or column. |
| `pinned` | `number \| undefined` | no | How many leading indices are frozen at the start of the viewport (a header row, a label column). Pinned entries never scroll; their content offset and screen offset are the same number. |

A `size` is a `SizeSpec`: either a constant number of pixels or a function of
the index, which is how a header row gets a different height from the body
rows:

```ts
type SizeSpec = number | ((index: number) => number);

rows: { count: 201, size: (i) => (i === 0 ? 32 : 40), pinned: 1 }
```

`lanes` is `'rows'`, `'columns'` or `false`. See
[Concepts](/docs/concepts#lanes).

## Items [#items]

**Item**

Something placed on the grid: a rectangle in fractional index space. `rows` and `columns` are half-open ranges, so a one-row event in row 3 spanning 09:37–10:30 (15-minute columns) is `{ rows: [3, 4], columns: [38.47, 42] }`. Extra fields are preserved: `ItemRect.item` is the same object you passed in, so renderers can carry their own data along.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `rows` | `[start: number, end: number]` | yes |  |
| `columns` | `[start: number, end: number]` | yes |  |

## The grid [#the-grid]

The object `createGrid` returns. Every setter replaces one slice of state
and notifies subscribers once; wrap several in `batch` to notify once for
all of them.

**Grid**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `getState` | `() => GridState<T>` | yes |  |
| `getFrame` | `() => Frame<T>` | yes | Memoised: the same object comes back until some input changes. |
| `subscribe` | `(listener: () => void) => () => void` | yes | Called once per change (or once per `batch`). Returns unsubscribe. |
| `batch` | `(fn: () => void) => void` | yes |  |
| `setViewport` | `(viewport: Size) => void` | yes |  |
| `setScroll` | `(scroll: Point) => void` | yes |  |
| `setRows` | `(rows: AxisOptions) => void` | yes |  |
| `setColumns` | `(columns: AxisOptions) => void` | yes |  |
| `setItems` | `(items: T[]) => void` | yes |  |
| `setOverscan` | `(overscan: number \| { rows: number; columns: number; }) => void` | yes |  |
| `setLanes` | `(lanes: LaneMode) => void` | yes |  |
| `rows` | `AxisApi` | yes |  |
| `columns` | `AxisApi` | yes |  |
| `indexAt` | `(point: Point) => { row: number; column: number; } \| null` | yes | Fractional row/column under a viewport-space point (pixels from the top-left of the scroll container), or `null` outside the content. Pinned bands are resolved before scroll is applied, so a point over the header row maps to the header row no matter how far down the user is. |
| `cellAt` | `(point: Point) => { row: number; column: number; } \| null` | yes | `indexAt` floored to whole cells. |
| `maxScroll` | `() => Point` | yes | Largest scroll offsets that still show content; useful for canvases. |

`rows` and `columns` expose read-only queries on each axis, all in content
space:

**AxisApi**

Read-only queries on one axis, all in content space.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `count` | `number` | yes |  |
| `pinned` | `number` | yes |  |
| `total` | `number` | yes | Total content size. |
| `offsetAt` | `(index: number) => number` | yes | Content offset of a fractional index; `offsetAt(3.5)` is mid-cell 3. |
| `indexAt` | `(offset: number) => number` | yes | Fractional index at a content offset; inverse of `offsetAt`. |
| `startOf` | `(index: number) => number` | yes |  |
| `sizeOf` | `(index: number) => number` | yes |  |

## The frame [#the-frame]

Everything a renderer needs to draw one frame. Plain numbers only.

**Frame**

Everything a renderer needs to draw one frame. Plain numbers only.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `rows` | `AxisEntry[]` | yes | Pinned entries first, then the visible (plus overscan) scrollable ones. |
| `columns` | `AxisEntry[]` | yes |  |
| `items` | `ItemRect<T>[]` | yes |  |
| `total` | `Size` | yes | Size of the whole content; what the scroll container should measure. |
| `scroll` | `Point` | yes |  |
| `viewport` | `Size` | yes |  |
| `range` | `{ rows: Range; columns: Range; }` | yes | The scrollable index ranges in this frame, pinned entries excluded. |
| `cells` | `() => Cell[]` | yes | Cross product of `rows` × `columns`, built on first call and cached. |

**AxisEntry**

A row or column the renderer should draw.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `index` | `number` | yes |  |
| `start` | `number` | yes | Content-space offset of the leading edge. |
| `size` | `number` | yes |  |
| `pinned` | `boolean` | yes |  |

**ItemRect**

An item resolved to content-space pixels, split into its lane.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `x` | `number` | yes |  |
| `y` | `number` | yes |  |
| `width` | `number` | yes |  |
| `height` | `number` | yes |  |
| `lane` | `number` | yes | Which lane this item occupies inside its overlap cluster. |
| `lanes` | `number` | yes | How many lanes the cluster has; `lane / lanes` is the item's slot. |
| `item` | `T` | yes |  |

`frame.cells()` builds the cross product of the frame's rows and columns on
first call and caches it. Use it when every cell needs a DOM node or a
canvas fill; skip it when only lines and items are drawn.

**Cell**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `row` | `number` | yes |  |
| `column` | `number` | yes |  |
| `x` | `number` | yes |  |
| `y` | `number` | yes |  |
| `width` | `number` | yes |  |
| `height` | `number` | yes |  |

`frame.range` is the scrollable index range the frame covers, pinned entries
excluded — the rows and columns a data source would need to fetch:

**Range**

Half-open index range `[start, end)`.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `start` | `number` | yes |  |
| `end` | `number` | yes |  |

`total`, `viewport` and `scroll` are the two shapes every measurement in the
library uses:

**Size**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `width` | `number` | yes |  |
| `height` | `number` | yes |  |

**Point**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `x` | `number` | yes |  |
| `y` | `number` | yes |  |

## State [#state]

`getState()` returns the resolved options — the same shape you passed to
`createGrid`, with the defaults filled in:

**GridState**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `rows` | `AxisOptions` | yes |  |
| `columns` | `AxisOptions` | yes |  |
| `overscan` | `{ rows: number; columns: number; }` | yes |  |
| `lanes` | `LaneMode` | yes |  |
| `items` | `T[]` | yes |  |
| `viewport` | `Size` | yes |  |
| `scroll` | `Point` | yes |  |

## Utilities [#utilities]

The module also exports the pieces the engine is built from, for anyone who
wants to lay out items without a grid.

### assignLanes(items, mode) [#assignlanesitems-mode]

```ts
function assignLanes(items: readonly Item[], mode: LaneMode): Lane[];
```

One `Lane` per item, in the order the items were given. Greedy interval
colouring generalised to rectangles: items are swept along one axis in start
order and each takes the lowest lane not used by an overlapping item already
placed. Overlapping items are unioned into a cluster and every member reports
the cluster's lane count, so a renderer can give each item `1 / lanes` of the
shared extent.

It runs over every item, not just the visible ones — an off-screen item can
still push a visible one into a higher lane, and lanes must not shift as the
user scrolls. The grid memoises the result on `items` identity, so passing
the same array back costs nothing.

**Lane**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `lane` | `number` | yes |  |
| `lanes` | `number` | yes |  |

`mode` is the same `LaneMode` the grid takes, and picks which dimension is
divided when items overlap. See [Concepts](/docs/concepts#lanes).

```ts
type LaneMode = 'rows' | 'columns' | false;
```

### itemsIntersect(a, b) and rangesIntersect(a, b) [#itemsintersecta-b-and-rangesintersecta-b]

```ts
function rangesIntersect(
  a: readonly [number, number],
  b: readonly [number, number],
): boolean;
function itemsIntersect(a: Item, b: Item): boolean;
```

Overlap of half-open ranges: touching ranges do not intersect, so `[0, 3]`
and `[3, 5]` are apart. `itemsIntersect` is `rangesIntersect` on both axes.

### createStore(initial) [#createstoreinitial]

```ts
function createStore<T extends object>(initial: T): Store<T>;
```

The subscribable store behind `getState`, `subscribe` and `batch`: the
smallest one `useSyncExternalStore` is happy with. Snapshots are immutable —
a new object per change — and `set` skips the notification entirely when
every patched key is `Object.is`-equal to the current value, which is why a
scroll handler reporting the same offset twice costs nothing downstream.

**Store**

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `get` | `() => T` | yes |  |
| `set` | `(patch: Partial<T>) => void` | yes | Shallow-merge a patch. Listeners run once per `set`, and not at all when every patched key is `Object.is`-equal to the current value, so a scroll handler that reports the same offset twice costs nothing downstream. |
| `subscribe` | `(listener: () => void) => () => void` | yes |  |
| `batch` | `(fn: () => void) => void` | yes | Run several `set`s and notify once at the end. Nests safely. |