@kerusiweb/angular 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,642 +1,673 @@
1
- # @kerusiweb/angular
2
-
3
- An Angular seat map that renders [Kerusi](https://github.com/ShadAhm/kerusi)
4
- documents as interactive, accessible inline SVG.
5
-
6
- ```bash
7
- npm install @kerusiweb/core @kerusiweb/angular
8
- ```
9
-
10
- Requires Angular 22+. Standalone, signal-based, zoneless-friendly.
11
-
12
- This package is the Angular binding only: the components and the signal-backed
13
- `KerusiStateStore`. The document types, the conformance validator, the render
14
- model and its geometry live in its peer
15
- [`@kerusiweb/core`](https://www.npmjs.com/package/@kerusiweb/core), which is
16
- framework-free; import them from there.
17
-
18
- ---
19
-
20
- ## `<kerusi-seatmap>`
21
-
22
- ```ts
23
- import { KerusiSeatmapComponent } from '@kerusiweb/angular';
24
-
25
- @Component({ imports: [KerusiSeatmapComponent], /* ... */ })
26
- ```
27
-
28
- ```html
29
- <kerusi-seatmap
30
- [map]="map"
31
- [state]="state"
32
- [(selection)]="picked"
33
- [showLegend]="true"
34
- (seatDisallowed)="explain($event)"
35
- />
36
- ```
37
-
38
- ### Inputs
39
-
40
- **Documents**
41
-
42
- | Input | Type | Default | |
43
- | --------- | --------------- | ---------- | --------------------------------------------------------------------------- |
44
- | `map` | `KerusiMap` | _required_ | The static venue layout. |
45
- | `state` | `KerusiState` | — | Live availability. Merged by `Seat.id`; an absent seat is available (§5.1). |
46
- | `session` | `KerusiSession` | — | The optional map↔event join (§5.3). Validated against the map and state. |
47
-
48
- **Selection**
49
-
50
- | Input | Type | Default | |
51
- | -------------------- | ------------------------------- | --------------- | -------------------------------------------------------------------------- |
52
- | `selection` | `readonly string[]` | `[]` | Selected seat ids. Two-way (`[(selection)]`) or read-only (`[selection]`). |
53
- | `selectableStatuses` | `SeatRenderStatus[]` | `['available']` | Which statuses a seat may be picked in. |
54
- | `companionMode` | `'auto' \| 'independent'` | `'auto'` | `auto` selects a seat's whole companion closure together (§4.6). |
55
- | `maxSelection` | `number` | — | Cap on selected seats. Counts a companion closure as its full size. |
56
- | `seatSelectable` | `(seat: RenderSeat) => boolean` | — | A final say, applied after the status test. |
57
- | `interactive` | `boolean` | `true` | `false` renders read-only. |
58
-
59
- **Appearance**
60
-
61
- | Input | Type | Default | |
62
- | --------------------------------- | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
63
- | `colors` | `KerusiSeatmapColors` | theme defaults | Per-status fills, element tones, focus ring, backdrop. See [Theming](#theming) for the CSS custom properties. |
64
- | `typeColors` | `boolean` | `true` | Let an available seat take its `SeatType.color` (§4.7). |
65
- | `seatTypeStyles` | `SeatTypeStyles` | — | Styles for every seat of a type, keyed by `SeatType.id`. Also shown in the legend. See [From code](#from-code). |
66
- | `seatStyle` | `SeatStyleFn` | — | A style for one seat, over its type's. Never recolours a selected or blocked seat. See [From code](#from-code). |
67
- | `<ng-template kerusiSeat>` | content child | — | Draws the inside of every seat yourself. See [Custom seat content](#custom-seat-content). |
68
- | `seatSize` | `number` | `28` | Grid cell edge, in viewBox units. |
69
- | `seatGap` | `number` | `6` | Gap between grid cells. |
70
- | `freeformBasis` | `number` | `1000` | Freeform viewBox width; height follows the aspect ratio. |
71
- | `unitScale` | `number` | `1` | CSS pixels per viewBox unit. Caps each section at its natural size so a narrow section and a wide one draw seats the same size. |
72
- | `showSectionLabels` | `boolean` | `true` | |
73
- | `showLegend` / `showLegendPrices` | `boolean` | `false` / `true` | |
74
-
75
- **Sections**
76
-
77
- | Input | Type | |
78
- | ------------------ | -------------------------------------- | ------------------------------------------------------------------- |
79
- | `sectionIds` | `readonly string[]` | Render only these, in this order. Default: all, by `Section.index`. |
80
- | `sectionOverrides` | `Record<string, SectionRenderOptions>` | Per-section `hidden`, `aspectRatio`, `seatSize`, `label`. |
81
-
82
- **Localization, validation, lifecycle**
83
-
84
- | Input | Type | Default | |
85
- | ------------------ | ------------------------------- | ---------------------------- | ---------------------------------------------------------------------- |
86
- | `locale` | `string` | `KerusiMap.locale` ?? `'en'` | BCP-47. Resolves `Section.label` / `SeatType.label` locale maps. |
87
- | `rtl` | `boolean \| 'auto'` | `'auto'` | Mirrors layout and arrow direction. `auto` derives it from the locale. |
88
- | `ariaStrings` | `SeatAriaStrings` | English | Every announced phrase, for translation. |
89
- | `validate` | `'collect' \| 'throw' \| 'off'` | `'collect'` | `collect` reports through `validationIssues` and renders anyway. |
90
- | `expireHolds` | `boolean` | `false` | Revert a lapsed `holdExpires` to available on a ticker. |
91
- | `expiryIntervalMs` | `number` | `1000` | |
92
-
93
- **Zoom, pan and maximize** (both off by default; see [Zoom, pan and maximize](#zoom-pan-and-maximize))
94
-
95
- | Input | Type | Default | |
96
- | ------------------ | ------------------------------- | ------- | -------------------------------------------------------------------------------------- |
97
- | `zoomable` | `boolean` | `false` | Pinch, Ctrl/Cmd + wheel and drag to zoom and pan; `+` `-` `0` keys; on-screen buttons. |
98
- | `maxZoom` | `number` | `4` | The zoom ceiling, relative to fit. The floor is always fit. |
99
- | `showZoomControls` | `boolean` | `true` | The built-in zoom buttons. Turn off to supply your own. |
100
- | `maximizable` | `boolean` | `false` | A button that fills the screen with the map. |
101
- | `controlStrings` | `Partial<KerusiControlStrings>` | English | The names of the zoom and maximize buttons, for translation. |
102
-
103
- ### Outputs
104
-
105
- | Output | Payload | |
106
- | ----------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
107
- | `selectionChange` | `readonly string[]` | From the `selection` model. |
108
- | `seatSelect` / `seatDeselect` | `SeatInteraction` | The `RenderSeat`, the resulting selection, and every seat that changed (companions included). |
109
- | `seatDisallowed` | `SeatDisallowed` | `reason` is `booked`, `held`, `blocked`, `not-selectable`, `max-selection` or `companion-unavailable`. |
110
- | `seatFocus` | `RenderSeat` | |
111
- | `validationIssues` | `readonly KerusiViolation[]` | Emitted whenever the documents change. |
112
- | `viewportChange` | `KerusiViewport` | `{ zoom, x, y }`, whenever the zoom or pan changes. Only with `zoomable`. |
113
- | `maximizedChange` | `boolean` | When the map is maximized or restored. Only with `maximizable`. |
114
-
115
- ### The seat glyph
116
-
117
- **Colour means seat type; shape means status.** A seat's fill is always its
118
- `SeatType.color`, or the theme's `availableBg` when the type has none. It
119
- does not change when the seat is held or booked. Overloading fill with both
120
- type and status hid the type colour on exactly the seats a busy map has most
121
- of; see the doc comment on `seatFill` for the full argument. `selected` is the
122
- one exception: it still owns the seat's colour outright, so your own picks
123
- are never ambiguous, and a purple fill on its own would in any case fail WCAG
124
- 1.4.1 (Use of Colour) for anyone who cannot distinguish it, hence the shape
125
- cues below.
126
-
127
- The seat body itself carries orientation: square-ish at the front, tapered at
128
- the back, so which way a seat faces is legible from its outline alone.
129
- `Seat.rotation` turns the whole group, and the taper turns with it; no
130
- separate frame is drawn on top. This is what makes a fanned lecture theatre or
131
- a stadium stand read at a glance. The seat number counter-rotates and stays
132
- upright regardless.
133
-
134
- Status is read from two marks layered over the body, never from its colour:
135
-
136
- | State | Fill | Wash | Occupant figure |
137
- | --------- | ---------------------------------------------- | ---------- | ---------------------------------------------------- |
138
- | Available | type colour | — | — |
139
- | Selected | `selectedBg` **frame** + `selectedFg` **core** | — | solid, tinted `selectedBg` |
140
- | Held | type colour | `heldBg` | **hollow**, stroked `heldFg` (a hold is provisional) |
141
- | Booked | type colour | `bookedBg` | solid, tinted `bookedFg` (settled, someone else's) |
142
- | Blocked | `blockedBg` | — | — (withheld by the venue; no one is there) |
143
-
144
- Solid vs. hollow is the main distinction (settled vs. still in progress), and
145
- every figure leans with the seat rather than staying upright, so it doubles as
146
- an orientation cue too.
147
-
148
- Selection is the only state drawn from **two** tones rather than one. `selectedBg` frames the seat; `selectedFg` fills a core inside
149
- that frame and is what the number and the figure are read against. Because the
150
- treatment always holds a light tone and a dark one at once, one of the two
151
- separates from the page whichever way you have themed it, so the library never
152
- has to detect a colour scheme, and there is no `prefers-color-scheme` default to
153
- fight with your own. An earlier revision had this inverted, a near-white rim
154
- around a mid-purple middle, which put the low-contrast tone on the outside
155
- boundary: a selected seat dissolved into a light page and sank into a dark one.
156
-
157
- Both tokens therefore carry visual weight, so override them as a pair. Swapping
158
- the two gives the inverse treatment, a light frame around a dark core, which works
159
- just as well.
160
-
161
- A theme that overrides `heldFg`/`bookedFg` owns the contrast of that mark
162
- against whatever `SeatType.color` the document supplies. The library cannot
163
- know the pairing in advance, since the figure tint is fixed while the type
164
- colour underneath it is not. The shipped defaults are light marks over a
165
- darkened wash, which reads across the tier colours in the demo fixtures.
166
-
167
- The geometry is exported, if you want to draw a matching seat elsewhere:
168
- `seatBodyPath`, `seatSelectedFrame`, `seatOccupantPath`, `seatOccupantStroke`.
169
- (`seatRingStroke` is the old name for `seatSelectedFrame` and still resolves to
170
- it.) A seat is always square, which makes `seatBodyPath`'s `inset` an exact
171
- scaled copy about the centre. That is how the selected core is derived, and why
172
- marks drawn to the core's box keep every clearance they had against the body.
173
-
174
- ### Theming
175
-
176
- Colors resolve through three tiers, highest first:
177
-
178
- 1. A `--kerusi-*` custom property in **your** stylesheet.
179
- 2. The **`[colors]` input**: a partial `KerusiSeatmapColors`, merged over the defaults.
180
- 3. The library default.
181
-
182
- The library never writes a `--kerusi-*` property onto its own host, which is
183
- what keeps that order true: every fill is emitted as
184
- `var(--kerusi-selected-bg, <the resolved input value>)`. Use whichever tier
185
- fits: the input for values known at build time, CSS for anything that has to
186
- respond to a media query or a theme class.
187
-
188
- ```css
189
- kerusi-seatmap {
190
- --kerusi-selected-bg: #8b6ad6;
191
- --kerusi-available-bg: #2f6b45;
192
- }
193
-
194
- @media (prefers-color-scheme: dark) {
195
- kerusi-seatmap {
196
- --kerusi-element-bg: #232a36;
197
- }
198
- }
199
- ```
200
-
201
- Every key of `KerusiSeatmapColors` has a property, kebab-cased:
202
-
203
- | Input key | Custom property |
204
- | ------------------------------------- | ----------------------------------------------------------- |
205
- | `availableBg` / `availableFg` | `--kerusi-available-bg` / `--kerusi-available-fg` |
206
- | `selectedBg` / `selectedFg` | `--kerusi-selected-bg` / `--kerusi-selected-fg` |
207
- | `heldBg` / `heldFg` | `--kerusi-held-bg` / `--kerusi-held-fg` |
208
- | `bookedBg` / `bookedFg` | `--kerusi-booked-bg` / `--kerusi-booked-fg` |
209
- | `blockedBg` / `blockedFg` | `--kerusi-blocked-bg` / `--kerusi-blocked-fg` |
210
- | `elementBg` / `elementFg` | `--kerusi-element-bg` / `--kerusi-element-fg` |
211
- | `elementAccentBg` / `elementAccentFg` | `--kerusi-element-accent-bg` / `--kerusi-element-accent-fg` |
212
- | `elementMutedBg` / `elementMutedFg` | `--kerusi-element-muted-bg` / `--kerusi-element-muted-fg` |
213
- | `focusRing` | `--kerusi-focus-ring` |
214
- | `backdrop` | `--kerusi-backdrop` |
215
-
216
- A `SeatType.color` from the document has **no theme token**, because it is
217
- the map's own value under §4.7. It can be overridden only seat by seat (or type
218
- by type) with `--kerusi-seat-fill`; see
219
- [Styling individual seats](#styling-individual-seats). Turn it off wholesale
220
- with `[typeColors]="false"` if you want the theme to own every fill.
221
-
222
- Overriding `selectedBg` does not cost you the selected cue: the core and the
223
- figure are shape, not color. Do set `selectedFg` alongside it, though. It is the
224
- core's fill, so it is most of a selected seat's area rather than just a label
225
- tint, and the two are read against each other. `heldBg`/`bookedBg` are the wash
226
- drawn over a taken seat's type colour, not the seat's own fill. See
227
- [The seat glyph](#the-seat-glyph) for the full state table.
228
-
229
- If you need the pre-1.1 flat selected seat back, the core is its own node:
230
-
231
- ```css
232
- kerusi-seatmap .kerusi-seat__core {
233
- display: none;
234
- }
235
- /* Without the core, these would be selectedBg on selectedBg. */
236
- kerusi-seatmap .kerusi-seat--selected .kerusi-seat__label,
237
- kerusi-seatmap .kerusi-seat--selected .kerusi-seat__occupant,
238
- kerusi-seatmap .kerusi-seat--selected .kerusi-seat__wheelchair {
239
- fill: var(--kerusi-selected-fg, #f3ecff);
240
- stroke: none;
241
- }
242
- ```
243
-
244
- For anything the palette does not cover, each seat group carries class hooks:
245
-
246
- | Class | |
247
- | --------------------------------------------------------------- | ------------------------------------------ |
248
- | `.kerusi-seat` | Every seat group. |
249
- | `.kerusi-seat--available` / `--held` / `--booked` / `--blocked` | Its `SeatStatus`. |
250
- | `.kerusi-seat--selected` | Currently picked. |
251
- | `.kerusi-seat--unselectable` | Fails the status test or `seatSelectable`. |
252
- | `.kerusi-seat--wheelchair` | `accessibility.wheelchairAccessible`. |
253
- | `.kerusi-seat--companion` | Has companions (§4.6). |
254
-
255
- Inside a seat: `.kerusi-seat__box`, `__wash`, `__core`, `__occupant`
256
- (`__occupant--selected` / `--held` / `--booked`), `__label`, `__wheelchair`, and,
257
- only while a [seat template](#custom-seat-content) is in use, `__focus`.
258
-
259
- ### Styling individual seats
260
-
261
- The palette themes every seat of a status at once. To pick out _some_ seats (a
262
- VIP block, every aisle seat, one row), select them in CSS and set the per-seat
263
- tokens. Each seat's `<g>` carries what the document says about it:
264
-
265
- | Attribute | Value | Example selector |
266
- | ---------------------- | ------------------------------------------------------ | --------------------------------- |
267
- | `data-seat-id` | `Seat.id` | `[data-seat-id="12A"]` |
268
- | `data-seat-type` | `Seat.type` | `[data-seat-type="recliner"]` |
269
- | `data-seat-row` | `Seat.row`, when the seat has one | `[data-seat-row="A"]` |
270
- | `data-seat-status` | `available`, `held`, `booked` or `blocked` | `[data-seat-status="held"]` |
271
- | `data-seat-attributes` | `Seat.attributes`, space-separated, when there are any | `[data-seat-attributes~="aisle"]` |
272
- | `data-section-id` | The seat's section | `[data-section-id="balcony"]` |
273
-
274
- Two tokens then recolour the seats a selector picks out:
275
-
276
- | Token | Replaces |
277
- | --------------------- | -------------------------------------------------------------- |
278
- | `--kerusi-seat-fill` | The seat's own colour: its `SeatType.color`, or `availableBg`. |
279
- | `--kerusi-seat-label` | The colour of the number drawn on it. |
280
-
281
- ```css
282
- kerusi-seatmap :is(.kerusi-seat, .kerusi-legend__item)[data-seat-type='vip'] {
283
- --kerusi-seat-fill: #d4a017;
284
- --kerusi-seat-label: #000000;
285
- }
286
-
287
- kerusi-seatmap .kerusi-seat[data-seat-attributes~='aisle'] {
288
- --kerusi-seat-fill: #2f6b45;
289
- --kerusi-seat-label: #ffffff;
290
- }
291
- ```
292
-
293
- - **Per seat, not global.** The library never sets these tokens and gives them
294
- no map-wide default. They are meant for a selector that picks seats out;
295
- setting one on the map itself would repaint every seat and hide the
296
- document's type colours, which is what `[typeColors]="false"` and `availableBg` are for.
297
- - **Status still shows.** They replace only the seat's own colour. A held or
298
- booked seat keeps it under its wash and occupant figure, while a selected seat
299
- keeps its selected frame and core, and a blocked seat its grey, whatever the
300
- tokens say. Status is never carried by colour alone.
301
- - **Set the label with the fill.** The library cannot read a colour set in CSS,
302
- so it cannot choose a readable number colour for it.
303
- - **Keep the legend in step.** Each type in the legend carries the same
304
- `data-seat-type`, and its swatch reads `--kerusi-seat-fill`. Include
305
- `.kerusi-legend__item` in a type selector, as above, and the key matches the
306
- map.
307
- - **Where the rules go.** Put them in a global stylesheet. From a component's
308
- own stylesheet, Angular's emulated encapsulation scopes them away from the
309
- map's elements unless you prefix them with `::ng-deep`.
310
-
311
- #### From code
312
-
313
- When the choice lives in TypeScript (a recommendation, a search result, the
314
- document's own `Seat.metadata`), use `seatTypeStyles` for whole types and
315
- `seatStyle` for single seats. Both take a `KerusiSeatStyle`:
316
-
317
- | Field | Type | |
318
- | ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
319
- | `fill` | `string` | The seat's own colour, the same slot as `--kerusi-seat-fill`. Any CSS colour. |
320
- | `labelColor` | `string` | The number's colour. Defaults to black or white for a hex `fill`; set it for any other kind of colour. |
321
- | `className` | `string \| readonly string[]` | Extra classes on the seat's `<g>`, for CSS that needs more than colour. |
322
- | `shape` | `KerusiSeatShape` | The seat's outline: `'seat'` (default), `'square'`, `'rounded'`, `'circle'`, or a function. See [Shapes](#shapes). |
323
-
324
- ```ts
325
- import { SeatStyleFn, SeatTypeStyles } from '@kerusiweb/core';
326
-
327
- export class VenueComponent {
328
- protected readonly typeStyles: SeatTypeStyles = {
329
- vip: { fill: '#d4a017', labelColor: '#000000' },
330
- };
331
- protected readonly highlight: SeatStyleFn = (seat) =>
332
- seat.source.metadata?.['bestView'] ? { fill: '#2f6b45', className: 'best-view' } : undefined;
333
- }
334
- ```
335
-
336
- ```html
337
- <kerusi-seatmap [map]="map" [seatTypeStyles]="typeStyles" [seatStyle]="highlight" />
338
- ```
339
-
340
- A seat's colour resolves in this order, first match wins:
341
-
342
- | # | Source | |
343
- | --- | ----------------------------------------- | -------------------------------------------------------- |
344
- | 1 | Selected | `selectedBg` frame and `selectedFg` core, always. |
345
- | 2 | Blocked | `blockedBg`, always. |
346
- | 3 | `--kerusi-seat-fill` from your stylesheet | See above. |
347
- | 4 | `seatStyle(seat)` | Its `fill`. |
348
- | 5 | `seatTypeStyles[seat.type]` | Its `fill`. Applies whether or not `[typeColors]` is on. |
349
- | 6 | `SeatType.color` | When `[typeColors]` is on. |
350
- | 7 | `availableBg` | The theme. |
351
-
352
- - **Status always reads.** Neither option can change a selected or blocked
353
- seat's colour, and a held or booked seat keeps its wash and occupant figure
354
- over whatever colour it has. A style that could recolour your own picks
355
- would make them ambiguous. For anything beyond this, use a
356
- [custom seat template](#custom-seat-content).
357
- - **Per-seat over per-type, field by field.** A `seatStyle` result replaces only
358
- the fields it sets, so a type's `fill` survives a per-seat `className`.
359
- Classes from both are kept.
360
- - **The legend shows `seatTypeStyles`**, because a type has one value to show.
361
- A per-seat `seatStyle` has no single value a legend could draw.
362
- - The function runs for every seat whenever the selection, the state or the
363
- theme changes, so keep it cheap.
364
-
365
- #### Shapes
366
-
367
- A style's `shape` changes the seat's outline. It works in `seatTypeStyles` (a
368
- sofa type drawn rounded, say) and in `seatStyle`; for every seat at once, use
369
- `[seatStyle]="circle"` with `circle: SeatStyleFn = () => ({ shape: 'circle' })`.
370
-
371
- | `shape` | |
372
- | -------------------------- | --------------------------------------------------------------------------- |
373
- | `'seat'` | The default: square-ish at the front, tapered at the back. |
374
- | `'square'` | A square with the same slight corners as the seat's front. |
375
- | `'rounded'` | A square with soft corners. |
376
- | `'circle'` | A circle filling the seat's box. |
377
- | `(box: SeatBox) => string` | Your own closed path `d`, drawn inside `box` (`x`, `y`, `width`, `height`). |
378
-
379
- - **Status marks follow the shape.** The wash, the selected frame and core, and
380
- a seat template's focus outline are all drawn in it, and the occupant figure
381
- and wheelchair marker sit inside every preset.
382
- - **Only `'seat'` shows which way a seat faces.** The other presets are
383
- symmetric, so on rotated seats (a fanned theatre, a stadium bowl) that cue is
384
- lost.
385
- - **A function shape must draw relative to the box it is given.** It is called
386
- once for the seat and again for the selected core's smaller box. Leave room
387
- for the occupant figure (the middle 44% of the width, from 15% to 86% of the
388
- height) and keep the top-right corner filled for the wheelchair marker.
389
- - **The legend draws a shaped type in its shape.** Types without a `shape`, and
390
- every status swatch, keep their usual swatches.
391
-
392
- ### Custom seat content
393
-
394
- When the palette and the class hooks are not enough (an icon, a badge, a price
395
- tag, a different shape), draw the inside of each seat yourself. Put an
396
- `<ng-template kerusiSeat>` inside the map:
397
-
398
- ```ts
399
- import { NgTemplateOutlet } from '@angular/common';
400
- import { KerusiSeatmapComponent, KerusiSeatTemplateDirective } from '@kerusiweb/angular';
401
-
402
- @Component({ imports: [KerusiSeatmapComponent, KerusiSeatTemplateDirective, NgTemplateOutlet] /* ... */ })
403
- ```
404
-
405
- ```html
406
- <kerusi-seatmap [map]="map" [state]="state" [(selection)]="picked">
407
- <ng-template kerusiSeat let-seat>
408
- <!-- The stock seat, then a badge on top of it. -->
409
- <ng-container *ngTemplateOutlet="seat.defaultContent; context: { $implicit: seat }" />
410
- @if (seat.seat.source.metadata?.['bestView']) {
411
- <svg:circle
412
- [attr.cx]="seat.glyph.box.x + seat.glyph.box.width"
413
- [attr.cy]="seat.glyph.box.y"
414
- r="4"
415
- fill="gold"
416
- />
417
- }
418
- </ng-template>
419
- </kerusi-seatmap>
420
- ```
421
-
422
- **Prefix every element with `svg:`.** The template is declared outside any
423
- `<svg>`, so without the prefix Angular creates `<circle>` as an unknown HTML
424
- element and nothing is drawn. Control flow and `<ng-container>` need no prefix.
425
-
426
- `let-seat` is a `SeatTemplateContext`:
427
-
428
- | Field | Type | |
429
- | ---------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
430
- | `seat` | `RenderSeat` | Status, type, price, accessibility, and the document's own `source.metadata`. |
431
- | `selected` | `boolean` | Whether the seat is in the current selection. |
432
- | `placed` | `PlacedSeat` | The seat's box, centre, font size and rotation, in viewBox units. |
433
- | `glyph` | `SeatGlyph` | The stock seat, resolved by `resolveSeatGlyph()` in core: every path, paint and position it draws. |
434
- | `defaultContent` | `TemplateRef<SeatTemplateOutletContext>` | The stock drawing. Render it with `context: { $implicit: seat }` to keep or add to it. |
435
-
436
- The template is stamped again whenever the selection, the state or the theme
437
- changes, and it can sit inside an `@if` to switch it on and off at run time.
438
-
439
- **What the library keeps.** Each seat's `<g>` stays the library's: its role,
440
- `aria-label`, `aria-pressed`, `aria-disabled`, tab stop, `data-seat-id`, class
441
- list, rotation and its click, focus and key handlers. Only what is drawn inside
442
- it is yours. Keyboard navigation, selection, companions and announcements
443
- therefore behave exactly as they do for the stock seat, and the accessible name
444
- still comes from the library (and `ariaStrings`), whatever text you draw.
445
-
446
- **Coordinates.** Draw in the section's viewBox units, using `glyph.box` (or
447
- `placed`) for the seat's position and size. The `<g>` already carries the seat's
448
- rotation, so draw it unrotated with the front at the top edge;
449
- `glyph.label.transform` counter-rotates text so it stays upright.
450
-
451
- **Status is yours to show.** A template replaces the stock marks, including the
452
- occupant figure, the wash and the selected frame and core, which are what tell
453
- held, booked and selected apart by shape rather than colour (WCAG 1.4.1). The
454
- library's own styling options never alter those marks, so a template is the one
455
- place that can, and it carries that responsibility. The simplest way to keep
456
- them is to render `defaultContent` and add to it. Otherwise reuse `glyph.wash`,
457
- `glyph.core` and `glyph.occupant`, and the `kerusi-seat__wash` and
458
- `kerusi-seat__occupant--*` classes, which keep the stock opacity.
459
-
460
- **Focus outline.** The stock focus ring strokes `.kerusi-seat__box`, which your
461
- content may not have. So while a seat template is in use, the library draws one
462
- more path in every seat, after your content: `.kerusi-seat__focus`, an outline
463
- of the seat's body shown only on `:focus-visible`, so keyboard focus stays
464
- visible whatever you draw (WCAG 2.4.7). It takes the same colour as the stock
465
- ring (`--kerusi-focus-ring`, then `colors.focusRing`, then `currentColor`). It
466
- follows the seat's outline (its [`shape`](#shapes), if a style gives one), not
467
- your drawing; if you draw your own focus indicator, restyle or hide it with
468
- `.kerusi-seat:focus-visible .kerusi-seat__focus`. Without a seat template it is
469
- not rendered at all, and the markup is unchanged.
470
-
471
- **Legend.** The legend keeps drawing the stock glyph. If your seats look very
472
- different, turn it off and show your own key.
473
-
474
- ### Zoom, pan and maximize
475
-
476
- Two features, both **off by default**: with them off the markup and behaviour are
477
- exactly what they were. They are independent; turn on either or both.
478
-
479
- On a phone a map of several hundred seats is drawn at fit-to-width, so a seat is
480
- too small to tap. `[zoomable]="true"` lets the reader zoom in and pan; `[maximizable]="true"` adds a button
481
- that fills the screen.
482
-
483
- **Zoom and pan.**
484
-
485
- - **Pinch** with two fingers on a touch screen. **Ctrl/Cmd + wheel**, or a
486
- trackpad pinch (Safari's included), on desktop. A plain wheel is left alone, so
487
- the page still scrolls.
488
- - **Drag** with one finger or the primary mouse button to pan.
489
- - **A tap still selects**, at any zoom level. A touch or click that moves less
490
- than 8 CSS pixels is a tap, and reaches the seat exactly as it does without
491
- `zoomable`. Anything that moves further is a drag, and a drag never selects the
492
- seat under it.
493
- - **Limits.** The floor is fit-to-container, the ceiling is `maxZoom` (default
494
- `4`), and panning stays inside the map. A map smaller than its container, which
495
- happens when it is maximized on a large screen, is centred.
496
- - **Gestures and the page.** `touch-action` is `pan-y` at fit, so a one-finger
497
- vertical swipe still scrolls the page while a pinch is the map's; once zoomed in
498
- or maximized it is `none`. With `zoomable` off, no `touch-action` is set.
499
- - **Reduced motion.** The short transition on the zoom buttons, the keys and
500
- reset is dropped under `prefers-reduced-motion: reduce`. A finger or wheel moves
501
- the map directly and never eases.
502
-
503
- | Key | |
504
- | --------------- | ------------------------------------------------------------ |
505
- | `+` / `=`, `-` | Zoom in / out, from anywhere inside the map |
506
- | `0` | Reset to fit |
507
- | `←` `→` `↑` `↓` | Pan, when the zoomable viewport itself has focus (Tab to it) |
508
-
509
- On a seat, the arrow keys keep their usual job of moving seat focus, and a seat
510
- that takes keyboard focus is scrolled into view. Ctrl, Cmd and Alt combinations
511
- are left to the browser, and keys typed into a field you put inside the map are
512
- ignored.
513
-
514
- **Controls.** The zoom buttons (zoom in, zoom out, reset to fit) are real
515
- `<button>`s with accessible names. At a limit they set `aria-disabled` rather than
516
- `disabled`, so keyboard focus is not thrown away mid-press. Their names come from
517
- `controlStrings`, for translation. They use the theme's existing tokens
518
- (`--kerusi-element-bg`, `--kerusi-element-fg`, `--kerusi-element-muted-fg`,
519
- `--kerusi-focus-ring`), so there is nothing new to theme.
520
-
521
- To put the controls somewhere else, turn the built-in ones off
522
- (`[showZoomControls]="false"`) and drive the map yourself:
523
-
524
- ```ts
525
- readonly map = viewChild.required(KerusiSeatmapComponent);
526
- // this.map().zoomIn(); this.map().zoomOut(); this.map().resetZoom(); this.map().toggleMaximized();
527
- // this.map().viewport() -> { zoom, x, y }
528
- // this.map().maximized() -> boolean
529
- ```
530
-
531
- `viewportChange` reports `{ zoom, x, y }` whenever the view changes, for a host that wants
532
- to display or persist it. (Restoring a saved view is not supported.)
533
-
534
- **Maximize.** The button fills the screen with the map and its legend, freezes the
535
- page behind it, and swaps to an "Exit full screen" button. The layout is a fixed
536
- overlay, which is all iPhone Safari offers; where the browser has the Fullscreen
537
- API it is requested on top, and leaving it with the browser's own Esc leaves the
538
- map's maximized state too. `Escape` from the map also leaves, except that on a
539
- seat with a non-empty selection the first `Escape` still clears the selection, as
540
- it always has. The backdrop is `--kerusi-backdrop`, or the page's own canvas colour
541
- while that is transparent; `--kerusi-maximized-z-index` (default `1000`) sets the
542
- stacking. It is not a modal dialog: focus is not trapped, and an ancestor with a
543
- `transform` or `filter` would anchor the fixed layout to itself instead of the
544
- screen.
545
-
546
- ### Keyboard
547
-
548
- Tab moves between sections; each section keeps its own tab stop. Within a
549
- section, arrow keys follow the §4.3.1 `col` order, so an aisle, which is a
550
- column no seat occupies, is stepped across rather than into.
551
-
552
- | Key | |
553
- | ------------------------ | ---------------------------------- |
554
- | `←` `→` | Previous / next seat in the row |
555
- | `↑` `↓` | Nearest column in the adjacent row |
556
- | `Home` / `End` | First / last seat of the row |
557
- | `Ctrl+Home` / `Ctrl+End` | First / last seat of the section |
558
- | `PageUp` / `PageDown` | First / last seat of the section |
559
- | `Enter` / `Space` | Toggle |
560
- | `Escape` | Clear the selection |
561
-
562
- Each seat is a `role="button"` announcing its position, type, price, status,
563
- every `Seat.accessibility` property and its attributes. A polite live region
564
- reports the running selection and any disallowed reason.
565
-
566
- With `zoomable` on there are a few more keys: see [Zoom, pan and maximize](#zoom-pan-and-maximize).
567
-
568
- ---
569
-
570
- ## `<kerusi-legend>`
571
-
572
- Rendered inline by `[showLegend]="true"`, or placed anywhere yourself:
573
-
574
- ```html
575
- <kerusi-legend [legend]="model.legend" [locale]="'ms'" [showPrices]="true" />
576
- ```
577
-
578
- It resolves swatches through the same path as the seat fills, so the two cannot
579
- drift apart. Every availability swatch, not just **Selected**, draws the
580
- seat glyph itself (wash, ring, occupant, as it applies) rather than a flat
581
- colour, because shape is the cue the map uses for status.
582
-
583
- ---
584
-
585
- ## Working with the format directly
586
-
587
- All of this lives in `@kerusiweb/core` and is pure (no Angular import), so it
588
- can run in a test, a build step or on a server.
589
-
590
- ```ts
591
- import {
592
- buildRenderModel, // KerusiMap + KerusiState -> the resolved render model
593
- checkKerusiMap, // every violation, no throw
594
- validateKerusiMap, // throws on the first error
595
- validateDocumentSet, // map / session / state joins
596
- resolveSeatPrice, // the §4.9 precedence order
597
- resolveLocalizedText, // string | Record<string, string> -> string
598
- formatMoney, // minor units -> "RM 45.00", "¥1,200", "KD 12.500"
599
- summarizeSelection, // seats, total, unpriced count
600
- computeSectionLayout, // placed geometry for one section
601
- buildNavigationGraph, // per-seat keyboard neighbours
602
- } from '@kerusiweb/core';
603
- ```
604
-
605
- ### Validation
606
-
607
- `checkKerusiMap` returns every violation in document order without throwing;
608
- `validateKerusiMap` throws a `KerusiValidationError` carrying the first error
609
- and the full list. Each violation has a stable `rule` slug, a `severity`
610
- (`error` blocks conformance, `warning` is advisory) and a document `path`.
611
-
612
- ```ts
613
- for (const v of checkKerusiMap(map)) {
614
- console.warn(`${v.severity} ${v.rule} at ${v.path}: ${v.message}`);
615
- }
616
- ```
617
-
618
- ### Live availability
619
-
620
- ```ts
621
- import { KerusiStateStore } from '@kerusiweb/angular';
622
-
623
- const store = new KerusiStateStore(initialState);
624
- socket.onmessage = (e) => {
625
- const result = store.apply(JSON.parse(e.data));
626
- if (result.outcome === 'gap') refetch(); // store.needsRefetch() is now true
627
- };
628
- const stop = store.startExpiryTicker();
629
- ```
630
-
631
- Deltas that are stale, duplicate or scoped to another session are discarded.
632
-
633
- > **Gap detection needs a sequence.** §5.2 requires `updatedAt` to be strictly
634
- > increasing but not contiguous, so it cannot by itself distinguish "a delta was
635
- > lost" from "nothing happened for a while". The store detects gaps when the
636
- > transport supplies a monotonic sequence: `metadata.seq` by default, or your
637
- > own `sequenceOf` reader. A gapped delta is still applied, so the map degrades
638
- > rather than freezes while you re-fetch.
639
-
640
- ---
641
-
642
- MIT © Arsyad "Shad Ahm" Ahmad
1
+ # @kerusiweb/angular
2
+
3
+ An Angular seat map that renders [Kerusi](https://github.com/ShadAhm/kerusi)
4
+ documents as interactive, accessible inline SVG.
5
+
6
+ ```bash
7
+ npm install @kerusiweb/core @kerusiweb/angular
8
+ ```
9
+
10
+ Requires Angular 22+. Standalone, signal-based, zoneless-friendly.
11
+
12
+ This package is the Angular binding only: the components and the signal-backed
13
+ `KerusiStateStore`. The document types, the conformance validator, the render
14
+ model and its geometry live in its peer
15
+ [`@kerusiweb/core`](https://www.npmjs.com/package/@kerusiweb/core), which is
16
+ framework-free; import them from there.
17
+
18
+ ---
19
+
20
+ ## `<kerusi-seatmap>`
21
+
22
+ ```ts
23
+ import { KerusiSeatmapComponent } from '@kerusiweb/angular';
24
+
25
+ @Component({ imports: [KerusiSeatmapComponent], /* ... */ })
26
+ ```
27
+
28
+ ```html
29
+ <kerusi-seatmap
30
+ [map]="map"
31
+ [state]="state"
32
+ [(selection)]="picked"
33
+ [showLegend]="true"
34
+ (seatDisallowed)="explain($event)"
35
+ />
36
+ ```
37
+
38
+ ### Inputs
39
+
40
+ **Documents**
41
+
42
+ | Input | Type | Default | |
43
+ | --------- | --------------- | ---------- | --------------------------------------------------------------------------- |
44
+ | `map` | `KerusiMap` | _required_ | The static venue layout. |
45
+ | `state` | `KerusiState` | — | Live availability. Merged by `Seat.id`; an absent seat is available (§5.1). |
46
+ | `session` | `KerusiSession` | — | The optional map↔event join (§5.3). Validated against the map and state. |
47
+
48
+ **Selection**
49
+
50
+ | Input | Type | Default | |
51
+ | -------------------- | ------------------------------- | --------------- | -------------------------------------------------------------------------- |
52
+ | `selection` | `readonly string[]` | `[]` | Selected seat ids. Two-way (`[(selection)]`) or read-only (`[selection]`). |
53
+ | `selectableStatuses` | `SeatRenderStatus[]` | `['available']` | Which statuses a seat may be picked in. |
54
+ | `companionMode` | `'auto' \| 'independent'` | `'auto'` | `auto` selects a seat's whole companion closure together (§4.6). |
55
+ | `maxSelection` | `number` | — | Cap on selected seats. Counts a companion closure as its full size. |
56
+ | `seatSelectable` | `(seat: RenderSeat) => boolean` | — | A final say, applied after the status test. |
57
+ | `interactive` | `boolean` | `true` | `false` renders read-only. |
58
+
59
+ **Appearance**
60
+
61
+ | Input | Type | Default | |
62
+ | --------------------------------- | --------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
63
+ | `colors` | `KerusiSeatmapColors` | theme defaults | Per-status fills, element tones, focus ring, backdrop. See [Theming](#theming) for the CSS custom properties. |
64
+ | `typeColors` | `boolean` | `true` | Let an available seat take its `SeatType.color` (§4.7). |
65
+ | `seatTypeStyles` | `SeatTypeStyles` | — | Styles for every seat of a type, keyed by `SeatType.id`. Also shown in the legend. See [From code](#from-code). |
66
+ | `seatStyle` | `SeatStyleFn` | — | A style for one seat, over its type's. Never recolours a selected or blocked seat. See [From code](#from-code). |
67
+ | `<ng-template kerusiSeat>` | content child | — | Draws the inside of every seat yourself. See [Custom seat content](#custom-seat-content). |
68
+ | `unitScale` | `number` | `1` | CSS pixels per canvas unit, at most: the map never draws wider than `canvas.width × unitScale`. The only sizing control; the document fixes every seat's size and position. |
69
+ | `showSectionLabels` | `boolean` | `true` | Draw each section's label just above its `bounds`. Only sections that declare `bounds` get one; every section's label is its group's accessible name either way. |
70
+ | `showLegend` / `showLegendPrices` | `boolean` | `false` / `true` | |
71
+
72
+ **Sections**
73
+
74
+ | Input | Type | |
75
+ | ------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------- |
76
+ | `sectionIds` | `readonly string[]` | Render only these, in this order. Default: all, by `Section.index`. |
77
+ | `sectionOverrides` | `Record<string, SectionRenderOptions>` | Per-section `hidden` and `label`. A hidden section leaves its space on the canvas empty. |
78
+
79
+ **Localization, validation, lifecycle**
80
+
81
+ | Input | Type | Default | |
82
+ | ------------------ | ------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------- |
83
+ | `locale` | `string` | `KerusiMap.locale` ?? `'en'` | BCP-47. Resolves `Section.label` / `SeatType.label` locale maps. |
84
+ | `rtl` | `boolean \| 'auto'` | `'auto'` | Mirrors the whole canvas, and the arrow keys with it. `auto` derives it from the locale. |
85
+ | `ariaStrings` | `SeatAriaStrings` | English | Every announced phrase, for translation. |
86
+ | `validate` | `'collect' \| 'throw' \| 'off'` | `'collect'` | `collect` reports through `validationIssues` and renders anyway. |
87
+ | `expireHolds` | `boolean` | `false` | Revert a lapsed `holdExpires` to available on a ticker. |
88
+ | `expiryIntervalMs` | `number` | `1000` | |
89
+
90
+ **Zoom, pan and maximize** (both off by default; see [Zoom, pan and maximize](#zoom-pan-and-maximize))
91
+
92
+ | Input | Type | Default | |
93
+ | ------------------ | ------------------------------- | ------- | -------------------------------------------------------------------------------------- |
94
+ | `zoomable` | `boolean` | `false` | Pinch, Ctrl/Cmd + wheel and drag to zoom and pan; `+` `-` `0` keys; on-screen buttons. |
95
+ | `maxZoom` | `number` | `4` | The zoom ceiling, relative to fit. The floor is always fit. |
96
+ | `showZoomControls` | `boolean` | `true` | The built-in zoom buttons. Turn off to supply your own. |
97
+ | `maximizable` | `boolean` | `false` | A button that fills the screen with the map. |
98
+ | `controlStrings` | `Partial<KerusiControlStrings>` | English | The names of the zoom and maximize buttons, for translation. |
99
+
100
+ ### Outputs
101
+
102
+ | Output | Payload | |
103
+ | ----------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
104
+ | `selectionChange` | `readonly string[]` | From the `selection` model. |
105
+ | `seatSelect` / `seatDeselect` | `SeatInteraction` | The `RenderSeat`, the resulting selection, and every seat that changed (companions included). |
106
+ | `seatDisallowed` | `SeatDisallowed` | `reason` is `booked`, `held`, `blocked`, `not-selectable`, `max-selection` or `companion-unavailable`. |
107
+ | `seatFocus` | `RenderSeat` | |
108
+ | `validationIssues` | `readonly KerusiViolation[]` | Emitted whenever the documents change. |
109
+ | `viewportChange` | `KerusiViewport` | `{ zoom, x, y }`, whenever the zoom or pan changes. Only with `zoomable`. |
110
+ | `maximizedChange` | `boolean` | When the map is maximized or restored. Only with `maximizable`. |
111
+
112
+ ### The seat glyph
113
+
114
+ **Colour means seat type; shape means status.** A seat's fill is always its
115
+ `SeatType.color`, or the theme's `availableBg` when the type has none. It
116
+ does not change when the seat is held or booked. Overloading fill with both
117
+ type and status hid the type colour on exactly the seats a busy map has most
118
+ of; see the doc comment on `seatFill` for the full argument. `selected` is the
119
+ one exception: it still owns the seat's colour outright, so your own picks
120
+ are never ambiguous, and a purple fill on its own would in any case fail WCAG
121
+ 1.4.1 (Use of Colour) for anyone who cannot distinguish it, hence the shape
122
+ cues below.
123
+
124
+ The seat body itself carries orientation: square-ish at the front, tapered at
125
+ the back, so which way a seat faces is legible from its outline alone.
126
+ `Seat.rotation` turns the whole group, and the taper turns with it; no
127
+ separate frame is drawn on top. This is what makes a fanned lecture theatre or
128
+ a stadium stand read at a glance. The seat number counter-rotates and stays
129
+ upright regardless.
130
+
131
+ Status is read from two marks layered over the body, never from its colour:
132
+
133
+ | State | Fill | Wash | Occupant figure |
134
+ | --------- | ---------------------------------------------- | ---------- | ---------------------------------------------------- |
135
+ | Available | type colour | — | — |
136
+ | Selected | `selectedBg` **frame** + `selectedFg` **core** | — | solid, tinted `selectedBg` |
137
+ | Held | type colour | `heldBg` | **hollow**, stroked `heldFg` (a hold is provisional) |
138
+ | Booked | type colour | `bookedBg` | solid, tinted `bookedFg` (settled, someone else's) |
139
+ | Blocked | `blockedBg` | — | — (withheld by the venue; no one is there) |
140
+
141
+ Solid vs. hollow is the main distinction (settled vs. still in progress), and
142
+ every figure leans with the seat rather than staying upright, so it doubles as
143
+ an orientation cue too.
144
+
145
+ Selection is the only state drawn from **two** tones rather than one. `selectedBg` frames the seat; `selectedFg` fills a core inside
146
+ that frame and is what the number and the figure are read against. Because the
147
+ treatment always holds a light tone and a dark one at once, one of the two
148
+ separates from the page whichever way you have themed it, so the library never
149
+ has to detect a colour scheme, and there is no `prefers-color-scheme` default to
150
+ fight with your own. An earlier revision had this inverted, a near-white rim
151
+ around a mid-purple middle, which put the low-contrast tone on the outside
152
+ boundary: a selected seat dissolved into a light page and sank into a dark one.
153
+
154
+ Both tokens therefore carry visual weight, so override them as a pair. Swapping
155
+ the two gives the inverse treatment, a light frame around a dark core, which works
156
+ just as well.
157
+
158
+ A theme that overrides `heldFg`/`bookedFg` owns the contrast of that mark
159
+ against whatever `SeatType.color` the document supplies. The library cannot
160
+ know the pairing in advance, since the figure tint is fixed while the type
161
+ colour underneath it is not. The shipped defaults are light marks over a
162
+ darkened wash, which reads across the tier colours in the demo fixtures.
163
+
164
+ The geometry is exported, if you want to draw a matching seat elsewhere:
165
+ `seatBodyPath`, `seatSelectedFrame`, `seatOccupantPath`, `seatOccupantStroke`.
166
+ (`seatRingStroke` is the old name for `seatSelectedFrame` and still resolves to
167
+ it.) A seat is always square, which makes `seatBodyPath`'s `inset` an exact
168
+ scaled copy about the centre. That is how the selected core is derived, and why
169
+ marks drawn to the core's box keep every clearance they had against the body.
170
+
171
+ ### Theming
172
+
173
+ Colors resolve through three tiers, highest first:
174
+
175
+ 1. A `--kerusi-*` custom property in **your** stylesheet.
176
+ 2. The **`[colors]` input**: a partial `KerusiSeatmapColors`, merged over the defaults.
177
+ 3. The library default.
178
+
179
+ The library never writes a `--kerusi-*` property onto its own host, which is
180
+ what keeps that order true: every fill is emitted as
181
+ `var(--kerusi-selected-bg, <the resolved input value>)`. Use whichever tier
182
+ fits: the input for values known at build time, CSS for anything that has to
183
+ respond to a media query or a theme class.
184
+
185
+ ```css
186
+ kerusi-seatmap {
187
+ --kerusi-selected-bg: #8b6ad6;
188
+ --kerusi-available-bg: #2f6b45;
189
+ }
190
+
191
+ @media (prefers-color-scheme: dark) {
192
+ kerusi-seatmap {
193
+ --kerusi-element-bg: #232a36;
194
+ }
195
+ }
196
+ ```
197
+
198
+ Every key of `KerusiSeatmapColors` has a property, kebab-cased:
199
+
200
+ | Input key | Custom property |
201
+ | ------------------------------------- | ----------------------------------------------------------- |
202
+ | `availableBg` / `availableFg` | `--kerusi-available-bg` / `--kerusi-available-fg` |
203
+ | `selectedBg` / `selectedFg` | `--kerusi-selected-bg` / `--kerusi-selected-fg` |
204
+ | `heldBg` / `heldFg` | `--kerusi-held-bg` / `--kerusi-held-fg` |
205
+ | `bookedBg` / `bookedFg` | `--kerusi-booked-bg` / `--kerusi-booked-fg` |
206
+ | `blockedBg` / `blockedFg` | `--kerusi-blocked-bg` / `--kerusi-blocked-fg` |
207
+ | `elementBg` / `elementFg` | `--kerusi-element-bg` / `--kerusi-element-fg` |
208
+ | `elementAccentBg` / `elementAccentFg` | `--kerusi-element-accent-bg` / `--kerusi-element-accent-fg` |
209
+ | `elementMutedBg` / `elementMutedFg` | `--kerusi-element-muted-bg` / `--kerusi-element-muted-fg` |
210
+ | `focusRing` | `--kerusi-focus-ring` |
211
+ | `backdrop` | `--kerusi-backdrop` |
212
+
213
+ A `SeatType.color` from the document has **no theme token**, because it is
214
+ the map's own value under §4.7. It can be overridden only seat by seat (or type
215
+ by type) with `--kerusi-seat-fill`; see
216
+ [Styling individual seats](#styling-individual-seats). Turn it off wholesale
217
+ with `[typeColors]="false"` if you want the theme to own every fill.
218
+
219
+ Overriding `selectedBg` does not cost you the selected cue: the core and the
220
+ figure are shape, not color. Do set `selectedFg` alongside it, though. It is the
221
+ core's fill, so it is most of a selected seat's area rather than just a label
222
+ tint, and the two are read against each other. `heldBg`/`bookedBg` are the wash
223
+ drawn over a taken seat's type colour, not the seat's own fill. See
224
+ [The seat glyph](#the-seat-glyph) for the full state table.
225
+
226
+ If you need the pre-1.1 flat selected seat back, the core is its own node:
227
+
228
+ ```css
229
+ kerusi-seatmap .kerusi-seat__core {
230
+ display: none;
231
+ }
232
+ /* Without the core, these would be selectedBg on selectedBg. */
233
+ kerusi-seatmap .kerusi-seat--selected .kerusi-seat__label,
234
+ kerusi-seatmap .kerusi-seat--selected .kerusi-seat__occupant,
235
+ kerusi-seatmap .kerusi-seat--selected .kerusi-seat__wheelchair {
236
+ fill: var(--kerusi-selected-fg, #f3ecff);
237
+ stroke: none;
238
+ }
239
+ ```
240
+
241
+ For anything the palette does not cover, each seat group carries class hooks:
242
+
243
+ | Class | |
244
+ | --------------------------------------------------------------- | ------------------------------------------ |
245
+ | `.kerusi-seat` | Every seat group. |
246
+ | `.kerusi-seat--available` / `--held` / `--booked` / `--blocked` | Its `SeatStatus`. |
247
+ | `.kerusi-seat--selected` | Currently picked. |
248
+ | `.kerusi-seat--unselectable` | Fails the status test or `seatSelectable`. |
249
+ | `.kerusi-seat--wheelchair` | `accessibility.wheelchairAccessible`. |
250
+ | `.kerusi-seat--companion` | Has companions (§4.6). |
251
+
252
+ Inside a seat: `.kerusi-seat__box`, `__wash`, `__core`, `__occupant`
253
+ (`__occupant--selected` / `--held` / `--booked`), `__label`, `__wheelchair`, and,
254
+ only while a [seat template](#custom-seat-content) is in use, `__focus`.
255
+
256
+ ### Styling individual seats
257
+
258
+ The palette themes every seat of a status at once. To pick out _some_ seats (a
259
+ VIP block, every aisle seat, one row), select them in CSS and set the per-seat
260
+ tokens. Each seat's `<g>` carries what the document says about it:
261
+
262
+ | Attribute | Value | Example selector |
263
+ | ---------------------- | ------------------------------------------------------ | --------------------------------- |
264
+ | `data-seat-id` | `Seat.id` | `[data-seat-id="12A"]` |
265
+ | `data-seat-type` | `Seat.type` | `[data-seat-type="recliner"]` |
266
+ | `data-seat-row` | `Seat.row`, when the seat has one | `[data-seat-row="A"]` |
267
+ | `data-seat-status` | `available`, `held`, `booked` or `blocked` | `[data-seat-status="held"]` |
268
+ | `data-seat-attributes` | `Seat.attributes`, space-separated, when there are any | `[data-seat-attributes~="aisle"]` |
269
+ | `data-section-id` | The seat's section | `[data-section-id="balcony"]` |
270
+
271
+ Two tokens then recolour the seats a selector picks out:
272
+
273
+ | Token | Replaces |
274
+ | --------------------- | -------------------------------------------------------------- |
275
+ | `--kerusi-seat-fill` | The seat's own colour: its `SeatType.color`, or `availableBg`. |
276
+ | `--kerusi-seat-label` | The colour of the number drawn on it. |
277
+
278
+ ```css
279
+ kerusi-seatmap :is(.kerusi-seat, .kerusi-legend__item)[data-seat-type='vip'] {
280
+ --kerusi-seat-fill: #d4a017;
281
+ --kerusi-seat-label: #000000;
282
+ }
283
+
284
+ kerusi-seatmap .kerusi-seat[data-seat-attributes~='aisle'] {
285
+ --kerusi-seat-fill: #2f6b45;
286
+ --kerusi-seat-label: #ffffff;
287
+ }
288
+ ```
289
+
290
+ - **Per seat, not global.** The library never sets these tokens and gives them
291
+ no map-wide default. They are meant for a selector that picks seats out;
292
+ setting one on the map itself would repaint every seat and hide the
293
+ document's type colours, which is what `[typeColors]="false"` and `availableBg` are for.
294
+ - **Status still shows.** They replace only the seat's own colour. A held or
295
+ booked seat keeps it under its wash and occupant figure, while a selected seat
296
+ keeps its selected frame and core, and a blocked seat its grey, whatever the
297
+ tokens say. Status is never carried by colour alone.
298
+ - **Set the label with the fill.** The library cannot read a colour set in CSS,
299
+ so it cannot choose a readable number colour for it.
300
+ - **Keep the legend in step.** Each type in the legend carries the same
301
+ `data-seat-type`, and its swatch reads `--kerusi-seat-fill`. Include
302
+ `.kerusi-legend__item` in a type selector, as above, and the key matches the
303
+ map.
304
+ - **Where the rules go.** Put them in a global stylesheet. From a component's
305
+ own stylesheet, Angular's emulated encapsulation scopes them away from the
306
+ map's elements unless you prefix them with `::ng-deep`.
307
+
308
+ #### From code
309
+
310
+ When the choice lives in TypeScript (a recommendation, a search result, the
311
+ document's own `Seat.metadata`), use `seatTypeStyles` for whole types and
312
+ `seatStyle` for single seats. Both take a `KerusiSeatStyle`:
313
+
314
+ | Field | Type | |
315
+ | ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
316
+ | `fill` | `string` | The seat's own colour, the same slot as `--kerusi-seat-fill`. Any CSS colour. |
317
+ | `labelColor` | `string` | The number's colour. Defaults to black or white for a hex `fill`; set it for any other kind of colour. |
318
+ | `className` | `string \| readonly string[]` | Extra classes on the seat's `<g>`, for CSS that needs more than colour. |
319
+ | `shape` | `KerusiSeatShape` | The seat's outline: `'seat'` (default), `'square'`, `'rounded'`, `'circle'`, or a function. See [Shapes](#shapes). |
320
+
321
+ ```ts
322
+ import { SeatStyleFn, SeatTypeStyles } from '@kerusiweb/core';
323
+
324
+ export class VenueComponent {
325
+ protected readonly typeStyles: SeatTypeStyles = {
326
+ vip: { fill: '#d4a017', labelColor: '#000000' },
327
+ };
328
+ protected readonly highlight: SeatStyleFn = (seat) =>
329
+ seat.source.metadata?.['bestView'] ? { fill: '#2f6b45', className: 'best-view' } : undefined;
330
+ }
331
+ ```
332
+
333
+ ```html
334
+ <kerusi-seatmap [map]="map" [seatTypeStyles]="typeStyles" [seatStyle]="highlight" />
335
+ ```
336
+
337
+ A seat's colour resolves in this order, first match wins:
338
+
339
+ | # | Source | |
340
+ | --- | ----------------------------------------- | -------------------------------------------------------- |
341
+ | 1 | Selected | `selectedBg` frame and `selectedFg` core, always. |
342
+ | 2 | Blocked | `blockedBg`, always. |
343
+ | 3 | `--kerusi-seat-fill` from your stylesheet | See above. |
344
+ | 4 | `seatStyle(seat)` | Its `fill`. |
345
+ | 5 | `seatTypeStyles[seat.type]` | Its `fill`. Applies whether or not `[typeColors]` is on. |
346
+ | 6 | `SeatType.color` | When `[typeColors]` is on. |
347
+ | 7 | `availableBg` | The theme. |
348
+
349
+ - **Status always reads.** Neither option can change a selected or blocked
350
+ seat's colour, and a held or booked seat keeps its wash and occupant figure
351
+ over whatever colour it has. A style that could recolour your own picks
352
+ would make them ambiguous. For anything beyond this, use a
353
+ [custom seat template](#custom-seat-content).
354
+ - **Per-seat over per-type, field by field.** A `seatStyle` result replaces only
355
+ the fields it sets, so a type's `fill` survives a per-seat `className`.
356
+ Classes from both are kept.
357
+ - **The legend shows `seatTypeStyles`**, because a type has one value to show.
358
+ A per-seat `seatStyle` has no single value a legend could draw.
359
+ - The function runs for every seat whenever the selection, the state or the
360
+ theme changes, so keep it cheap.
361
+
362
+ #### Shapes
363
+
364
+ A style's `shape` changes the seat's outline. It works in `seatTypeStyles` (a
365
+ sofa type drawn rounded, say) and in `seatStyle`; for every seat at once, use
366
+ `[seatStyle]="circle"` with `circle: SeatStyleFn = () => ({ shape: 'circle' })`.
367
+
368
+ | `shape` | |
369
+ | -------------------------- | --------------------------------------------------------------------------- |
370
+ | `'seat'` | The default: square-ish at the front, tapered at the back. |
371
+ | `'square'` | A square with the same slight corners as the seat's front. |
372
+ | `'rounded'` | A square with soft corners. |
373
+ | `'circle'` | A circle filling the seat's box. |
374
+ | `(box: SeatBox) => string` | Your own closed path `d`, drawn inside `box` (`x`, `y`, `width`, `height`). |
375
+
376
+ - **Status marks follow the shape.** The wash, the selected frame and core, and
377
+ a seat template's focus outline are all drawn in it, and the occupant figure
378
+ and wheelchair marker sit inside every preset.
379
+ - **Only `'seat'` shows which way a seat faces.** The other presets are
380
+ symmetric, so on rotated seats (a fanned theatre, a stadium bowl) that cue is
381
+ lost.
382
+ - **A function shape must draw relative to the box it is given.** It is called
383
+ once for the seat and again for the selected core's smaller box. Leave room
384
+ for the occupant figure (the middle 44% of the width, from 15% to 86% of the
385
+ height) and keep the top-right corner filled for the wheelchair marker.
386
+ - **The legend draws a shaped type in its shape.** Types without a `shape`, and
387
+ every status swatch, keep their usual swatches.
388
+
389
+ ### Custom seat content
390
+
391
+ When the palette and the class hooks are not enough (an icon, a badge, a price
392
+ tag, a different shape), draw the inside of each seat yourself. Put an
393
+ `<ng-template kerusiSeat>` inside the map:
394
+
395
+ ```ts
396
+ import { NgTemplateOutlet } from '@angular/common';
397
+ import { KerusiSeatmapComponent, KerusiSeatTemplateDirective } from '@kerusiweb/angular';
398
+
399
+ @Component({ imports: [KerusiSeatmapComponent, KerusiSeatTemplateDirective, NgTemplateOutlet] /* ... */ })
400
+ ```
401
+
402
+ ```html
403
+ <kerusi-seatmap [map]="map" [state]="state" [(selection)]="picked">
404
+ <ng-template kerusiSeat let-seat>
405
+ <!-- The stock seat, then a badge on top of it. -->
406
+ <ng-container *ngTemplateOutlet="seat.defaultContent; context: { $implicit: seat }" />
407
+ @if (seat.seat.source.metadata?.['bestView']) {
408
+ <svg:circle
409
+ [attr.cx]="seat.glyph.box.x + seat.glyph.box.width"
410
+ [attr.cy]="seat.glyph.box.y"
411
+ r="4"
412
+ fill="gold"
413
+ />
414
+ }
415
+ </ng-template>
416
+ </kerusi-seatmap>
417
+ ```
418
+
419
+ **Prefix every element with `svg:`.** The template is declared outside any
420
+ `<svg>`, so without the prefix Angular creates `<circle>` as an unknown HTML
421
+ element and nothing is drawn. Control flow and `<ng-container>` need no prefix.
422
+
423
+ `let-seat` is a `SeatTemplateContext`:
424
+
425
+ | Field | Type | |
426
+ | ---------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
427
+ | `seat` | `RenderSeat` | Status, type, price, accessibility, and the document's own `source.metadata`. |
428
+ | `selected` | `boolean` | Whether the seat is in the current selection. |
429
+ | `placed` | `PlacedSeat` | The seat's box, centre, font size and rotation, in canvas units. |
430
+ | `glyph` | `SeatGlyph` | The stock seat, resolved by `resolveSeatGlyph()` in core: every path, paint and position it draws. |
431
+ | `defaultContent` | `TemplateRef<SeatTemplateOutletContext>` | The stock drawing. Render it with `context: { $implicit: seat }` to keep or add to it. |
432
+
433
+ The template is stamped again whenever the selection, the state or the theme
434
+ changes, and it can sit inside an `@if` to switch it on and off at run time.
435
+
436
+ **What the library keeps.** Each seat's `<g>` stays the library's: its role,
437
+ `aria-label`, `aria-pressed`, `aria-disabled`, tab stop, `data-seat-id`, class
438
+ list, rotation and its click, focus and key handlers. Only what is drawn inside
439
+ it is yours. Keyboard navigation, selection, companions and announcements
440
+ therefore behave exactly as they do for the stock seat, and the accessible name
441
+ still comes from the library (and `ariaStrings`), whatever text you draw.
442
+
443
+ **Coordinates.** Draw in canvas units, using `glyph.box` (or
444
+ `placed`) for the seat's position and size. The `<g>` already carries the seat's
445
+ rotation, so draw it unrotated with the front at the top edge;
446
+ `glyph.label.transform` counter-rotates text so it stays upright.
447
+
448
+ **Status is yours to show.** A template replaces the stock marks, including the
449
+ occupant figure, the wash and the selected frame and core, which are what tell
450
+ held, booked and selected apart by shape rather than colour (WCAG 1.4.1). The
451
+ library's own styling options never alter those marks, so a template is the one
452
+ place that can, and it carries that responsibility. The simplest way to keep
453
+ them is to render `defaultContent` and add to it. Otherwise reuse `glyph.wash`,
454
+ `glyph.core` and `glyph.occupant`, and the `kerusi-seat__wash` and
455
+ `kerusi-seat__occupant--*` classes, which keep the stock opacity.
456
+
457
+ **Focus outline.** The stock focus ring strokes `.kerusi-seat__box`, which your
458
+ content may not have. So while a seat template is in use, the library draws one
459
+ more path in every seat, after your content: `.kerusi-seat__focus`, an outline
460
+ of the seat's body shown only on `:focus-visible`, so keyboard focus stays
461
+ visible whatever you draw (WCAG 2.4.7). It takes the same colour as the stock
462
+ ring (`--kerusi-focus-ring`, then `colors.focusRing`, then `currentColor`). It
463
+ follows the seat's outline (its [`shape`](#shapes), if a style gives one), not
464
+ your drawing; if you draw your own focus indicator, restyle or hide it with
465
+ `.kerusi-seat:focus-visible .kerusi-seat__focus`. Without a seat template it is
466
+ not rendered at all, and the markup is unchanged.
467
+
468
+ **Legend.** The legend keeps drawing the stock glyph. If your seats look very
469
+ different, turn it off and show your own key.
470
+
471
+ ### Zoom, pan and maximize
472
+
473
+ Two features, both **off by default**: with them off the markup and behaviour are
474
+ exactly what they were. They are independent; turn on either or both.
475
+
476
+ On a phone a map of several hundred seats is drawn at fit-to-width, so a seat is
477
+ too small to tap. `[zoomable]="true"` lets the reader zoom in and pan; `[maximizable]="true"` adds a button
478
+ that fills the screen.
479
+
480
+ **Zoom and pan.**
481
+
482
+ - **Pinch** with two fingers on a touch screen. **Ctrl/Cmd + wheel**, or a
483
+ trackpad pinch (Safari's included), on desktop. A plain wheel is left alone, so
484
+ the page still scrolls.
485
+ - **Drag** with one finger or the primary mouse button to pan.
486
+ - **A tap still selects**, at any zoom level. A touch or click that moves less
487
+ than 8 CSS pixels is a tap, and reaches the seat exactly as it does without
488
+ `zoomable`. Anything that moves further is a drag, and a drag never selects the
489
+ seat under it.
490
+ - **Limits.** The floor is fit-to-container, the ceiling is `maxZoom` (default
491
+ `4`), and panning stays inside the map. A map smaller than its container, which
492
+ happens when it is maximized on a large screen, is centred.
493
+ - **Gestures and the page.** `touch-action` is `pan-y` at fit, so a one-finger
494
+ vertical swipe still scrolls the page while a pinch is the map's; once zoomed in
495
+ or maximized it is `none`. With `zoomable` off, no `touch-action` is set.
496
+ - **Reduced motion.** The short transition on the zoom buttons, the keys and
497
+ reset is dropped under `prefers-reduced-motion: reduce`. A finger or wheel moves
498
+ the map directly and never eases.
499
+
500
+ | Key | |
501
+ | --------------- | ------------------------------------------------------------ |
502
+ | `+` / `=`, `-` | Zoom in / out, from anywhere inside the map |
503
+ | `0` | Reset to fit |
504
+ | `←` `→` `↑` `↓` | Pan, when the zoomable viewport itself has focus (Tab to it) |
505
+
506
+ On a seat, the arrow keys keep their usual job of moving seat focus, and a seat
507
+ that takes keyboard focus is scrolled into view. Ctrl, Cmd and Alt combinations
508
+ are left to the browser, and keys typed into a field you put inside the map are
509
+ ignored.
510
+
511
+ **Controls.** The zoom buttons (zoom in, zoom out, reset to fit) are real
512
+ `<button>`s with accessible names. At a limit they set `aria-disabled` rather than
513
+ `disabled`, so keyboard focus is not thrown away mid-press. Their names come from
514
+ `controlStrings`, for translation. They use the theme's existing tokens
515
+ (`--kerusi-element-bg`, `--kerusi-element-fg`, `--kerusi-element-muted-fg`,
516
+ `--kerusi-focus-ring`), so there is nothing new to theme.
517
+
518
+ To put the controls somewhere else, turn the built-in ones off
519
+ (`[showZoomControls]="false"`) and drive the map yourself:
520
+
521
+ ```ts
522
+ readonly map = viewChild.required(KerusiSeatmapComponent);
523
+ // this.map().zoomIn(); this.map().zoomOut(); this.map().resetZoom(); this.map().toggleMaximized();
524
+ // this.map().zoomToSection('balcony');
525
+ // this.map().viewport() -> { zoom, x, y }
526
+ // this.map().maximized() -> boolean
527
+ ```
528
+
529
+ `viewportChange` reports `{ zoom, x, y }` whenever the view changes, for a host that wants
530
+ to display or persist it. (Restoring a saved view is not supported.)
531
+
532
+ **Zoom to a section.** `zoomToSection(id)` zooms to a section's declared `bounds`, as
533
+ large as the viewport allows and centred, or to the box around its seats and
534
+ elements when it declares none. It does nothing unless the map is `zoomable`.
535
+ Reset with `resetZoom()`, which shows the whole canvas again.
536
+
537
+ **Maximize.** The button fills the screen with the map and its legend, freezes the
538
+ page behind it, and swaps to an "Exit full screen" button. The layout is a fixed
539
+ overlay, which is all iPhone Safari offers; where the browser has the Fullscreen
540
+ API it is requested on top, and leaving it with the browser's own Esc leaves the
541
+ map's maximized state too. `Escape` from the map also leaves, except that on a
542
+ seat with a non-empty selection the first `Escape` still clears the selection, as
543
+ it always has. The backdrop is `--kerusi-backdrop`, or the page's own canvas colour
544
+ while that is transparent; `--kerusi-maximized-z-index` (default `1000`) sets the
545
+ stacking. A maximized zoomable map fits the whole canvas to the screen at the
546
+ canvas's own proportions. It is not a modal dialog: focus is not trapped, and an ancestor with a
547
+ `transform` or `filter` would anchor the fixed layout to itself instead of the
548
+ screen.
549
+
550
+ ### Keyboard
551
+
552
+ Tab moves between sections; each section keeps its own tab stop. Within a
553
+ section, arrow keys follow §4.3.5 adjacency: along a row in `col` order, and
554
+ across an aisle (a `col` no seat occupies) rather than into it. Up and down move
555
+ to the next row and pick the seat nearest on the canvas. The arrows follow the
556
+ screen, so they swap along with a mirrored right-to-left map.
557
+
558
+ | Key | |
559
+ | ------------------------ | -------------------------------- |
560
+ | `←` `→` | Previous / next seat in the row |
561
+ | `↑` `↓` | Nearest seat in the adjacent row |
562
+ | `Home` / `End` | First / last seat of the row |
563
+ | `Ctrl+Home` / `Ctrl+End` | First / last seat of the section |
564
+ | `PageUp` / `PageDown` | First / last seat of the section |
565
+ | `Enter` / `Space` | Toggle |
566
+ | `Escape` | Clear the selection |
567
+
568
+ Each seat is a `role="button"` announcing its position, whether it is next to
569
+ an aisle, its type, price and status, every `Seat.accessibility` property and its
570
+ attributes. Each section is a `role="group"` named by its label. A polite live region
571
+ reports the running selection and any disallowed reason.
572
+
573
+ With `zoomable` on there are a few more keys: see [Zoom, pan and maximize](#zoom-pan-and-maximize).
574
+
575
+ ---
576
+
577
+ ## `<kerusi-legend>`
578
+
579
+ Rendered inline by `[showLegend]="true"`, or placed anywhere yourself:
580
+
581
+ ```html
582
+ <kerusi-legend [legend]="model.legend" [locale]="'ms'" [showPrices]="true" />
583
+ ```
584
+
585
+ It resolves swatches through the same path as the seat fills, so the two cannot
586
+ drift apart. Every availability swatch, not just **Selected**, draws the
587
+ seat glyph itself (wash, ring, occupant, as it applies) rather than a flat
588
+ colour, because shape is the cue the map uses for status.
589
+
590
+ ---
591
+
592
+ ## Working with the format directly
593
+
594
+ All of this lives in `@kerusiweb/core` and is pure (no Angular import), so it
595
+ can run in a test, a build step or on a server.
596
+
597
+ ```ts
598
+ import {
599
+ buildRenderModel, // KerusiMap + KerusiState -> the resolved render model
600
+ checkKerusiMap, // every violation, no throw
601
+ validateKerusiMap, // throws on the first error
602
+ validateDocumentSet, // map / session / state joins
603
+ resolveSeatPrice, // the §4.9 precedence order
604
+ resolveLocalizedText, // string | Record<string, string> -> string
605
+ formatMoney, // minor units -> "RM 45.00", "¥1,200", "KD 12.500"
606
+ summarizeSelection, // seats, total, unpriced count
607
+ computeMapLayout, // every seat and element placed on the canvas
608
+ buildNavigationGraph, // per-seat keyboard neighbours
609
+ } from '@kerusiweb/core';
610
+ ```
611
+
612
+ ### Validation
613
+
614
+ `checkKerusiMap` returns every violation in document order without throwing;
615
+ `validateKerusiMap` throws a `KerusiValidationError` carrying the first error
616
+ and the full list. Each violation has a stable `rule` slug, a `severity`
617
+ (`error` blocks conformance, `warning` is advisory) and a document `path`.
618
+
619
+ ```ts
620
+ for (const v of checkKerusiMap(map)) {
621
+ console.warn(`${v.severity} ${v.rule} at ${v.path}: ${v.message}`);
622
+ }
623
+ ```
624
+
625
+ ### Live availability
626
+
627
+ ```ts
628
+ import { KerusiStateStore } from '@kerusiweb/angular';
629
+
630
+ const store = new KerusiStateStore(initialState);
631
+ socket.onmessage = (e) => {
632
+ const result = store.apply(JSON.parse(e.data));
633
+ if (result.outcome === 'gap') refetch(); // store.needsRefetch() is now true
634
+ };
635
+ const stop = store.startExpiryTicker();
636
+ ```
637
+
638
+ Deltas that are stale, duplicate or scoped to another session are discarded.
639
+
640
+ > **Gap detection needs a sequence.** §5.2 requires `updatedAt` to be strictly
641
+ > increasing but not contiguous, so it cannot by itself distinguish "a delta was
642
+ > lost" from "nothing happened for a while". The store detects gaps when the
643
+ > transport supplies a monotonic sequence: `metadata.seq` by default, or your
644
+ > own `sequenceOf` reader. A gapped delta is still applied, so the map degrades
645
+ > rather than freezes while you re-fetch.
646
+
647
+ ## Migrating from 2.x
648
+
649
+ 3.0.0 follows Kerusi rev 15, where a map has **one canvas** and every seat its
650
+ own position on it. The full list of changes is in the
651
+ [CHANGELOG](https://github.com/ShadAhm/kerusiweb/blob/master/CHANGELOG.md#migrating-from-2x);
652
+ for this package:
653
+
654
+ - **Documents.** Give the map a `canvas` and a `seatSize`, and every seat and
655
+ element an `x`/`y` in canvas units. Remove `Section.layout`,
656
+ `Section.aspectRatio`, and an element's `row`/`col`. `row`/`col` on a seat now
657
+ mean adjacency only.
658
+ - **Removed inputs.** `seatSize`, `seatGap` and `freeformBasis`, and
659
+ `aspectRatio` and `seatSize` in `sectionOverrides`. The document fixes seat
660
+ size and position now; `unitScale` is the one sizing control left.
661
+ - **Markup.** The map is one `<svg class="kerusi-seatmap__svg">`, and each section
662
+ a `<g class="kerusi-section" data-section-id>` inside it. The per-section
663
+ `<section class="kerusi-seatmap__section kerusi-seatmap__section--{mode}">`
664
+ wrapper, its `<h3>` heading and the per-section `svg.kerusi-section-svg` are
665
+ gone. A section label is drawn inside the SVG instead, only for a section
666
+ that declares `bounds`.
667
+ - **New.** `zoomToSection(id)`, and a "next to an aisle" phrase in each seat's
668
+ accessible name (`besideAisle` in `ariaStrings`, which you must add if you
669
+ supply your own strings).
670
+
671
+ ---
672
+
673
+ MIT © Arsyad "Shad Ahm" Ahmad