# Table clicks and highlights

> Messages between the 3D embeds and your page: learn which table a guest clicked, and colour the tables your app offers, selects or blocks.

The 3D view of both embeds talks to the page that frames it with `window.postMessage`. Your app draws every card, form and decision. FloorPlanCraft only reports which table was clicked and colours the tables you name.

For a booking app the tables are its areas, and for a play venue its zones. Every message is a plain object with a `type` and `"v": 1`. Unknown types and fields are ignored on both sides.

## Messages from the embed

| `type` | When | Message |
| --- | --- | --- |
| `fpc:ready` | Once per load, when the scene is drawn and its tables can be clicked; again when the scene reloads | `{ "type": "fpc:ready", "v": 1, "tables": ["T1", "T2"] }` |
| `fpc:table-click` | A click or tap on a table | `{ "type": "fpc:table-click", "v": 1, "label": "T7" }` |
| `fpc:embed-expired` | The live embed's address expired | `{ "type": "fpc:embed-expired" }` |

- `tables` in `fpc:ready` and `label` in `fpc:table-click` are zone names exactly as the plan draws them, in plan order.
- The tables are the zones of the shown level whose names your pushes report as tables, compared ignoring case. Stations and other zones never are.
- In the live embed, the scene reloads when the viewer moves the replay to another stored minute, presses **Live** or picks another service day. `fpc:ready` waits for the first floor, and does not come while a replay plays.

## What counts as a click

One press and release of the primary mouse button, or of one finger, that never moves 6 CSS pixels or more. Orbiting, panning, wheel or pinch zoom and a second finger are never clicks. The table, its chairs and the zone's floor inside its outline all count as that table; a wall in the way does not, unless the view's see-through walls are on. A click only posts the message: the view opens nothing itself.

## Messages to the embed

| `type` | Message | Effect |
| --- | --- | --- |
| `fpc:highlight` | `{ "type": "fpc:highlight", "v": 1, "tables": [{ "label": "T2", "state": "available" }, { "label": "T7", "state": "selected" }], "dimOthers": true }` | Replaces the whole highlight |
| `fpc:focus` | `{ "type": "fpc:focus", "v": 1, "label": "T7" }` | Eases the camera to frame that table in 450 ms, at once with reduced motion, keeping its direction. A press on the view stops it. An unknown label does nothing. |

| `state` | Look |
| --- | --- |
| `available` | A raised white band with dark edges along the zone's edge, and a pointer cursor over it |
| `selected` | A wide violet outline and a low, see-through violet fence around the zone |
| `unavailable` | A pale, see-through veil over the zone and what stands in it |

- `"dimOthers": true` veils every table not in the list.
- `"tables": []` clears everything, whatever `dimOthers` says.
- Labels are matched ignoring case. A label with no table is ignored, and a later entry for the same table wins.
- In the live embed, the highlight sits on each zone's edge or is see-through, so the live colour stays readable.
- A highlight stays in that frame only: it is never stored or seen by another viewer. It lasts through live updates and replay moves until you replace or clear it; a reload starts with none.
- Limits: at most 500 entries; each `label` 1 to 64 characters and not blank; `state` one of the three above; `dimOthers` a boolean when present; `v` exactly `1`. A message that breaks any of them is ignored whole, without an error.

## Example

```ts
const ORIGIN = "https://floorplancraft.com";
const frame = document.querySelector<HTMLIFrameElement>("#live-floor")!;

function send(message: object): void {
  frame.contentWindow?.postMessage(message, ORIGIN);
}

window.addEventListener("message", (event: MessageEvent) => {
  if (event.origin !== ORIGIN || event.source !== frame.contentWindow) return;
  const message = event.data as { type?: string; tables?: string[]; label?: string };
  if (message.type === "fpc:ready") {
    // Offer the tables that are free in your app, and fade the rest.
    send({ type: "fpc:highlight", v: 1, tables: [{ label: "T2", state: "available" }], dimOthers: true });
  }
  if (message.type === "fpc:table-click" && typeof message.label === "string") {
    send({ type: "fpc:highlight", v: 1, tables: [{ label: message.label, state: "selected" }], dimOthers: true });
    send({ type: "fpc:focus", v: 1, label: message.label });
    // Then open your own card for that table.
  }
});
```

## Where messages work

- The live embed always has them.
- The plan embed has them only in its 3D view (`?view=3d`), while a connection that holds the plan's share has Live 3D, and only when the plan's allowed origins name at least one origin. This is decided when the page loads: reload the plan embed when a new session starts.
- Without them, the plan embed sends nothing and ignores highlights. Treat no `fpc:ready` within 10 seconds as no 3D picking.

## Security

- The embed posts only to `window.parent`, once per allowed origin with that origin as the target, never `"*"`. The allowed origins are your connection's `embedOrigins` for the live embed, and the plan's allowed origins for the plan embed.
- It reads a message only from `window.parent`, from one of those origins, and only when the message's shape is exactly right. Anything else is dropped without an error.
- Before you trust a message, check that `event.origin` is `https://floorplancraft.com` and that `event.source` is the frame's window.
- Only table labels and states cross, in either direction.

## Related

- [Embedding the live view](https://floorplancraft.com/docs/embed-the-live-view.md)
- [Live updates](https://floorplancraft.com/docs/live-updates.md)

---

Source: https://floorplancraft.com/docs/table-clicks-and-highlights · Updated 2026-11-07
