Skip to content

Section layout as Prisma rows, not SVG: the positionX/Y/rotation choice

6/17/2024Mobile DevelopmentEvent Ticketing Platform11 min read

The brief in one paragraph

A multi-venue event-ticketing platform needs to render a seating chart for any venue, on any device, with the same data the admin uses to lay it out and the same data the door scanner uses to validate a ticket. The chart is interactive — sections highlight on hover, individual seats can be selected, prices update as the customer picks tier. The team has two choices for how the chart is stored. One: an SVG file per venue, hand-tuned or exported from a graphics tool, with seat ids baked into element attributes. Two: the chart is data — every section and every seat is a Prisma row with coordinates, and the canvas is rendered at runtime from a database query. The platform shipped with option two. This is the breakdown of why, and where the choice leaks.

The constraints that shaped the technical decisions

Three constraints decided the shape.

First, the same chart has to be editable by a venue manager who is not a designer. A venue manager opens an admin panel, drags a section, drops a row of seats, saves. There is no Adobe Illustrator in this loop. If the chart is an SVG file, the editor has to either embed a vector tool or limit the manager to property forms — and the property forms re-implement the vector tool badly.

Second, the chart has to be readable by code that knows nothing about visual rendering. The pricing engine queries which seats are in which section. The availability engine queries which seats are unsold. The validator at the door queries which seat the QR points to. Three consumers, three queries, all of them straightforward SQL when the data is in rows and all of them hostile when the data is XML attributes inside a binary asset.

Third, the chart has to scale to dozens of venues without each one becoming a separate engineering task. A new venue means a new row of Section records and a new batch of Seat records, both of them written through the same admin UI that already exists. No new file in /public, no new pipeline that turns an SVG into a queryable index, no new deploy.

These three constraints push the same direction: the chart is data.

The architecture

The schema carries the layout in two models, each carrying its own coordinates, rotation, shape, and size:

model Section {
  id            String  @id @default(cuid())
  venueSlug     String
  name          String
  standing      Boolean @default(false)
  positionX     Float
  positionY     Float
  rotation      Float   @default(0)
  shape         String  @default("rectangle")
  sizeX         Float
  sizeY         Float
  disabled      Boolean @default(false)
  startingPrice Int
  seats         Seat[]
  venue         Venue   @relation(fields: [venueSlug], references: [slug])
}

model Seat {
  id         String     @id @default(cuid())
  seatId     String
  sectionId  String
  name       String
  status     SeatStatus @default(AVAILABLE)
  positionX  Float
  positionY  Float
  rotation   Float      @default(0)
  shape      String     @default("rectangle")
  sizeX      Float
  sizeY      Float
  section    Section    @relation(fields: [sectionId], references: [id])
}

enum SeatStatus { AVAILABLE DISABLED }

A Section has a position relative to the venue, a Seat has a position relative to its Section. The renderer is a single React component that fetches the Section list for a venue, maps each one to a <div> with a CSS transform, then fetches the Seats for the active Section and maps each to a child <div>. The math is two coordinate spaces and one matrix multiplication.

function Venue({ sections, activeSection, onSelect }) {
  return (
    <div className="venue-canvas">
      {sections.map((s) => (
        <div
          key={s.id}
          className={cn('section', { active: s.id === activeSection })}
          style={{
            transform: `translate(${s.positionX}px, ${s.positionY}px) rotate(${s.rotation}deg)`,
            width: s.sizeX, height: s.sizeY,
            clipPath: s.shape === 'curve' ? 'ellipse(50% 50%)' : undefined,
          }}
          onClick={() => onSelect(s.id)}
        >
          {s.name}
        </div>
      ))}
    </div>
  );
}

The admin uses the same component with the drag handles enabled. On drop, the new positionX / positionY are PATCHed back to /api/private/venues/:slug/sections/:id. The save is one row update. There is no file to regenerate.

The customer side is the same component with drag disabled and a price overlay. The door scanner does not render the chart at all — it queries the Seat by id and asks whether the most recent live Order on the matching session contains it.

The hardest sub-problem and how it resolved

The hardest sub-problem was rotation. A Section rotated thirty degrees still has rectangular bounds in the database — positionX, positionY, sizeX, sizeY — but its on-screen footprint is a rotated rectangle whose bounding box is bigger than the storage. The hover-and-click hit detection has to match the visual, not the storage. So has the "does Section A overlap Section B" check that the admin runs on save.

The first instinct was to store the rotated bounds. That doubles the schema — the storage rectangle and the rendered rectangle become two separate things and the admin has to keep them consistent on every drag. Drift was inevitable.

The shipped answer was to keep the schema as the un-rotated rectangle and compute the rotated bounds at render time. CSS transform: rotate() handles the visual; a small getBoundingClientRect()-based check handles the hit. Overlap is checked by projecting both rectangles onto a separating axis — the cleanest expression of "are two oriented rectangles colliding" — and is called only on save, not on drag.

function rotatedCorners(s: Section) {
  const cx = s.positionX + s.sizeX / 2;
  const cy = s.positionY + s.sizeY / 2;
  const rad = (s.rotation * Math.PI) / 180;
  const cos = Math.cos(rad), sin = Math.sin(rad);
  return [
    [-s.sizeX / 2, -s.sizeY / 2],
    [ s.sizeX / 2, -s.sizeY / 2],
    [ s.sizeX / 2,  s.sizeY / 2],
    [-s.sizeX / 2,  s.sizeY / 2],
  ].map(([dx, dy]) => [
    cx + dx * cos - dy * sin,
    cy + dx * sin + dy * cos,
  ]);
}

This is the kind of geometry that an SVG asset would have hidden inside the vector tool's hit-testing code. Pulling it into the application meant writing it once. The benefit was that the same projection check feeds the admin overlap validator, the customer-side seat-tooltip placement, and the print-layout export.

What shipped and what did not

What shipped: the canvas-based renderer, the drag-and-drop admin, the per-section pricing tier, the disabled flag for sections and seats. Adding a new venue is a sequence of admin actions. There is no separate "import SVG" path because there is no SVG to import.

What did not ship: a programmatic SVG export for printing. The print artifact a venue manager occasionally needs — for safety inspections, for marketing materials — is a screenshot of the canvas, not a generated vector file. Adding the export is a project in itself because the styles in CSS do not map one-to-one to SVG attributes; some shapes that render fine in the browser do not render at all in SVG without a manual translation layer. The team accepted this gap because the demand is monthly, not daily, and a high-resolution browser screenshot is good enough for the actual use case.

The other thing that did not ship: support for non-rectangular sections beyond a simple curve. The schema has shape: string with a default of rectangle and one alternative for curve-shaped boundary sections. A real concert venue with a horseshoe balcony needs more, and the team did not ship the polygon editor. The schema can carry the data — shape: "polygon" with an array of points serialised into a JSON column — but the admin UI to draw a polygon is its own product.

CTA — what a reader with a similar brief should ask first

If you are about to choose between asset-based and data-based layouts for a multi-tenant editor, the first question is: who edits the layout. If it is a designer with a vector tool, the asset path is fine. If it is the customer of your software with a phone in their hand, the asset path is a dead end before you start.

The second question is: who else consumes the layout. If the only consumer is the rendered chart, the asset path has fewer moving parts. If the layout has to be queried by pricing, availability, and validation, the data path is the only one that keeps those queries simple.

The third question is: how often does the layout change. If the answer is "never after the first export," the asset path is fine. If the answer is "every time a venue rearranges for a different show," the data path is the only one that survives.

Trade-off

The data-based layout pays an upfront cost in geometry. The team has to implement hit detection, overlap detection, and at least one rotation transform in application code, rather than getting them for free from a vector tool. The first feature — drag sections around the canvas — takes longer than it would with an off-the-shelf SVG editor.

What you buy with that cost is every later feature. Pricing per section is a startingPrice column. Availability per seat is a join. Per-venue customisation is a row, not a deploy. The cost is paid once. The benefit accrues every time someone needs to query the layout for anything other than rendering it.

Business impact

A venue manager adds a new room layout without filing a ticket. The platform supports a new venue in the time it takes to drag the sections in, not in the time it takes to wait for a designer's vector file. The salesperson can demo a custom layout in a discovery call by drawing it live. The support team can answer "why is this seat showing as taken" by pointing at one row in one table, instead of opening a binary asset to find out.

For a platform that needs to scale across venues without scaling engineering hours per venue, this is the unlock. The choice that looks like it costs more on day one is the one that costs nothing on day three hundred.

What to do next

If your layout is in an SVG file and is starting to feel like a bottleneck, the experiment is to model one section and one row of seats as Prisma rows and render them as a CSS-transform overlay. The query is short, the render is straightforward, and the moment the overlay matches the SVG is the moment the SVG can be retired. Until then they coexist, and the new path can be tested against the old one for free.

If you are starting fresh, model the layout as data from the first migration. The geometry helpers are a hundred lines of TypeScript you write once and reuse across every consumer of the chart. The artifact to copy: a Section model with position and rotation, a Seat model that references it, and a single React component that walks both with a CSS transform. Everything else — the admin, the customer chart, the validator — is a query against that model.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox