@ticketlayer/seatmap-render
v0.1.2
Published
The Ticketlayer seat map renderer: a framework-free 2D canvas renderer and camera for a venue plan, with seats in every state, the curved stage, pins, hover, pan, pinch, wheel zoom and section focus. Colours and sizes come from @ticketlayer/theme
Readme
@ticketlayer/seatmap-render
The seat map's 2D canvas renderer and camera, from the Seat Explorer
prototype (3dvenue/prototypes/explorer/src/app-a.js) without its tilt,
panels or list. Framework free: the tl-seat-* harnesses, Box Office's
tlb-seat-* and the Manage editor each own a <canvas> and hand it here.
It knows nothing of Live, the seat map token or fetch. The host fetches and
decodes with @ticketlayer/seatmap and passes the results in; the renderer
draws them and reports what the person did through callbacks.
pnpm --filter @ticketlayer/seatmap-render build # dist/esm, ESM only
pnpm --filter @ticketlayer/seatmap-render test # node --test on a fake canvasUse
import { decodeOverlay, decodeTLSC, decodeTLST, parseOverview } from '@ticketlayer/seatmap';
import { createSeatmapRenderer } from '@ticketlayer/seatmap-render';
const map = createSeatmapRenderer(canvas, {
overview: parseOverview(overviewJson),
onSeatTap: ({ seat, sectionId, state }) => { /* reserve on tap when state is 'available' */ },
onSeatHover: (e) => { /* e is { seat, sectionId, state } or null */ },
onSectionFocus: ({ sectionId }) => { /* fetch that section's chunk; null is back to the venue */ },
onSectionHover: (e) => {},
onPinTap: ({ id }) => {},
});
map.addChunk(section.chunk, decodeTLSC(bytes)); // once per block, as it arrives
map.setOverlay(decodeOverlay(overlayJson, overview.seatCount));
map.setStatus(decodeTLST(statusBytes)); // every poll; null before the first
map.setMine([10422, 10423]); // the seats this buyer has reserved
map.setPins([{ id: 's1', title: 'Stalls Left', label: 'From £15.50', at: { sectionId: 's1' }, color: '#7c3aed', priority: 3 }]);
map.focusSection('s1'); // or null for the whole venue
map.destroy();API
createSeatmapRenderer(canvas, options): SeatmapRenderer
| Option | |
|---|---|
| overview | the parsed overview (required) |
| colorScheme | light (default) or dark: which theme defaults to use for tokens the canvas's style does not set |
| style | token values by name that win over the computed style |
| tokenSource | where tokens are read; the canvas's computed style by default |
| pixelRatio | device pixels per CSS pixel; devicePixelRatio, at most 3 |
| insets | CSS pixels at each edge kept clear for the host's controls; framing centres in what is left |
| obstacles | rectangles the host draws over the canvas (zoom controls, a map key, a top bar); a fit clears each by whichever edge leaves the venue bigger, with a margin in proportion to the view, and no pin is placed under one |
| turnToStage | turn a focused section so its seats face up the screen (default true) |
| animate | animate camera moves (default true; off under prefers-reduced-motion) |
| keyboard | arrows pan, + and - zoom, Escape leaves a section (default true; give the canvas a tabindex) |
| stageLabel | the text across the stage, upper-cased; the decoration's own label ("Stage", "Pitch") by default (a harness passes its own string for another language) |
| backgroundAlpha | how opaque what is painted under the seats is, 0 to 1: the background, the floor plates and the section fills. 1 by default; 0 leaves the canvas transparent under the venue (see "A picture underneath") |
| onSeatHover, onSeatTap, onSectionHover, onSectionFocus, onPinTap | the events out |
| onCameraChange | the camera moved (a drag, a pinch, the wheel, a key, a frame of a fly-to): called once per drawn frame in which it changed, so a harness keeps what it places over the canvas with the plan |
| Method | |
|---|---|
| addChunk(name, chunk) | add a decoded TLSC block; the sections it covers draw their seats and turn to face their seats' way |
| setOverlay(overlay \| null) | categories and their colours, not-sold and killed seats |
| setStatus(status \| null) | a TLST snapshot; with none, every sold seat draws as available |
| setMine(seats) | the seats reserved by this buyer |
| setPins(pins) | { id, label, at: { sectionId } \| { seat } \| { point }, color?, active?, title?, priority?, members? }; one greedy collision pass, active pins first, then by priority. A section's pin is a chip that stands in for its name (title over price), on the label anchor or just outside the section when the anchor is among seats, never over a seat; a pin with members stands in for an area's sections while they are too small for one each, and a tap on it zooms to them |
| setInsets(insets) | change the clear edges and refit |
| setObstacles(rects) | change what the host draws over the map; refits unless the buyer has moved it |
| viewBox | the size and insets the camera projects into, for a host's own project and unproject |
| focusSection(id \| null, { animate? }), focusedSection | fly to a section, or back to the venue |
| zoomBy(factor), reset() | |
| flyTo(camera, { animate? }) | move the camera there, its centre kept over the plan: for a harness bringing a seat and the card beside it into view. Asked while another move is under way (a section focus), it takes that move over from where the camera is and ends when that one would have: one move, not two |
| target | where the camera is going: the end of the move under way, or camera when nothing moves. A harness that adjusts a move (room for a card beside a seat in a section it just focused) works from this |
| toScreen(x, y) | where a plan point is on screen now, in CSS pixels from the canvas's top left |
| hitRadius(seat) | how far from the seat's centre, in CSS pixels, a tap still lands on it now: the circle a harness keeps clear when it puts a card beside the seat. 0 before the seat's block is loaded |
| seatAt(x, y) | the seat index under a point in CSS pixels from the canvas's top left, or -1. Only seats at least seat-hit-radius on screen |
| sectionAt(x, y) | the topmost section id under the point, or null |
| seatState(seat) | mine, killed, unsold, unavailable or available, in that order of precedence |
| camera | { cx, cy, scale, bearing } |
| resize() | read the canvas size again; a ResizeObserver calls it when there is one |
| refreshStyle() | read the tokens again, after a theme change |
| setBackgroundAlpha(alpha), backgroundAlpha | change how opaque the background, plates and section fills are, clamped to 0..1 |
| render() | draw now; otherwise drawing waits for the next animation frame |
| destroy() | remove every listener and stop drawing |
A tap on a seat (big enough to tap) is onSeatTap with its state, whatever
that state is: the harness decides what a tap on an unavailable seat means. A
tap on a section other than the focused one focuses it. A drag pans and never
taps.
The building blocks are exported for a harness that needs them: Scene (the
plan in plan units, seatNear, sectionAt), the camera functions
(project, unproject, zoomAt, panBy, interpolate),
resolveStyle with SEATMAP_RENDER_TOKENS, and the seat's paint as pure
functions, seatStateOf(seat, { overlay, status, mine, marks, released })
and seatColourOf(seat, state, overlay, style), which the renderer draws with
and which any other view of the same seats (the explorer's 3D mode) uses so a
seat is the same colour everywhere.
Colours and sizes
Every colour and size is a @ticketlayer/theme token in the seatmap group
(TOKENS.md, "Seat map"), read once from the canvas's computed style and
falling back to TOKEN_DEFAULTS (or TOKEN_DARK with colorScheme: 'dark').
Nothing is written down here. The names are in three families: map-* for
the map itself (background, plates, sections, labels, pins, pillars and
aisles), stage-* for the stage and seat-* for the seats. The derived
tokens follow the brand: a
selected seat (--tl-seat-selected) is --tl-primary, the hover ring
--tl-foreground. A seat's category
colour is the event's, from the overlay. After a channel theme is applied,
call refreshStyle().
Pillars, aisles and text
The overview's decorations (@ticketlayer/seatmap's README, "Decorations")
are drawn when there are any, and nothing changes when there are none. Under
the seats, after the section fills: each aisle as its walkway in map-aisle
with a dashed map-aisle-line marking along it (the marking alone when the
plan gives no width or the walkway is under two pixels), then the pillars,
solid in map-pillar with a map-pillar-edge outline, round or rectangular,
at any size they can be seen at (at least a pixel and a half across), since a
pillar is what a restricted view is restricted by. Over them, after the pins
are placed: the venue's text, upright in map-label, at its letter height
(the map's label size when it has none), once it is eight pixels or more and
only where it covers no seat, pin or name placed before it. Scene reads
them into scene.decorations (aisles, pillars, texts, each with its
floor's draw order).
A picture underneath
Manage traces a venue's source plan (a PDF page or a picture) with the seats
drawn over it. The picture belongs under the seats, not over them: put it
behind the canvas (an element or a second canvas below it) and pass
backgroundAlpha: 0. The renderer then clears the canvas each frame and
paints no background, no plate and no section fill, so the picture shows
through under the stage, the seats, their labels and the pins, all drawn as
usual. A value between 0 and 1 tints the picture with the map's own colours
instead; setBackgroundAlpha changes it without recreating the renderer, for
a slider. The canvas element itself must have no CSS background.
Harnesses
ESM only: it reads the theme package at runtime, and that is ESM only. A
Stencil spec under Jest has no canvas, so a tl-seat-* spec mocks this
package rather than loading it; the drawing and the input are tested here on
a fake canvas (test/fake-canvas.mjs).
Levels are drawn in plan position, upper floors over lower ones, and hit testing takes the topmost section. A venue whose levels overlap on the plan needs its overview's outlines laid out apart, which is the publisher's job.
One stage
A plan that draws its levels apart has a stage decoration per level. The building has one stage: the lowest floor draws it, and each higher level gets a cue at its front edge (a short bar and the word) saying which way it faces, as the prototype draws it. Each section turns, when focused, to the stage of its own floor.
