@ticketlayer/seatmap
v0.1.2
Published
The Ticketlayer seat map formats and geometry: TLSC, TLSV and TLST readers and encoders, the event plan overlay, the seat map token, hit testing, outlines, the curved stage and the single seat gap check. Pure TypeScript, no DOM
Downloads
498
Readme
@ticketlayer/seatmap
The seat map's formats and geometry, exactly as the shared contract defines
them (3dvenue/docs/contract.md, sections 3 to 6). Pure TypeScript: no DOM, no
network, no Node or Workers globals beyond WebCrypto for the token. One copy of
each rule, used by the elements core, @ticketlayer/seatmap-render, Live
(apps/live), the seat map Worker (apps/seatmap), Manage and Box Office.
pnpm --filter @ticketlayer/seatmap build # dist/esm (with .d.ts) and dist/cjs
pnpm --filter @ticketlayer/seatmap test # node --test against dist/esmWhat a seat is
A seat is its index within a venue plan version (0..n-1, section by
section, row by row, as the publisher writes them). Nothing here carries a
seat id. A version is addressed on the wire by its publish key pv, 16
lowercase hex.
API
Seat geometry: TLSC/1
decodeTLSC(bytes: Uint8Array): SeatChunk
encodeTLSC(chunk: SeatChunkInput): Uint8Array
chunkFromSeats(seats: PlanSeat[], seatBase: number, quantum = 0.1): SeatChunkInput
rowsOfChunk(chunk): number[][] // global seat indexes per row
localSeatOf(chunk, seat): number // -1 when the chunk does not hold it
seatIndexOf(chunk, local): number
rotationDegrees(chunk, local): numberA SeatChunk holds the block's rows (label, seatCount), the quantised
integers (qx, qy), the plan positions (x, y, Float64Array),
numbers (strings), rotation (256 steps), zone (index into
overview.zones), attrs (bits in overview.attributes order), rowOf and
rowStart. encodeTLSC(decodeTLSC(b)) is b, byte for byte. The reader
refuses a chunk with flag bit 0 (an id column), another version, unknown flag
bits, and short or over-long bytes, with a SeatmapFormatError.
Views: TLSV/1
decodeTLSV(bytes): SeatViews
encodeTLSV(views: SeatViewsInput): Uint8Array
seatView(views, local): { eye, yawDegrees, score, distanceMetres, stageVisible, viewpoint, obstructions }score is decoded but is shown only as words or a band, and only when the
overview says viewsPublic. That decision is the caller's.
Availability: TLST/1 and the summary
decodeTLST(bytes): SeatStatus // { statusVersion, seatCount, bits }
encodeTLST(status): Uint8Array
statusFromAvailable(statusVersion, seatCount, seats: Iterable<number>): SeatStatus
isAvailable(status, seat): boolean
countAvailable(status, from?, to?): number
parseStatusSummary(json): SeatStatusSummary // tl-seat-status/1The event plan overlay
decodeOverlay(doc, seatCount?): SeatOverlay // categories, seatCategory (u8), killed (bitset)
encodeOverlay(overlay): SeatOverlayDocument
categoryOf(overlay, seat): OverlayCategory | null // null: 255, not sold on this plan
isKilled(overlay, seat): boolean
killedBitset(seatCount, seats): Uint8Array
NOT_SOLD // 255The overview and the pointer
parseOverview(json): SeatmapOverview // floors, zones, attributes, decorations, sections
sectionOfSeat(overview, seat): OverviewSection | null
sectionsByChunk(overview): Map<chunkName, OverviewSection[]>
pillarsOf(overview): OverviewPillar[] // { floorId, label, centre, radius | null, rect | null }
aislesOf(overview): OverviewAisle[] // { floorId, label, from, to, width | null }
textsOf(overview): OverviewText[] // { floorId, text, at, size | null }
parsePointer(json): SeatmapPointer // GET /sales/occurrences/{occ}/seatmapA section carries name (its display name), label (the label anchor),
outline, seatBase, seatCount, rowCount, chunk (the TLSC block holding
it), views, and block and rowBase. The overview's blocks list each
block's chunk, seat range and sections: whole consecutive areas, greedily, up
to 4,000 seats. section.seatBase - chunk.seatBase is where a section's
seats start inside its chunk. Zone and category colours are spelled color,
as on the wire.
Decorations
overview.decorations is what is drawn on the plan besides the seats: the
stage, and since TKT-515 the pillars, aisles and text a venue's plan carries,
so a buyer sees what a restricted view is restricted by. The list is optional
and backwards compatible: absent or empty means none, and an overview written
before this reads exactly as it did. Every position and length is in plan
units, the frame the section outlines are in. Every entry has a type, and may
name its floorId (one of overview.floors) and a label.
| type | Fields | Drawn |
|---|---|---|
| stage | rect [x, y, width, height] or outline [[x, y], ...]; label ("Stage", "Pitch") | the stage, curved towards the house |
| pillar | round: centre [x, y] and radius (> 0). Rectangular: rect [x, y, width, height] (width and height > 0) and no radius. A round pillar may carry its bounding rect too; centre and radius win | solid, in map-pillar with a map-pillar-edge outline, under the seats, at any size it can be seen at |
| aisle | from [x, y] and to [x, y], two different points: the walkway's centre line. width (> 0) across, optional | the walkway in map-aisle with a dashed map-aisle-line marking along it; the marking alone without a width |
| text | text (not blank), at [x, y] (the text's centre), size (> 0, the letter height), optional | upright, in map-label, at size (the map's label size without one), once it is big enough to read and only where it covers no seat |
| custom | rect or outline, label | kept for the reader that wants it; the renderer does not draw it |
parseOverview hands back a pillar, aisle or text with exactly those
fields and drops one that lacks what it needs, so a renderer never meets half
a pillar. A type it does not know is kept as it came, so a newer
publisher's decorations reach a reader that knows them without breaking one
that does not. pillarsOf, aislesOf and textsOf read them typed.
What the publisher emits
The Backstage API's publisher (src/domains/inventory/seatmap/encode.ts,
encodeVersion, the decorations of the overview) and the reference encoder
(3dvenue scripts/chunk_package.py) write them from the plan's own
decorations: Manage's plan editor keeps pillars, aisles and text in the
version's structure (apps/manage/src/components/plan-editor/model/output.ts,
planStructure), already in plan units. The publisher maps each structure
entry to one overview entry, in the structure's order, every number through
the same pyFloat as the rest of the overview, and no id (the overview is
public and immutable, and nothing reads a decoration by id):
| In the structure | In the overview |
|---|---|
| { type: 'stage', floorId, label, rect, height } | { type: 'stage', label, rect }, as today |
| { type: 'pillar', id, floorId, name, centre, radius, rect } | { type: 'pillar', floorId, label: name, centre, radius } |
| a pillar with rect and no centre or radius (a square column from an import) | { type: 'pillar', floorId, label: name, rect } |
| { type: 'aisle', id, floorId, name, from, to, width } | { type: 'aisle', floorId, label: name, from, to, width } |
| { type: 'label', id, floorId, label, at, size } | { type: 'text', floorId, text: label, at, size } |
| { type: 'custom', label or name, rect } | { type: 'custom', label, rect }, as today |
Note the one rename: the structure's text decoration is type: 'label' with
its words in label; the overview's is type: 'text' with its words in
text, because label is every decoration's optional caption. A structure
entry of any other type, or one missing the fields above, is left out. For a
worked example, the Playhouse fixture (test/fixtures/contract-v1/playhouse)
carries two pillars where its 3D model stands them, named as its views name
the obstruction ("Pillar Left", "Pillar Right"), a cross aisle behind the
stalls and a text; the Storybook workbench draws them from it.
Viewpoints and media
parseViewpoints(json): SeatViewpoints // tl-viewpoints/1: per section-row, eye, yaw, stills, pano
stillPathOf(viewpoint, seat): string | null // still/<row>.<h>/<seat>.jpg
panoTilePathOf(viewpoint, z, x, y): string | null // pano/<row>.<h>/<z>/<x>_<y>.jpg
nearestStillSeat(viewpoint, seat): { seat, along } | null
isMediaPath(path): boolean // stills/, still/ or pano/, as the Worker routes themA seat's viewpoint is TLSV/1's viewpoint column, an index into the
document's viewpoints. A row's stills name its thumbnail atlas
(stills/<row>.<h>.jpg, tiles mapping a seat index to its cell) and the
directory of its full stills; its pano names the equirectangular tile
pyramid (z0 the whole sphere at 512x256, then 2x1, 4x2 and 8x4 tiles of 512
px) with each level's [width, columns, rows]. Every media path is checked
against the contract's forms, and one that is not is dropped, so a reader
never fetches anything the publisher would not have written. Whether a
version has media at all is the pointer's hasStills.
The venue model
modelOf(overview): SeatmapModel | null // the overview's `model`: one display mesh per level, base first
isMeshPath(path): boolean // mesh/<level>(.meshopt).<h>.glb, as the Worker routes itWhen the 3D pipeline has run, the overview names a display mesh per level
(base, then level-<n> for floors[n]), and the meshopt twin of each when
there is one; modelOf picks the twin, drops any path that is not in the
contract's form, and says which floor each level is. A mesh is glTF 2.0, Y
up, with its materials named for their render role (plate, wall,
detail, void, stage); a glTF point (x, y, z) is (x, -z, y) in the
views' frame (metres, z up, +y towards the stage), so the seats' eyes from
TLSV/1 and the meshes line up. Whether a version has a model at all is the
pointer's hasModel. tl-seat-explorer's 3D mode draws them.
Geometry
Plan units, x right and y down the page.
convexHull(points), pointInPolygon(x, y, poly), boundsOf(points), centroidOf(points), signedArea2(poly)
seatHullOutline(seats, pad) // the publisher's outline: hull of padded seats
rowOutline(rows: Point[][], offset, smooth = 2) // the prototype's outline: follows curved rows
offsetPolygon(poly, d), chaikin(poly, n), dedupe(poly)
curvedStage(rect | corners, audience: Point): { rect, poly, centre, front }
sectionBearing(rotationsDegrees, centroid, focus): radians // turns a section's seats to face up
frame(points, viewport, { fill, bearing, maxScale }): { centre, scale, bearing } // focus framing
rotate(point, bearing), wrapAngle(a)
seatPitch(chunk): number // median neighbour distance in a row
new PointGrid(xs, ys, cell, include?).nearest(x, y, radius, accept?): index | -1The single seat gap check
checkSingleSeatGap({ rows: GapRow[], isFree(seat), requested: number[] }):
{ ok, strands: number[], suggestion: number[] | null }
gapRowsOf(chunk): GapRow[]A seat is stranded when the request leaves it free and alone between two taken
seats, or between a taken seat and the row end, and a neighbour is one of the
requested seats. A lone seat that was already there is not the request's.
suggestion is the nearest shift of the request along its row that strands
nothing, keeping its shape and size; ties go to the shift that takes a seat
the original would have stranded, then to the left. Online (warn) the
client shows the warning and offers the suggestion; Box Office (confirm) may
go ahead after a confirm.
The token
SEATMAP_SCOPES, QUEUED_SCOPES // overview, overlay, sections, views, viewpoints, status, mesh, stills, still, pano; ['overview'] while queued
parseTokenKeys('kid:base64,kid:base64') // the first signs, every one verifies
signingKey(keys)
mintSeatmapToken(claims, kid, secret): Promise<string>
verifySeatmapToken(token, keys, nowSeconds?): Promise<{ ok: true, claims, kid } | { ok: false, reason }>
tokenAllows(claims, { scope, pv?, occ? }): boolean
originAllowed(claims, originHeader): boolean // present and different from org: refused
sessionHash(secret, sessionId): Promise<string> // sid: 22 base64url charactersLifetime at most 15 minutes (MAX_TOKEN_LIFETIME_SECONDS), 30 seconds of
skew, renew after 12. Imported HMAC keys are cached per isolate.
Tests
test/formats.test.mjs builds a curved section and round-trips every format
through its encoder and reader, byte for byte, and checks the header offsets
the contract fixes.
test/fixtures.test.mjs reads the contract fixtures, the Playhouse and the
arena as 3dvenue's reference encoder (scripts/chunk_package.py --fixtures)
publishes them, and asserts what their README asks: index.json hashes to the
pv, every listed file has its bytes and hash, every twin decompresses to its
file, the blocks, zones and attributes, every TLSC header, every TLSC, TLSV and
TLST file round-trips byte for byte, the sample seats, and the overlay, the
snapshot and the summary agreeing. The fixtures are committed under
test/fixtures/contract-v1/ as 3dvenue wrote them and are never edited here:
pnpm --filter @ticketlayer/seatmap run fixtures:sync # from ../3dvenue, or SEATMAP_FIXTURES_SOURCE