@seatlayer/core 0.9.0 → 0.10.1
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 +409 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +198 -5
- package/dist/index.d.ts +198 -5
- package/dist/index.js +409 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -20,9 +20,10 @@ function layerOf(obj) {
|
|
|
20
20
|
case "booth":
|
|
21
21
|
case "section":
|
|
22
22
|
return "interactive";
|
|
23
|
-
// stage / décor live on 'shape'; free text
|
|
23
|
+
// stage / décor live on 'shape'; free text + decor images are background furniture.
|
|
24
24
|
case "shape":
|
|
25
25
|
case "text":
|
|
26
|
+
case "decorImage":
|
|
26
27
|
return "background";
|
|
27
28
|
default:
|
|
28
29
|
return "interactive";
|
|
@@ -251,6 +252,8 @@ function objectCenter(o) {
|
|
|
251
252
|
return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
|
|
252
253
|
}
|
|
253
254
|
return { x: o.x ?? 0, y: o.y ?? 0 };
|
|
255
|
+
case "decorImage":
|
|
256
|
+
return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
|
|
254
257
|
}
|
|
255
258
|
}
|
|
256
259
|
function floorsOf(doc) {
|
|
@@ -286,6 +289,8 @@ function translateObject(o, dx, dy) {
|
|
|
286
289
|
...o.x != null ? { x: o.x + dx } : {},
|
|
287
290
|
...o.y != null ? { y: o.y + dy } : {}
|
|
288
291
|
};
|
|
292
|
+
case "decorImage":
|
|
293
|
+
return { ...o, x: o.x + dx, y: o.y + dy };
|
|
289
294
|
}
|
|
290
295
|
}
|
|
291
296
|
function stackFloors(doc, spread = 900) {
|
|
@@ -324,6 +329,9 @@ function chartBounds(doc) {
|
|
|
324
329
|
for (const p of obj.points) acc(p.x, p.y);
|
|
325
330
|
} else if (obj.type === "section") {
|
|
326
331
|
for (const p of obj.outline) acc(p.x, p.y);
|
|
332
|
+
} else if (obj.type === "decorImage") {
|
|
333
|
+
acc(obj.x, obj.y);
|
|
334
|
+
acc(obj.x + obj.width, obj.y + obj.height);
|
|
327
335
|
} else if (obj.type === "shape") {
|
|
328
336
|
if (obj.points && obj.points.length) {
|
|
329
337
|
for (const p of obj.points) acc(p.x, p.y);
|
|
@@ -624,6 +632,7 @@ var SEAT_RADIUS = 9;
|
|
|
624
632
|
var SEAT_LEGIBLE_SCALE = 0.9;
|
|
625
633
|
var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
|
|
626
634
|
var LABEL_SCALE = 1;
|
|
635
|
+
var SEAT_TAP_SLOP_PX = 14;
|
|
627
636
|
var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
|
|
628
637
|
var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
|
|
629
638
|
var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
|
|
@@ -641,6 +650,13 @@ var ZONE_SUB_PX = 12;
|
|
|
641
650
|
var HELD_FILL = "#6b7280";
|
|
642
651
|
var TAKEN_FILL = "#374151";
|
|
643
652
|
var NFS_STROKE = "#4b5563";
|
|
653
|
+
var CLOSED_SECTION_FILL = "#586070";
|
|
654
|
+
var CLOSED_SEAT_FILL = "#4b5563";
|
|
655
|
+
var CLOSED_SEAT_OPACITY = 0.4;
|
|
656
|
+
var FOCUS_DIM_OPACITY = 0.16;
|
|
657
|
+
var FOCUS_DESATURATE = 0.72;
|
|
658
|
+
var FOCUS_NEUTRAL = "#6b7280";
|
|
659
|
+
var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
|
|
644
660
|
var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
|
|
645
661
|
var ACCESS_RING = {
|
|
646
662
|
wheelchair: "#3b82f6",
|
|
@@ -787,6 +803,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
787
803
|
this.accessFilter = null;
|
|
788
804
|
/** Category highlight (legend hover): dims free seats NOT of this category. */
|
|
789
805
|
this.categoryHighlight = null;
|
|
806
|
+
/** Price-band filter (F4): dim free seats whose category is NOT in this set. */
|
|
807
|
+
this.categoryFilter = null;
|
|
790
808
|
// Section/zone overlays (bgLayer) — the 3-rung LOD: seats → section blocks →
|
|
791
809
|
// zone blocks. Kept for the melt restyle and for hit-testing a zoomed-out tap.
|
|
792
810
|
this.sections = [];
|
|
@@ -795,6 +813,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
795
813
|
this.catPrice = /* @__PURE__ */ new Map();
|
|
796
814
|
/** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
|
|
797
815
|
this.dimmedSections = /* @__PURE__ */ new Set();
|
|
816
|
+
/** Phase 2: section/zone ids in the event-level `closed` state — flat grey
|
|
817
|
+
* block, seats greyed + not pickable, but the section stays rendered. */
|
|
818
|
+
this.closedSections = /* @__PURE__ */ new Set();
|
|
819
|
+
/** AXS section-focus: the currently-focused section id (others dim), or null. */
|
|
820
|
+
this.focusedSectionId = null;
|
|
821
|
+
/** Light backdrop panel drawn behind the focused section (removed on clear). */
|
|
822
|
+
this.focusBackdrop = null;
|
|
798
823
|
/** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
|
|
799
824
|
this.objectFloor = /* @__PURE__ */ new Map();
|
|
800
825
|
/** Zone id → colour (drives extruded side faces in iso view). */
|
|
@@ -1275,6 +1300,27 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
1275
1300
|
if (cur === null || next === null) return cur === next;
|
|
1276
1301
|
return cur.length === next.length && cur.every((t2, i) => t2 === next[i]);
|
|
1277
1302
|
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Price-band filter (F4): dim free, unselected seats whose category key is NOT
|
|
1305
|
+
* in `keys`. `null` clears the filter (all categories fully visible). The
|
|
1306
|
+
* widget resolves which categories fall inside the buyer's chosen band.
|
|
1307
|
+
*/
|
|
1308
|
+
setCategoryFilter(keys) {
|
|
1309
|
+
const next = keys === null ? null : new Set(keys);
|
|
1310
|
+
const same = next === null && this.categoryFilter === null || next !== null && this.categoryFilter !== null && next.size === this.categoryFilter.size && [...next].every((k) => this.categoryFilter.has(k));
|
|
1311
|
+
if (same) return;
|
|
1312
|
+
this.categoryFilter = next;
|
|
1313
|
+
for (const seat of this.seats) {
|
|
1314
|
+
const c = this.circleById.get(seat.id);
|
|
1315
|
+
if (c) this.paintSeat(c, seat.id);
|
|
1316
|
+
}
|
|
1317
|
+
if (this.cached) {
|
|
1318
|
+
this.seatLayer.clearCache();
|
|
1319
|
+
this.cacheSeatLayer();
|
|
1320
|
+
} else {
|
|
1321
|
+
this.seatLayer.batchDraw();
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1278
1324
|
// ---- isometric ("3D") view mode -------------------------------------------
|
|
1279
1325
|
/**
|
|
1280
1326
|
* Switch the projection between flat top-down and the isometric "3D" view
|
|
@@ -1601,12 +1647,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
1601
1647
|
if (this.categoryHighlight && status === "free" && !selected && seat.categoryKey !== this.categoryHighlight) {
|
|
1602
1648
|
c.opacity(0.25);
|
|
1603
1649
|
}
|
|
1650
|
+
if (this.categoryFilter && status === "free" && !selected && !this.categoryFilter.has(seat.categoryKey)) {
|
|
1651
|
+
c.opacity(0.22);
|
|
1652
|
+
}
|
|
1604
1653
|
if (this.dimmedSections.size) {
|
|
1605
1654
|
const sec = this.seatSection.get(id);
|
|
1606
1655
|
if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
|
|
1607
1656
|
c.opacity(0.18);
|
|
1608
1657
|
}
|
|
1609
1658
|
}
|
|
1659
|
+
if (this.closedSections.size && this.seatInClosedSection(id)) {
|
|
1660
|
+
c.fill(CLOSED_SEAT_FILL);
|
|
1661
|
+
c.stroke("");
|
|
1662
|
+
c.strokeWidth(0);
|
|
1663
|
+
c.dash([]);
|
|
1664
|
+
c.opacity(CLOSED_SEAT_OPACITY);
|
|
1665
|
+
}
|
|
1666
|
+
if (this.focusedSectionId) {
|
|
1667
|
+
const sec = this.seatSection.get(id);
|
|
1668
|
+
const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
|
|
1669
|
+
if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/** True when a seat sits in a section/zone currently marked `closed`. */
|
|
1673
|
+
seatInClosedSection(id) {
|
|
1674
|
+
if (!this.closedSections.size) return false;
|
|
1675
|
+
const sec = this.seatSection.get(id);
|
|
1676
|
+
return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
|
|
1610
1677
|
}
|
|
1611
1678
|
/**
|
|
1612
1679
|
* Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
|
|
@@ -1646,6 +1713,109 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
1646
1713
|
this.seatLayer.batchDraw();
|
|
1647
1714
|
}
|
|
1648
1715
|
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Phase 2 event-level section states: mark these section/zone ids `closed` —
|
|
1718
|
+
* flat grey block, seats greyed + not pickable, but the section stays rendered
|
|
1719
|
+
* (unlike the buyer's applyHidden which strips it). `null`/empty clears.
|
|
1720
|
+
*/
|
|
1721
|
+
setClosedSections(ids) {
|
|
1722
|
+
const next = new Set(ids ?? []);
|
|
1723
|
+
if (next.size === this.closedSections.size && [...next].every((i) => this.closedSections.has(i))) return;
|
|
1724
|
+
this.closedSections = next;
|
|
1725
|
+
this.repaintSectionsAndSeats();
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* AXS section-focus: dim + desaturate every other section, draw a calm backdrop
|
|
1729
|
+
* panel behind this section's seats, and glide the camera in to frame it (the
|
|
1730
|
+
* seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
|
|
1731
|
+
*/
|
|
1732
|
+
focusSection(id) {
|
|
1733
|
+
if (!this.sections.some((s) => s.id === id)) return;
|
|
1734
|
+
this.focusedSectionId = id;
|
|
1735
|
+
this.drawFocusBackdrop(id);
|
|
1736
|
+
this.repaintSectionsAndSeats();
|
|
1737
|
+
this.updateLOD();
|
|
1738
|
+
this.focusRegion(id);
|
|
1739
|
+
}
|
|
1740
|
+
/** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
|
|
1741
|
+
clearSectionFocus() {
|
|
1742
|
+
if (!this.focusedSectionId) return;
|
|
1743
|
+
this.focusedSectionId = null;
|
|
1744
|
+
if (this.focusBackdrop) {
|
|
1745
|
+
this.focusBackdrop.destroy();
|
|
1746
|
+
this.focusBackdrop = null;
|
|
1747
|
+
}
|
|
1748
|
+
this.repaintSectionsAndSeats();
|
|
1749
|
+
this.updateLOD();
|
|
1750
|
+
}
|
|
1751
|
+
/** The currently AXS-focused section id, or null. */
|
|
1752
|
+
getFocusedSection() {
|
|
1753
|
+
return this.focusedSectionId;
|
|
1754
|
+
}
|
|
1755
|
+
/** Draw (or replace) the light backdrop panel behind the focused section. */
|
|
1756
|
+
drawFocusBackdrop(id) {
|
|
1757
|
+
if (this.focusBackdrop) {
|
|
1758
|
+
this.focusBackdrop.destroy();
|
|
1759
|
+
this.focusBackdrop = null;
|
|
1760
|
+
}
|
|
1761
|
+
const sec = this.sections.find((s) => s.id === id);
|
|
1762
|
+
if (!sec) return;
|
|
1763
|
+
const panel = new Line({
|
|
1764
|
+
points: sec.outline.flatMap((p) => [p.x, p.y]),
|
|
1765
|
+
closed: true,
|
|
1766
|
+
fill: FOCUS_BACKDROP_FILL,
|
|
1767
|
+
stroke: rgba("#ffffff", 0.1),
|
|
1768
|
+
strokeWidth: 1,
|
|
1769
|
+
listening: false,
|
|
1770
|
+
perfectDrawEnabled: false
|
|
1771
|
+
});
|
|
1772
|
+
this.bgLayer.add(panel);
|
|
1773
|
+
panel.moveToTop();
|
|
1774
|
+
this.focusBackdrop = panel;
|
|
1775
|
+
}
|
|
1776
|
+
/** Repaint every seat + section block to reflect closed/focus state, then redraw. */
|
|
1777
|
+
repaintSectionsAndSeats() {
|
|
1778
|
+
for (const seat of this.seats) {
|
|
1779
|
+
const c = this.circleById.get(seat.id);
|
|
1780
|
+
if (c) this.paintSeat(c, seat.id);
|
|
1781
|
+
}
|
|
1782
|
+
for (const sec of this.sections) sec.blockPoly.fill(this.sectionBlockFill(sec));
|
|
1783
|
+
if (this.cached) {
|
|
1784
|
+
this.seatLayer.clearCache();
|
|
1785
|
+
this.cacheSeatLayer();
|
|
1786
|
+
} else {
|
|
1787
|
+
this.seatLayer.batchDraw();
|
|
1788
|
+
}
|
|
1789
|
+
this.bgLayer.batchDraw();
|
|
1790
|
+
}
|
|
1791
|
+
/** The world-space rectangle currently visible in the viewport (minimap F3). */
|
|
1792
|
+
getVisibleWorldRect() {
|
|
1793
|
+
const tl = this.screenToWorld({ x: 0, y: 0 });
|
|
1794
|
+
const br = this.screenToWorld({ x: this.stage.width(), y: this.stage.height() });
|
|
1795
|
+
return {
|
|
1796
|
+
x: Math.min(tl.x, br.x),
|
|
1797
|
+
y: Math.min(tl.y, br.y),
|
|
1798
|
+
width: Math.abs(br.x - tl.x),
|
|
1799
|
+
height: Math.abs(br.y - tl.y)
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
/** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
|
|
1803
|
+
getWorldBounds() {
|
|
1804
|
+
let minX = Infinity;
|
|
1805
|
+
let minY = Infinity;
|
|
1806
|
+
let maxX = -Infinity;
|
|
1807
|
+
let maxY = -Infinity;
|
|
1808
|
+
const grow = (x, y) => {
|
|
1809
|
+
if (x < minX) minX = x;
|
|
1810
|
+
if (y < minY) minY = y;
|
|
1811
|
+
if (x > maxX) maxX = x;
|
|
1812
|
+
if (y > maxY) maxY = y;
|
|
1813
|
+
};
|
|
1814
|
+
for (const s of this.seats) grow(s.x, s.y);
|
|
1815
|
+
for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
|
|
1816
|
+
if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
|
|
1817
|
+
return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
|
|
1818
|
+
}
|
|
1649
1819
|
/** Legend hover: highlight one category (dim the rest), or null to clear. */
|
|
1650
1820
|
setCategoryHighlight(key) {
|
|
1651
1821
|
if (this.categoryHighlight === key) return;
|
|
@@ -1663,6 +1833,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
1663
1833
|
}
|
|
1664
1834
|
renderBackground(doc) {
|
|
1665
1835
|
if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
|
|
1836
|
+
for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
|
|
1666
1837
|
for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
|
|
1667
1838
|
this.renderZones(doc);
|
|
1668
1839
|
for (const obj of doc.objects) {
|
|
@@ -1711,6 +1882,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
1711
1882
|
};
|
|
1712
1883
|
img.src = bg.url;
|
|
1713
1884
|
}
|
|
1885
|
+
/**
|
|
1886
|
+
* A decor graphic (rink / court / stage art). The KImage node is added to the
|
|
1887
|
+
* bgLayer synchronously so it keeps its z-slot beneath the sections drawn right
|
|
1888
|
+
* after; the bitmap is decoded async and pasted in on load. A single node = a
|
|
1889
|
+
* single drawImage per frame, and it rides the same layer cache — effectively
|
|
1890
|
+
* zero per-frame cost. Never listens, so it can't intercept a seat click.
|
|
1891
|
+
*/
|
|
1892
|
+
renderDecorImage(obj) {
|
|
1893
|
+
const img = new window.Image();
|
|
1894
|
+
const node = new KImage({
|
|
1895
|
+
image: img,
|
|
1896
|
+
x: obj.x + obj.width / 2,
|
|
1897
|
+
y: obj.y + obj.height / 2,
|
|
1898
|
+
offsetX: obj.width / 2,
|
|
1899
|
+
offsetY: obj.height / 2,
|
|
1900
|
+
width: obj.width,
|
|
1901
|
+
height: obj.height,
|
|
1902
|
+
rotation: obj.rotation ?? 0,
|
|
1903
|
+
opacity: clamp(obj.opacity ?? 1, 0, 1),
|
|
1904
|
+
listening: false,
|
|
1905
|
+
perfectDrawEnabled: false
|
|
1906
|
+
});
|
|
1907
|
+
this.bgLayer.add(node);
|
|
1908
|
+
img.onload = () => {
|
|
1909
|
+
if (!node.getLayer()) return;
|
|
1910
|
+
this.bgLayer.batchDraw();
|
|
1911
|
+
};
|
|
1912
|
+
img.src = obj.href;
|
|
1913
|
+
}
|
|
1714
1914
|
renderTable(obj) {
|
|
1715
1915
|
if (obj.shape === "round") {
|
|
1716
1916
|
this.bgLayer.add(
|
|
@@ -2032,11 +2232,32 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
2032
2232
|
}
|
|
2033
2233
|
/** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
|
|
2034
2234
|
refreshSectionFill(sec) {
|
|
2035
|
-
|
|
2036
|
-
sec.blockPoly.fill(darken(sec.baseFill, sold * SOLD_DARKEN));
|
|
2235
|
+
sec.blockPoly.fill(this.sectionBlockFill(sec));
|
|
2037
2236
|
sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
|
|
2038
2237
|
sec.subLabel.offsetX(sec.subLabel.width() / 2);
|
|
2039
2238
|
}
|
|
2239
|
+
/** True when a section/zone is currently in the `closed` event-state. */
|
|
2240
|
+
isSectionClosed(sec) {
|
|
2241
|
+
return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* The block-fill colour for a section: flat desaturated grey when `closed`,
|
|
2245
|
+
* else the availability-darkened category mix; then desaturated toward neutral
|
|
2246
|
+
* when another section holds focus (AXS dim treatment).
|
|
2247
|
+
*/
|
|
2248
|
+
sectionBlockFill(sec) {
|
|
2249
|
+
let fill;
|
|
2250
|
+
if (this.isSectionClosed(sec)) {
|
|
2251
|
+
fill = CLOSED_SECTION_FILL;
|
|
2252
|
+
} else {
|
|
2253
|
+
const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
|
|
2254
|
+
fill = darken(sec.baseFill, sold * SOLD_DARKEN);
|
|
2255
|
+
}
|
|
2256
|
+
if (this.focusedSectionId && sec.id !== this.focusedSectionId && sec.zone !== this.focusedSectionId) {
|
|
2257
|
+
fill = lerpColor(fill, FOCUS_NEUTRAL, FOCUS_DESATURATE);
|
|
2258
|
+
}
|
|
2259
|
+
return fill;
|
|
2260
|
+
}
|
|
2040
2261
|
/**
|
|
2041
2262
|
* Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
|
|
2042
2263
|
* shown at the farthest zoom in place of per-section detail. Skipped entirely
|
|
@@ -2128,12 +2349,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
2128
2349
|
const sx = this.stage.scaleX();
|
|
2129
2350
|
const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
|
|
2130
2351
|
if (rescale) this.lodScale = scale;
|
|
2352
|
+
const focus = this.focusedSectionId;
|
|
2131
2353
|
for (const sec of this.sections) {
|
|
2132
|
-
sec.
|
|
2133
|
-
|
|
2354
|
+
const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
|
|
2355
|
+
sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
|
|
2356
|
+
for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT) * dim);
|
|
2134
2357
|
sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
|
|
2135
|
-
sec.nameLabel.opacity(1 - zoneT);
|
|
2136
|
-
sec.subLabel.opacity(blockT * (1 - zoneT));
|
|
2358
|
+
sec.nameLabel.opacity((1 - zoneT) * dim);
|
|
2359
|
+
sec.subLabel.opacity(blockT * (1 - zoneT) * dim);
|
|
2137
2360
|
if (rescale) {
|
|
2138
2361
|
this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
|
|
2139
2362
|
this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
|
|
@@ -2149,8 +2372,50 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
2149
2372
|
if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
|
|
2150
2373
|
}
|
|
2151
2374
|
}
|
|
2375
|
+
this.decollideRungLabels(sx);
|
|
2152
2376
|
this.bgLayer.batchDraw();
|
|
2153
2377
|
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Greedy label de-collision for the zone/section rungs (same approach as the
|
|
2380
|
+
* designer's cullRowLabels): price/"N LEFT" sublabels are lowest priority and
|
|
2381
|
+
* drop first; name labels keep top-to-bottom, left-to-right; anything whose
|
|
2382
|
+
* on-screen box (+4px gap) overlaps an already-kept box hides. Recomputed on
|
|
2383
|
+
* every LOD pass so hidden labels reappear as zoom spreads them apart.
|
|
2384
|
+
* Culling multiplies the opacity applySectionLod just assigned (never raises).
|
|
2385
|
+
*/
|
|
2386
|
+
decollideRungLabels(sx) {
|
|
2387
|
+
const GAP = 4;
|
|
2388
|
+
const cands = [];
|
|
2389
|
+
const boxOf = (t2) => {
|
|
2390
|
+
const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
|
|
2391
|
+
const w = t2.width() * sx;
|
|
2392
|
+
const h = t2.height() * sx;
|
|
2393
|
+
return { x: p.x - w / 2, y: p.y - h / 2, w, h };
|
|
2394
|
+
};
|
|
2395
|
+
for (const zone of this.zones) {
|
|
2396
|
+
if (zone.label.opacity() > 0.05) cands.push({ node: zone.label, tier: 0, box: boxOf(zone.label) });
|
|
2397
|
+
if (zone.sub && zone.sub.opacity() > 0.05) cands.push({ node: zone.sub, tier: 2, owner: zone.label, box: boxOf(zone.sub) });
|
|
2398
|
+
}
|
|
2399
|
+
for (const sec of this.sections) {
|
|
2400
|
+
if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, box: boxOf(sec.nameLabel) });
|
|
2401
|
+
if (sec.subLabel.opacity() > 0.05) cands.push({ node: sec.subLabel, tier: 3, owner: sec.nameLabel, box: boxOf(sec.subLabel) });
|
|
2402
|
+
}
|
|
2403
|
+
if (cands.length < 2) return;
|
|
2404
|
+
cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
|
|
2405
|
+
const kept = [];
|
|
2406
|
+
const culled = /* @__PURE__ */ new Set();
|
|
2407
|
+
const collides = (b) => kept.some(
|
|
2408
|
+
(k) => b.x < k.x + k.w + GAP && k.x < b.x + b.w + GAP && b.y < k.y + k.h + GAP && k.y < b.y + b.h + GAP
|
|
2409
|
+
);
|
|
2410
|
+
for (const c of cands) {
|
|
2411
|
+
if (c.owner && culled.has(c.owner) || collides(c.box)) {
|
|
2412
|
+
c.node.opacity(0);
|
|
2413
|
+
culled.add(c.node);
|
|
2414
|
+
} else {
|
|
2415
|
+
kept.push(c.box);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2154
2419
|
/** Set a centred label's world fontSize (for a target screen px) and re-anchor it. */
|
|
2155
2420
|
sizeLabel(t2, fontSize, y) {
|
|
2156
2421
|
t2.fontSize(Math.max(1, fontSize));
|
|
@@ -2197,6 +2462,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
2197
2462
|
}
|
|
2198
2463
|
// ---- selection ------------------------------------------------------------
|
|
2199
2464
|
isSelectable(id) {
|
|
2465
|
+
if (this.seatInClosedSection(id)) return false;
|
|
2200
2466
|
const statuses = this.opts.selectableStatuses ?? ["free"];
|
|
2201
2467
|
return statuses.includes(this.statusById.get(id) ?? "free");
|
|
2202
2468
|
}
|
|
@@ -2268,19 +2534,84 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
2268
2534
|
}
|
|
2269
2535
|
}
|
|
2270
2536
|
// ---- interaction ----------------------------------------------------------
|
|
2537
|
+
/**
|
|
2538
|
+
* The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
|
|
2539
|
+
* frames this section in the current viewport. Used to decide whether
|
|
2540
|
+
* redirecting a seat tap to section-focus would actually zoom in FURTHER, or
|
|
2541
|
+
* whether the section already fills the viewport (small container) so the tap
|
|
2542
|
+
* must fall through and pick.
|
|
2543
|
+
*/
|
|
2544
|
+
sectionFrameScale(id) {
|
|
2545
|
+
const sec = this.sections.find((s) => s.id === id);
|
|
2546
|
+
if (!sec) return this.stage.scaleX();
|
|
2547
|
+
const b = polyBounds(sec.outline);
|
|
2548
|
+
const w = this.stage.width();
|
|
2549
|
+
const h = this.stage.height();
|
|
2550
|
+
const { min, max } = this.zoomBounds();
|
|
2551
|
+
const margin = 1.12;
|
|
2552
|
+
if (b.width <= 0 || b.height <= 0) return this.stage.scaleX();
|
|
2553
|
+
return clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
|
|
2554
|
+
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
|
|
2557
|
+
* then toggle. The gate (§4) redirects small on-screen seats to section-focus
|
|
2558
|
+
* to avoid blind mis-picks at overview scales — but ONLY when focusing would
|
|
2559
|
+
* zoom the seat up to a safe size. If we're already focused on the section, or
|
|
2560
|
+
* the section already fills the viewport (a narrow — OR even a wide — container
|
|
2561
|
+
* frames a big section BELOW LABEL_SCALE), redirecting is a no-op that would
|
|
2562
|
+
* leave the seat permanently unpickable (§5 `picking-gate-unreachable`), so we
|
|
2563
|
+
* fall through and PICK. This is the invariant: seats are never unpickable.
|
|
2564
|
+
*/
|
|
2565
|
+
handleSeatTap(id) {
|
|
2566
|
+
if (this.stacked && this.opts.onDeckTap) {
|
|
2567
|
+
const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
|
|
2568
|
+
if (floorId) {
|
|
2569
|
+
this.opts.onDeckTap(floorId);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
if (this.effScale() < LABEL_SCALE && this.sections.length) {
|
|
2574
|
+
const sec = this.seatSection.get(id);
|
|
2575
|
+
if (sec) {
|
|
2576
|
+
const alreadyFocused = this.focusedSectionId === sec.id;
|
|
2577
|
+
const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
|
|
2578
|
+
if (!alreadyFocused && canZoomInFurther) {
|
|
2579
|
+
if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
|
|
2580
|
+
else this.focusSection(sec.id);
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
this.toggleSeat(id);
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Nearest SELECTABLE seat whose centre is within `slopPx` (screen space) of a
|
|
2589
|
+
* seat circle edge, or null. Enlarges the effective tap target for small seats
|
|
2590
|
+
* so a near-miss on mobile still lands on the intended seat, without ever
|
|
2591
|
+
* hijacking a clean tap on empty space beyond the slop.
|
|
2592
|
+
*/
|
|
2593
|
+
nearestSeatToScreen(screen, slopPx) {
|
|
2594
|
+
const s = this.stage.scaleX() || 1;
|
|
2595
|
+
const reachWorld = this.seatR + slopPx / s;
|
|
2596
|
+
const world = this.screenToWorld(screen);
|
|
2597
|
+
let best = null;
|
|
2598
|
+
let bestD = reachWorld;
|
|
2599
|
+
for (const seat of this.seats) {
|
|
2600
|
+
if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
|
|
2601
|
+
const d = Math.hypot(seat.x - world.x, seat.y - world.y);
|
|
2602
|
+
if (d < bestD) {
|
|
2603
|
+
bestD = d;
|
|
2604
|
+
best = seat.id;
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
return best;
|
|
2608
|
+
}
|
|
2271
2609
|
wireInteraction() {
|
|
2272
2610
|
this.seatLayer.on("click tap", (e) => {
|
|
2273
2611
|
if (this.moved > 8) return;
|
|
2274
2612
|
const id = seatIdOf(e.target);
|
|
2275
2613
|
if (!id) return;
|
|
2276
|
-
|
|
2277
|
-
const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
|
|
2278
|
-
if (floorId) {
|
|
2279
|
-
this.opts.onDeckTap(floorId);
|
|
2280
|
-
return;
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
this.toggleSeat(id);
|
|
2614
|
+
this.handleSeatTap(id);
|
|
2284
2615
|
});
|
|
2285
2616
|
this.seatLayer.on("mouseover", (e) => {
|
|
2286
2617
|
const id = seatIdOf(e.target);
|
|
@@ -2306,10 +2637,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
|
|
|
2306
2637
|
const factor = Math.exp(-e.evt.deltaY * 2e-3);
|
|
2307
2638
|
this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
|
|
2308
2639
|
});
|
|
2309
|
-
this.stage.on("click tap", () => {
|
|
2310
|
-
if (
|
|
2640
|
+
this.stage.on("click tap", (e) => {
|
|
2641
|
+
if (this.moved > 8) return;
|
|
2311
2642
|
const pointer = this.stage.getPointerPosition();
|
|
2312
2643
|
if (!pointer) return;
|
|
2644
|
+
if (!this.cached) {
|
|
2645
|
+
if (seatIdOf(e.target)) return;
|
|
2646
|
+
const near = this.nearestSeatToScreen(pointer, SEAT_TAP_SLOP_PX);
|
|
2647
|
+
if (near) this.handleSeatTap(near);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2313
2650
|
if (this.stacked && this.opts.onDeckTap) {
|
|
2314
2651
|
const floorId = this.deckFloorAt();
|
|
2315
2652
|
if (floorId) {
|
|
@@ -2639,6 +2976,9 @@ var PickerController = class {
|
|
|
2639
2976
|
this._doc = null;
|
|
2640
2977
|
/** Section/zone ids hidden from buyers this event (3.3) — seats vanish, not grey. */
|
|
2641
2978
|
this.hidden = /* @__PURE__ */ new Set();
|
|
2979
|
+
/** Section/zone ids in the `closed` event-state (Phase 2) — seats stay, greyed
|
|
2980
|
+
* + not pickable. Kept separate from `hidden` (which strips them). */
|
|
2981
|
+
this.closedSections = /* @__PURE__ */ new Set();
|
|
2642
2982
|
/** label ⇄ id maps — backend speaks labels, the engine speaks ids. */
|
|
2643
2983
|
this.labelToId = /* @__PURE__ */ new Map();
|
|
2644
2984
|
this.labelToSeat = /* @__PURE__ */ new Map();
|
|
@@ -2679,6 +3019,19 @@ var PickerController = class {
|
|
|
2679
3019
|
this.renderer?.setChart(this.visibleDoc());
|
|
2680
3020
|
return true;
|
|
2681
3021
|
}
|
|
3022
|
+
/** Adopt a new closed-section set; restyle (grey + non-pickable) if it differs.
|
|
3023
|
+
* Cheap restyle — no chart rebuild — so mid-sale open/close repaints live. */
|
|
3024
|
+
syncClosed(ids) {
|
|
3025
|
+
const next = (ids ?? []).filter((x) => typeof x === "string");
|
|
3026
|
+
if (next.length === this.closedSections.size && next.every((id) => this.closedSections.has(id))) return false;
|
|
3027
|
+
this.closedSections = new Set(next);
|
|
3028
|
+
this.renderer?.setClosedSections?.(next);
|
|
3029
|
+
return true;
|
|
3030
|
+
}
|
|
3031
|
+
/** Whether a section is currently in the `closed` state (card must not open). */
|
|
3032
|
+
isSectionClosed(id) {
|
|
3033
|
+
return this.closedSections.has(id);
|
|
3034
|
+
}
|
|
2682
3035
|
currentHold() {
|
|
2683
3036
|
return this.hold_;
|
|
2684
3037
|
}
|
|
@@ -2746,9 +3099,11 @@ var PickerController = class {
|
|
|
2746
3099
|
}
|
|
2747
3100
|
this.renderer = renderer;
|
|
2748
3101
|
let seats = null;
|
|
3102
|
+
let closedIds = [];
|
|
2749
3103
|
try {
|
|
2750
3104
|
const objs = await this.api.objects(this.key);
|
|
2751
3105
|
this.hidden = new Set(objs.hidden ?? []);
|
|
3106
|
+
closedIds = (objs.closed ?? []).filter((x) => typeof x === "string");
|
|
2752
3107
|
seats = objs.seats;
|
|
2753
3108
|
} catch {
|
|
2754
3109
|
}
|
|
@@ -2759,6 +3114,8 @@ var PickerController = class {
|
|
|
2759
3114
|
}
|
|
2760
3115
|
renderer.setChart(this.visibleDoc());
|
|
2761
3116
|
if (this.opts.colorblindSafe) renderer.setColorblindSafe?.(true);
|
|
3117
|
+
this.closedSections = new Set(closedIds);
|
|
3118
|
+
if (closedIds.length) renderer.setClosedSections?.(closedIds);
|
|
2762
3119
|
if (seats) this.applySeatsMap(seats);
|
|
2763
3120
|
this.connect();
|
|
2764
3121
|
return {
|
|
@@ -2843,13 +3200,23 @@ var PickerController = class {
|
|
|
2843
3200
|
*/
|
|
2844
3201
|
categoryAvailability() {
|
|
2845
3202
|
const out = {};
|
|
3203
|
+
const closedMembers = this.closedMemberIds();
|
|
2846
3204
|
for (const [id, s] of this.seatById) {
|
|
3205
|
+
if (closedMembers.has(id)) continue;
|
|
2847
3206
|
if ((this.getStatus(id) ?? "free") === "free") {
|
|
2848
3207
|
out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
|
|
2849
3208
|
}
|
|
2850
3209
|
}
|
|
2851
3210
|
return out;
|
|
2852
3211
|
}
|
|
3212
|
+
/** Seat ids belonging to a currently-closed section (excluded from counts). */
|
|
3213
|
+
closedMemberIds() {
|
|
3214
|
+
const out = /* @__PURE__ */ new Set();
|
|
3215
|
+
const r = this.renderer;
|
|
3216
|
+
if (!r || !this.closedSections.size) return out;
|
|
3217
|
+
for (const id of this.closedSections) for (const sid of r.sectionMembers?.(id) ?? []) out.add(sid);
|
|
3218
|
+
return out;
|
|
3219
|
+
}
|
|
2853
3220
|
getGAAreas() {
|
|
2854
3221
|
const doc = this.visibleDoc();
|
|
2855
3222
|
if (!doc) return [];
|
|
@@ -3094,20 +3461,39 @@ var PickerController = class {
|
|
|
3094
3461
|
}
|
|
3095
3462
|
this.renderer?.setRung?.(rung);
|
|
3096
3463
|
}
|
|
3464
|
+
/** Price-band filter (F4): dim free seats whose category is outside `keys`
|
|
3465
|
+
* (null clears). The widget resolves which categories fall in the band. */
|
|
3466
|
+
setCategoryFilter(keys) {
|
|
3467
|
+
this.renderer?.setCategoryFilter?.(keys);
|
|
3468
|
+
}
|
|
3469
|
+
/** World-space rect currently visible + full chart bounds (F3 minimap frame). */
|
|
3470
|
+
getViewport() {
|
|
3471
|
+
const r = this.renderer;
|
|
3472
|
+
if (!r?.getVisibleWorldRect || !r.getWorldBounds) return null;
|
|
3473
|
+
return { visible: r.getVisibleWorldRect(), bounds: r.getWorldBounds() };
|
|
3474
|
+
}
|
|
3097
3475
|
/** Glide in on a section and surface its summary (same path as a section tap). */
|
|
3098
3476
|
focusSection(id) {
|
|
3099
3477
|
this.handleSectionTap(id);
|
|
3100
3478
|
}
|
|
3101
|
-
/** Zoom back out to the whole chart and clear the section-summary card. */
|
|
3479
|
+
/** Zoom back out to the whole chart and clear the section-summary card + focus. */
|
|
3102
3480
|
overview() {
|
|
3481
|
+
this.renderer?.clearSectionFocus?.();
|
|
3103
3482
|
this.renderer?.zoomToFit();
|
|
3104
3483
|
this.opts.onSectionFocus?.(null);
|
|
3105
3484
|
}
|
|
3106
|
-
/** Glide the camera into a tapped section and emit its computed summary.
|
|
3485
|
+
/** Glide the camera into a tapped section and emit its computed summary. Uses
|
|
3486
|
+
* the AXS focus treatment (dim + backdrop) when the engine supports it; a
|
|
3487
|
+
* closed section is framed but never opens a buyer card. */
|
|
3107
3488
|
handleSectionTap(id) {
|
|
3108
3489
|
const r = this.renderer;
|
|
3109
3490
|
if (!r) return;
|
|
3110
|
-
r.
|
|
3491
|
+
if (r.focusSection) r.focusSection(id);
|
|
3492
|
+
else r.focusRegion?.(id);
|
|
3493
|
+
if (this.isSectionClosed(id)) {
|
|
3494
|
+
this.opts.onSectionFocus?.(null);
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3111
3497
|
this.opts.onSectionFocus?.(this.sectionSummary(id));
|
|
3112
3498
|
}
|
|
3113
3499
|
/**
|
|
@@ -3381,6 +3767,9 @@ var PickerController = class {
|
|
|
3381
3767
|
void this.resnapshot();
|
|
3382
3768
|
this.opts.onStatusChange?.();
|
|
3383
3769
|
}
|
|
3770
|
+
if (Array.isArray(m.closed) && this.syncClosed(m.closed)) {
|
|
3771
|
+
this.opts.onStatusChange?.();
|
|
3772
|
+
}
|
|
3384
3773
|
if (m.type === "hidden") return;
|
|
3385
3774
|
if (m.seats && typeof m.seats === "object") {
|
|
3386
3775
|
this.applySeatsMap(m.seats);
|