# ichno

> Seat plans for React: a Seats.io-level JSON document with a zod schema, geometry, headless SVG components that render on the server, and a headless editor hook.

Latest version **0.2.0** (published 2026-09-24) · MIT license · 0 weekly downloads

## Install

```sh
npm install ichno
pnpm add ichno
yarn add ichno
bun add ichno
```

## Health

**Score 60/100 (C)** — status: active.

Positive: esm support; no vulnerabilities; recently updated; high maintenance score.

Warnings: low downloads; no types; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.2.0 |
| Published | 2026-09-24 |
| First published | 2026-09-23 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM |
| Dependencies | 0 |
| Unpacked size | 175.7 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| GitHub stars | 0 |
| Author | kecan0406 |
| Maintainers | kecan0406 |
| Keywords | seat-map, floor-plan, seating, react, svg, editor |

## Links

- npm: https://www.npmjs.com/package/ichno
- Repository: https://github.com/kecan0406/ichno
- Homepage: https://github.com/kecan0406/ichno#readme
- Issues: https://github.com/kecan0406/ichno/issues
- npm.io page: https://npm.io/package/ichno

## Alternatives

- [mobx-react](https://npm.io/package/mobx-react.md) — 2.8M weekly downloads
- [rc-tree](https://npm.io/package/rc-tree.md) — 2.6M weekly downloads
- [@react-oauth/google](https://npm.io/package/@react-oauth/google.md) — 1.3M weekly downloads
- [@wagmi/connectors](https://npm.io/package/@wagmi/connectors.md) — 877.0K weekly downloads
- [vee-validate](https://npm.io/package/vee-validate.md) — 836.4K weekly downloads

## Recent versions

- 0.2.0 (latest) — 2026-09-24
- 0.1.0 — 2026-09-23

## README

# ichno

Seat plans for React — one JSON document, headless primitives around it.

- **`ichno`** — zod-free core: the document types, grid snapping, geometry (desks, curved rows, tables, section
  crops), soft lint rules, selection rules and a renderer-independent interaction core (hit-testing, gestures,
  keyboard movement)
- **`ichno/schema`** — `createSeatPlanSchema({ sectionIds })`: zod validation with structured issue codes; upgrades
  documents stored by ichno 0.1
- **`ichno/react`** — `SeatMap.*`: headless SVG components. The parts render as React Server Components; the
  `Viewport` adds pan, zoom, taps, drags and keyboard movement on the client
- **`ichno/editor`** — `useSeatPlanEditor`: headless editor state and operations; you build the panels

The library ships **no copy** and **no canvas**. Section names, category names and every message come from you;
everything is SVG you can style with CSS. Where the project is heading is in [`docs/direction.md`](docs/direction.md).

```sh
pnpm add ichno zod   # core + schema
```

Peer dependencies: `react ^19.2` (components, editor), `zod ^4` (schema). Client code ships already compiled with React
Compiler.

## The document

```ts
type SeatPlan<S extends string> = {
  version: 2
  width: number
  height: number
  sections: { id: S; points: { x: number; y: number }[] }[] // venue sections — composition fixed by you
  categories: { key: string; accessible?: boolean }[] // names, colours and prices are yours
  objects: PlanObject<S>[] // drawing order
}

type PlanObject<S> =
  | { kind: 'desk'; id; section: S; x; y; w; h; chairSide: 'up' | 'right' | 'down' | 'left' } // one person
  | { kind: 'row'; id; section: S; start; end; curve; seatSize; seats: { id }[] } // seats spread along an arc
  | { kind: 'table'; id; section: S; shape: 'round' | 'rect'; x; y; w; h; seatSize; seats: { id }[]; wholeBooking? }
  | { kind: 'booth'; id; section: S; x; y; w; h }
  | { kind: 'area'; id; section: S; shape: 'rect' | 'ellipse'; x; y; w; h; capacity; wholeBooking? }
  | { kind: 'fixture'; id; role: string; x; y; w; h } // walls, TVs, stages — never capacity
```

Every bookable unit also takes `label`, `category` and `tags`. Coordinates are integer plan units.

- **Ids are booking keys.** A place's `id` (1–8 characters) is what your other records (bookings, sessions) point
  at — keep it stable. `label` is what people read and may change freely; it defaults to the id. Ids share one
  namespace across the document, seats inside rows and tables included.
- **Row seats are computed.** A row stores its end points, `curve` (0 straight, ±1 a half circle, positive bows to
  the left of start → end) and its seat list; `seatPlan.rowSeatsOf(row)` returns the positions.
- **Status stays outside.** Taken, held or blocked are yours to keep; renderers take them as props.
- **Desks use the grid.** Desks snap to a 46-unit cell grid (a standard desk is 2×2 cells = 88 units with a 4-unit
  gap), fixtures to half cells, section outlines to cell corners.

`seatPlan.placesOf(plan)` flattens every bookable unit — desks, row seats, table seats (or whole tables), booths
and areas — with its section, label, centre, bounds and capacity.

## Validate

```ts
import { createSeatPlanSchema, seatPlanIssueOf } from 'ichno/schema'

export const { SeatPlan } = createSeatPlanSchema({ sectionIds: ['A', 'B'] as const })

const result = SeatPlan.safeParse(input) // 0.1 documents (rooms under `zones`) are upgraded here
if (!result.success) {
  for (const issue of result.error.issues) {
    const planIssue = seatPlanIssueOf(issue) // null for ordinary zod issues (types, sizes)
    // { code: 'overlap', ids: ['A1', 'A2'] } → your message
  }
}
```

Plan-level issue codes: `duplicate_section`, `duplicate_category`, `duplicate_id`, `unknown_category`,
`out_of_bounds`, `overlap`, `fixture_overlap`. Callers holding a trusted 0.1 document can use `upgradeSeatPlan`.

Softer rules warn instead of refusing a save. Each has a severity you can change:

```ts
import { lintSeatPlan } from 'ichno'

lintSeatPlan(plan, { empty_section: 'warning', missing_category: 'off' })
// [{ code: 'duplicate_label', label: '1', ids: ['A1', 'B1'], severity: 'warning' }, …]
```

Lint codes: `duplicate_label`, `outside_section`, `missing_category` (only in plans with categories) and
`empty_section` (off by default).

## Selection rules

```ts
import { validateSelection } from 'ichno'

validateSelection(plan, { selected, unavailable }, { max: 4, consecutive: true, noOrphans: true })
// [{ code: 'orphan_seat', id: 'A2' }]
```

Codes: `too_few`, `too_many`, `not_consecutive` (row seats side by side in one row) and `orphan_seat` (a free
seat the selection strands between taken seats or a row end).

## Interaction core

Pure building blocks any renderer can share:

- `spatialIndex.create(items)` / `.at(index, point)` / `.query(index, rect)` — hit-testing and area selection
- `gesture.down/move/up/cancel` — a pointer state machine that turns pointer events into `tap`, `drag`
  (`target: null` means pan) and `pinch`
- `placeNavigation.next(items, fromId, 'left')` — arrow-key movement between places

## Draw

```tsx
import { SeatMap } from 'ichno/react'

// Read-only — a Server Component; no JavaScript ships for it.
;<SeatMap.Root plan={plan} aria-label="Floor plan">
  <SeatMap.Content
    plan={plan}
    status={{ A2: 'booked' }} // your words — exposed as data-status; places with a status are disabled
    selected={['B4']}
    sectionLabel={(section) => names[section.id]}
    fixtureVariant={(fixture) => (fixture.role === 'wall' ? 'wall' : 'box')}
  />
</SeatMap.Root>
```

```tsx
'use client'
// Interactive — pan, wheel/pinch zoom, taps, arrow keys (Enter/Space picks), the same children.
<SeatMap.Viewport plan={plan} view={view} onViewChange={setView} onPlaceClick={(place) => toggle(place.id)}
  className="h-[480px]">
  <SeatMap.Content plan={plan} selected={picked} />
</SeatMap.Viewport>
```

`SeatMap.Content` is a convenience; compose the parts yourself when you need something else —
`SeatMap.Section`, `SeatMap.Place`, `SeatMap.Fixture`, `SeatMap.Table`, `SeatMap.RowLabel`, `SeatMap.Grid`,
`SeatMap.Handles` and `SeatMap.Marquee`, fed by
`seatPlan.placesOf(plan)`. `renderPlace` swaps one place's drawing while keeping the rest.

**Styling.** Parts paint with presentation attributes that read the theme variables, so any class overrides them.
State is on the element: `data-kind`, `data-status`, `data-selected`, `data-disabled`, `data-highlighted`,
`data-dimmed`, `data-invalid`, `data-category`, `data-chair-side`; pieces are named by `data-part` (`shape`, `chair`, `label`,
`floor`, `wall`, `top`, `row-label`, `handle`, `marquee`, `focus-ring`).

```tsx
<SeatMap.Place
  place={place}
  status={status[place.id]}
  className="[&_[data-part=shape]]:fill-muted data-[status=held]:[&_[data-part=shape]]:fill-amber-200"
/>
```

**Large plans.** Give the viewport a function child and pass the frame on — objects outside the view (plus a
margin) are skipped and labels too small to read are left out. Content redraws only when a pan leaves the margin
or the zoom crosses a step, so 20,000 seats pan at 60fps.

```tsx
<SeatMap.Viewport plan={plan}>
  {(frame) => <SeatMap.Content plan={plan} region={frame.region} scale={frame.scale} />}
</SeatMap.Viewport>
```

**Views** are viewBoxes in plan units (`planView.home(plan)`, `planView.zoom`, `planView.pan`, `planView.fitTo`
for zoom-to-places), so nothing is measured while rendering. Leave `view` out and the viewport keeps its own.

**Accessibility.** The viewport is a listbox: places are options with `aria-selected`; arrow keys move a focus ring
to the nearest place in that direction and `aria-activedescendant` follows it.

For narrow screens, `seatPlan.sectionPlansOf(plan)` returns one crop per section at a shared scale — stack them.

## Edit

```tsx
'use client'
import { labeling } from 'ichno'
import { useSeatPlanEditor, useSeatPlanEditorShortcuts } from 'ichno/editor'
import { SeatMap } from 'ichno/react'

const editor = useSeatPlanEditor({ initialPlan, lockedIds: bookedSeatIds })
useSeatPlanEditorShortcuts(editor) // Delete, R (turn chair), ⌘Z / ⇧⌘Z, ⌘D (duplicate)

<SeatMap.Viewport {...editor.viewportProps}>
  <SeatMap.Grid plan={editor.displayPlan} />
  <SeatMap.Content plan={editor.displayPlan} selected={editor.selectedPlaceIds} invalid={editor.conflictIds} />
  <SeatMap.Handles handles={editor.handles} />
  <SeatMap.Marquee rect={editor.marquee} />
</SeatMap.Viewport>

<button onClick={() => editor.addDesk('A') ?? alert('No free 2×2 spot')}>Add desk</button>
<button onClick={() => editor.addRow('B', { start: { x: 552, y: 400 }, end: { x: 920, y: 400 }, seats: 10, curve: 0.2 })}>
  Add row
</button>
<button onClick={() => editor.labelSeats(rowId, labeling.numbers({ reverse: true }))}>Number right to left</button>
<button disabled={!editor.canUndo} onClick={editor.undo}>Undo</button>
<button disabled={!editor.dirty} onClick={() => save(editor.plan)}>Save</button>
```

Tapping selects (a seat selects its row or table; shift/⌘ adds to the selection) and dragging moves the selection
with the snapping the commit will use. Dragging on a section's floor draws a marquee that selects everything it
touches; a selected section moves instead. Space outside every section and the middle mouse button pan.
Nothing stops you from dragging one object onto another; `conflictIds` lists the objects that overlap (the same
check the schema runs on save) so you can mark them while arranging. A single selected item shows handles: corners resize rectangles (desks by whole cells), a row's ends set its
length and direction and its middle handle sets the curve, a section's vertices reshape it. `displayPlan` is the plan with the drag in
progress; `plan` is what you save. `onTap` also reports the plan point, for tools that place things where you click.

- **Create:** `addDesk`, `addFixture(role, size)`, `addRow`, `addTable`, `addBooth`, `addArea`,
  `duplicateSelected` — each returns the new id (or null) and selects it. New places continue the section numbering.
- **Change:** `moveObject`, `moveSection`, `reshapeSection`, `rotateDesk`, `setSeatCount` (a row keeps its length),
  `labelSeats` / `labelObjects` with `labeling.numbers`, `labeling.letters({ skip: ['I'] })` or `labeling.custom`,
  `alignSelected`, `distributeSelected`, `renameObject`, `updateObject`.
- **Remove:** `removeObjects`, `removeSelected` — return the ids they refused.
- **History:** `undo`, `redo`, `canUndo`, `canRedo`, `reset`.

`lockedIds` are ids your other records point at: objects holding one cannot be removed, renamed or shrunk past it,
while labels stay free. Every operation is also available as a pure function in `planEdits` (plan in, plan out) for
imports and scripts. The plan is grid-normalized on open. To adopt a new baseline after saving, remount the editor
(key it by the saved version).

## Theme

Parts read CSS custom properties; set them on `:root` (and again for dark mode). Unset ones fall back to neutral
defaults (`themeVars` lists them). Classes on parts override them outright.

| Variable                    | Role                                            |
| --------------------------- | ----------------------------------------------- |
| `--ichno-surface`           | section floor, desks, empty chairs              |
| `--ichno-fixture`           | fixture faces                                   |
| `--ichno-grid`              | editor grid                                     |
| `--ichno-label`             | furniture outlines, seat numbers, fixture names |
| `--ichno-ink`               | walls, taken places, selection rings            |
| `--ichno-ink-foreground`    | numbers on ink-filled places                    |
| `--ichno-accent`            | picked places, editor selection                 |
| `--ichno-accent-foreground` | numbers on accent-filled places                 |
| `--ichno-warning`           | warning marks                                   |
| `--ichno-font`              | text font (inherits when unset)                 |
| `--ichno-number-font`       | seat number font (falls back to `--ichno-font`) |

## License

MIT

---
_Source: https://npm.io/package/ichno · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
