v3.0.0

<bmx-seat-map>

A seat picker for venues of every kind: theatres, concert halls, arenas and stadiums, cinemas, lecture theatres, coaches, trains and aircraft.

20 properties · 5 events · 20 methods · 7 parts

Example

A fictional theatre: stalls in curved rows with a centre aisle, a dress circle, two boxes and wheelchair spaces with companion seats. Choose seats on the map, with the arrow keys, from the seat list, or with "Find seats" - the page holds each seat for you as a box office would (about one in fifteen has "just been taken" by someone else), and a ten-minute hold counts down once you have seats.

Show markup
<div class="row" role="group" aria-label="Venue" style="gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-block-end: 0.75rem">
  <button type="button" id="ex-sm-theatre" aria-pressed="true">Theatre</button>
  <button type="button" id="ex-sm-arena" aria-pressed="false">Arena</button>
  <button type="button" id="ex-sm-cabin" aria-pressed="false">Aircraft cabin</button>
  <label style="margin-inline-start: auto"><input type="checkbox" id="ex-sm-edit" /> Edit the layout</label>
</div>

<bmx-seat-map id="ex-sm" locale="en-GB" max-seats="8"></bmx-seat-map>
<p class="note" id="ex-sm-out" role="status">A fictional theatre: stalls in curved rows with a centre aisle, a dress circle, two boxes and wheelchair spaces with companion seats. Choose seats on the map, with the arrow keys, from the seat list, or with "Find seats" - the page holds each seat for you as a box office would (about one in fifteen has "just been taken" by someone else), and a ten-minute hold counts down once you have seats.</p>

<script type="module">
  await customElements.whenDefined('bmx-seat-map');
  const el = document.getElementById('ex-sm');
  const out = document.getElementById('ex-sm-out');

  // The same "sales" on every visit.
  let seed = 11;
  const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);

  const theatre = {
    name: 'The Harbour Playhouse',
    event: 'The Lighthouse Keeper - Saturday 14 November, 19:30',
    currency: 'GBP',
    zones: [
      { id: 'prime', name: 'Premium', price: 68, description: 'The best view of the stage' },
      { id: 'a', name: 'Band A', price: 52 },
      { id: 'b', name: 'Band B', price: 38 },
      { id: 'c', name: 'Band C', price: 24, description: 'Some seats have a restricted view' },
      { id: 'box', name: 'Boxes', price: 85 },
    ],
    stage: { label: 'Stage', shape: 'arc', x: -14, y: -3, width: 28, height: 5 },
    sections: [
      { id: 'STALLS-L', name: 'Stalls left', zone: 'b', shape: 'arc', x: 0, y: -14, bearing: -16, radius: 18, rows: 'A-N', seats: [8, 8, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 12], numbering: 'descending', seatStart: 101, rowZones: { 'A-E': 'prime', 'F-J': 'a' }, overrides: { 'N:101': { type: 'wheelchair' }, 'N:102': { type: 'companion' }, 'N:103': { type: 'wheelchair' }, 'N:104': { type: 'companion' } } },
      { id: 'STALLS-R', name: 'Stalls right', zone: 'b', shape: 'arc', x: 0, y: -14, bearing: 16, radius: 18, rows: 'A-N', seats: [8, 8, 9, 9, 10, 10, 10, 11, 11, 11, 12, 12, 12, 12], seatStart: 1, rowZones: { 'A-E': 'prime', 'F-J': 'a' }, overrides: { 'N:11': { type: 'companion' }, 'N:12': { type: 'wheelchair' } } },
      { id: 'CIRCLE', name: 'Dress circle', zone: 'a', shape: 'arc', x: 0, y: -14, bearing: 0, radius: 40, rows: 'A-H', seats: [30, 32, 34, 34, 36, 36, 38, 38], aisles: [8, 26], rowZones: { 'F-H': 'c' }, overrides: { 'H:1': { type: 'restricted', note: 'Pillar to the left' }, 'H:2': { type: 'restricted', note: 'Pillar to the left' }, 'H:37': { type: 'restricted', note: 'Pillar to the right' }, 'H:38': { type: 'restricted', note: 'Pillar to the right' } } },
      { id: 'BOX-L', name: 'Box A', zone: 'box', x: -27, y: 6, rotate: 62, rows: 2, seats: 3 },
      { id: 'BOX-R', name: 'Box B', zone: 'box', x: 27, y: 6, rotate: -62, rows: 2, seats: 3 },
    ],
    labels: [{ text: 'Main entrance', x: 0, y: 39, size: 1.4 }],
  };

  // A bowl of twenty blocks in two tiers round the floor, open at the stage end, and a standing floor.
  const lowerSeats = Array.from({ length: 17 }, (_, r) => Math.floor((60 + r * 1.2) * 0.227));
  const upperSeats = Array.from({ length: 21 }, (_, r) => Math.floor((92 + r * 1.2) * 0.227));
  const arena = {
    name: 'Riverside Arena',
    event: 'Northern Lights Live - Friday 4 December, 20:00',
    currency: 'GBP',
    zones: [
      { id: 'gold', name: 'Gold', price: 95 },
      { id: 'lower', name: 'Lower tier', price: 65 },
      { id: 'upper', name: 'Upper tier', price: 42 },
      { id: 'floor', name: 'Floor standing', price: 55 },
    ],
    stage: { label: 'Stage', x: -24, y: -58, width: 48, height: 13 },
    areas: [{ id: 'PIT', name: 'Floor standing', zone: 'floor', capacity: 2500, x: -38, y: -40, width: 76, height: 72 }],
    sections: Array.from({ length: 20 }, (_, i) => ({ id: `${101 + i}`, name: `Block ${101 + i}`, zone: Math.abs(i - 9.5) < 4 ? 'gold' : 'lower', shape: 'arc', x: 0, y: 0, bearing: -142.5 + i * 15, radius: 60, rows: 'A-Q', seats: lowerSeats, rowZones: Math.abs(i - 9.5) < 4 ? { 'J-Q': 'lower' } : {} }))
      .concat(Array.from({ length: 20 }, (_, i) => ({ id: `${201 + i}`, name: `Block ${201 + i}`, zone: 'upper', shape: 'arc', x: 0, y: 0, bearing: -142.5 + i * 15, radius: 92, rows: 'A-U', seats: upperSeats }))),
  };

  const cabin = {
    name: 'Example Air A321',
    event: 'Flight EA 204 Manchester - Lisbon, 08:15',
    currency: 'EUR',
    zones: [
      { id: 'biz', name: 'Business', price: 189 },
      { id: 'xl', name: 'Extra legroom', price: 34 },
      { id: 'std', name: 'Standard', price: 12 },
    ],
    stage: { label: 'Front', x: -4, y: -5, width: 8, height: 2.5 },
    sections: [
      { id: 'BIZ', name: 'Business', zone: 'biz', shape: 'cabin', x: 0, y: 0, rows: '1-3', columns: 'AC DF', seatPitch: 1.5, rowPitch: 1.7 },
      { id: 'MAIN', name: 'Economy', zone: 'std', shape: 'cabin', x: 0, y: 6.5, rows: '4-12,14-33', columns: 'ABC DEF', exitRows: ['15', '16'], rowZones: { '4-6': 'xl', '15-16': 'xl' } },
    ],
  };

  const venues = { theatre, arena, cabin };
  // Seats already sold, in small groups as people buy them.
  const rowsOf = spec => String(spec).split(',').flatMap(part => {
    const [a, b = a] = part.split('-');
    if (/^\d+$/.test(a)) return Array.from({ length: Number(b) - Number(a) + 1 }, (_, i) => String(Number(a) + i));
    return Array.from({ length: b.charCodeAt(0) - a.charCodeAt(0) + 1 }, (_, i) => String.fromCharCode(a.charCodeAt(0) + i));
  });
  const sold = {};
  for (const [name, layout] of Object.entries(venues)) {
    sold[name] = [];
    const share = name === 'arena' ? 0.62 : name === 'cabin' ? 0.5 : 0.45;
    for (const section of layout.sections) {
      rowsOf(section.rows).forEach((row, r) => {
        const labels = section.shape === 'cabin'
          ? [...section.columns.replace(/ /g, '')]
          : Array.from({ length: Array.isArray(section.seats) ? section.seats[Math.min(r, section.seats.length - 1)] : section.seats }, (_, i) => String((section.seatStart ?? 1) + i));
        for (let i = 0; i < labels.length; ) {
          if (random() < share / 2.5) {
            const run = 1 + Math.floor(random() * 4);
            for (let k = 0; k < run && i < labels.length; k++, i++) sold[name].push(`${section.id}:${row}:${labels[i]}`);
          } else i += 1 + Math.floor(random() * 3);
        }
      });
    }
  }

  let current = 'theatre';
  const show = name => {
    current = name;
    for (const id of ['theatre', 'arena', 'cabin']) document.getElementById(`ex-sm-${id}`).setAttribute('aria-pressed', String(id === name));
    el.layout = venues[name];
    el.availability = { sold: sold[name], held: name === 'theatre' ? ['CIRCLE:C:14-17'] : [], areas: { PIT: 1830 } };
    el.ticketTypes = name === 'cabin' ? [] : [
      { id: 'adult', name: 'Adult' },
      { id: 'child', name: 'Child (under 16)', factor: 0.5, zones: ['a', 'b', 'c', 'lower', 'upper'] },
      { id: 'conc', name: 'Concession', factor: 0.8 },
    ];
    el.fees = name === 'cabin' ? [] : [{ label: 'Booking fee', perTicket: 1.5 }];
    el.holdUntil = undefined;
  };
  show('theatre');

  for (const id of ['theatre', 'arena', 'cabin']) document.getElementById(`ex-sm-${id}`).addEventListener('click', () => show(id));
  document.getElementById('ex-sm-edit').addEventListener('change', e => (el.mode = e.target.checked ? 'edit' : 'pick'));

  // The page holds each seat on its "server" first, as a box office would.
  el.addEventListener('bmxSelect', e => {
    if (e.detail.action !== 'add') return;
    e.preventDefault();
    const id = e.detail.seat.id;
    out.textContent = `Holding ${e.detail.seat.name}…`;
    setTimeout(() => {
      if (random() < 1 / 15) {
        el.setSeatStatus([id], 'sold');
        out.textContent = `${e.detail.seat.name} has just been taken by someone else.`;
        return;
      }
      el.select([id]);
      if (!el.holdUntil) el.holdUntil = new Date(Date.now() + 10 * 60000).toISOString();
      out.textContent = `${e.detail.seat.name} is held for you.`;
    }, 250);
  });
  el.addEventListener('bmxSelectionChange', e => {
    const { seats, areas, totals } = e.detail;
    if (!seats.length && !areas.length) el.holdUntil = undefined;
    console.info('Order:', seats.map(s => `${s.id} ${s.ticketType ?? ''} ${s.price}`), areas, totals);
  });
  el.addEventListener('bmxCheckout', e => {
    out.textContent = `Checkout: ${e.detail.totals.count} tickets, ${new Intl.NumberFormat('en-GB', { style: 'currency', currency: e.detail.currency }).format(e.detail.totals.total)}. A real page goes to its payment step here.`;
  });
  el.addEventListener('bmxHoldExpire', () => {
    el.clearSelection();
    out.textContent = 'The hold ended, so the seats went back on sale.';
  });
  el.addEventListener('bmxLayoutChange', e => {
    venues[current] = e.detail.layout;
    console.info('Layout changed:', e.detail.reason);
  });
</script>

WHAT IT DOES

  • Draws the venue from a compact layout: sections of straight rows, rows curved round a centre, or cabins of lettered columns; standing areas with a number of places; price zones; the stage; labels. Tens of thousands of seats stay quick: each frame draws only what is in view, and from afar the sections are drawn as blocks with what is left in each.
  • Customers choose seats by click, tap or keyboard, or ask for the best available - the nearest block side by side in one row, in a zone, with a wheelchair space if they need one. Rules keep the order sound: how many seats, no single seat stranded beside theirs, a companion seat only with a wheelchair space.
  • Prices by zone and ticket type (adult, child, concession), fees, a total, and an optional countdown of the hold the page placed.
  • The page stays in charge: bmxSelect can refuse a seat (or hold it on the server first), setAvailability brings in seats others have taken since, and bmxCheckout hands over the order. The element keeps nothing and sends nothing anywhere.
  • mode="edit" lays the venue out: add sections, rows and areas, move and turn them, paint zones, mark wheelchair, companion and restricted-view seats, remove seats for pillars and gangways, undo, open and save JSON.

ACCESSIBILITY

The map is a multi-select listbox whose focused seat is announced by name, zone, price and state; the arrow keys move across the map as it looks. The seat list shows the same seats as lists of buttons, row by row.

Properties

PropertyAttributeTypeDefaultDescription
availability availability BmxSeatAvailability | string — Seats sold, held and not on sale, and places left in standing areas (an object, or its JSON).
compactBelow compact-below number 760 Below this width (pixels) the summary moves under the map.
companionRule companion-rule 'block' | 'warn' | 'off' 'warn' A companion seat chosen without a wheelchair space: block, warn or off.
continueLabel continue-label string — The words on the summary's main button.
fees fees readonly Partial<BmxSeatFee>[] | string — Fees added to the order: per ticket, per cent or per order (a list, or its JSON).
finder finder boolean true Show the best-available finder.
holdUntil hold-until string — When the page's hold on the chosen seats ends (ISO date and time): a countdown is shown.
label label string — The element's accessible name.
layout property only unknown — The venue: zones, sections, standing areas, stage and labels (an object, or its JSON).
locale locale string — The locale for prices and numbers. Unset: the page's lang.
maxSeats max-seats number 10 Most seats and places in one order.
minSeats min-seats number 1 Fewest seats and places in one order.
mode mode BmxSeatMapMode 'pick' pick (choose seats), view (look only) or edit (lay the venue out).
selected selected readonly string[] | string — The chosen seats' ids (a list, or its JSON). Kept up to date as the customer chooses.
showLegend show-legend boolean true Show the key of zones and seat kinds.
showSummary show-summary boolean true Show the chosen seats, prices and total beside the map.
singleGap single-gap 'block' | 'warn' | 'off' 'block' A single seat left empty beside the customer's: block the order, warn, or off.
strings strings Partial<BmxSeatMapStrings> | string — Replacements for any of the wording (an object, or its JSON).
ticketTypes ticket-types readonly Partial<BmxSeatTicketType>[] | string — Ticket types (adult, child, concession), each with prices or a factor of the zone's price (a list, or its JSON).
view view BmxSeatMapView 'map' The map, or the seat list.

Events

EventDetailDescription
bmxCheckout BmxSeatMapSelection Fired when the customer presses Continue on an order with nothing stopping it.
bmxHoldExpire void Fired when the time in hold-until runs out.
bmxLayoutChange BmxSeatMapLayoutDetail Fired when the editor changes the layout.
bmxSelect BmxSeatMapSelectDetail Fired before a seat is chosen or let go by the customer. Cancelable: cancel to refuse it, or to hold it on the server and then call select.
bmxSelectionChange BmxSeatMapSelection Fired whenever the order changes: seats, places, ticket types, prices, problems.

Methods

MethodSignatureDescription
check check() => Promise<BmxSeatProblem[]> What is wrong with the order.
checkLayout checkLayout() => Promise<BmxSeatLayoutIssue[]> What the issues are with the layout (empty sections, sections drawn over each other, zones with no price).
clearSelection clearSelection() => Promise<void> Let every seat and place go.
deselect deselect(ids: readonly string[] | string) => Promise<void> Let seats go.
findBest findBest(search?: Partial<BmxSeatSearch> & { select?: boolean; }) => Promise<string[] | null> The best block of seats together; with select, they replace the order. Returns their ids, or null.
focusSeat focusSeat(id: string) => Promise<boolean> Move the keyboard's seat to a seat, and focus the map.
getLayout getLayout() => Promise<Record<string, unknown>> The layout as JSON-ready data (with any edits).
getSelection getSelection() => Promise<BmxSeatMapSelection> The order: seats with their prices and ticket types, standing places, totals and problems.
getStats getStats() => Promise<ReturnType<typeof seatStats>> Counts by zone and section: total, free, prices.
redo redo() => Promise<boolean> Redo the last edit undone.
select select(ids: readonly string[] | string, options?: { replace?: boolean; }) => Promise<string[]> Choose seats (ids or patterns such as STALLS:F:11-14). Returns the ids chosen. Does not fire bmxSelect.
setAvailability setAvailability(availability: BmxSeatAvailability | string, options?: { merge?: boolean; }) => Promise<void> New availability: seats sold, held or not on sale, places left in areas. With merge, only the seats named change (others keep theirs). Chosen seats that are no longer free are reported as problems and announced.
setFocus setFocus() => Promise<void> Focus the map (or the seat list).
setLayout setLayout(layout: unknown) => Promise<void> Replace the layout (an object, or its JSON).
setQuantity setQuantity(area: string, quantity: number) => Promise<number> Places in a standing area for this order.
setSeatStatus setSeatStatus(ids: readonly string[] | string, status: BmxSeatStatus) => Promise<void> Set seats' status (ids or patterns).
setTicketType setTicketType(seat: string, type: string) => Promise<boolean> A chosen seat's ticket type.
toPng toPng(scale?: number) => Promise<Blob | null> The map as a PNG picture, the whole venue at a readable size.
undo undo() => Promise<boolean> Undo the last edit.
zoomTo zoomTo(target?: string) => Promise<void> Show a section, area or seat (by id), or everything.

CSS shadow parts

PartDescription
finder The best-available finder.
legend The key of zones and seat kinds.
list The seat list.
map The map.
properties The editor's properties panel.
summary The chosen seats, prices and total.
toolbar The bar above the map.

CSS custom properties

PropertyDescription
--bmx-seat-map-accent The main button and chosen controls.
--bmx-seat-map-background The map's ground.
--bmx-seat-map-height Height of the map.
--bmx-seat-map-radius Corner radius of the frame.
--bmx-seat-map-section The ground under a section's seats.
--bmx-seat-map-selected The customer's own seats.
--bmx-seat-map-selected-text The tick on the customer's seats.
--bmx-seat-map-side-width Width of the summary or properties panel.
--bmx-seat-map-stage The stage.
--bmx-seat-map-stage-text The stage's label.
--bmx-seat-map-unavailable Seats sold, on hold or not on sale.