@seatlayer/core 0.10.0 → 0.10.2

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/dist/index.cjs CHANGED
@@ -951,6 +951,7 @@ var SEAT_RADIUS = 9;
951
951
  var SEAT_LEGIBLE_SCALE = 0.9;
952
952
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
953
953
  var LABEL_SCALE = 1;
954
+ var SEAT_TAP_SLOP_PX = 14;
954
955
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
955
956
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
956
957
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
@@ -2852,27 +2853,84 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2852
2853
  }
2853
2854
  }
2854
2855
  // ---- interaction ----------------------------------------------------------
2855
- wireInteraction() {
2856
- this.seatLayer.on("click tap", (e) => {
2857
- if (this.moved > 8) return;
2858
- const id = seatIdOf(e.target);
2859
- if (!id) return;
2860
- if (this.stacked && this.opts.onDeckTap) {
2861
- const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2862
- if (floorId) {
2863
- this.opts.onDeckTap(floorId);
2864
- return;
2865
- }
2856
+ /**
2857
+ * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
2858
+ * frames this section in the current viewport. Used to decide whether
2859
+ * redirecting a seat tap to section-focus would actually zoom in FURTHER, or
2860
+ * whether the section already fills the viewport (small container) so the tap
2861
+ * must fall through and pick.
2862
+ */
2863
+ sectionFrameScale(id) {
2864
+ const sec = this.sections.find((s) => s.id === id);
2865
+ if (!sec) return this.stage.scaleX();
2866
+ const b = polyBounds(sec.outline);
2867
+ const w = this.stage.width();
2868
+ const h = this.stage.height();
2869
+ const { min, max } = this.zoomBounds();
2870
+ const margin = 1.12;
2871
+ if (b.width <= 0 || b.height <= 0) return this.stage.scaleX();
2872
+ return clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
2873
+ }
2874
+ /**
2875
+ * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
2876
+ * then toggle. The gate (§4) redirects small on-screen seats to section-focus
2877
+ * to avoid blind mis-picks at overview scales — but ONLY when focusing would
2878
+ * zoom the seat up to a safe size. If we're already focused on the section, or
2879
+ * the section already fills the viewport (a narrow — OR even a wide — container
2880
+ * frames a big section BELOW LABEL_SCALE), redirecting is a no-op that would
2881
+ * leave the seat permanently unpickable (§5 `picking-gate-unreachable`), so we
2882
+ * fall through and PICK. This is the invariant: seats are never unpickable.
2883
+ */
2884
+ handleSeatTap(id) {
2885
+ if (this.stacked && this.opts.onDeckTap) {
2886
+ const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2887
+ if (floorId) {
2888
+ this.opts.onDeckTap(floorId);
2889
+ return;
2866
2890
  }
2867
- if (this.effScale() < LABEL_SCALE && this.sections.length) {
2868
- const sec = this.seatSection.get(id);
2869
- if (sec) {
2891
+ }
2892
+ if (this.effScale() < LABEL_SCALE && this.sections.length) {
2893
+ const sec = this.seatSection.get(id);
2894
+ if (sec) {
2895
+ const alreadyFocused = this.focusedSectionId === sec.id;
2896
+ const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
2897
+ if (!alreadyFocused && canZoomInFurther) {
2870
2898
  if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
2871
2899
  else this.focusSection(sec.id);
2872
2900
  return;
2873
2901
  }
2874
2902
  }
2875
- this.toggleSeat(id);
2903
+ }
2904
+ this.toggleSeat(id);
2905
+ }
2906
+ /**
2907
+ * Nearest SELECTABLE seat whose centre is within `slopPx` (screen space) of a
2908
+ * seat circle edge, or null. Enlarges the effective tap target for small seats
2909
+ * so a near-miss on mobile still lands on the intended seat, without ever
2910
+ * hijacking a clean tap on empty space beyond the slop.
2911
+ */
2912
+ nearestSeatToScreen(screen, slopPx) {
2913
+ const s = this.stage.scaleX() || 1;
2914
+ const reachWorld = this.seatR + slopPx / s;
2915
+ const world = this.screenToWorld(screen);
2916
+ let best = null;
2917
+ let bestD = reachWorld;
2918
+ for (const seat of this.seats) {
2919
+ if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
2920
+ const d = Math.hypot(seat.x - world.x, seat.y - world.y);
2921
+ if (d < bestD) {
2922
+ bestD = d;
2923
+ best = seat.id;
2924
+ }
2925
+ }
2926
+ return best;
2927
+ }
2928
+ wireInteraction() {
2929
+ this.seatLayer.on("click tap", (e) => {
2930
+ if (this.moved > 8) return;
2931
+ const id = seatIdOf(e.target);
2932
+ if (!id) return;
2933
+ this.handleSeatTap(id);
2876
2934
  });
2877
2935
  this.seatLayer.on("mouseover", (e) => {
2878
2936
  const id = seatIdOf(e.target);
@@ -2898,10 +2956,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2898
2956
  const factor = Math.exp(-e.evt.deltaY * 2e-3);
2899
2957
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
2900
2958
  });
2901
- this.stage.on("click tap", () => {
2902
- if (!this.cached || this.moved > 8) return;
2959
+ this.stage.on("click tap", (e) => {
2960
+ if (this.moved > 8) return;
2903
2961
  const pointer = this.stage.getPointerPosition();
2904
2962
  if (!pointer) return;
2963
+ if (!this.cached) {
2964
+ if (seatIdOf(e.target)) return;
2965
+ const near = this.nearestSeatToScreen(pointer, SEAT_TAP_SLOP_PX);
2966
+ if (near) this.handleSeatTap(near);
2967
+ return;
2968
+ }
2905
2969
  if (this.stacked && this.opts.onDeckTap) {
2906
2970
  const floorId = this.deckFloorAt();
2907
2971
  if (floorId) {
@@ -3104,14 +3168,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3104
3168
  this.seatLayer.batchDraw();
3105
3169
  }
3106
3170
  }
3107
- cacheSeatLayer() {
3171
+ /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
3172
+ * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
3173
+ rebuildSeatCache() {
3108
3174
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
3109
3175
  this.seatLayer.clearCache();
3110
3176
  this.seatLayer.cache({ pixelRatio: pr });
3111
3177
  this.seatLayer.listening(false);
3112
3178
  this.cached = true;
3179
+ }
3180
+ cacheSeatLayer() {
3181
+ this.rebuildSeatCache();
3113
3182
  this.seatLayer.batchDraw();
3114
3183
  }
3184
+ /**
3185
+ * SYNCHRONOUS repaint that bypasses requestAnimationFrame — see the
3186
+ * ISeatmapRenderer.forceDraw() contract. Chrome pauses rAF (and therefore
3187
+ * Konva's batchDraw) on hidden/backgrounded/occluded tabs, so seat-status
3188
+ * deltas applied via setStatus() mutate the scene graph but never reach the
3189
+ * canvas until the tab is foregrounded. This flushes the cache-debounce and
3190
+ * paints every layer setStatus() can touch, immediately.
3191
+ *
3192
+ * Additive: the foreground path never calls this, so foreground behaviour is
3193
+ * byte-identical.
3194
+ */
3195
+ forceDraw() {
3196
+ if (this.recacheTimer) {
3197
+ clearTimeout(this.recacheTimer);
3198
+ this.recacheTimer = null;
3199
+ this.rebuildSeatCache();
3200
+ }
3201
+ this.bgLayer.draw();
3202
+ this.seatLayer.draw();
3203
+ this.overlayLayer.draw();
3204
+ }
3115
3205
  updateLabels() {
3116
3206
  const show = this.effScale() > LABEL_SCALE;
3117
3207
  this.labelGroup.destroyChildren();
@@ -3247,6 +3337,10 @@ var PickerController = class {
3247
3337
  this.reconnectTimer = null;
3248
3338
  this.attempt = 0;
3249
3339
  this.closed = false;
3340
+ /** Bound visibilitychange listener (buyer catch-up on tab regain). Kept on the
3341
+ * instance so destroy() can detach it; null when not attached (guards double
3342
+ * mounts / non-DOM environments). */
3343
+ this.onVisibilityChange = null;
3250
3344
  // hold state
3251
3345
  this.hold_ = null;
3252
3346
  this.liveStatuses = /* @__PURE__ */ new Map();
@@ -3372,6 +3466,7 @@ var PickerController = class {
3372
3466
  this.closedSections = new Set(closedIds);
3373
3467
  if (closedIds.length) renderer.setClosedSections?.(closedIds);
3374
3468
  if (seats) this.applySeatsMap(seats);
3469
+ this.attachVisibilityListener();
3375
3470
  this.connect();
3376
3471
  return {
3377
3472
  doc: res.doc,
@@ -3798,6 +3893,7 @@ var PickerController = class {
3798
3893
  }
3799
3894
  destroy() {
3800
3895
  this.closed = true;
3896
+ this.detachVisibilityListener();
3801
3897
  if (this.reconnectTimer) {
3802
3898
  clearTimeout(this.reconnectTimer);
3803
3899
  this.reconnectTimer = null;
@@ -3994,6 +4090,37 @@ var PickerController = class {
3994
4090
  }
3995
4091
  }
3996
4092
  // ---- realtime socket ------------------------------------------------------
4093
+ /**
4094
+ * Buyer catch-up on tab regain. While a tab is hidden Chrome pauses rAF, so
4095
+ * seat-status deltas that arrived over the WS mutated the scene graph but the
4096
+ * canvas colors never repainted. When the tab becomes visible again we (1)
4097
+ * resnapshot to pull authoritative state for anything the socket may have
4098
+ * missed while backgrounded, then (2) forceDraw() a synchronous repaint so the
4099
+ * seat colors are correct immediately rather than on the next incidental draw.
4100
+ *
4101
+ * Attached once per mount; guarded against double-attach and non-DOM
4102
+ * environments; detached in destroy() (no leaks).
4103
+ */
4104
+ attachVisibilityListener() {
4105
+ if (this.onVisibilityChange) return;
4106
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return;
4107
+ const handler = () => {
4108
+ if (this.closed) return;
4109
+ if (document.visibilityState !== "visible") return;
4110
+ void this.resnapshot().then(() => {
4111
+ if (!this.closed) this.renderer?.forceDraw();
4112
+ });
4113
+ };
4114
+ this.onVisibilityChange = handler;
4115
+ document.addEventListener("visibilitychange", handler);
4116
+ }
4117
+ detachVisibilityListener() {
4118
+ if (!this.onVisibilityChange) return;
4119
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
4120
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
4121
+ }
4122
+ this.onVisibilityChange = null;
4123
+ }
3997
4124
  connect() {
3998
4125
  if (this.closed) return;
3999
4126
  let ws;
@@ -4039,6 +4166,9 @@ var PickerController = class {
4039
4166
  }
4040
4167
  r.setStatus([id], next);
4041
4168
  }
4169
+ if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
4170
+ r.forceDraw();
4171
+ }
4042
4172
  this.clearBookedHoldIfSettled();
4043
4173
  this.opts.onStatusChange?.();
4044
4174
  }