# RoomViewer

**When to use:** Shows a floor plan someone can look at and click, without letting them change it — a building directory, a booking screen, a dashboard beside a room list.

**Keywords:** map, site plan, wayfinding, hotspot, clickable regions, blueprint, storey, apartment, read-only

**Import:** `import { RoomViewer } from '@ahrowe/ui'`  
**Types:** `import type { RoomViewerProps, RoomViewerHandle } from '@ahrowe/ui'`

It renders a **`FloorPlan`** — the same document [RoomDrawer](./RoomDrawer.md) edits and downloads, drawn on the same canvas — so the two can never drift apart. See [FloorPlan](./FloorPlan.md) for the model.

```tsx
<RoomViewer plan={plan} onSelect={(roomId, room) => console.log(roomId, room?.area)} />
```

## Selecting rooms

Rooms are clickable and keyboard-reachable: each is a `role="button"` with `aria-pressed`, a `<title>` naming it, and `Enter` / `Space` to select. Because clicking a shape is silent to a screen reader, the selection is also announced through an `aria-live` region.

Selection, hover and the active floor are each controlled or uncontrolled:

```tsx
// Uncontrolled
<RoomViewer plan={plan} defaultSelectedRoomId={roomId} onSelect={setRoomId} />

// Controlled, which is what a room list beside the plan needs
<RoomViewer
  plan={plan}
  selectedRoomId={selectedId}
  onSelect={(id) => setSelectedId(id)}
  hoveredRoomId={hoveredId}
  onHoverChange={setHoveredId}
/>
```

`onSelect(roomId, room, floorId)` hands back the derived room, so its `area`, `outer` ring and `wallIds` are there without a second lookup. `roomId` is `null` when the selection is cleared by clicking away.

`interactive={false}` makes it a picture: no focus stops, no hit targets, no callbacks.

**Hover tints the room's floor; selection colours the walls that bound it** — two channels, so a hovered room and a selected one can be told apart at a glance. Hover answers *which room is this* and a fill is instant; a wall is shared, so colouring one is ambiguous between the rooms on either side. Selection is state you work inside, where a wash over the room would hide its doors and label. Override with `--plan-room-fill-hover` and `--plan-room-edge-selected`, or style either through `classNames.roomFill` and its `data-state` attribute.

## Floors

A plan holds a stack of storeys and the viewer shows one at a time, with a tab per floor listed **top first**, the way a building reads in section. The strip hides itself for a single-storey plan.

```tsx
<RoomViewer plan={plan} activeFloorId={floorId} onFloorChange={setFloorId} />
<RoomViewer plan={plan} showFloorBar={false} />
```

## Panning and zooming

Drag to pan, wheel or pinch to zoom, and the controls in the corner zoom and fit. The plan fits itself to the viewport on mount, and refits when the floor or the canvas size changes — **until the viewer pans or zooms**, after which the view is theirs and the component stops moving it. `pannable={false}` freezes it.

## Theming the plan

Walls are drawn as mass rather than as ink: the body is `var(--text-color)` mixed 58% into the canvas, its edge the same colour at 38%, and the corners are rounded. Override with `--plan-wall-fill`, `--plan-wall-outline` and `--plan-wall-join: miter`. The full list of plan variables is in [RoomDrawer.md](RoomDrawer.md#theming), and both components read the same ones.

## Key props

| Prop | Type | Description |
|------|------|-------------|
| `plan` | `FloorPlan` | The plan to show. Omit for the empty state |
| `activeFloorId` / `defaultActiveFloorId` / `onFloorChange` | `string` / `string` / `(id) => void` | Which storey is showing |
| `selectedRoomId` / `defaultSelectedRoomId` / `onSelect` | `string \| null` / … / `(roomId, room, floorId) => void` | Selection |
| `hoveredRoomId` / `onHoverChange` | `string \| null` / `(roomId) => void` | Hover, for cross-highlighting |
| `view` / `defaultView` / `onViewChange` | `PlanView` | Pan and zoom |
| `interactive` | `boolean` | Rooms respond to pointer and keyboard (default `true`) |
| `pannable` | `boolean` | Drag to pan, wheel and pinch to zoom (default `true`) |
| `fitOnMount` | `boolean` | Fit the plan into view (default `true`) |
| `showFloorBar` / `showViewControls` / `showGrid` / `showRoomLabels` / `showOpeningSymbols` | `boolean` | Chrome and layers |
| `getRoomColor` / `renderRoomLabel` / `getRoomLabel` / `formatArea` | functions | Colour, label and area rendering |
| `height` | `number \| string` | Canvas height (default `360`) |
| `emptyState` | `ReactNode` | Shown when there is no plan, or nothing in it |
| `labels` / `icons` | objects | Every string and icon in the chrome. `selectedRoom` is `(name, area) => string`, and `unnamedRoom` stands in for a room with no name |

**Imperative handle:** `fitToContent(options?)`, `getView()`, `setView(view)`, `zoomBy(factor)`, `select(roomId)`, `getFloorId()`, `setFloorId(id)`, `getSvgElement()`.

**Slots:** `root` `floorBar` `floorTab` `canvasWrapper` `canvas` `grid` `rooms` `roomFill` `roomLabel` `walls` `wallBody` `openings` `opening` `viewControls` `viewButton` `empty`

## Accessibility, honestly

A free-form plan cannot be fully keyboard operated, and this does not pretend otherwise. What it does give you: every room is a focusable `role="button"` with an accessible name, `Enter`/`Space` select, the floor tabs are real buttons, the zoom controls are real buttons, and the current selection is announced. Panning and zooming are pointer-only; the zoom buttons and `fitToContent()` are the keyboard path.
