@seatlayer/core 0.28.4 → 0.30.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.
@@ -0,0 +1,385 @@
1
+ import { C as ChartDoc, E as ExpandedSeat } from '../types-CG2pEDoI.cjs';
2
+
3
+ /**
4
+ * Dirty-flag render loop. A frame is drawn only while the camera is moving /
5
+ * damping or an availability update arrived; when idle, no rAF is scheduled at
6
+ * all (zero CPU/GPU when parked). Tracks an FPS EMA over frames actually
7
+ * rendered — it reads as "idle" when nothing is scheduled.
8
+ */
9
+ interface RenderLoopStats {
10
+ fps: number;
11
+ rendered: number;
12
+ idle: boolean;
13
+ }
14
+
15
+ /**
16
+ * view3d palette + seat-state model — the single source of colour truth for the
17
+ * OGL venue view. Pure data (no GPU, no DOM) so the scene builder and the unit
18
+ * tests share one definition. Colours are linear-ish RGB triplets in 0..1.
19
+ *
20
+ * Look brief (docs/3d-usp-strategy §3): desaturated cool greys for structure,
21
+ * one warm accent for the stage, availability colours only on seats.
22
+ */
23
+ type SeatState3D = 'available' | 'held' | 'sold' | 'selected' | 'dimmed';
24
+ type RGB = [number, number, number];
25
+ /** Structure palette — cool desaturated greys + one warm stage accent. */
26
+ declare const STRUCTURE: {
27
+ readonly ground: RGB;
28
+ readonly tierTop: RGB;
29
+ readonly tierWall: RGB;
30
+ readonly stageTop: RGB;
31
+ readonly stageWall: RGB;
32
+ readonly decorTop: RGB;
33
+ readonly decorWall: RGB;
34
+ readonly gaTop: RGB;
35
+ readonly gaWall: RGB;
36
+ /** Exhibition / trade-show booth stand. */
37
+ readonly boothTop: RGB;
38
+ readonly boothWall: RGB;
39
+ /** Banquet table top — warmer, so a laid table reads apart from structure. */
40
+ readonly tableTop: RGB;
41
+ readonly tableWall: RGB;
42
+ };
43
+
44
+ /**
45
+ * Resolve a chart's authored theme into the 3D scene's colours.
46
+ *
47
+ * Every chart already carries a `ChartTheme` — background, brand accent, seat
48
+ * scale — and the 2D renderer and the picker chrome honour it. The 3D view did
49
+ * not: `palette.ts` was a fixed set of constants, so a white-labelled event
50
+ * rendered in SeatLayer's own dark grey whatever the organizer had branded. That
51
+ * is a visible gap in a paid feature, and it is the kind of thing a customer
52
+ * notices immediately when they switch from the 2D map to the 3D view.
53
+ *
54
+ * The palette in `palette.ts` stays the DEFAULT and the reference. This module
55
+ * only rebases it, so an unthemed chart is byte-for-byte what it was.
56
+ *
57
+ * ## How structure is rebased
58
+ *
59
+ * Structure colours are not replaced by the brand colour — a venue rendered in
60
+ * flat brand paint reads as a diagram, not a building, and the look brief is
61
+ * deliberately "desaturated greys for structure, saturated colour only on
62
+ * seats". Instead each structure colour is blended a little way toward the
63
+ * authored background, so the whole venue picks up the brand's cast and sits in
64
+ * its own light, while keeping the tonal relationships (tier above wall, stage
65
+ * warmer than tier) that make the geometry readable.
66
+ */
67
+
68
+ interface Theme3D {
69
+ background: {
70
+ top: RGB;
71
+ bottom: RGB;
72
+ };
73
+ structure: typeof STRUCTURE;
74
+ seatStates: Record<SeatState3D, RGB>;
75
+ /** Multiplier on the seat dot's world radius. */
76
+ seatScale: number;
77
+ }
78
+
79
+ /**
80
+ * Venue labels — anchors, level-of-detail, and world→screen projection.
81
+ *
82
+ * ## Why labels are DOM, not geometry
83
+ *
84
+ * The renderer is deliberately texture-free at three draw calls. Drawing text on
85
+ * the GPU means a signed-distance font atlas: a texture, another shader, a build
86
+ * asset, and a resolution ceiling — a lot of machinery for the few dozen labels a
87
+ * venue actually needs. Projecting anchors and positioning DOM elements costs
88
+ * nothing when a chart has no labels, and buys properties the GPU path cannot:
89
+ *
90
+ * - **Real text.** A screen reader can read the venue's structure. That is the
91
+ * accessibility gap in 3D, not just a rendering convenience.
92
+ * - Crisp at any device pixel ratio and any zoom, with no atlas to outgrow.
93
+ * - `ChartTheme.fontFamily`, i18n and RTL come from the browser.
94
+ *
95
+ * This module is the pure half — what to label, where its anchor sits, and when
96
+ * it should show. The overlay that positions elements lives in `index.ts`.
97
+ *
98
+ * ## Why the rungs mirror 2D
99
+ *
100
+ * 2D melts through zones → sections → seats. Labels follow the same idea for the
101
+ * same reason: at a distance a buyer needs to know which part of the venue they
102
+ * are looking at, and up close they need to know which block and which door. A
103
+ * label set that does not thin out with distance turns a 51-section arena into
104
+ * unreadable confetti.
105
+ */
106
+
107
+ type LabelKind = 'zone' | 'section' | 'annotation' | 'booth';
108
+ interface SceneLabel {
109
+ id: string;
110
+ kind: LabelKind;
111
+ text: string;
112
+ /** World-metre anchor the label is pinned to. */
113
+ anchor: [number, number, number];
114
+ /** Authored colour (`#rrggbb`), when the object carries one. */
115
+ color?: string;
116
+ /** Authored rotation in degrees, for annotations that specify one. */
117
+ rotation?: number;
118
+ }
119
+
120
+ /**
121
+ * Pure geometry primitives for the view3d scene — no OGL, no DOM, so the whole
122
+ * scene-model builder is unit-testable in a plain runtime.
123
+ *
124
+ * Coordinate convention: chart units (x, y) map to world metres as
125
+ * worldX = x * METRES_PER_CHART_UNIT
126
+ * worldZ = y * METRES_PER_CHART_UNIT
127
+ * worldY = up (height in metres)
128
+ * i.e. the chart's audience-depth (+y) becomes world +Z, and Y is the vertical.
129
+ */
130
+
131
+ interface MeshData {
132
+ /** Non-indexed triangle soup: 3 floats per vertex. */
133
+ position: Float32Array;
134
+ normal: Float32Array;
135
+ /** Baked vertex colour incl. AO, 3 floats per vertex. */
136
+ color: Float32Array;
137
+ /**
138
+ * Owning floor index per vertex.
139
+ *
140
+ * Lets one merged mesh be dimmed per floor without splitting it into a draw
141
+ * call per floor. A multi-floor chart (the opera house has three) draws every
142
+ * floor at once, so the balcony sits over the parterre and hides it; isolating
143
+ * one is the only way to look at the level you are actually booking.
144
+ */
145
+ floor: Float32Array;
146
+ /** Vertex count (position.length / 3). */
147
+ count: number;
148
+ }
149
+
150
+ /**
151
+ * Pure builder for the instanced seat cloud. Produces the per-instance arrays a
152
+ * single OGL InstancedMesh consumes (one draw call for every seat), plus the
153
+ * seatId → instanceIndex map that `setAvailability` uses to patch only the seats
154
+ * that actually changed via a sub-range `bufferSubData` upload.
155
+ */
156
+
157
+ interface SeatInstanceData {
158
+ count: number;
159
+ /** vec3 per instance: world (x, y, z) in metres. */
160
+ iPosition: Float32Array;
161
+ /** float per instance: index into the seat-state colour LUT. */
162
+ iState: Float32Array;
163
+ /**
164
+ * float per instance: the largest world radius this dot may take, metres.
165
+ *
166
+ * The shader enforces `uMinPixels` by GROWING a dot's world radius with depth,
167
+ * which is what merges rows into a solid mass at range — measured mean seat
168
+ * spacing is 0.53–0.58 m against a 0.44 m dot diameter, so there is very little
169
+ * slack to spend before neighbours touch. A global cap cannot fix it: spacing
170
+ * is a property of the chart, and varies between sections of the same venue
171
+ * (measured min 0.21 m on the amphitheatre against a 0.58 m mean).
172
+ *
173
+ * So the ceiling travels per seat, derived from that seat's own nearest
174
+ * neighbour. A dot grows to hold its minimum pixel size and then STOPS,
175
+ * whatever the distance. Past that point holding legibility is the LOD ladder's
176
+ * job (fade toward the tier tint), not the dot's.
177
+ */
178
+ iMaxRadius: Float32Array;
179
+ /**
180
+ * vec3 per instance: accommodation ring colour, or (0,0,0) for none.
181
+ *
182
+ * 2D draws a coloured ring around every seat with an accessibility type, and
183
+ * 3D drew nothing at all — so a wheelchair space, a companion seat or a
184
+ * lift-armrest seat was indistinguishable from any other the moment a buyer
185
+ * switched to the 3D view. A ring mirrors the 2D treatment exactly, needs no
186
+ * texture, and costs one instanced attribute rather than a draw call.
187
+ */
188
+ iRing: Float32Array;
189
+ /** float per instance: owning floor index, for per-floor isolation. */
190
+ iFloor: Float32Array;
191
+ /** seatId → instance index (drives targeted availability updates). */
192
+ idToIndex: Map<string, number>;
193
+ }
194
+
195
+ /**
196
+ * A navigable zone — the venue's own top-level grouping (Orchestra, Lower Bowl,
197
+ * Hall A), resolved for camera framing and for the section/zone LOD rung.
198
+ *
199
+ * Every shipped chart authors zones and gives every section one, and the 2D
200
+ * renderer uses them as its farthest LOD rung. 3D ignored them entirely, so the
201
+ * one structure a buyer navigates by ("take me to the Grand Circle") had no
202
+ * representation at all in the 3D view.
203
+ */
204
+ interface SceneZone {
205
+ id: string;
206
+ label: string;
207
+ /** Authored zone colour, or null when the chart leaves it to the category mix. */
208
+ color: RGB | null;
209
+ /** Section object ids belonging to this zone. */
210
+ sectionIds: string[];
211
+ /** Seats resolved into this zone. */
212
+ seatCount: number;
213
+ /** World-metre centre of the zone's seats (camera target). */
214
+ center: [number, number, number];
215
+ /** Half-diagonal of its footprint, world metres (camera fit). */
216
+ radius: number;
217
+ /** What this zone faces — its authored focal, else the venue's. */
218
+ focalWorld: [number, number, number];
219
+ }
220
+ /** A navigable floor — a logical level of the venue. */
221
+ interface SceneFloor {
222
+ index: number;
223
+ id: string;
224
+ label: string;
225
+ seatCount: number;
226
+ center: [number, number, number];
227
+ radius: number;
228
+ }
229
+ interface SceneModel {
230
+ /** Every non-seat surface merged into one triangle soup (1 draw call). */
231
+ solids: MeshData;
232
+ seats: SeatInstanceData;
233
+ bounds: {
234
+ /** World-metre venue centre (camera target). */
235
+ center: [number, number, number];
236
+ /** Half-diagonal of the horizontal footprint, metres (camera fit). */
237
+ radius: number;
238
+ groundY: number;
239
+ };
240
+ /** 5 × vec3 flat LUT for the seat fragment shader. */
241
+ stateColorLUT: number[];
242
+ /** Resolved colours for this chart's authored theme (background, seat scale). */
243
+ theme: Theme3D;
244
+ seatCount: number;
245
+ /** Venue focal point in world metres (cinematic look-at target). */
246
+ focalWorld: [number, number, number];
247
+ /** The venue's zones, in authored order. Empty when the chart has none. */
248
+ zones: SceneZone[];
249
+ /**
250
+ * The venue's floors, in authored order. A single-floor chart reports one.
251
+ *
252
+ * Every shipped multi-floor template puts all its floors at baseHeightM 0 and
253
+ * takes height from the sections instead, so floors are a logical grouping,
254
+ * not a physical stack. What they need is ISOLATION: draw all three of an
255
+ * opera house at once and the balcony sits over the parterre.
256
+ */
257
+ floors: SceneFloor[];
258
+ /**
259
+ * Everything worth naming, with a world anchor: zones, sections, authored
260
+ * wayfinding text, and booths. Rendered as a DOM overlay — see `labels.ts` for
261
+ * why text is not geometry here.
262
+ */
263
+ labels: SceneLabel[];
264
+ }
265
+ interface SceneModelInput {
266
+ doc: ChartDoc;
267
+ seats: ExpandedSeat[];
268
+ /** Optional initial per-seat state (default all available). */
269
+ initialState?: (seat: ExpandedSeat) => SeatState3D;
270
+ }
271
+ declare function buildSceneModel(input: SceneModelInput): SceneModel;
272
+
273
+ /**
274
+ * Slice 3 hand-off — the DOM panorama overlay the fly-to-seat cinematic
275
+ * dissolves into. Decoupled by design: the CALLER supplies the equirectangular
276
+ * image (via mountVenue3D's getSeatView), so view3d never imports the app's
277
+ * panorama generator and the chunk stays lean.
278
+ *
279
+ * Technique (mirrors SeatPicker.openSeatView, reimplemented small): an equirect
280
+ * image panned with `repeat-x`; the initial horizontal offset is set so the
281
+ * panorama's bearing matches the final camera yaw — the dissolve reads as the
282
+ * same view sharpening, not a cut. CSS opacity fade is compositor-only.
283
+ */
284
+ interface SeatView {
285
+ url: string;
286
+ /** Bearing (deg, 0 = facing the focal/stage) the panorama should open centred
287
+ * on, to match the camera's final yaw. Default 0 (both face the stage). */
288
+ initialBearingDeg?: number;
289
+ }
290
+
291
+ /**
292
+ * view3d analytics — a tiny, decoupled event emitter for the venue view. The
293
+ * caller (app/harness) supplies `onAnalytics`; this class owns the per-mount
294
+ * state (first-orbit latch, panorama dwell timing) and, crucially, wraps EVERY
295
+ * callback invocation in try/catch so a throwing analytics sink can never break
296
+ * rendering. No DOM, no GL — unit-testable in isolation.
297
+ */
298
+ type Analytics3DCallback = (event: string, props?: Record<string, unknown>) => void;
299
+
300
+ /**
301
+ * view3d — the sole dynamic-import boundary for the lazy OGL venue-view chunk.
302
+ *
303
+ * const { mountVenue3D } = await import('../view3d');
304
+ * const handle = mountVenue3D(container, { doc, seats }, { onSeatPick, getSeatView });
305
+ * await handle.flyToSeat(seatId);
306
+ *
307
+ * Read-only 3D of any chart, fed entirely from the existing height contract.
308
+ * Slice 1: orbit camera, extruded tiers/stage/GA, instanced seat dots, sub-range
309
+ * availability, dispose + context-loss survival. Slice 2: GPU color-pick. Slice
310
+ * 3: the fly-to-seat cinematic that dissolves into the view-from-seat panorama.
311
+ */
312
+
313
+ interface Venue3DInput {
314
+ doc: ChartDoc;
315
+ /** Expanded seats (from `expandChart`) — carry x/y + resolved eyeHeightM. */
316
+ seats: ExpandedSeat[];
317
+ /** Optional initial per-seat state (default all available). */
318
+ initialState?: (seat: ExpandedSeat) => SeatState3D;
319
+ }
320
+ interface Venue3DOptions {
321
+ /** Fired on a tap that hits a seat (GPU color-pick). Not fired on empty taps. */
322
+ onSeatPick?: (seatId: string) => void;
323
+ /**
324
+ * Supplies the view-from-seat panorama for the cinematic hand-off. Decoupled:
325
+ * the caller (app/harness) owns panorama generation; view3d never imports it.
326
+ * Called at PICK time to pre-render, so flyToSeat has zero wait on landing.
327
+ */
328
+ getSeatView?: (seatId: string) => SeatView | Promise<SeatView>;
329
+ /**
330
+ * Decoupled analytics sink. Emits the venue-view journey: `3d_opened`,
331
+ * `3d_orbit_engaged` (first user gesture), `3d_seat_picked`,
332
+ * `3d_cinematic_played`/`_skipped`/`_cancelled`, `3d_panorama_opened`/`_closed`.
333
+ * Every invocation is wrapped in try/catch — a throwing sink never breaks
334
+ * rendering. Absent = no events emitted.
335
+ */
336
+ onAnalytics?: Analytics3DCallback;
337
+ }
338
+ interface Venue3DStats extends RenderLoopStats {
339
+ drawCalls: number;
340
+ seatCount: number;
341
+ }
342
+ interface Venue3DHandle {
343
+ dispose(): void;
344
+ setAvailability(updates: {
345
+ seatId: string;
346
+ state: SeatState3D;
347
+ }[]): void;
348
+ setSelection(seatIds: string[]): void;
349
+ /** Fly the camera from the overview into `seatId` and dissolve into its
350
+ * view-from-seat panorama. Resolves at flight end; a drag cancels it, a second
351
+ * call retargets, dispose resolves early. Reduced-motion → a short fade. */
352
+ flyToSeat(seatId: string): Promise<void>;
353
+ resize(): void;
354
+ stats(): Venue3DStats;
355
+ loseContextForTest(): void;
356
+ /** Test hook: force (or clear) the reduced-motion path. */
357
+ setReducedMotionForTest(value: boolean | null): void;
358
+ /** The venue's zones (id, label, colour, seat count) in authored order. */
359
+ zones(): SceneZone[];
360
+ /**
361
+ * Frame a zone: the camera moves to sit over that zone looking at what the
362
+ * zone faces. Returns false for an unknown or empty zone.
363
+ *
364
+ * This is the navigation the venue's own structure implies — a buyer picks
365
+ * "Grand Circle", not a set of coordinates — and it is what the 2D renderer's
366
+ * farthest LOD rung already offers. Approaching from the zone's focal side
367
+ * means the seats face the camera rather than presenting their backs.
368
+ */
369
+ focusZone(zoneId: string): boolean;
370
+ /** The venue's floors (id, name, seat count) in authored order. */
371
+ floors(): SceneFloor[];
372
+ /**
373
+ * Isolate one floor, or pass null to show the whole venue.
374
+ *
375
+ * Every shipped multi-floor chart puts its floors at the same base height and
376
+ * takes relief from the sections, so all three of an opera house draw at once
377
+ * and the balcony sits over the parterre. Unfocused floors are DIMMED rather
378
+ * than hidden, so the buyer keeps the venue as context while looking at the
379
+ * level they are booking. Returns false for an unknown index.
380
+ */
381
+ focusFloor(index: number | null): boolean;
382
+ }
383
+ declare function mountVenue3D(container: HTMLElement, input: Venue3DInput, opts?: Venue3DOptions): Venue3DHandle;
384
+
385
+ export { type Analytics3DCallback, type SeatState3D, type SeatView, type Venue3DHandle, type Venue3DInput, type Venue3DOptions, type Venue3DStats, buildSceneModel, mountVenue3D };