Skip to content

Placement (yoyaku_placement)

yoyaku_placement adds numbered places on top of the pooled-capacity core: a booking names a specific physical place, and that place is booked at most once per slot. Who names it, the booker or the house, is a per-slot decision.

Model

  • A venue (yoyaku_venue) is a physical place - a concert hall, a theatre, a stadium. Its layout (sections and places) is defined once on the venue and reused by every resource that runs there. A resource (say a concert, with its representations as slots) points at the venue it runs in, via an optional venue reference this module adds to resources; a slot inherits the venue from its resource.
  • A section (yoyaku_section) is a named area of a venue - orchestre, corbeille, balcon, paradis - defined once and stable across slots. Its public label is translatable, so a multilingual site shows each visitor the section name in their language. It also states its default mode, how it is normally booked (placed, assigned or pooled), which a configuration overrides per slot.
  • A place (yoyaku_place) is a physical unit of a venue, stable across slots. Its printed identity is two labels, a row (A) and a place (12), concatenated into the place number (A12); a full ticket prefixes them with the section label. Separately, a position integer gives the physical order in the row - it drives keyboard navigation and adjacency and need not match the printed place, since a row may skip numbers or number by side. The place also carries its section (physical area), its grade (below), its map geometry (x/y/angle), and a subsection number - a numbered part of its section that a configuration can close on its own (so half a section can be shut). Every place is in a subsection: an undivided section keeps all of its places in subsection 1, and dividing it puts the extra parts in 2 and up. So a place's number is never NULL, which is what lets a query narrow places by subsection without spelling out the NULL case beside every comparison - a NOT IN is neither true nor false for a NULL row and would drop it. It is never 0 either: 0 belongs to the section-setting table alone, where it means a decision about the whole section rather than one part. It has no price: price varies per slot and lives outside this layer.
  • A section also carries the names of its subsections, as a translatable subsection_names field holding one name per line: line 1 names subsection 1, line 2 names subsection 2. So a screen offering the front of the balcony can call it what the house calls it instead of "Balcon, subsection 2". A blank line leaves that subsection unnamed and shifts nothing after it, which is why the names are one field of lines and not one field item each - a multi-value field discards an empty item on save, so naming the second part and not the first would have slid the second name onto the first. Leave the whole field empty for a section that is not divided. It names an area and never identifies one: a place, a section setting and a booking all go on carrying the bare number, so a name is only ever a name. The names live on the section rather than in records of their own because every screen that names an area has already loaded the section, so naming one costs no query at all; yoyaku_placement.area_name is the one reader that chooses between the three shapes, so the picker, the area list, the settings form, the numbering report and the layout guides cannot end up calling one area five things. Two questions reach it, and a bare number means something different in each. A setting names the part it decides about and leaves the number out for all of it, so every number it carries is one somebody scoped it to: label(). A place always carries one, and the first is where an undivided section keeps every place, so an unnamed first subsection reads as its section rather than labeling a hall that was never divided "Parterre 1": getPlaceLabel(). A name authored for that part still wins over both. Row and position are what make "places together" definable: adjacent places are consecutive positions in one row.
  • A grade (yoyaku_grade) is the venue's quality-of-viewing band for a place - Optima, Cat. 1 … Cat. 9, the color bands an opera prints on its plan. It reflects how good the place is and cuts across sections (one section holds several grades, one grade spans several sections). Operators name their own grades, so the label is free text and translatable, and a color drives the venue map. It is deliberately not called a tariff.
  • A placement configuration (yoyaku_configuration) is a named way of opening a venue - full house, reduced capacity, choir on stage, standing floor. The same physical places are reused; a configuration records only its deviations, one section setting per area, saying whether the area is offered place by place, offered as a pool, or not offered at all. It deviates nowhere by default, so it stays a short list, never a copy of the map.
  • A booking points at a place ((slot, place)): place A12 is held for this slot, and the place is booked at most once per slot. A booking in a pooled area has no place to point at, so it records the area instead ((slot, section, subsection)) and is bounded by that area's capacity.

This keeps the physical layer clean and shared: where (venue, sections), which place and how good (grade). Price is per-slot: a tariff (yoyaku_resource_tariff, the booking tariff) groups one or more grades and prices them, so a slot prices places by viewing quality and can merge grades it does not distinguish. Because a venue is shared across slots, that pricing lives on the tariff (per resource), never on the shared place (see Not yet).

Place uniqueness

PlaceBookable enforces that a physical place is taken at most once per slot, inside the locks the hold holds on that part of the house, so concurrent requests for the same place cannot both win. The slot's own seat rows carry the answer: claiming a seat moves a row that is still free, and a hold that moves fewer rows than it claimed is taken back whole rather than committed on top of the booking that got there first.

The same rows refuse a writer that never asked. Every booking passes them on its way to being saved, so a save that would take a seat somebody else holds, from an operator screen, a migration or an API call, is refused there instead of landing quietly beside the booking already on it.

Underneath both is a key the code cannot cross. A booking carries the seat it consumes, as the slot and the place together, and a unique key over that column makes two consuming bookings on one seat impossible rather than unlikely. It matters because everything above reads rows that are derived: a slot drawn before this module was installed carries none, and anything writing bookings by hand leaves them saying whatever they said before, so a row that lies is a guard that agrees. What keeps them honest is below. The column is empty whenever a booking consumes nothing or names no seat, and repeated empties do not collide, so released, canceled and expired bookings stop competing for their old seat, and a pooled unit or a hall without seats is outside the rule entirely. A hold that meets the key is refused the way a full house refuses rather than failing.

The generic engine capacity (slot and per-tariff quotas) still applies independently for resources that use it; it is orthogonal to place identity.

GradeMatchesTariff links the physical grade to the price: when a booking carries both a place and a tariff that lists grades, the place's grade must be one of them, so an Optima place cannot be booked under a tariff priced for a lower grade. A tariff that lists no grades prices any place.

The graphical place picker shows each tariff's price resolved through the payment policy (yoyaku_payment): the same price the cart charges, honoring the tariff payment-inheritance and any per-slot override, so the picker and checkout never disagree.

Checking and repairing a slot's seats

A slot carries one row per seat of its hall, and every read on the booking path asks those rows. So a seat with no row is a seat nobody can take: it is not offered, and it is not reported as taken either. A slot missing all of them reads as a full house, which at least looks wrong to somebody. A slot missing a handful looks like a slot.

Both are reachable. A site whose code moved forward without the module being reinstalled has none of the rows, and a hall drawn in the moments a save failed halfway, a table restored from a backup taken before some of it existed, or rows removed by hand while chasing something else leaves some of them.

Saving a slot reconciles it. The seats its hall has and the rows it offers are compared, what is missing is added, and what belongs to a seat the hall no longer has is settled. Set operations rather than a walk, so it is a fixed handful of statements whatever the size of the house: measured on a hall of 80000 places, saving a slot costs about 0.35 s and reading the check about 0.1 s, against 26 ms and 9 ms on a hall of 8000. A slot is saved by an operator in the quiet weeks rather than by a booker during a rush, and a slot of 80000 places that had lost every row was written back in under a second.

A row the repair adds is free only where no booking holds that seat: the bookings are read in the same pass. A repair that opened a seat somebody was sitting in would not restore availability, it would invent it, and the seat would be booked twice. For the same reason a house that is legitimately full is left exactly as it is.

Each row says one of three things, and the third is what a deleted seat needs:

State What it means
free Nobody holds it and the hall still has it. The only state a booker may be offered.
taken A consuming booking of this slot holds it.
gone The hall no longer has it, and a booking still does.

A seat only leaves a hall when somebody deletes it, and deleting one a booking holds is refused while the booking is still in reach to be moved. Where a seat has left anyway, by being moved to another hall or by a hand that went around the entity, the repair keeps its row and marks it gone rather than dropping it or freeing it: dropping it would leave the booking naming nothing, and freeing it would offer a seat that is not in the building to somebody else. Every read asks whether a seat is free, so a gone seat is out of the offer, out of the section counts and out of the seating without a single query changing. Releasing that booking drops the row, which is the moment nothing needs it any more.

Two doors ask the question deliberately, in the manner of the venue's Numbering tab:

  • the slot's Seats tab says how many seats have no row, how many rows are for seats the hall no longer has, and how many rows disagree with what is booked. Reading it changes nothing. A Repair the seats button beside the findings does the work, through the Batch API rather than in the request that pressed it: one slot of a stadium is eighty thousand rows, and a repair that timed out half way would leave the state it is meant to cure.
  • drush yoyaku:slot-seats reports the same for one slot or for every slot played in a hall, and exits non-zero when it finds anything, so a deployment can gate on it. Add --fix to repair what it finds. This is the door an upgrade needs: a site that arrives with none of the rows has as many slots as it has evenings, and nobody wants to press a button once per evening.

Running either twice changes nothing the second time: both compare what is there rather than rewriting it.

How it works

The core engine carries a generic per-booking fields payload: BookingManager::hold([['slot' => $slot, 'quantity' => 1, 'fields' => ['place' => $id]]]) writes the place onto the booking and passes it to the constraints. The engine stays place-agnostic; yoyaku_placement supplies the place field and the PlaceBookable constraint that reads it under the lock.

What the map payload costs

The picker is drawn from one payload, and that payload has two halves with very different lifetimes. The venue's inventory - every place with its coordinates, its grade and its printed number, plus the grades and sections for the legend - changes only when someone edits the venue. The slot's state - which seats are gone, which are already in this visitor's basket, what each tariff charges - changes with every booking.

So the inventory is cached per venue and the state is read per request. A hall of a few thousand seats therefore costs two cache gets and two indexed queries instead of loading every place (and every booking) it owns. The map renders inside a form, which is rebuilt on every request, so this happens in VenueMapBuilder rather than through render caching.

The entry is keyed by the venue alone, not by the placement configuration: the configuration is applied when the entry is read, so every way of opening a hall shares one cached inventory. Area names are translated, so those live in a small second entry keyed by language as well; the coordinates are shared by every language. Invalidation is by list tag (yoyaku_place_list, yoyaku_section_list, yoyaku_grade_list, plus the venue), which is safe to be blunt about because nothing writes inventory while a slot is being booked.

What the plan offers is what the front being served offers. A tariff naming channels is drawn only where one of them is served, the same rule every other list of offers goes through, and it is asked once: the map's tariffs, an area's bookable tariffs and the per-order allowances all come off one read.

What a basket already holds is not an offer, and is not narrowed. A tariff withdrawn from a front while somebody holds a seat at it travels with that basket, in the half built per visitor, so their seat keeps the offer it was taken at and their total keeps its price. Nothing more may be taken of it: it is listed on the seats holding it and on no others, and its stepper goes no higher than what they already have.

hook_yoyaku_placement_map_alter() runs on the slot's settled half, before it is kept. What it fills is settled too: the prices, and the mention of whatever is added to them, are what an operator changes rather than what a booking moves, and holding them is the point, since resolving a hall's prices once per visitor is most of what a rush pays for.

What makes them safe to hold is the key. The entry is per slot, so one slot's price is never shown on another's map; per content language, so a label is never read in the wrong one; and per booking channel, so the terms one front quotes are never printed on the front next door. Those three are the whole of what an implementation of the hook may vary by, and the entry names the front it quotes among its own dependencies, so rewording what a front charges retires it.

The background artwork is not in the payload at all. See The artwork is a file of its own below.

The map is fetched, not inlined

GET /booking/venue-map/{yoyaku_venue} returns the venue's map as JSON: the plan, the sprites, the legend and every drawable place with its coordinates. GET /booking/venue-map/{yoyaku_venue}/{yoyaku_configuration} returns the same under a placement configuration, so a reduced-capacity layout never publishes the seats it has closed. Both need place booking, the picker page's own permission, or administer yoyaku for the pinning picker, which draws the same hall and books nothing. A configuration belonging to another venue is a 404 rather than a nonsense answer.

The picker page names that URL and carries only what moves: which seats are gone, which are the visitor's own and at what tariff, the pooled areas, the tariffs and where to post a click. On a 1,422-seat hall that took the page from about 293 KB of inline payload to under a kilobyte, with the map fetched once and kept.

The map is fetched once, not once per visit

The URL the picker names carries a version: ?v=fr.41. It is the content language plus the invalidation checksum of everything the map is made of, so editing a place, a grade, a section or a layout moves every URL naming that map, and nothing else does.

That is what lets the endpoint answer Cache-Control: public, max-age=31536000, immutable. A browser holding a map is holding the answer to a question that included which version it wanted, so it can keep it for a year and still never show a hall that has been redrawn: the redrawn hall is asked for under a different URL. Neither a lifetime to tune nor a staleness window to accept.

Whether a shared cache may keep it is asked of the site, once, and not of the visitor: if the anonymous role holds place booking, the map is already fetchable by anyone without logging in, so a proxy keeping one copy for everybody publishes nothing new. It is public then, for the booker with a basket as much as for the passer-by. On a site where booking needs a login it is private, and the map goes no further than the browser that asked.

Asking it per request instead would be the same answer at a much worse price. It would have to be declared with Vary: Cookie, and a proxy keys its copy on the whole Cookie header: any site carrying an analytics or consent cookie hands out a different one per visitor, so the shared copy would be shared with nobody. There is nothing to vary on here, and no Vary is sent.

Stamping the URL costs the picker page nothing, and that is a constraint on what may go into the stamp rather than a happy accident. Every tag it is made of is one the page has already read: the venue with its places, grades and sections when it read the geometry, the layouts and their areas when it read the settled state. A tag nobody else wanted would be a lookup of its own on every view of the page a rush lands on. VenueMapVersionTest counts the statements and fails on one.

A map asked for without a version is served as before, held by nothing in particular: an old link cannot freeze a layout in somebody's browser for a year.

The endpoint answers with a plain response rather than one declaring its own cacheability, and that is what makes the header above stick. Core has the last word on Cache-Control: it overwrites a response that declares cacheability whenever the request is not one a shared cache could have answered, which is every visitor with a session open, and holding a seat opens one. A plain response it asks first and leaves alone. VenueMapCacheControlTest asserts both halves from one dispatch.

Nothing is lost by that, because the finished body is held a layer down instead: VenueMapBuilder::getMapDocument() keeps the encoded JSON per venue, layout and language, retired by the same tags the version is made of. One entry serves every visitor, where the page caches would have kept one copy for visitors with a session and another for those without, and what it holds is the encoded string rather than a whole response object.

hook_yoyaku_placement_map_inventory_alter() is the seam for anything venue-stable another module wants on the map. What it adds is shared by every production and every visitor, so nothing slot-scoped, session-scoped or read from the request may enter there; that belongs in hook_yoyaku_placement_map_alter(), whose half is kept per slot, per language and per front. Anything belonging to one basket belongs in neither, and has hook_yoyaku_placement_map_basket_alter(): that half is built per request and never kept, so it may vary by anything at all about the visitor asking.

The artwork is a file of its own

The map says where the seats are; the background artwork is the picture they sit on, and it is fetched separately from the venue's own uploaded file. The picker page names both URLs, so the browser asks for the map and the artwork at the same moment rather than one after the other.

The artwork belongs to the venue, while the map is per configuration, so keeping them apart means one copy of the picture serves every configuration and every production in the hall, and the browser reuses it when a visitor moves between two productions there. Inlined in the map, the same artwork was delivered again for each of them: on a 1,422-seat hall it was 19,938 bytes, byte-for-byte identical in each configuration's document.

It is injected into the page, not linked as an image, and that is a requirement rather than an implementation detail. The seats are drawn as <use> references to a <symbol> the artwork defines, and such a reference resolves only within the same document, so an <img> would leave every seat invisible.

The URL carries the artwork's modification time, so replacing the picture replaces what visitors see. Uploading through the venue form needs no help, since a new upload is a new file with a new name; the venue importer is the case that does, because it overwrites the artwork in place. Either way the URL moves, and a browser holding the old picture asks for the new one.

A venue that offers only pooled areas has no artwork and needs none. One that has artwork but cannot fetch it shows the failure message instead of a plan: without it there is no coordinate space to place a seat in.

Managing a venue

For building a plan from nothing, start with Building a venue map, which walks through the artwork, the seat coordinates and the booking setup in the order the work is done. This section describes the screens it uses.

yoyaku_placement attaches its own admin UI (it does not need yoyaku_ui; when that module is present the pages appear under its Booking menu). Venues are listed at /admin/booking/venue. A venue owns its sections, place grades, places and configurations, so none of those are cross-venue lists: each venue has Sections, Place grades, Places and Configurations tabs, and everything is added, edited and removed from there, scoped to that venue.

A venue page carries two rows of tabs. The top row is the venue itself (Edit), what runs in it (Schedule), how it is built (Inventory) and, where the site has the translation UI, Translate. The seven screens for building it sit under Inventory: Sections, Place grades, Places, Layout, Numbering, Configurations and Pinnings. Two rows rather than one because ten tabs fill the row at a normal window width, and a language with longer words than English wraps every label onto two or three lines.

Every field naming one of those records is narrowed the same way. A place is offered the sections and place grades of its own venue; a venue's Default placement configuration and Default pinning offer its own configurations and pinnings. Each is a list to pick from, in the order that venue's own screen was arranged into, and naming another venue's record is refused rather than merely left unoffered. These names repeat from hall to hall, since two halls both have an Orchestre, a Cat. 1 and a Full house, and a place given another hall's section is drawn nowhere and priced by nothing.

Set a venue up once:

  1. Add a venue (the hall).
  2. On its Sections tab, add the sections (Parterre, Balcon, Galerie…); on its Place grades tab, add the grades (Optima, Cat. 1…). Both are prefilled with the venue.
  3. On its Places tab, use Generate places: pick one of the venue's sections and an optional grade, enter the rows (a comma list, each item a label or a range such as A-F or 1-10) and the places per row. Each place gets its row plus a place equal to its position number, forming the place number (A12); running it again skips places that already exist, so it is safe to extend a layout.

The Places tab lists the hall section by section, then row by row, then along each row, and filters by section, subsection and row. A hall holds well over a thousand places, so the filter is how one row is reached; it lives in the URL, so a narrowed list can be linked, paged and returned to after editing a place.

Point a resource at the venue (the resource's Venue field), and its slots inherit the layout.

Placement configurations

A venue can be opened in more than one way. A placement configuration says how each area of the venue is offered, reusing the same physical places - so "reduced capacity" is just a configuration that closes the balcony, with no places duplicated. It stores only its deviations, as section settings, so a configuration that decides nothing is the full venue.

A section setting (yoyaku_configuration_section) is one decision about one area: offer it place by place (placed), offer it as a capacity with no named place (pooled), or keep it out of bookings (closed). It addresses either a whole section or one subsection of one, so the front of a parterre can be pooled while its back is shut and the balcony stays on seats.

Manage them on the venue's Configurations tab. A configuration has its own Areas tab listing the settings it holds, one row per area it decides about, with Edit and Delete on each and Add area under them, the same way a venue manages its sections, grades and places. An area with no setting needs no row: it follows its section, so a configuration that decides nothing is the full venue.

Adding one asks for the area as a single choice, listing the sections of the venue and the subsections its places carry, each under the name it has been given, so there is no slice number to look up; an area the configuration already decides about is not offered, since one area gets one setting. The mode is placed, assigned, pooled or closed, and the pool capacity and grade appear only for a pooled area, being read only then. Beside them the form says how many places the area is drawn with, which is the number a capacity for unreserved seating is meant to match and the capacity an assigned area has.

The mode of an area is resolved, most specific first:

  1. the setting for its own subsection, when the configuration has one;
  2. the section-wide setting;
  3. the section's own Default mode field (placed, assigned or pooled);
  4. placed.

Subsection numbers are per section, so each section numbers its own slices independently. A slice with no setting follows its section, which is why closing a section closes its slices with it.

A resource runs under one configuration, resolved in order:

  1. the resource's own Placement configuration field, when set;
  2. otherwise the venue's Default placement configuration;
  3. otherwise none - every section follows its own default mode.

So set a venue default for the common case and only override it on a resource that needs a different layout. Places the configuration does not offer as places (closed, or pooled) are dropped from the picker and the venue map, and PlaceBookable refuses a hold on one under the slot lock, so such a place can never be booked as a place by any path.

How an area is offered

Two questions decide the mode, and posture is neither of them: does a booking name a place, and where it does, who points at it.

Mode Names a place Who chooses it
Placed yes the booker, on the plan
Assigned yes the house, inside the area the booker took
Pooled no nobody: it is a capacity
Closed not bookable at all

An unreserved seated section and a standing hall behave identically for booking (a capacity, and no named place), which is why pooled covers both and posture is prose for the booker. That also keeps a name like "standing" out of the schema, which would date the first time an unreserved seated balcony is modeled.

Because the mode lives on the configuration, the same section is placed for one slot and pooled for the next: a hall plays one night with a numbered parterre and the next with a standing floor while its balconies stay seated in both, out of one venue and one set of places.

A pool's capacity is the number its setting declares, else the number of places the area holds. So a section of numbered places offered unreserved needs no number typed, while a standing floor that was never drawn as places needs one. A slice that carries its own setting is carved out of its section, and out of the section's pool with it.

A pool's grade is what lets a tariff price it, since a tariff prices grades: the grade its setting declares, else the grades of the places the area holds. A declared grade is the only way to price an area holding no places, and it is required when a declared capacity covers places of several grades, since the capacity could not otherwise be attributed to one of them. A pool of ungraded places is priced by no grade, so only a tariff naming none can offer it.

A booking of a pooled unit records the area rather than a place: its section, and its subsection when the pool is one slice. That pair is the pool's identity. It is recorded rather than a pointer to the setting, because a setting is a per-slot decision that gets edited and deleted while a ticket has to keep meaning something.

SectionPoolBookable bounds those bookings by the pool under the slot lock, exactly as PlaceBookable bounds a place. What counts against a pool is every consuming booking in the area: the pooled ones that name it, and the placed ones whose places sit inside it. The second is what makes an area safe to pool after some of its places were booked one by one; without it the area would offer those seats again, overbooked by exactly the number already gone.

The first of those two is a counter on the area's anchor row, kept in step by the booking entity, so the bound reads a row rather than adding up the bookings inside the lock. It counts units rather than rows, because a booking holding units of an area holds as many as it says, and it is kept at the grain the booking recorded: a house pooling one slice of a part and offering the rest of that part its own way has two numbers, and neither absorbs the other.

Availability follows the same split: a pooled area contributes its remaining capacity, rather than its places contributing themselves, so a venue can bound a tariff while holding no place at all (see Capacity and holds).

A booking by quantity draws on places first and on the pools for the remainder, in house order. A named place is the more specific inventory, so there is nothing to honor by asking the pools first, unless the request named an area.

Assigned areas

An assigned area is the third answer, and it is what a big hall does: the booker takes the Corbeille, the house gives each booking a place in it, and the ticket still says row F, place 12. Its places are real places and behave like any other place everywhere it matters. They are counted as places, they bound a tariff as places, PlaceBookable protects each of them under the slot lock, a pinning may hold them for an allotment, and a ticket names the seat.

What differs is who points at the seat, and that is the only thing that differs:

  • The capacity of the area is its places, full stop. A declared pool capacity or pool grade is never read for it, since the house hands out those very seats and cannot hand out a number somebody typed.
  • The booker takes it by quantity, from the same control a pooled area is taken from: a section beside the plan, or the Placement select on the booking page. Both surfaces read one list of what is on offer, so neither can offer what the other does not.
  • The seats are drawn on the plan, dim and not clickable, and the ones the booker was given paint as theirs. Most venues let you see where you will sit in the part of the house you took; the area's outline is the control, and a click on a seat falls through to it.
  • The panel names every seat the house gave and prices none of them. Each booking states the offer it was given at and carries no control of its own: the mix of offers in such an area is what the section's steppers are for, and the section is where the units are priced. The section is rebuilt from those very seats on every answer, so pricing the bookings as well counted each seat twice.
  • PlaceBookable refuses a place the booker chose in such an area, which is the authoritative rule behind the map not offering them: it answers a relocate op and an ask built by hand as well. The bookings the seating composes carry place_chosen FALSE, so they are held exactly as they always were.
  • Stepping the section down gives back seats, and which of them is a seating question rather than an order in a list: the same reader that answers it for the booking page answers it here, so giving up the middle of a run cannot strand the place beside it.

Nothing new is stored on a booking. It names its place, and the place carries the section and subsection that say which area it sits in, so the section and subsection fields on a booking stay pool-only.

How much of it has gone is asked of the seats, not of the bookings. An area taken by the number has to be bounded by one, and for an area that names its places that number is what the hall holds there less what the slot still offers of it: both readings the module already keeps, and the second is the very figure the area's card shows a booker. A pooled area has no seats to count instead, so it keeps a counter on its own lock anchor row, one per area at the grain the area is offered at, maintained by the booking entity and read by the statement that already has the row. Neither mode reads the bookings, which is what takes the last grouped read over a slot's bookings out of the lock, on the mode a house of five thousand seats and upwards is most likely to be run in: measured on eighty thousand places under forty bookers at once, a hall whose areas are all assigned fills at 59.8 seats a second against 59.9 for the same hall taken seat by seat.

Asking for an area

In a big hall nobody picks a seat: they pick the part of the house. So an ask may name an area, and the engine seats its units inside that area or refuses. A $wanted entry, and an ask handed to BookingManager::hold(), may carry 'area' => ['section' => int, 'subsection' => int|null]. A whole section covers every part of itself; one part covers only itself.

It is a preference on the ask, not a property of the area. Asking for the balcony from a control beside the price is the same act as clicking its zone on the plan, and both are useful whether the area is placed or pooled: a confined request draws only on the places inside it, and only on the pools inside it.

Two rules make it worth trusting:

  • A named area that cannot take the party refuses, and names itself. Nobody is seated elsewhere and nothing falls back to letting the engine choose: somebody who asked for the balcony has not agreed to sit in the stalls, so the whole request fails and the message names the area that could not take them. It says no more than that: an area may be out of places, or hold places a rule will not let this party take, and a booker who chose an area rather than a seat can act on neither cause.
  • A party asking for two areas is two seating questions. The party is split by confinement and each half seated from the places of its own area, one proposal each, from the one read the whole party shares. The confined halves go first and an unconfined one takes what is left, so a booker who asked for the balcony is not refused because a booker who asked for nowhere in particular emptied it.

A booker who chooses another part of the house moves the places they already hold, and the move is worked out in two halves for the same reason a hold is. How many move, at what prices, and which places are given up is read from the party's own seats, which no other booker can reach, so it is settled before the hold and it settles which parts of the house the hold waits on. Which seats they land on is chosen inside those locks, against what is committed there: chosen beforehand it would be chosen against a house that goes on moving while the party queues, and under a rush that queue is long enough for the seats to be gone by the time they are asked for. A booker who asked for the balcony would then be refused for a seat in it they never named.

Because the movers are seated around the party rather than as strangers, a move can also settle places the party already holds inside the area it is moving into: the seating may hand some of them back to find a run that takes everybody together, which is the same thing it does for a party arriving by quantity. A place the booker pointed at on the plan is never among them, since giving places up is off the table once one of them was the booker's own choice. What the booker is told about how close together they ended up counts the party whole, including places held at another price, because that is where they are sitting.

Closing a hole is confined too. Where the resource lets bookers ask for an area at all, consolidate() only moves a party within the area it is sitting in: the area a party sits in is where they meant to sit, and moving them out of it to sit closer together is the booking being changed under them. Where no booker is ever offered the choice, the whole party is one group as before, and closing a hole may still move it across the house.

Nothing new is stored on a booking. A placed one names a place, and the place carries its section and subsection.

Offering the choice

AreaOffers answers which areas are worth offering on a slot, for the grades a booker is being priced at, and how much each has left. Both surfaces that offer the choice read it, so neither can offer an area the other does not.

  • An area holding none of the priced grades is not on offer at that price, however empty it is. A tariff naming no grade prices anything, so every area is.
  • An area that is FULL is still answered for, at nothing available, and so is one with room for fewer places than the booker wants. Which of them are worth OFFERING is the surface's to decide and cannot be decided here: see below.
  • A pooled area is offered at the grain it is pooled at, which is its commercial unit. A placed one is offered at the grain the site asks for.

The choice is only offered where the page can carry it legibly, which is a decision of its own: the booking page is arranged by place grade or by tariff class (offer_display, see Calendar). By place grade, each set of them heads a panel with the prices for it inside, and the control sits at the top of that panel, so the control and the offers it governs are one section. By tariff class there is no such heading, and the only place left would be a row of selects above the whole page, none of them saying which prices it governs, so that arrangement offers no place choice at all.

Whether the choice is offered inside that arrangement, and how finely, is a second field rule on the resource, area_choice (see Field rules), so a resource, its type, the tenant or the site may each answer it:

Answer What a control offers
never Nothing. Every unit is placed wherever the strategy puts it.
sections One option per section, whatever it is divided into.
subsections A divided section offers its parts instead of itself.
both The section, and each of its parts under it.

sections is the shipped site default: a short list, always meaningful, and a hall divided into forty rows does not open by offering a booker forty of them. The grain decides what a control lists and nothing else - both grains are valid requests either way - so moving a site from one to another changes what is on offer, never what a booking means.

Which areas are worth offering is decided in the browser, and it has to be. An area is worth offering when it could take the WHOLE PARTY: a booker picks a part of the house to sit in it together, so an area that could take three of their four is a split or a refusal wearing the clothes of a choice, and somebody who does not mind sitting apart has Auto and the plan. How big the party is is one visitor's business, and the answer the areas travel on is shared by every booker asking about the slot, so the filtering happens where the party size is known.

So placement publishes every area of a set with what each has left, through hook_yoyaku_slot_offer_state_alter, and the page filters. Three things follow, and they are worth keeping apart:

  • Fewer than two areas worth offering is not a choice, so the page hides the control: Auto and a single option are two names for one booking. Hidden rather than absent, because a control added to a page later is a control a screen reader was never told about, and because whether there IS a choice moves as the house fills, which a page kept in a cache could never decide for itself.
  • The area a booker has already chosen stays on their list whatever it has left. It is theirs: a control that drops it the moment it fills up puts itself back to Auto behind their back, and then the page says their places cannot fit in an area the box no longer even shows. Listed, the stepper stops at what the area holds and the booking beside it says why.
  • A choice already made goes on resolving after it has stopped being possible, so it refuses by name rather than turning into "anywhere" at the very moment it became impossible.

Auto is the first option and the default. It is the absence of a choice rather than a place in the list: the request carries no area, and the engine reads the whole house exactly as it does on a venue that never offers the choice.

What a booker may then ask for

Choosing an area lowers the quantity ceilings of the offers it governs to what that area has left, since the ceiling is otherwise what the tariff has left across the whole house: a booker who picks a balcony of two could step to five and hear about it only from the hold. Auto lowers nothing.

Worked out in the browser, for the same reason the list is: the bound is what the area has left PLUS what this booker already holds there, and only the page knows the second half. There is one reader of that arithmetic, which is what keeps the page from disagreeing with itself; the words it prints are placement's, and they travel with each option so the sentence about an area comes from the module that knows what an area is.

It is a ceiling per offer rather than per group, so a party could still ask for more than the area holds by splitting the ask between two offers that cover the same grades. Nothing on a page of independent number fields can express "these two share a ceiling", and the hold refuses that case by name, which is where a shared bound has to be checked anyway.

On the picker, a pooled area is offered two ways, both leading to the same hold.

On the plan, the area is a clickable zone, and the picker draws it. A section carries one outline per part of itself, as SVG path data in the artwork's own coordinates, one per line, so the first line outlines subsection 1 and an undivided section outlines all of itself on that line. An area covering several parts is drawn from all of their outlines at once, which is what lets one production offer the two halves of a balcony apart and the next offer the balcony whole, with one geometry behind both and no shape stored per production. Geometry is a requirement about the room, and grouping is a decision about a production.

Clicking or activating the zone takes one place at the first tariff, which is what puts the area in the selection panel. The shape itself is the control, so the hit area follows the outline and never covers a neighboring section the way a box around it would; it carries role="button" and a tab stop, answers Enter and Space, highlights on hover and on focus, greys out when the pool is full and then refuses to act on a key as well as saying so, and names itself on the plan with what it has left or what you hold. An area with no outline has no zone, and is offered from the panel alone.

The outlines are arranged in the Areas layer of the venue layout (see Venue map), which seeds each one from a hull around that part's own seats. Nothing is looked for inside the artwork by name: an id nobody checked left an area with nothing to click and no error anywhere, and a hall with no artwork at all can be booked by area.

In the selection panel, an area holding units shows one counter per tariff, all drawing on its single capacity, so a party takes three at full price and two at the youth tariff out of one floor. The panel holds every action for an area, and the map is where a selection starts: an area with a zone shows its counters once it holds something. Each counter is the site's shared quantity stepper, the one the slot page's offer cards use (Steppers and the click queue). Every step, from the zone or from a counter, goes through the same place-op endpoint a seat click uses, so the pool bound applies under the slot lock and neither can offer what the constraint would refuse. A venue that offers only pools needs neither place geometry nor a map background to have a picker, which is what makes a standing hall bookable there.

Note what a zone needs of the artwork: nothing at all. The shape is stored on the section and drawn by the picker over the artwork, so a hall with no picture can still be booked by the part of the house. Building a venue map covers arranging those shapes.

The area a party is given when it chooses none

A party that asks only for a number of places is given an area by the house, out of the areas its tariff is offered in that are open, are not offered by the number, and have room. How many of them it may be given is a third field rule on the resource, party_area: a number from one to eight, or zero for every area that has room for the party.

One means the best area, for every party, every time. More than one divides the queue, because a hold waits on the part of the house it is being seated in, so offering everybody the same area puts them all in a single line for it. What it costs is a party sitting further down the house order than it had to. Zero is the other end of the same trade: the house spreads as wide as it can, over every area open to that party with room for it, which on a hall of one area is the same as one.

Measured on a hall of 8000 places in 16 areas with 160 bookers at once, each run as A, B, A so the machine could not flatter one of them:

Areas What a booker waits Seats taken
1 about 3.5 s no different
2 about 2.1 s no different
4 about 1.6 s no different

Four is the shipped site default. The house fills no faster at any of them, because what bounds the rush is the work each claim costs rather than the queue, so the number gains latency alone. One restores offering everybody the front of the house and queueing for it.

Which of the areas in play a party gets is read off the subject of its hold, so two bookers arriving together are sent to different ones while a hold asked for twice is answered the same way both times. Whatever the number, a party is only ever seated in an area its tariff is offered in.

Booking a place

The picker at /placement/{yoyaku_slot} (permission administer yoyaku for now) shows the venue's seat map: places are drawn to scale and the booker clicks an available seat to hold it, and each area offered as a pool appears as a section with a quantity stepper. A click paints at once, in a color of its own until the server answers, and is posted with whatever else the booker clicked in the same few seconds, as one request carrying a list (Steppers and the click queue), re-checked by PlaceBookable under the slot lock, so nothing is overbooked. The selection panel beside the plan lists only what is held, so nothing appears there that an answer could take away again. Once seats are held the page offers Go to cart, shown when the cart module is installed, and placement keeps no hard dependency on it.

That link is the only way on from here. Committing a basket belongs to the cart page alone, because that is where a venue's policy gets to put a question in the booker's way - accepting places that are not all next to each other, for one - and a second way to commit would be a way around it.

What a per-order rule does on the plan

A booker is bounded by two things, not one: what the hall has left, and what a policy lets one booking take. The first has always been on the plan. The second is a constraint policy - "at most four in Catégorie 1" - and it is on the plan as well, so a press past it is answered in the browser rather than posted, refused under the slot lock and answered with a message. A rush is exactly when those presses cluster.

The allowance is configuration and the same for everybody, so it travels in the shared availability document alongside the prices, per tariff. How much of it one booker has left is not shareable at all: that document is kept for seconds and served to every visitor, and a remaining count in it would hand one booker's basket to the next. So the browser subtracts what it already holds, which it knows exactly - its own seats plus its own section units - and the bound is exact with no extra request.

What follows from it:

  • A section's stepper stops at the lower of what the area has left and what the allowance leaves, and the reason stands under the control in the same words the booking page uses for the same rule.
  • An area's outline asks the same question before it acts, since it takes one place at the section's first offer.
  • A seat click asks it too. That was the one gesture with no guard at all: a stepper reads its allowance off its own maximum, but a click on a seat checked nothing and posted regardless.
  • Repricing a held seat asks it of the tariff the seat is moving to.

The engine stays the authority. The bound in the browser is an optimistic guard that removes a doomed request in the ordinary case, and it is deliberately looser than the engine rather than tighter: an attachment on a resource caps every tariff it offers together, while the plan subtracts per tariff, and a policy reaching wider than one slot - so many per booker across a season, or across a resource type - cannot be computed from one map at all. The refusal under the slot lock is what enforces the rule, and the map shows it the way it shows any other.

Changing a place

A place already held can be swapped for another without giving it back first. The panel row offers it, and the hall then stands down: every place is dimmed and takes no clicks except the legal destinations, which are the available places of the same grade, plus the place being replaced, because clicking that one is how a booker changes their mind. The grade is the rule, not the tariff: a tariff may price several grades, and a swap must not quietly move a booker to a worse view.

The dimming is the refusal rather than a picture of it, so there is no eligibility test when a click lands on the plan. A keyboard reaches places a pointer cannot, though, so the same rule is applied again for that path, and an ineligible place says what is wanted instead of doing nothing.

Which places are exempt is one rule the map writes when the change begins, naming the grade, and empties when it ends. One rule, rather than a class written onto every drawn place at both ends of the gesture, which would be a hall's worth of work to move one seat (#3616042).

Checking for changes

The shared availability answer is a little behind on purpose: it is kept for availability_max_age seconds so a rush on a slot does not become a rush on Drupal. Without a way to ask, a booker who suspects the map is stale reaches for the browser's reload, which fetches the page, the geometry and the artwork all over again in order to renew the one part that moves.

So the map carries a refresh control, in its own group beside the zoom buttons because those are hidden for a venue offering only pooled areas. It answers in words every time, and always the same sentence: the map is up to date, and when to check again.

  • Past the window it asks again and repaints what changed. The read forces no cache mode, which is what makes it correct as well as cheap.
  • Inside the window it asks nothing at all. The server is holding the very entry this copy came from and the two run out together, so no fresher answer exists anywhere: a forced fetch would reach Drupal, read a still-valid entry and return the same bytes with a larger age. The countdown is what tells a booker that reloading harder achieves nothing.

While any request is out the map takes no clicks and the pointer says so, which is the same rule every operation on this page follows.

Group placement

A party is seated by asking for a quantity. The slot booking page shows one number field per tariff and nothing else, whatever the resource is; where each unit ends up is the placement seam's to decide. So a booker who wants five seats together types 5, and a seating strategy finds them. Nobody has to open the map to seat a group, and the map is the alternative rather than the way in. The picker link on that page comes from this module, offered when the venue has anything left to pick, since the calendar knows nothing about venues.

Which seats the strategy picks, how it extends a party across several clicks and several tariffs, what the booker is told when it cannot seat them side by side, and which seats it hands back when the quantity is stepped down again, are all in Seating a party. The rest of this section is what happens to the answer once the strategy has given it.

A party that cannot sit together is placed and warned, not refused. When no row has a long enough run the units are still placed, on whatever the tariff leaves, and VenuePlacementProvider returns a message beside the bookings, typed warning: scattered seats are a disappointment, not a failure, and only the booker can judge whether to take them. A shortfall is the opposite and refuses the whole booking. One unit is never warned about, and neither is a party taken wholly out of one pool: an area is one place to stand, however many units it gives out. A party split between the seats and an area, or across two areas, is.

The message travels with the asks because the party is only knowable at the ask: a held order no longer says which of its bookings were one party, so a party of three and a party of two in one tariff could not be told apart afterwards. UnitAssignment (asks plus messages) is what the seam and BookingComposer::compose() return for that reason.

The provider sets each message's type, because it is the layer that knows which is which. A party moved into the part of the house it asked for is a status: the booker asked, and it happened. What the seating could not do - a party in another row now, a party it could not keep together - is a warning. Both can arrive in one answer, which is why the type travels on the message rather than being decided by whoever shows it.

The proposal itself is a best-effort read; the authoritative allocation happens when the places are held as a group, each re-checked by PlaceBookable under the slot lock, so nothing is overbooked and no partial group is left booked. (The old manual "place a party together" finder on the picker was retired in #3612887; this is where it went.)

That read is set-based on purpose. PlaceAvailability::getAvailableCandidates() returns the available places a tariff may fill as PlaceCandidate values - id, grade, section, subsection, printed row and position, which is everything a proposal looks at - rather than as place entities, so seating a party in a hall of a few thousand seats costs one query instead of a few thousand hydrations. The database also does the ordering, in house order (section weight, then the row as it is printed), which is what lets the proposal take the first section that fits without ranking anything itself, and what makes the same request answer the same way twice. How often that query runs, and why its exclusion is written the way it is, are measured under Cost.

Not yet

  • A public booking permission (the picker is operator-gated for now).
  • Per-slot pricing over grades: a price per tariff (each tariff groups one or more of the venue's grades) so a slot prices places by viewing quality, plus the discount/tariff axis (full / reduced / student). The place already carries its grade; what the pricing layer adds is the per-slot price on the tariff, never on the shared place.
  • Tickets/check-in (#3611638, #3611639), which build on the place a booking now carries.