Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
T
jheapsandClaude Opus 5 2e372d4677 Take the glass off the middle of the table
The board — the five community cards a dealer taps to enter the flop, the
turn and the river — could not be pressed at all. #seatsLayer is an
`inset: 0` sibling of the table that holds the seat pucks, and an element
with no background still hit-tests, so the empty middle of that layer took
every tap aimed at what was underneath it. Measured at 1280x900 with five
cards out: all five hit-tested to `div#seatsLayer`, on every screen wide
enough to keep the board in the middle rather than on the strip. #betsLayer,
the other sheet over the felt, has been pointer-events: none since it was
written.

Underneath that, the cards were drawing at a little over half their size.
placeBoard measures how much middle the felt has and writes it to the
centre's max-width — but max-width caps a box and does not claim anything,
and the centre is a flex column that sizes to its content. So with 463px of
measured room it went on being 136px wide, the row inside it is width: 100%
of that, and five cards sharing 136px come to rest on their own min-width:
24px, against the 44px floor every other control on the felt is held to.

Neither was visible to anything. A scripted probe calling .click() on the
node skips hit-testing entirely, and the sweep's occlusion check had only
ever been shown three selectors' worth of the page — the action bar, the
header and the seats. It asks about every control now, which took three
refinements to be honest: a control that set pointer-events: none has opted
out rather than been covered; a shut <details> hands out a real rect for
everything in it, and checkVisibility is the browser's own answer; and flow
content under a fixed layer is a position rather than a fault, so it is said
rather than failed.

The five sheets a dealer spends half the night in were getting one rule of
twenty-five. They get all of them now. Three rules had to learn what a modal
is first: two ask about the fixed bar, which is not a question while a scrim
is covering it, and the third called the bottom four rows of a sheet that
scrolls unreachable buttons. The helper all three lean on had its own blind
spot — it asked whether an element had left its box entirely, which is true
of something well below the fold and false of something hanging eight pixels
over the edge of it.

That turned up two controls that were a glyph and nothing else: the pot
wizard's close, on the one dialog that divides money, and the cross that
removes a custom field, in a column of identical crosses.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 00:01:37 -06:00

3308 lines
172 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// All DOM rendering + drag/drop for the table. Talks to the outside world only through
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
// re-draws everything (used after structural changes like resizing seats or the chip
// legend).
//
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
// full form in the seat editor panel instead.
const Render = {
state: null,
hooks: null,
selectedSeatIndex: null,
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.selectedSeatIndex = null;
// A reset hands over a different table entirely, so nothing cached about the
// last one still applies.
this.lastHistorySignature = null;
this.handTimeCache.clear();
this.watchSeatSheetHeight();
this.renderAll();
},
// How tall the seat sheet is, published the way the action bar publishes its own.
//
// Two things are fixed to the bottom of a phone here, and the toast is the one that
// has to give way. It already lifts clear of the action bar — `body.hasActionBar
// #toastHost` — because a toast sitting on Fold is a toast the dealer taps by
// accident. The sheet is the same situation and had never been told about: opening
// a seat put a "Keeping the screen on" toast squarely over the sheet's own rows,
// with its Undo and its × live above them.
//
// Measured rather than guessed, for the same reason the bar's is: the height moves
// when a section is unfolded, so any number written down here would be wrong as
// soon as somebody tapped "What they've put in".
watchSeatSheetHeight() {
const panel = document.getElementById("seatEditorPanel");
if (!panel || this.seatSheetWatcher) return;
const publish = () => {
// Only when it is actually a sheet over the bottom of the screen. Above
// 700px this is a panel in the sidebar column, and its height is nothing to
// do with how much of the bottom edge is spoken for.
const isSheet = getComputedStyle(panel).position === "fixed";
const open = document.body.classList.contains("seatSheetOpen");
const height = isSheet && open ? panel.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--seatSheetHeight", `${Math.round(height)}px`);
};
this.publishSeatSheetHeight = publish;
if (typeof ResizeObserver === "function") {
this.seatSheetWatcher = new ResizeObserver(publish);
this.seatSheetWatcher.observe(panel);
}
window.addEventListener("resize", publish);
window.addEventListener("orientationchange", publish);
publish();
},
renderAll() {
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
this.selectedSeatIndex = null;
}
this.renderSeats();
this.renderBoard();
this.renderSeatEditor();
this.renderChipLegend();
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
this.renderPanelGlances();
this.renderHeaderActions();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
// Last, and from here rather than from the dozen places a hand can start or
// end. Every one of those already ends in a renderAll, and a wake lock that is
// taken in some of them and not others is worse than none: the dealer learns
// the screen stays on, and then one night it doesn't.
if (typeof WakeLock !== "undefined") WakeLock.sync(this.state);
},
// ---- Bringing something into view, at the speed the reader asked for ----
//
// Three places scroll something into view — the raise pad's amount field, the seat
// editor sheet, and the sidebar section a shortcut jumps to — and all three asked
// for "smooth" unconditionally.
//
// A smooth scroll is the most provocative motion a page can make for somebody with a
// vestibular disorder, because what moves is the whole viewport rather than one
// control. The stylesheet has honoured `prefers-reduced-motion` for a while; the
// scripts were never asked to, and between them they held the three largest
// movements in the tool.
//
// Read per call rather than cached. The setting can change while the page is open,
// and a dealer who turns it on mid-session should not have to reload the table to be
// taken seriously.
prefersReducedMotion() {
return typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
},
// Same arguments as scrollIntoView, minus the one the caller shouldn't be choosing.
bringIntoView(el, options = {}) {
if (!el || typeof el.scrollIntoView !== "function") return;
el.scrollIntoView({
...options,
behavior: this.prefersReducedMotion() ? "auto" : "smooth",
});
},
renderSetupFields() {
document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction;
document.getElementById("basePotInput").value = this.state.basePotAmount ?? "";
document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer;
document.getElementById("trackBoardToggle").checked = !!this.state.trackBoard;
document.getElementById("keepScreenAwakeToggle").checked = this.state.keepScreenAwake !== false;
document.getElementById("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
const rake = this.state.rake || {};
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
// wants to see the running total right there.
const taken = Math.max(0, Number(this.state.rakeTaken) || 0);
const hint = document.getElementById("rakeTakenHint");
hint.hidden = taken <= 0;
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
},
// ---- What a shut panel says about itself ----
//
// The sidebar is eight <details>, and every one of them was exactly 60px tall with
// nothing in it but its own name: 480px of a phone's screen, and 480px of a 660px
// desktop column, spent saying "Standings", "Hands", "Waiting list". Whether the box
// balances, whether anyone is waiting for a seat, how many hands have been played —
// the page has all three worked out already, and every one of them cost a tap to
// find out, on a row two thirds of which was empty.
//
// That is the failure mode the disclosure pattern is warned about by name: a header
// you have to open to learn whether it was worth opening. Salesforce and Carbon both
// answer it the same way — put the status in the header — and NN/g's objection to
// accordions is exactly the cost of the taps this saves.
//
// So a shut panel now carries the one fact you would have opened it for. The panel
// itself still holds the sentence: "Off by $40" is the alarm, and which seat is
// holding chips nobody wrote down is a paragraph, which is the half that belongs
// behind the tap.
//
// Silence is a state too. A glance appears when it has something a dealer would act
// on and stays away otherwise — nobody waiting, no notes, no hands yet — because a
// row reading "Nobody waiting" all night is the clutter this is here to remove, and
// eight rows that always say something are eight rows that say nothing again.
PANEL_GLANCES: ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"],
// Pure, so the wording can be tested without a page to hang it on. Every value is
// read off the state the panel below it renders from, never off the panel's own
// DOM — a shut <details> has no rendered body to read.
panelGlances(state) {
const glance = (text, tone = "") => ({ text: text || "", tone });
if (!state) return {};
const blinds = state.smallBlind > 0 && state.bigBlind > 0
? ` · ${Money.format(state.smallBlind)}/${Money.format(state.bigBlind)}`
: "";
const waiting = (state.waitingList || []).length;
const hands = (state.handHistory || []).length;
// Denominations rather than a count of rows, because "are the values right for
// tonight's set" is the question, and five colours is the answer to a different
// one. Chips with no value on them aren't in the range: a blank row is a chip
// the dealer hasn't got to yet, not a zero-dollar chip.
const values = (state.chipLegend || [])
.map(chip => Number(chip.value) || 0).filter(v => v > 0).sort((a, b) => a - b);
const span = values.length
? (values[0] === values[values.length - 1]
? Money.format(values[0])
: `${Money.format(values[0])}–${Money.format(values[values.length - 1])}`)
: "";
const chips = values.length
? `${values.length} ${values.length === 1 ? "colour" : "colours"} · ${span}`
: "";
// The first line that has anything on it. Notes get typed as a list — "Sam owes
// Dee 20" under a blank line under a heading — and the first line of the string
// is often not the first line of the note.
const notes = (state.notes || "").split("\n")
.map(line => line.trim()).find(Boolean) || "";
const bank = HoldemState.bankReconciliation(state);
// Before anybody has bought in there is nothing to reconcile, and the panel
// hides its own check for the same reason — so the glance saying "Balanced"
// over an empty table would be answering for a count that was never taken.
const nothingBanked = bank.cashIn === 0 && bank.chipsInPlay === 0;
return {
glanceTableSetup: glance(`${state.seatCount} seats${blinds}`),
glanceStandings: nothingBanked ? glance("")
: bank.balanced ? glance("Balanced", "ok")
: glance(`Off by ${Money.format(Math.abs(bank.difference))}`, "warn"),
glanceWaiting: glance(waiting ? `${waiting} waiting` : ""),
glanceHands: glance(hands ? `${hands} ${hands === 1 ? "hand" : "hands"}` : ""),
glanceChips: glance(chips),
glanceNotes: glance(notes),
};
},
renderPanelGlances() {
const glances = this.panelGlances(this.state);
// renderAll runs after every tap a dealer makes and none of this changes between
// most of them. Writing textContent is cheap; making the browser re-style eight
// rows for the same eight strings is the part worth not doing.
const signature = this.PANEL_GLANCES
.map(id => `${id}:${(glances[id] || {}).text}|${(glances[id] || {}).tone}`).join(";");
if (signature === this.lastGlanceSignature) return;
this.lastGlanceSignature = signature;
this.PANEL_GLANCES.forEach(id => {
const host = document.getElementById(id);
if (!host) return;
const { text, tone } = glances[id] || { text: "", tone: "" };
host.hidden = !text;
host.textContent = text;
host.classList.toggle("panelGlanceOk", tone === "ok");
host.classList.toggle("panelGlanceWarn", tone === "warn");
});
},
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
// is nothing to take back, and afterwards the header would be offering to unwind a
// hand that has already been paid — so the button comes and goes with the hand
// rather than sitting there all night waiting to be pressed by accident.
renderHeaderActions() {
const misdeal = document.getElementById("misdealBtn");
if (!misdeal) return;
misdeal.hidden = !this.state.bettingOpen;
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
// Pot has no pot, so both are visibly not-yet rather than sitting there looking
// ready.
//
// This used to be half of a colour argument as well — Resolve Pot was the accent
// green, so the loudest control on a new dealer's first screen was the one that
// could do the least. The disabling fixed that for the empty table and left it
// everywhere else: measured over a whole hand, the header was green at all
// seventeen steps, including the four where the bar held the only button there
// was to press. The accent has since gone to the bar's own next-step button,
// where it belongs at every stage rather than at one of them, so what is left
// here is only the part about what a control can do — which is still worth
// saying, because a shortcut that cannot work yet should not look ready.
//
// Dimmed rather than hidden: hiding them would move the header the moment the
// first player is named, and a control that vanishes teaches nothing about what
// it was for.
//
// Dimmed rather than `disabled`, because there is a reason and it was only ever
// readable with a mouse. `disabled` took both of these out of the tab order, so
// the sentence hung off them never reached a screen reader, and it swallowed the
// tap on the device this tool is built for — leaving a dealer's first screen
// holding two grey buttons that answered nothing when pressed. See Unavailable.
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
[["resolvePotBtn", "Nothing to resolve yet — seat a player first."],
["nextHandBtn", "Nothing to move on from — seat a player first."]]
.forEach(([id, why]) => {
const button = document.getElementById(id);
if (!button) return;
// Next Hand explains itself in the markup, and that sentence has to
// survive being borrowed for the disabled reason and handed back.
if (button.dataset.titleWhenUsable === undefined) {
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
}
Unavailable.set(button, nothingToRunYet, why);
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
if (title) button.setAttribute("title", title);
else button.removeAttribute("title");
});
},
// Whether this device can actually drag a seat. HTML5 drag events are never
// generated from a touch screen, so on a phone the last clause of the hint below
// described something impossible — on the device the rest of the sentence was
// written for. Touch gets told about the menu route instead, which works.
canDragSeats() {
return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches;
},
renderTableHint() {
const hint = document.getElementById("tableHint");
// Gestures are invisible until someone tells you about them, and this is the
// only place on the table with room to say so.
hint.textContent = this.canDragSeats()
? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats."
: "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat.";
// But only on the first hand. Two lines of instructions under the felt are worth
// having on a table nobody has dealt at yet, and worth nothing on the fifth hand
// of the night — by then the dealer either knows the gestures or is not going to
// learn them from a caption they have scrolled past forty times.
//
// Keyed to the hand number rather than to whether a hand is currently out, on
// purpose. `bettingOpen` was the obvious test and it is the wrong one: the hint
// would vanish at every deal and come back at every payout, so a two-line block
// under the felt would appear and disappear twice a hand, shunting the rulings
// link and the whole panel stack up and down all night. This way it goes once,
// at the first deal of the session, and stays gone.
hint.hidden = (Number(this.state.handNumber) || 1) > 1 || !!this.state.bettingOpen;
},
renderNotes() {
const notesEl = document.getElementById("tableNotes");
if (document.activeElement !== notesEl) notesEl.value = this.state.notes || "";
},
updatePotBadge() {
// Chips still sitting in front of players on the current street are part of
// the pot as far as anyone at the table is concerned, so count them — waiting
// until the street closes would show a stale number for most of every hand.
const swept = HoldemState.totalPot(this.state);
const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0);
document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet);
},
// ---- Seats around the table (compact pucks) ----
// The widest the ring has ever been allowed to be. These are the numbers the ring
// used to be pinned at, kept as a ceiling so nothing gets looser than it was.
RING_MAX_RX: 44,
RING_MAX_RY: 42,
// A little daylight between the outermost puck and the rail.
SEAT_EDGE_MARGIN_PX: 6,
// Below this the ring has collapsed into the pot, which is worse than an overhang.
RING_MIN_R: 18,
// How far out the ring can sit and still keep a whole seat on the table.
//
// It used to be 44% by 42%, flat, at every size — two numbers picked against a
// phone. A fixed pair can only be right for one ratio of puck to felt, and the
// felt's ratio changes with every viewport: measured, at 1280x900 nine-handed,
// five of nine pucks finished past the outer edge of the table, the worst by 49px,
// and on a phone held sideways all six did. Seats floating off the felt onto the
// page read as the table being too small for the game rather than as players at a
// rail — and it is a different amount of wrong on every screen, which is what makes
// it look like a mistake rather than a choice.
//
// So it is derived rather than declared: the widest ring whose outermost seat still
// finishes inside the table. Falls back to the old pair whenever there is nothing to
// measure, which is every call from the unit tests and the first render before the
// felt has been laid out.
// `puckSize` is the footprint the caller has just measured, badges included. Without
// one this falls back to the first chair on the felt, which is what it always did —
// and which is wrong whenever some other chair is the taller one.
seatRingRadii(puckSize = null) {
const fallback = { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY };
if (typeof document === "undefined") return fallback;
const box = this.ringBox();
const table = document.getElementById("pokerTable");
const puck = document.querySelector(".seat");
if (!box || !box.width || !box.height || !table || !puck) return fallback;
const tableBox = table.getBoundingClientRect();
const puckBox = puckSize || puck.getBoundingClientRect();
if (!tableBox.width || !puckBox.width) return fallback;
// Worked in absolute pixels rather than from half-widths, because the seat
// layer is *not* centred on the table: its insets differ top from bottom (the
// landscape rule sets a floor on the top edge and a ceiling on the bottom), so
// assuming symmetry left the shorter side still hanging over. Measured that
// way, a phone still had 12px of puck past the rail after the first attempt.
//
// The reach that matters is to the nearer of the two edges.
const reach = (layerCentre, nearEdge, farEdge, puckSide, boxSide) => {
const room = Math.min(layerCentre - nearEdge, farEdge - layerCentre);
return ((room - puckSide / 2 - this.SEAT_EDGE_MARGIN_PX) / boxSide) * 100;
};
return {
rx: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RX,
reach(box.left + box.width / 2, tableBox.left, tableBox.right,
puckBox.width, box.width))),
ry: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RY,
reach(box.top + box.height / 2, tableBox.top, tableBox.bottom,
puckBox.height, box.height))),
};
},
seatPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
return this.positionOnRing(index, count, radii.rx, radii.ry, aspect);
},
// How many times wider than tall the felt actually is, which is the one thing the
// ring needs from the layout and can't work out from percentages. Falls back to a
// circle when there's nothing to measure — the same answer the ring gave before it
// asked.
feltAspect() {
const box = this.ringBox();
return box ? box.width / box.height : 1;
},
// The box a seat's left/top percentages actually resolve against. Not the felt: on
// a phone the seat layer is inset by half a puck so the outermost seats don't hang
// off the screen, and measuring the felt instead put the ring's shape out by that
// inset — twelve seats at 640x360 were computed 90px apart and drawn 60px apart,
// which is the difference between "fine" and "overlapping".
ringBox() {
if (typeof document === "undefined") return null;
const layer = document.getElementById("seatsLayer") || document.getElementById("pokerTableWrap");
if (!layer) return null;
const box = layer.getBoundingClientRect();
return box.width && box.height ? box : null;
},
// Bets sit on the felt between their player and the pot, the way chips actually
// do — closer in than the seats, on the same spoke. The bets layer is inset to
// match the seat ring (see the CSS), so this one radius holds at every size.
//
// The angle is the seat's own rather than one walked again round a tighter ellipse.
// The two answers are within half a degree of each other, so this is not a fix for
// anything visible — it is just the honest way to say "on that player's spoke",
// and it saves the second walk.
betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii(), spacing = null) {
const seat = this.seatPosition(index, count, aspect, radii);
if (!spacing || !spacing.box || !spacing.box.width || !spacing.box.height) {
// Fractions of the seat's own reach, not of the felt — so when the ring pulls
// in to keep the pucks on the table, the chips come with it and stay on the
// same spoke, the same proportion of the way to the pot. This is what the
// ring always did, and it is still the answer when there is nothing to
// measure: every call from the unit suite, and the first render.
return {
left: 50 + (seat.left - 50) * (27 / 44),
top: 50 + (seat.top - 50) * (25 / 42),
};
}
// With measurements to hand, the chip is placed by clearance rather than by
// proportion. A fixed fraction is a guess about how much room there is between a
// puck's inner edge and the pot, and the answer changes with the shape of the
// felt: on a phone held sideways the ring pulls right in, 27/44 of a small reach
// is inside the puck, and every one of the five chips on the felt was painted
// behind a seat — the seats carry `z-index: 1` and the bets layer carries none,
// so a chip that lands under a puck is simply gone.
const { box, puck, chip, clearance } = spacing;
const dx = ((seat.left - 50) / 100) * box.width;
const dy = ((seat.top - 50) / 100) * box.height;
const reach = Math.hypot(dx, dy);
if (reach < 1) return { left: seat.left, top: seat.top };
const ux = dx / reach, uy = dy / reach;
const inset = this.clearanceReach(puck, chip, ux, uy) + clearance;
// Never nearer the middle than this: the pot badge lives there, and a chip on
// top of the pot total is the same fault at the other end of the spoke.
const pulled = Math.max(reach * 0.38, reach - inset);
const scale = pulled / reach;
return {
left: 50 + ((dx * scale) / box.width) * 100,
top: 50 + ((dy * scale) / box.height) * 100,
};
},
// Seats spaced evenly around the rim — by the distance between them, not by the
// angle they sit at.
//
// Equal angles are only equal spacing on a circle. The felt is an ellipse, and a
// phone on its side makes it a very flat one: 601x126 at 640x360. Ten seats at 36°
// apart then put four of them down each steep side, where 36° of turn is barely any
// distance — seats 3 and 4 came out at the same x and 20px apart vertically, each
// puck 72px tall. Their centres landed on each other, so tapping a player got their
// neighbour, and the layout audit had been calling it at 640x360 and 667x375.
//
// Walking the perimeter instead puts the seats where a real table has them: along
// the two long rails, one at each end. The arc length is summed numerically because
// an ellipse's has no closed form — 720 steps is far more than enough for a rim a
// few hundred pixels long, and it runs once per render.
positionOnRing(index, count, rx, ry, aspect = 1) {
return this.ringPositions(count, rx, ry, aspect)[index];
},
// Past this much wider than tall, the rim stops being an ellipse and becomes a
// stadium — two straight rails with a semicircular cap at each end.
//
// An ellipse that flat has nowhere to put the seats near its ends. At 640x360 with
// the bar grown by a side pot the felt is 616x96, and six seats came out with two
// of them stacked 25px apart at each end, pucks 64px tall. No puck size fixes 25px:
// the shape is wrong. A stadium's cap is a half-circle of the felt's own height, so
// an end holds one seat and the rest of the table is rail — which is how a real
// poker table is built, and why it is shaped that way.
//
// Only where it is needed. Anything rounder than this keeps the ellipse it has
// always had: measured, the cut-off lands between a landscape phone (4.4 and up)
// and every other layout the tool has (1.45 at 1280x900, 1.0 to 1.4 on a phone
// upright), so no currently-working table changes shape.
STADIUM_RATIO: 2.5,
// Seats spaced evenly around whichever rim this table has, as percentages.
ringPositions(count, rx, ry, aspect = 1) {
if (count <= 0) return [];
const halfWidth = rx * aspect;
const halfHeight = ry;
if (halfWidth <= halfHeight * this.STADIUM_RATIO) {
return this.ringAngles(count, rx, ry, aspect).map(angle => ({
left: 50 + rx * Math.cos(angle),
top: 50 + ry * Math.sin(angle),
}));
}
return this.stadiumPositions(count, halfWidth, halfHeight, aspect);
},
// The same walk, around a stadium instead. Its perimeter has a closed form, so
// this needs no sampling: two rails of 2(A-B) and two caps of piB.
stadiumPositions(count, halfWidth, halfHeight, aspect) {
const rail = halfWidth - halfHeight;
const cap = Math.PI * halfHeight;
const perimeter = 4 * rail + 2 * cap;
return Array.from({ length: count }, (_, seat) => {
// Clockwise from the top centre, so seat one sits where it always has.
let walked = (perimeter * seat) / count;
let x;
let y;
if (walked <= rail) { // top rail, going right
x = walked;
y = -halfHeight;
} else if (walked <= rail + cap) { // right cap
const turned = (walked - rail) / halfHeight;
x = rail + halfHeight * Math.sin(turned);
y = -halfHeight * Math.cos(turned);
} else if (walked <= 3 * rail + cap) { // bottom rail, going left
x = rail - (walked - rail - cap);
y = halfHeight;
} else if (walked <= 3 * rail + 2 * cap) { // left cap
const turned = (walked - 3 * rail - cap) / halfHeight;
x = -rail - halfHeight * Math.sin(turned);
y = halfHeight * Math.cos(turned);
} else { // top rail, back to the start
x = -rail + (walked - 3 * rail - 2 * cap);
y = -halfHeight;
}
return { left: 50 + x / aspect, top: 50 + y };
});
},
RING_STEPS: 720,
ringCache: {},
// The closest two neighbouring seats come to each other, straight line, in pixels.
//
// Rim length divided by seat count was the first answer and it is the wrong one:
// it measures the walk between two seats rather than the gap between them, and on
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
// and two seats a long walk apart are stacked one above the other.
closestSeatGapPx(count, box = this.ringBox(), radii = this.seatRingRadii()) {
if (count < 2 || !box || !box.width || !box.height) return Infinity;
// The ring the seats are actually drawn on, not the widest one they could be.
// This asked about a flat 44/42 while the renderer had pulled the ring in to
// keep the pucks on the table, so it measured gaps that did not exist: in
// landscape it reported plenty of room and the seats were overlapping each
// other's centres, which the audit caught as taps landing on the wrong player.
const points = this.ringPositions(count, radii.rx, radii.ry, box.width / box.height).map(seat => ({
x: (seat.left / 100) * box.width,
y: (seat.top / 100) * box.height,
}));
return points.reduce((closest, point, i) => {
const next = points[(i + 1) % points.length];
return Math.min(closest, Math.hypot(point.x - next.x, point.y - next.y));
}, Infinity);
},
// A puck is 76px across, so neighbours whose centres are closer than that overlap
// by definition; a few pixels more keeps them from touching. Under it the pucks
// come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 84,
// The range a puck may be drawn at. The bottom of it is where a name stops being a
// name: at 44px the tight layout shows about five characters and an ellipsis, which
// is still "Chris…" rather than nothing.
SEAT_WIDTH_MAX: 96,
SEAT_WIDTH_MIN: 44,
// The step the search walks down in. Six pixels is finer than the difference between
// two font sizes and coarse enough to settle in ten tries.
SEAT_WIDTH_STEP: 6,
// And the ceiling when the felt has room to spare, which on anything tablet-sized or
// larger it does. 96px was the only ceiling there was, so the felt grew with the
// window and the seats did not: measured nine-handed, the pucks covered 19% of the
// felt on a phone, 11% on a 1280px desktop and 8% at 1440px and beyond, with the
// stack figure a dealer reads sitting at 10px on a 2560px monitor.
//
// 144 rather than further. Past it the extra width buys nothing a dealer can use —
// at 144 a 15px "Mike from work" is already on one line, which is the longest thing
// a name is asked to be — and a puck much wider than its own contents stops reading
// as a seat and starts reading as a card.
SEAT_WIDTH_ROOMY_MAX: 144,
// Coarser above the ordinary ceiling, because up there the difference is 8% of the
// puck rather than the difference between two font sizes, and every candidate width
// costs a forced layout.
SEAT_WIDTH_ROOMY_STEP: 12,
// And the puck may not be more than a fifth of the shorter span of the ring.
//
// The containment arithmetic keeps a puck inside the table's *box*; the rail it
// looks like it should stay inside is an ellipse, and at the top and bottom of the
// ring the ellipse curves away from the box. Seats have always sat a little over
// the rail because of it — measured before any of this, 10px on a desktop and 46px
// on a phone held sideways — and the wider the puck, the wider that gap opens.
//
// Which made the first version of this a bad trade on exactly the screens it was
// supposed to leave alone. A 144px puck on an iPhone's 278px ring is half the width
// of the table: six-handed, all six sat over the rail by 15px against 5px before,
// and it read as a stack of cards rather than a table with people around it.
//
// A fifth is measured, not chosen. The shorter span is 248-318px on every phone in
// the sweep including sideways, and 672-978px on tablets and desktops, so any
// divisor between about 3.3 and 7 splits them; five sits in the middle with room
// either side. Above it the pass is skipped and a phone measures exactly the ten
// widths, and settles on exactly the puck, that it did before.
SEAT_ROOMY_SPAN_RATIO: 5,
// A puck past the ordinary ceiling gets the larger type, which is the point of the
// extra width — see `.seatsRoomy` in holdem.css.
SEAT_ROOMY_FROM_PX: 108,
// Below this a puck cannot hold two lines of name and a row of hole cards, so the
// typography drops to the tight set in one go. Width scales continuously; type
// changes deliberately, which is the split the modern guidance actually draws —
// clamp for the continuum, a query for the things meant to be discrete.
SEAT_TIGHT_BELOW_PX: 76,
// Daylight between two pucks. They are rounded, so touching reads as overlapping.
SEAT_CLEARANCE_PX: 4,
// And between a puck's inner edge and the chip sitting in front of it.
BET_CLEARANCE_PX: 5,
// How far a marker hangs off the puck it belongs to — the `top` and `bottom` on
// .blindBadge and on .dealerChip in holdem.css, which seatFootprint.test.js holds this
// to. It points away from the table at both ends; what it must not do is reach
// *sideways*, which is the other thing that test checks.
//
// Charged once per seat, not once per marker, and the two markers are why that is
// still right: they share one edge and sit at opposite ends of it, so a seat wearing
// both is no taller than a seat wearing either.
SEAT_BADGE_OVERHANG_PX: 5,
// How much space two neighbouring seats need between their centres.
//
// Kept for closestSeatGapPx's sake and no longer what decides the layout. It was a
// proxy — a single distance standing in for "do these two boxes want the same
// pixels" — and the answer it gave was wrong in both directions: a 96px puck was
// allowed within 84px of its neighbour on the theory that rounded corners forgive a
// little overlap, and the overlap it forgave turned out to be 20px of a stack
// figure. seatsCollide asks the real question instead.
SEAT_ROOM_RATIO: 84 / 96,
seatRoomNeededPx() {
if (typeof document === "undefined") return this.SEAT_ROOM_PX;
const puck = document.querySelector(".seat");
if (!puck) return this.SEAT_ROOM_PX;
const width = puck.getBoundingClientRect().width;
return width ? width * this.SEAT_ROOM_RATIO : this.SEAT_ROOM_PX;
},
// Does any pair of seats want the same pixels?
//
// Two equal axis-aligned boxes overlap exactly when their centres are closer than
// one box in *both* axes, so this answers the question rather than standing in for
// it. The distance test it replaces could not: distance has one number in it and
// the pucks are half again as wide as they are tall, so the same 84px was generous
// sideways and nowhere near enough on the diagonal.
//
// Every pair, not only neighbours — this is the half that was actually missing.
// On a felt 528px wide and 87 tall the ring is very nearly a straight line, and the
// seat that lands on top of you is the one across the table rather than the one
// beside you. Measured on the running page before this went in: ten of the tables
// in the sweep had a collision between two seats that a neighbours-only check
// cannot see, five of them at 640x360, where seats 1 and 7 overlapped by 68px.
seatsCollide(count, box, radii, width, height, clearance = this.SEAT_CLEARANCE_PX) {
if (count < 2 || !box || !box.width || !box.height) return false;
const room = { x: width + clearance, y: height + clearance };
const points = this.ringPositions(count, radii.rx, radii.ry, box.width / box.height)
.map(seat => ({
x: (seat.left / 100) * box.width,
y: (seat.top / 100) * box.height,
}));
for (let a = 0; a < points.length; a++) {
for (let b = a + 1; b < points.length; b++) {
if (Math.abs(points[a].x - points[b].x) < room.x &&
Math.abs(points[a].y - points[b].y) < room.y) return true;
}
}
return false;
},
// The box a seat actually takes up, at whatever width is applied right now.
//
// Widest and tallest are taken separately and across every chair, because a
// two-line name makes one puck taller than its neighbours and the ring has to have
// room for the worst of them, not the first one it measures.
measuredSeatBox() {
if (typeof document === "undefined") return null;
const seats = document.querySelectorAll("#seatsLayer .seat");
if (!seats.length) return null;
let width = 0, height = 0;
seats.forEach(seat => {
const box = seat.getBoundingClientRect();
width = Math.max(width, box.width);
height = Math.max(height, box.height);
});
return width && height ? { width, height } : null;
},
// What two seats have to clear each other by, which is the puck and nothing else.
//
// The blind badge was the reason to think otherwise, and it was worth chasing: "SB"
// was painted across the next player's $1,137 on an iPhone Pro Max whose pucks were
// not touching at all, so something was reaching past a puck's own box.
//
// It was reaching sideways. The badge sat at `left: -6px`, hanging off the puck's
// left corner onto whatever was beside it, and that is fixed where it was caused —
// in the stylesheet, by moving the badges inside the puck's own width.
//
// Its remaining overhang is vertical: `top: -9px` on the upper arc, `bottom: -9px`
// on the lower one, so it always points away from the table and two seats facing
// each other across the felt wear their badges back to back.
//
// That is why it was left out of this for a while, and the reasoning has one hole
// in it. "Two seats on the same arc wear them at the same height" is true of a
// straight rail and false of a ring: neighbours on a curve sit at different heights,
// so the badge that points away from the table on one of them points at the corner
// of the other. Measured, three tables in the sweep had a player's stack painted
// under the next player's SB or BB — the badge outranks everything on the felt, so
// what a dealer reads there is the wrong number entirely.
//
// Counted once, not twice. Both boxes here are the same size, so the separation
// seatsCollide asks for is one box plus this — which is exactly right for two seats
// each wearing a badge on the same side, and generous for the far commoner case
// where only one of them is in the blinds at all.
//
// Adding it was tried once before and abandoned, because a phone held sideways gave
// the ring 87px of height and two rows of 55px pucks had 18px between them: nine
// more was unsatisfiable at any width. That felt is 205px tall now — see the rail in
// holdem.css — and the shortage that made this impossible is the one that block went
// and fixed.
seatFootprint(puckBox) {
return puckBox && {
width: puckBox.width,
height: puckBox.height + this.SEAT_BADGE_OVERHANG_PX,
};
},
applySeatWidth(layer, width) {
layer.style.setProperty("--seatPuckW", `${width}px`);
layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_PX);
layer.classList.toggle("seatsRoomy", width >= this.SEAT_ROOMY_FROM_PX);
},
// Is it worth asking about pucks wider than the ordinary ceiling?
//
// Arithmetic, not a measurement, and that is the whole point: closestSeatGapPx is
// the tightest centre-to-centre distance the seats will sit at on the widest ring
// they could have, so if that already clears the widest ordinary puck and its
// clearance, the felt has room going spare and the coarse pass above the ceiling
// will find some of it. If it does not — every phone in the sweep, at every seat
// count — the pass is skipped and exactly the ten widths that were measured before
// are measured now.
//
// Worth being strict about, because the candidates are not free. Raising the ceiling
// for everybody took the layout pass from 24ms to 73ms on a twelve-handed desktop,
// and it is the phones, which would gain nothing at all, that can least afford it.
roomForWiderPucks(count, box) {
if (!box) return false;
if (this.roomyWidthCeiling(box) <= this.SEAT_WIDTH_MAX) return false;
const gap = this.closestSeatGapPx(count, box, this.seatRingRadii());
return gap > this.SEAT_WIDTH_MAX + this.SEAT_CLEARANCE_PX;
},
// The widest a puck may be drawn on this felt, whatever the seats would allow.
roomyWidthCeiling(box) {
if (!box || !box.width || !box.height) return this.SEAT_WIDTH_MAX;
const span = Math.min(box.width, box.height) / this.SEAT_ROOMY_SPAN_RATIO;
return Math.min(this.SEAT_WIDTH_ROOMY_MAX, Math.floor(span));
},
// Widest first, because the widest that fits is the one we want.
seatWidthCandidates(count, box) {
const widths = [];
if (count != null && this.roomForWiderPucks(count, box)) {
const ceiling = this.roomyWidthCeiling(box);
for (let w = ceiling; w > this.SEAT_WIDTH_MAX; w -= this.SEAT_WIDTH_ROOMY_STEP) {
widths.push(w);
}
}
for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) {
widths.push(w);
}
widths.push(this.SEAT_WIDTH_MIN);
return widths;
},
// What the table has changed about the shape of its pucks — which is now almost
// nothing, and that is the point.
//
// This used to name every player's name, the dealer's seat, whether anybody had hole
// cards and whether anybody had chips out, because each of those could change how
// tall a puck was and the ring is sized against the tallest puck on it. Four things,
// three of which flip during an ordinary hand, and every flip is a ten-candidate
// re-search: ten widths applied and read back, ten forced layouts, on a phone, while
// somebody is waiting to know whether the bet is called.
//
// A puck's height no longer depends on any of them. The name gets one line whatever
// it says, the amount pill holds its row whether or not there is an amount in it, and
// the button's chip changes a padding rather than a line count — see the row-height
// tokens in holdem.css. What is left is the hole-card row, which still collapses when
// the table isn't tracking cards, because that is a decision a table makes once and
// not a thing that changes under a dealer's thumb.
//
// A boolean over the whole table rather than a list per seat, because that is what
// the measurement is: measuredSeatBox takes the tallest puck on the ring, so what
// matters is whether *anybody* has one.
//
// Being wrong here is a stale ring rather than a wrong one, and it would not stay
// wrong: renderSeats measures the finished pucks against the box the search settled
// on and asks again if they no longer match. That check is what catches a fifth thing
// nobody thought of, and it is what catches the first render of all — where the pucks
// are built empty and the search that used to run against them was measuring nine
// blank boxes and caching the answer under this table's key.
seatShapeSignature() {
if (!this.state) return "";
return this.state.seats.some(p => (p.cards || []).some(Boolean)) ? "c" : "";
},
// Which width the pucks get, and how wide a ring they sit on.
//
// This used to choose between two numbers — 96px or 58px — and when neither fitted
// it widened the ring and let the seats overlap, on the reasoning that a dealer can
// see an overhang but cannot see a tap landing on the wrong player. That trade was
// real and it was also a false choice, because the tables that overlapped did not
// want either number. Measured across the sweep at nine seats: a Galaxy S8 had room
// for a 61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79.
// Handing all four the same 58 or the same 96 is how a puck ends up twenty pixels
// inside its neighbour with a stack figure underneath it — which is what the running
// page did, on every phone in the list, at the most ordinary table there is.
//
// So the width is searched for rather than chosen. There is a width that works on
// every table in the sweep, down to a dozen seats on the smallest phone: the code
// was never asking for it.
//
// Walked from the top rather than bisected. Feasibility is *nearly* monotone — a
// narrower puck sits on a wider ring and has a smaller box — but not quite, because
// a narrower puck can wrap a name onto a second line and come back taller. Walking
// down takes the widest that actually measures clean, which is the honest answer;
// bisection would take the widest that a model of the puck says is clean.
//
// Measuring is the expensive half — each width has to be applied and read back, and
// that is a forced layout apiece — so it happens once per width, up front. Which
// ring each width then sits on is arithmetic on numbers already in hand.
//
// The ring is allowed to grow past the one that keeps every seat on the felt, but
// only after every width has been tried on the contained one. That order is the
// trade the old code named and got right: a dealer can see a puck over the rail;
// they cannot see that the seat they pressed wasn't the one that answered. It only
// ever comes up on a phone held sideways, where the felt is a 692x117 slot.
RING_GROWTH_STEPS: 8,
resolveSeatLayout(count) {
const layer = document.getElementById("seatsLayer");
if (!layer) return { radii: this.seatRingRadii() };
const box = this.ringBox();
const key = box &&
`${Math.round(box.width)}x${Math.round(box.height)}:${count}:${this.seatShapeSignature()}`;
// Ten candidate widths mean ten forced layouts, and renderSeats runs after every
// tap a dealer makes. Nothing in the key changes between one tap and the next
// unless the table itself has, so within a hand this is measured once and read
// back afterwards.
if (key && this.seatLayoutCache && this.seatLayoutCache.key === key) {
this.applySeatWidth(layer, this.seatLayoutCache.width);
return this.seatLayoutCache;
}
const candidates = [];
for (const width of this.seatWidthCandidates(count, box)) {
this.applySeatWidth(layer, width);
const puck = this.measuredSeatBox();
if (!puck) {
// Nothing on the felt to measure at all. Take the widest and say nothing
// about it — no `puck`, so renderSeats' second pass knows this answer was
// not measured and asks again once there is something to measure.
//
// It used to cache this one, on the reasoning that "the resize that
// follows" would put it right. Nothing resizes a phone. A table opened
// upright kept a ring measured before it had any seats on it until the
// dealer turned the phone sideways and back.
return { width, radii: this.seatRingRadii() };
}
candidates.push({
width,
puck,
contained: this.seatRingRadii(puck),
footprint: this.seatFootprint(puck),
tooTall: this.tooTallForTheTable(),
});
}
const fits = (candidate, radii) => !candidate.tooTall &&
!this.seatsCollide(count, box, radii, candidate.footprint.width, candidate.footprint.height);
let chosen = null;
for (let step = 0; step <= this.RING_GROWTH_STEPS && !chosen; step++) {
const t = step / this.RING_GROWTH_STEPS;
for (const candidate of candidates) {
const radii = {
rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t,
ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * t,
};
if (fits(candidate, radii)) {
chosen = { width: candidate.width, radii, puck: candidate.puck };
break;
}
}
}
// Nothing cleared at any width on any ring. Keep the narrowest puck on the
// widest ring, and let the audit's "no seat covers another seat's writing" say
// so out loud rather than the layout quietly papering over it.
if (!chosen) {
const last = candidates[candidates.length - 1];
chosen = {
width: last.width,
radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY },
puck: last.puck,
};
}
this.applySeatWidth(layer, chosen.width);
if (key) this.seatLayoutCache = { key, ...chosen };
return chosen;
},
updateSeatDensity() {
if (!this.state) return;
this.resolveSeatLayout(this.state.seatCount);
},
// Room is not only sideways.
//
// "Tight" used to mean one thing: seats close enough together to bump into each
// other. On a phone held sideways the felt is 141px tall and the pucks had grown to
// 88 — two-line names — so six seats had all the horizontal room in the world and
// could not fit top to bottom whatever the ring did. The gap check said everything
// was fine and 20px of every seat hung over the rail.
//
// A puck more than half the table's height is the honest test: two seats on
// opposite sides of the ring would then have to overlap the pot to fit at all, so
// the pucks are what has to give.
tooTallForTheTable() {
if (typeof document === "undefined") return false;
const table = document.getElementById("pokerTable");
const puck = document.querySelector(".seat");
if (!table || !puck) return false;
const tableBox = table.getBoundingClientRect();
const puckBox = puck.getBoundingClientRect();
if (!tableBox.height || !puckBox.height) return false;
return puckBox.height > tableBox.height * 0.5;
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
// Every seat on a ring wants the same answer, so it is worked out once per shape
// and kept — the ring only changes when the table is resized or the window is.
ringAngles(count, rx, ry, aspect) {
if (count <= 0) return [];
const key = `${count}:${rx}:${ry}:${aspect.toFixed(3)}`;
if (this.ringCache[key]) return this.ringCache[key];
const start = -Math.PI / 2;
// Percentages of the width and of the height are not the same distance, so the
// horizontal radius is scaled into the vertical one's units before measuring.
const a = rx * aspect;
const b = ry;
const step = (2 * Math.PI) / this.RING_STEPS;
const cumulative = [0];
for (let i = 1; i <= this.RING_STEPS; i++) {
const from = start + (i - 1) * step;
const to = start + i * step;
const dx = a * (Math.cos(to) - Math.cos(from));
const dy = b * (Math.sin(to) - Math.sin(from));
cumulative.push(cumulative[i - 1] + Math.hypot(dx, dy));
}
const perimeter = cumulative[this.RING_STEPS];
const angles = [];
let walked = 0;
for (let seat = 0; seat < count; seat++) {
const wanted = (perimeter * seat) / count;
while (walked < this.RING_STEPS && cumulative[walked + 1] < wanted) walked++;
// Interpolate inside the step the seat falls in, so the answer doesn't
// jump in 0.5° increments.
const spanned = cumulative[walked + 1] - cumulative[walked];
const into = spanned > 0 ? (wanted - cumulative[walked]) / spanned : 0;
angles.push(start + (walked + into) * step);
}
this.ringCache[key] = angles;
return angles;
},
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
// gets bets that look like the chips actually being pushed out.
chipColorFor(amount) {
const legend = [...(this.state.chipLegend || [])]
.filter(chip => Number(chip.value) > 0)
.sort((a, b) => Number(b.value) - Number(a.value));
const match = legend.find(chip => amount >= Number(chip.value));
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
},
// ---- Bets on the felt ----
makeBetChip(bet) {
const chip = document.createElement("div");
chip.className = "betChip";
// The denomination's colour, published for the anchored layout to put on the
// chip's rim — there the disc itself is 17px the puck hasn't got.
chip.style.setProperty("--betChipColour", this.chipColorFor(bet));
const disc = document.createElement("span");
disc.className = "betChipDisc";
disc.style.background = this.chipColorFor(bet);
const amount = document.createElement("span");
amount.className = "betChipAmount";
amount.textContent = Money.format(bet);
chip.appendChild(disc);
chip.appendChild(amount);
return chip;
},
// How far it is from the centre of an axis-aligned box to its edge, in a given
// direction. Exact, and it has to be: a puck is half again as wide as it is tall, so
// a single radius would be wrong on one axis or the other.
edgeReach(width, height, ux, uy) {
const ax = Math.abs(ux), ay = Math.abs(uy);
return Math.min(ax > 1e-6 ? (width / 2) / ax : Infinity,
ay > 1e-6 ? (height / 2) / ay : Infinity);
},
// How far one box has to travel from the centre of another, along a given direction,
// before the two are clear of each other.
//
// Not `edgeReach(puck) + edgeReach(chip)`, which is what this used to be and is only
// right when the direction is straight up, down or sideways. Two axis-aligned boxes
// are clear when they are apart on *one* axis, so the distance is the reach of the
// one box you get by adding their widths and adding their heights — and adding the
// two reaches is always less than that. Measured at 768x1024, a $10 leaving a seat on
// the diagonal wanted 88px and was given 77, and the chip came to rest 11px inside
// its own player's puck. Every seat off the four compass points had the same error,
// which is most of them on any table with more than four players.
clearanceReach(a, b, ux, uy) {
return this.edgeReach(a.width + b.width, a.height + b.height, ux, uy);
},
// Where an anchored chip sits relative to its own puck's centre: on the spoke,
// just clear of the puck's edge, on the side facing the pot.
//
// This started as four choices — above, below, and the two sides — which is better
// than the one it replaced (always above or below, so a seat at the side of the ring
// wore its bet over its own name) and still wrong, because four choices have three
// seams. Measured on a Galaxy S8: seat 4 is on the right arc and took the left edge,
// seat 5 is on the bottom arc and took the top edge, and at the corner between the
// two arcs their chips overlapped by 11px. The seam is the bug, so there are no
// sides now — the direction is the spoke itself, which is also where a chip actually
// goes. A player on the right of the table pushes their bet to their left.
// Off the rim rather than down the spoke, which are the same direction only on a
// round table. A phone held sideways gives the felt 692x117: the ring is a slot, the
// spoke to a seat on the top arc points diagonally inward, and "inward" there runs
// along the rim straight into the next seat. Off the rim it is very nearly straight
// down, which is where the empty felt is. The inward normal of the ellipse the ring
// is drawn on gives both answers from one expression.
anchoredChipOffset(seatPos, puck, chip, box, radii, clearance = this.BET_CLEARANCE_PX) {
const { x: ux, y: uy } = this.rimNormal(seatPos, box, radii);
const travel = this.clearanceReach(puck, chip, ux, uy) + clearance;
return { x: ux * travel, y: uy * travel };
},
// Straight off the rim, inward: the unit normal of the ellipse the seat ring is
// drawn on, at that seat.
rimNormal(seatPos, box, radii) {
const dx = ((seatPos.left - 50) / 100) * box.width;
const dy = ((seatPos.top - 50) / 100) * box.height;
const a = Math.max(1, (radii.rx / 100) * box.width);
const b = Math.max(1, (radii.ry / 100) * box.height);
const nx = -dx / (a * a);
const ny = -dy / (b * b);
const length = Math.hypot(nx, ny);
// A seat sitting on the middle of the felt has no rim to be off. Send its chip
// upward rather than dividing by nothing.
if (length < 1e-9) return { x: 0, y: -1 };
return { x: nx / length, y: ny / length };
},
// What is standing in the middle of the felt, in the bets layer's own pixels.
//
// The pot total and, when the dealer is tracking it, the board above it. Both live
// on `#pokerTable` rather than on either overlay, and the two are not concentric —
// the layers are inset by different amounts top and bottom — so this is measured
// rather than assumed to be at 50%, 50%. One box for the pair of them, because a
// chip keeping clear of the middle is one question, not two.
feltCentreBox(box) {
const centre = document.getElementById("tableCentre") || document.getElementById("potBadge");
if (!centre) return null;
const at = centre.getBoundingClientRect();
if (!at.width || !at.height) return null;
return {
x: at.left + at.width / 2 - box.left,
y: at.top + at.height / 2 - box.top,
width: at.width, height: at.height,
};
},
// How far each anchored chip actually gets to travel, once the others are taken
// into account.
//
// Off the rim is only somewhere to go if there is felt out there. At 640x360 the
// table is 528x83 and a seat puck is 55 of those 83 pixels tall: clearing it takes
// 41px, the players facing each other across the table are 70px apart, and both
// chips set off 41px into a gap that is 70 wide. Measured on the running site, a
// twelve-handed table had three pairs of chips on top of each other and a six-handed
// one had two — every pair a number about one player's money hiding a number about
// another's.
//
// Each chip asks for the room it wants and gives ground only to a chip it is
// actually on top of, a pixel at a time until the pair is clear. Two rules that
// looked simpler were tried first and both cost more than they saved. Stopping every
// chip at the felt's midline punishes the seats at the ends of a long rail, which
// are 70px from their nearest neighbour and were never going to hit anything —
// measured at 692x113, it parked two chips on top of their own player's stack to
// solve a collision that did not exist. Scaling every chip's travel by one factor
// has the same fault in the other direction: the end seats give up the same
// proportion as the crowded ones and end up inside their own pucks.
//
// Where a chip is pulled all the way back it rests on its own puck's inner edge.
// Overlapping the edge of your own puck costs a corner of your own stack;
// overlapping the chip opposite costs both amounts outright.
CHIP_PULLBACK_STEP_PX: 1,
CHIP_PULLBACK_PASSES: 80,
anchoredChipTravel(chips, count, aspect, radii, spacing) {
const { box, puck, clearance, pot } = spacing;
const centreOf = pos => ({ x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height });
const pucks = Array.from({ length: count }, (_, i) =>
centreOf(this.seatPosition(i, count, aspect, radii)));
const rows = chips.map(({ index, width, height }) => {
const seatPos = this.seatPosition(index, count, aspect, radii);
const dir = this.rimNormal(seatPos, box, radii);
const want = this.clearanceReach(puck, { width, height }, dir.x, dir.y) + clearance;
return { index, width, height, dir, travel: want, seat: centreOf(seatPos) };
});
const at = row => ({ x: row.seat.x + row.dir.x * row.travel,
y: row.seat.y + row.dir.y * row.travel });
// Touching counts as clear. Two boxes that share an edge to within half a pixel
// are not hiding anything from anybody, and treating that as a collision costs a
// whole step of travel: on a felt where the seats are symmetric about the middle,
// a facing pair lands exactly edge to edge and every one of them was then pulled
// back another pixel for nothing.
const TOUCHING = 0.5;
const hits = (a, b, w, h) =>
Math.abs(a.x - b.x) < w - TOUCHING && Math.abs(a.y - b.y) < h - TOUCHING;
for (let pass = 0; pass < this.CHIP_PULLBACK_PASSES; pass++) {
let moved = false;
const back = row => {
if (row.travel <= 0) return;
row.travel = Math.max(0, row.travel - this.CHIP_PULLBACK_STEP_PX);
moved = true;
};
// Somebody else's puck. It cannot move, so the chip does all the giving —
// and it is the neighbour's puck that matters, not its own, which the chip
// is anchored to and is allowed to rest on. Off the rim on a felt 83px tall
// is straight into the row of seats opposite: measured at 640x360, six of
// the seven chips on a ten-handed table were painted under a stranger's
// puck, which on this layer means painted out of existence.
rows.forEach(row => {
const p = at(row);
if (pucks.some((seat, i) => i !== row.index &&
hits(p, seat, (row.width + puck.width) / 2,
(row.height + puck.height) / 2))) back(row);
});
// And the pot total, which is what is in the middle of the felt and the one
// number on the table that belongs to everybody. A seat on the middle of a
// rail sends its chip straight at it: on a Galaxy S8 on its side, seat 1's
// $10 was sitting on top of "Pot $65".
if (pot) {
rows.forEach(row => {
if (hits(at(row), pot, (row.width + pot.width) / 2,
(row.height + pot.height) / 2)) back(row);
});
}
for (let a = 0; a < rows.length; a++) {
for (let b = a + 1; b < rows.length; b++) {
if (!hits(at(rows[a]), at(rows[b]),
(rows[a].width + rows[b].width) / 2,
(rows[a].height + rows[b].height) / 2)) continue;
// The one that has come further gives way; if they are level, both
// do, which is what a facing pair across a narrow table always is.
const lead = Math.max(rows[a].travel, rows[b].travel);
[rows[a], rows[b]].forEach(row => {
if (row.travel < lead - 0.01) return;
back(row);
});
}
}
if (!moved) break;
}
const travels = {};
rows.forEach(row => {
travels[row.index] = { x: row.dir.x * row.travel, y: row.dir.y * row.travel };
});
return travels;
},
// A phone's felt has no room for a third ring: measured every radius between the
// seats and the pot badge and each one collides with one or the other. So on a
// narrow screen the bet clings to its own puck instead — same chip, no ring —
// sitting on whichever side faces the middle of the table.
//
// `radii` is the ring the seats were actually drawn on, handed down by renderSeats.
// Asking for it again here was wrong once the ring stopped being a fixed pair: the
// width search settles on a radius that can be wider than the one seatRingRadii()
// derives on its own, so the chips were being laid out on a ring the seats had left.
renderBets(aspect = this.feltAspect(), radii = this.seatRingRadii()) {
const layer = document.getElementById("betsLayer");
if (!layer) return;
layer.innerHTML = "";
// The phone layout puts the chip *inside* its seat, and seats are no longer
// thrown away and rebuilt on every render — they are kept and updated in place,
// which is what made renderAll fast enough to run after every tap. Clearing the
// layer therefore stopped clearing these, and nothing else ever did: measured on
// an iPhone 14, a seat had one chip after the deal, four after three more
// renders, and five by the third call. A dealer's every tap left another disc on
// the felt, and the pile only ever grew.
document.querySelectorAll(".seat .betChip").forEach(chip => chip.remove());
// Put them on the felt first, position them second. A chip has to be in the
// document before it can say how wide it is, and how wide it is decides both
// where it goes and whether there is room for a ring at all — so this is one
// write, one read, one write, rather than a read for every chip in a loop that
// is also writing to the same layout.
const placed = [];
this.state.seats.forEach((player, index) => {
const bet = Betting.committed(player);
if (bet <= 0) return;
const chip = this.makeBetChip(bet);
layer.appendChild(chip);
placed.push({ chip, index });
});
if (!placed.length) return;
const box = this.ringBox();
const puck = this.measuredSeatBox();
// Each chip's own size, not the first one's standing in for all of them. A chip
// is as wide as the number on it: measured on a phone held sideways, "$5" is
// 21px and "$10" is 27px, so taking the first chip's width laid out every $10 on
// the felt as if it were 6px narrower than it is — six pixels of somebody else's
// puck. Taking the *widest* instead is wrong the other way: at 768x1024 a single
// 47px chip made the whole table give up a ring that the other four had room for.
const sizes = placed.map(({ chip, index }) => {
const at = chip.getBoundingClientRect();
return { index, width: at.width, height: at.height };
});
if (!box || !puck || !sizes.some(s => s.width)) return;
const spacing = { box, puck, clearance: this.BET_CLEARANCE_PX, pot: this.feltCentreBox(box) };
// Whether the chips get a ring of their own is a question about how much felt
// there is between the seats and the pot, and it was being answered by asking
// how wide the *window* is. A phone held sideways is 844px across and has 117px
// of felt to work with: it took the ring, and every chip on it was painted behind
// a puck — the seats carry `z-index: 1` and the bets layer carries none, so a
// chip that lands under a seat is simply gone.
//
// Measured instead, and only measured. The width test was left in front of the
// measurement as a belt-and-braces `||`, which meant every phone still took the
// anchored layout whatever the felt said — including the ones with room for a
// ring, where clinging to the puck is the worse of the two: a chip that has been
// pulled back onto its own puck sits on that player's stack.
const anchorToSeat = !this.chipsClearTheSeats(sizes, aspect, radii, spacing);
// Worked out across all of them at once, because how far one chip can go off the
// rim depends on where the others have got to.
const offsets = anchorToSeat
? this.anchoredChipTravel(sizes, this.state.seatCount, aspect, radii, spacing)
: null;
const sizeOf = index => sizes.find(s => s.index === index);
placed.forEach(({ chip, index }) => {
if (anchorToSeat) {
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!seat) return;
chip.classList.add("betChipAnchored");
seat.appendChild(chip);
const offset = offsets[index];
chip.style.left = `calc(50% + ${offset.x.toFixed(1)}px)`;
chip.style.top = `calc(50% + ${offset.y.toFixed(1)}px)`;
return;
}
const size = sizeOf(index);
const pos = this.betPosition(index, this.state.seatCount, aspect, radii,
{ ...spacing, chip: { width: size.width, height: size.height } });
chip.style.left = `${pos.left}%`;
chip.style.top = `${pos.top}%`;
});
},
// Would a ring of chips keep clear of every puck on the felt, of the pot, and of
// each other?
//
// The same box-against-box question the seats settle among themselves, asked across
// the two layers. betPosition() walks a chip in along its own spoke until it is clear
// of its own puck, but it will not walk past 38% of the way to the middle, because
// the pot badge is there — and on a felt 117px tall that floor leaves the chip
// sitting on the seat opposite.
//
// That 38% is a guess at where the pot badge ends. Where the badge has actually been
// measured, the guess gives way to it: a ring that lands a chip on the pot total is
// no more use than one that lands it on a puck.
chipsClearTheSeats(chips, aspect, radii, spacing) {
const { box, puck, pot } = spacing;
const count = this.state.seatCount;
const seats = this.state.seats.map((_, i) => {
const pos = this.seatPosition(i, count, aspect, radii);
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height };
});
// Each chip is as wide as the number on it, and asking the question with one
// size for all of them answers it for a table that isn't there: at 768x1024 a
// single 47px "$1,200" made the whole felt give up a ring the other four 40px
// chips had room for, and dropped them all onto their own players' stacks.
const at = ({ index, width, height }) => {
const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } });
return { x: (pos.left / 100) * box.width, y: (pos.top / 100) * box.height, width, height };
};
const placed = chips.map(at);
// Chips against each other, which this never asked. A ring is a smaller circle
// than the seats' one and holds the same number of things, so it is the tighter
// fit of the two: on a Pixel 7 with twelve players, two $10s on the ring sat on
// top of each other while every puck on the felt was clear. The anchored layout
// resolves its own collisions one chip at a time; the ring has no such recourse,
// so a ring that collides is a ring this table cannot have.
for (let a = 0; a < placed.length; a++) {
for (let b = a + 1; b < placed.length; b++) {
if (Math.abs(placed[a].x - placed[b].x) < (placed[a].width + placed[b].width) / 2 &&
Math.abs(placed[a].y - placed[b].y) < (placed[a].height + placed[b].height) / 2) {
return false;
}
}
}
return chips.every(({ index, width, height }) => {
const pos = this.betPosition(index, count, aspect, radii, { ...spacing, chip: { width, height } });
const x = (pos.left / 100) * box.width;
const y = (pos.top / 100) * box.height;
if (pot && Math.abs(x - pot.x) < (width + pot.width) / 2 &&
Math.abs(y - pot.y) < (height + pot.height) / 2) {
return false;
}
return seats.every(seat =>
Math.abs(x - seat.x) >= (width + puck.width) / 2 ||
Math.abs(y - seat.y) >= (height + puck.height) / 2);
});
},
puckLabel(player, seatIndex) {
return HoldemState.displayName(player, seatIndex);
},
// Builds the pucks once and keeps them. Everything below this line updates the
// nodes that are already on the felt.
//
// A seat's node belongs to its index for as long as the table is that size, so the
// listeners can close over the index the way they always did. Only a change in the
// number of chairs throws the row away.
buildSeatNodes(layer, count) {
const template = document.getElementById("seatTemplate");
const nodes = [];
for (let index = 0; index < count; index++) {
const node = template.content.firstElementChild.cloneNode(true);
node.dataset.seatIndex = String(index);
node.querySelector(".seatChairNumber").textContent = index + 1;
// Tapping the puck is the same act as "Give the button" in the seat menu,
// so it goes through the same hook — otherwise this one path would move
// the button without moving the blinds that follow it.
node.querySelector(".dealerChip").addEventListener("click", e => {
// The seat that already has the button has nothing to give itself, and
// this handler swallows the event — so a chip that answered here would
// be a hole in its own seat. CSS keeps the pointer off it; the keyboard
// can still reach it, and this is what it finds.
if (index === this.state.dealerSeatIndex) return;
e.stopPropagation();
this.hooks.onGiveButton(index);
});
this.wireSeatGestures(node, index);
this.wireDragAndDrop(node, index);
nodes.push(node);
}
layer.replaceChildren(...nodes);
},
// Drawing the seats used to mean throwing all of them away and building them again:
// `innerHTML = ""`, then a template clone, three sets of event listeners and a
// dozen attributes per chair, on every render. Nine seats is ninety nodes, and
// renderAll() runs after every single tap a dealer makes.
//
// Measured on a nine-handed table: renderAll was 12.4ms, of which this function was
// 7.3. That is most of a frame gone before the browser has drawn anything, on a
// desktop — a phone is several times slower, which is where the tool actually runs
// and where a dealer's tap felt like it landed a moment late.
//
// Nothing about what gets drawn changes here. The pucks are built once and then
// updated in place, which is what the rest of this function was already doing to
// their contents anyway.
renderSeats() {
const layer = document.getElementById("seatsLayer");
const count = this.state.seats.length;
if (layer.childElementCount !== count) this.buildSeatNodes(layer, count);
// Settles the tight class and the ring radius together, and hands back the
// radius it settled on — so the loop below doesn't have to ask again. Both of
// these read the layout back, so they are taken once, here, before the loop
// starts writing to it: asked per seat they would force the browser to
// recompute the layout on every iteration.
let { radii, puck } = this.resolveSeatLayout(count);
const aspect = this.feltAspect();
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
// fixing. Once it's the dead button it means the opposite — it's exactly where
// it belongs — so it gets marked rather than looking like the thing to correct.
const deadButtonHere = this.state.deadButton &&
HoldemState.deadPositionsApply(this.state) &&
!this.state.seats[dealerIndex].inHand;
this.state.seats.forEach((player, index) => {
const node = layer.children[index];
if (!node) return;
this.placeSeat(node, index, aspect, radii);
// The chair number is set once, in buildSeatNodes — a seat's index is the
// one thing about it that cannot change while the node exists.
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
// The seat used to be marked too — `seatHasButton` — so the puck's text could
// keep out from under a chip that sat in its corner. The chip is on the rim
// with the blind badges now and there is nothing to keep out of, so the seat
// no longer needs to know which of nine chairs the chip is on.
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
// The label now says whose it is, or what pressing it would do.
const who = (player.name || "").trim() || `seat ${index + 1}`;
dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
const chipLabel = hasButton
? (deadButtonHere
? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
: `${who} has the dealer button`)
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
const sbBadge = node.querySelector(".sbBadge");
const bbBadge = node.querySelector(".bbBadge");
sbBadge.classList.toggle("visible", index === sbIndex);
bbBadge.classList.toggle("visible", index === bbIndex);
// A blind the dealer named by hand isn't where the button would have put
// it, so it's marked — otherwise the table looks like it's just wrong.
// The `!!` matters: getBlindSeats leaves `manual` undefined when the
// blinds are positional, and classList.toggle with an undefined second
// argument *flips* the class instead of clearing it, so the marker
// appeared and disappeared on alternate renders.
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
// A straddle is a blind too, and the seat wearing it is the one the action
// comes back to. Marked from what the deal actually posted rather than from
// the count, so the badge can't outlive the chips it stands for — before
// the cards there is nothing out there yet.
const straddleBadge = node.querySelector(".strBadge");
const straddled = (this.state.postedStraddles || []).includes(index);
straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
});
this.fitSeatNames(layer);
this.fitAmounts(this.seatAmountCells(layer));
// The pucks are only now what they will be drawn as, and the ring was sized
// before any of it went in. Usually those agree — a puck's height is a stack of
// rows the stylesheet holds open, and none of the writing above changes it.
//
// Twice they don't. The first render of all builds nine empty pucks and then
// fills them, so a search run against that measured blank boxes; and any
// assumption in seatShapeSignature that turns out to be wrong lands here too.
// Both used to be permanent, because the answer was cached under a key naming a
// table the measurement had never seen: a phone that never resizes kept a ring
// sized for empty seats for the whole session.
//
// So the finished pucks are measured against the ones the ring was built from,
// and if they have moved the search is asked again with the real thing in front
// of it. Once. The second answer is cached and the render after this one reads it
// back, which is what stops this being a loop.
if (this.seatBoxMoved(puck)) {
this.seatLayoutCache = null;
({ radii, puck } = this.resolveSeatLayout(count));
for (let index = 0; index < layer.childElementCount; index++) {
this.placeSeat(layer.children[index], index, aspect, radii);
}
this.fitSeatNames(layer);
// The puck is a different width than the one these were fitted to.
this.fitAmounts(this.seatAmountCells(layer));
}
const settled = this.settleSeatsOnTable(layer, aspect, radii);
// Kept so the board can ask for the chips to be laid out again if placing it
// changes what is in the middle of the felt — see placeBoard().
this.lastBetLayout = { aspect, radii: settled };
this.renderBets(aspect, settled);
},
// Are the pucks on the felt still the puck the ring was measured for?
//
// A pixel of slack, the same as settleSeatsOnTable's, because the two measurements
// are taken at different moments and a sub-pixel difference between them is a
// rounding artefact rather than a table that has changed shape. Without it this
// would ask the search to run again on every render for the rest of the session.
//
// No box at all means the search never measured one — the first render, before the
// pucks were filled in. That is exactly the case worth asking again about.
seatBoxMoved(puck) {
if (!puck) return true;
const now = this.measuredSeatBox();
if (!now) return false;
return Math.abs(now.width - puck.width) > this.SEAT_SETTLE_SLACK_PX ||
Math.abs(now.height - puck.height) > this.SEAT_SETTLE_SLACK_PX;
},
// One seat, onto the ring.
placeSeat(node, index, aspect, radii) {
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
node.style.left = `${pos.left}%`;
node.style.top = `${pos.top}%`;
// Which side of the seat the pot is on, which is the side its bet chip arrives
// from — the chip sits on the same spoke, closer in. For a seat on the top of
// the ring that's from below; on the bottom, from above.
//
// The blind badge has to be on the other side or the chip lands on top of it.
// Measured on a 390px phone: the big blind's own $2 chip covered 196 of the
// badge's 288 square pixels, and it sits a layer higher, so the marker telling
// the dealer who the big blind is was simply gone.
node.classList.toggle("seatBelowCentre", pos.top > 50);
},
// Every seat on the table, checked against the table rather than predicted onto it.
//
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
// the rail, and it is right about that — but it is right about a puck it measured
// before the ring moved, before the width search settled, and before a name was
// shortened or wrapped to a second line. Every one of those changes a puck's height
// by up to 15px after the arithmetic is done. Measured on the running page, four of
// the thirteen viewports finished with the top seat 5-6px past the rail: not a wrong
// formula, a stale input to a right one.
//
// So the ring is settled the way the layout audit judges it — by looking at where
// the pucks actually landed. If the worst of them hangs 6px over, the ring comes in
// by 6px and they are placed again. Two passes, because the first correction is
// exact and the second only exists in case moving them changed a height again.
//
// Never past RING_MIN_R. Below that the ring has collapsed into the pot, which is a
// worse table than one with a puck touching the rail — and a felt that small is the
// case the floor was put there for.
SEAT_SETTLE_PASSES: 2,
// The audit's own slack. Anything under a pixel is a rounding difference between
// two ways of measuring the same edge, and chasing it would move the ring forever.
SEAT_SETTLE_SLACK_PX: 1,
settleSeatsOnTable(layer, aspect, radii) {
const table = typeof document !== "undefined" && document.getElementById("pokerTable");
const box = this.ringBox();
if (!table || !box || !box.width || !box.height) return radii;
let current = radii;
for (let pass = 0; pass < this.SEAT_SETTLE_PASSES; pass++) {
const felt = table.getBoundingClientRect();
if (!felt.width || !felt.height) return current;
let overX = 0;
let overY = 0;
for (let index = 0; index < layer.childElementCount; index++) {
const seat = layer.children[index].getBoundingClientRect();
if (!seat.width || !seat.height) continue;
overX = Math.max(overX, felt.left - seat.left, seat.right - felt.right);
overY = Math.max(overY, felt.top - seat.top, seat.bottom - felt.bottom);
}
if (overX <= this.SEAT_SETTLE_SLACK_PX && overY <= this.SEAT_SETTLE_SLACK_PX) return current;
const next = {
rx: Math.max(this.RING_MIN_R, current.rx - (Math.max(0, overX) / box.width) * 100),
ry: Math.max(this.RING_MIN_R, current.ry - (Math.max(0, overY) / box.height) * 100),
};
// The floor has taken over, and pulling in further is no longer something
// this can do. Stopping is the honest answer; looping would only spend
// another layout to be told the same thing.
if (next.rx === current.rx && next.ry === current.ry) return current;
current = next;
for (let index = 0; index < layer.childElementCount; index++) {
this.placeSeat(layer.children[index], index, aspect, current);
}
}
return current;
},
// Names that don't fit are shortened at a word, not chopped in the middle of one.
//
// A puck is 34px of text on a nine-handed phone, and the label wraps to two lines
// and then ellipsises. What that produced for the four real names on the test table
// was "Christo…", "Mike / from…", "Alexan…" — and "Mike from work" is the one of
// those where the ellipsis is throwing away nothing the dealer needs. He is Mike.
// The rest of it is how this table tells him from the other Mike.
//
// So a name that overflows drops whole trailing words until it fits: "Mike from
// work" becomes "Mike from", then "Mike", and stops there with a real word rather
// than a fragment.
//
// What is left when there is no word to drop is one word too long for its puck, and
// the ellipsis used to be the end of the story: "Christo…", which is a name only
// until a Christine sits down. The amounts on the same puck have never accepted that
// trade — see fitAmount, which steps a tournament stack's type down rather than
// rounding it off — and a misread name is the same kind of failure as a misread
// stack. So the last resort before the ellipsis is one step of type. Every name that
// has one, not only the ones with a spare word, because "Christopher" is exactly the
// case this is for.
//
// Written as passes over all the seats rather than a loop per seat: each pass writes
// to every puck and then reads back from every puck, so the browser lays the felt out
// a fixed number of times whatever the table's size. Interleaved, nine seats would
// have cost nine layouts, and this runs on every render.
fitSeatNames(layer) {
if (!layer) return;
const labels = [];
this.state.seats.forEach((player, index) => {
const node = layer.children[index];
const el = node && node.querySelector(".seatPuckName");
if (!el || !el.textContent) return;
const full = (player.name || "").trim();
// The tooltip is the whole name whether or not this one gets shortened: a
// label clamped to its one line was already hiding some of it.
el.title = full;
// Put back whatever a previous run of this took off, so what gets measured
// below is the name against the puck it has now. Normally a no-op —
// updatePuckContent has just written the full name — and not a no-op on the
// second pass of a render that had to resolve the ring twice, which is the
// one time a puck's width changes underneath a name already fitted to it.
if (el.textContent !== full) el.textContent = full;
// Cleared before anything is measured, so a name that fitted last render at
// a smaller size is measured at the size it is meant to be read at. Left on,
// it would be a ratchet: once a puck went small it could never come back.
el.classList.remove("puckNameTight");
labels.push({ el, words: full.split(/\s+/).filter(Boolean), kept: 0 });
});
if (!labels.length) return;
labels.forEach(label => { label.kept = label.words.length; });
for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) {
const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el));
if (!shorten.length) break;
shorten.forEach(label => {
label.kept = this.dropAWord(label.words, label.kept);
label.el.textContent = label.words.slice(0, label.kept).join(" ");
});
}
// One read for the whole table, then one write, in that order — the same reason
// the loop above is written in passes.
labels
.filter(label => this.overflowing(label.el))
.forEach(label => label.el.classList.add("puckNameTight"));
},
// Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike
// from", which is not a shorter name — it is a name that looks cut off, and it
// reads worse than the ellipsis it was meant to replace. Whereas "Mike" is just
// Mike, which is what the table calls him.
//
// Small and closed on purpose. This is not trying to understand names; it is the
// handful of English joining words that turn up in the way people label each other
// at a home game — "Mike from work", "Al with the hat", "Dee at the end". Anything
// it doesn't know stays, which is the safe direction to be wrong in: a name that
// keeps a word it could have dropped is merely long.
NAME_TAIL_WORDS: new Set(["from", "of", "the", "at", "in", "on", "with", "and", "&",
"de", "van", "von", "da", "di", "der", "la", "le"]),
// One word off, plus any joining word that leaves behind. Never below one.
dropAWord(words, kept) {
let next = Math.max(1, kept - 1);
while (next > 1 && this.NAME_TAIL_WORDS.has(words[next - 1].toLowerCase())) next -= 1;
return next;
},
// A pass is a layout of the whole felt, and this runs on every render, so the budget
// is small. Three is more than the names actually need: "Mike from work" reaches
// "Mike" in one, because dropAWord takes the joining word with it, and a name of
// four plain words reaches its first in three.
SEAT_NAME_PASSES: 3,
// Past the box in either direction. Width is the one that answers now that a name
// cannot wrap — see .seatPuckName — and height is kept because it costs nothing and
// is the honest question for anything else that ever asks this.
overflowing(el) {
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
},
isRedSuit(card) {
return /[HD]$/i.test(card || "");
},
SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" },
// "KD" -> "K♦". Stored cards stay two plain characters so every existing reader
// keeps working; the suit only becomes a symbol on its way to a human.
formatCard(card) {
const value = (card || "").toUpperCase();
if (!value) return "";
return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`;
},
// An empty slot has to look like something you can tap, so it gets a placeholder
// rather than going blank.
paintCardSlot(slot, card) {
const value = (card || "").toUpperCase();
slot.classList.toggle("cardRed", this.isRedSuit(value));
slot.classList.toggle("cardSlotEmpty", !value);
slot.textContent = value ? this.formatCard(value) : "+";
},
// The community cards, in the middle of the felt.
//
// Only the cards the dealer has actually turned get a slot: three after the flop,
// four after the turn. A row of five gaps before the flop would be the tool asking
// for cards that are still in the deck, and on a phone it is a row of furniture in
// the middle of the table earning nothing.
//
// Hidden entirely unless the dealer asked for it. Everything about this feature is
// opt-in — a table that does not want to enter cards should not have a board on it,
// or a picker opening at them every street.
renderBoard() {
const row = document.getElementById("boardRow");
if (!row) return;
const showing = this.state.trackBoard
? Betting.boardCardsFor(this.state.bettingOpen ? this.state.street : "preflop")
: 0;
const strip = document.getElementById("boardStrip");
row.hidden = showing === 0;
if (strip) strip.hidden = true;
row.innerHTML = "";
if (showing === 0) return;
const board = this.state.board || [];
for (let slot = 0; slot < showing; slot++) {
const card = (board[slot] || "").toUpperCase();
const btn = document.createElement("button");
btn.type = "button";
btn.className = "boardCard";
btn.classList.toggle("cardRed", this.isRedSuit(card));
btn.classList.toggle("boardCardEmpty", !card);
btn.setAttribute("aria-label", card
? `${CardPicker.boardSlotLabel(slot)}, ${this.formatCard(card)}`
: `${CardPicker.boardSlotLabel(slot)}, not turned yet`);
if (card) {
const rank = document.createElement("span");
rank.textContent = card[0];
const suit = document.createElement("span");
suit.className = "boardCardSuit";
suit.textContent = this.SUIT_SYMBOLS[card[1]] || "";
btn.appendChild(rank);
btn.appendChild(suit);
} else {
btn.textContent = "+";
}
btn.addEventListener("click", () => this.openBoardPicker(slot));
row.appendChild(btn);
}
this.placeBoard(row, strip);
},
// Daylight between the board and the nearest seat puck.
BOARD_CLEARANCE_PX: 10,
// The smallest middle worth putting five cards in. Below this the board comes off
// the felt entirely rather than being squeezed into something unreadable.
BOARD_MIN_W_PX: 150,
// Where the board goes: in the middle of the felt, or on a strip above it.
//
// The middle is the right place for it and the place a real table keeps it — but a
// phone on its side has no middle. The felt there is 528x83 and a seat puck is 55 of
// those pixels tall, so the ring closes over the centre entirely: measured at
// 640x360, the board was under six seat pucks at a full table and two at a
// six-handed one, and the seats are painted above it, so what the dealer got was a
// board with players on top of it.
//
// So when the middle cannot hold it, it comes off the felt and sits in its own strip
// above the table, at the same size with nothing over it. Decided by measuring the
// room rather than by naming a viewport, because how much middle a felt has depends
// on how many people are sitting down at least as much as on the size of the screen.
//
// Called after the cards are in the DOM: how wide the middle can be depends on how
// tall what is in it turned out to be, and that is not a thing to guess at when the
// answer is one measurement away.
placeBoard(row, strip) {
const centre = document.getElementById("tableCentre");
const width = this.centreRoomWidth(centre);
const onStrip = width < this.BOARD_MIN_W_PX;
const was = strip ? !strip.hidden : false;
const before = this.centreSignature(centre);
if (onStrip) {
if (strip && row.parentElement !== strip) strip.appendChild(row);
if (strip) strip.hidden = false;
if (centre) { centre.style.width = ""; centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
} else {
if (centre && row.parentElement !== centre) centre.insertBefore(row, centre.firstChild);
if (strip) strip.hidden = true;
if (centre) {
// Both, and the width is the half that was missing.
//
// `max-width` caps a box; it does not claim anything. The centre is a
// flex column that sizes to its content, so with 463px of measured room
// written into its max-width it went on being 136px wide — and the board
// row inside it is `width: 100%` of that, which is a loop that comes to
// rest on the cards' own `min-width`. Measured with the board on: the
// middle had 294px on an iPad, 463px on a desktop and 542px on a wide
// one, and in all three the five community cards drew 24px across. On
// the strip, where the row simply gets the width of the page, the same
// cards draw 44. They are buttons — a tap on one opens the picker for
// that card — so 24px was a little over half the floor this sweep holds
// every other control on the felt to.
centre.style.width = `${Math.round(width)}px`;
centre.style.maxWidth = `${Math.round(width)}px`;
}
}
// The chips were laid out against whatever was in the middle of the felt a
// moment ago, and putting the board there — or taking it away — has just changed
// that. Only when it actually changed: on every render after the first, this is
// the same box it already was and the chips stay where they are.
if (this.centreSignature(centre) !== before && this.lastBetLayout) {
this.renderBets(this.lastBetLayout.aspect, this.lastBetLayout.radii);
}
// A strip appearing above the table moves the table down, and how tall the felt
// may be is worked out from where it starts — a number nothing else recomputes,
// because it is watched by way of the action bar's height and the bar has not
// changed. Without this the seats keep the height they had and the bottom row
// ends up behind the Fold button.
if (was === onStrip) return;
// Which re-lays the seats and their chips if the felt has changed height as a
// result — see publishFeltRoom(). Once, not in a loop: a felt that has just
// given up height to the strip cannot then have room for the board back.
if (typeof ActionBar !== "undefined" && ActionBar.publishHeight) ActionBar.publishHeight();
},
centreSignature(centre) {
if (!centre) return "";
const at = centre.getBoundingClientRect();
return `${Math.round(at.left)}:${Math.round(at.top)}:${Math.round(at.width)}:${Math.round(at.height)}`;
},
// How wide the middle of the felt can be without a seat puck landing on it.
//
// Not the ring's clearance at its widest point, which is what this was first written
// as and is only the answer for a seat sitting exactly on the horizontal axis. Every
// other seat is nearer the middle than that, and on a nine-handed phone table two of
// them were on top of the board while the ring said there was 77px of room either
// side. So it is asked of each seat in turn: a seat that clears the board vertically
// sets no limit on its width at all, and one that doesn't sets the limit to however
// far away it is.
centreRoomWidth(centre) {
const box = this.ringBox();
const puck = this.measuredSeatBox();
if (!centre || !box || !puck) return 0;
const radii = this.seatRingRadii(puck);
const count = this.state.seatCount;
const clearance = this.BOARD_CLEARANCE_PX;
// Its natural height, not whatever a previous pass clamped it to.
const wasMaxWidth = centre.style.maxWidth;
centre.style.maxWidth = "";
const halfHeight = centre.getBoundingClientRect().height / 2;
centre.style.maxWidth = wasMaxWidth;
let halfWidth = box.width / 2;
this.ringPositions(count, radii.rx, radii.ry, box.width / box.height).forEach(seat => {
const dx = Math.abs(((seat.left - 50) / 100) * box.width);
const dy = Math.abs(((seat.top - 50) / 100) * box.height);
if (dy >= halfHeight + puck.height / 2 + clearance) return;
halfWidth = Math.min(halfWidth, dx - puck.width / 2 - clearance);
});
return Math.max(0, Math.round(halfWidth * 2));
},
// Opens the picker on the board, at `slot` or at the first gap.
openBoardPicker(slot = null) {
if (!this.state.trackBoard) return;
const showing = Betting.boardCardsFor(this.state.bettingOpen ? this.state.street : "preflop");
if (showing === 0) return;
const board = this.state.board || [];
const firstGap = board.slice(0, showing).findIndex(card => !card);
const at = slot === null ? (firstGap === -1 ? 0 : firstGap) : slot;
CardPicker.openBoard(this.state, {
onPick: () => {
HoldemState.save(this.state);
this.renderBoard();
},
}, at, showing);
},
// Who still has cards to turn over, in the order they turn them over.
//
// The board half of this feature has always worked: every street asks for the cards
// as they come off the deck. The hands half never did — the dealer entered five
// community cards, pressed the showdown button, and the pot wizard told them
// "Chris, Mike and Alex haven't turned over, so who won is still yours to say",
// with nowhere in the sheet to turn anybody over. The one moment the tool is certain
// to know the hands is the moment it stopped asking.
//
// Order is the showdown order the bar already announces out loud, then round the
// table from there — the sequence the cards actually come over in, so the dealer is
// never hunting for the seat the sheet has decided to ask about next.
//
// Anyone already entered is skipped. A dealer who typed a hand into the seat editor
// mid-hand has answered this question, and asking again would offer to overwrite it.
showdownCardQueue(state) {
if (!state.trackBoard) return [];
const live = Betting.liveSeats(state);
if (live.length < 2) return [];
const order = HoldemState.liveOrderFromButton(state)
.filter(index => live.some(seat => seat.index === index));
const first = Betting.showdownOrder(state);
const at = first ? order.indexOf(first.index) : -1;
const rotated = at > 0 ? order.slice(at).concat(order.slice(0, at)) : order;
return rotated.filter(index => {
const cards = state.seats[index].cards || [];
return !(cards[0] && cards[1]);
});
},
// Asks each of them for their two cards, one player after another, and then hands
// back so the pot can be resolved.
//
// Leaving the sheet stops the asking rather than skipping to the next player: a
// dealer who closes it is telling the tool it has the wrong end of the stick, and
// the answer to that is to get out of the way and let them settle the pot by hand,
// not to put the same sheet in front of them four more times. The wizard still
// names whoever didn't turn over, so nothing is lost by stopping early.
openShowdownPicker(onFinished) {
const queue = this.showdownCardQueue(this.state);
const finish = () => onFinished();
if (queue.length === 0) { finish(); return; }
const askNext = () => {
const index = queue.shift();
if (index === undefined) { finish(); return; }
const cards = this.state.seats[index].cards || [];
const slot = cards[0] ? 1 : 0;
CardPicker.open(this.state, {
onPick: () => {
HoldemState.save(this.state);
this.renderAll();
},
onDone: reason => (reason === "filled" ? askNext() : finish()),
}, index, slot);
};
askNext();
},
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
// Worth calling out, because at a short-handed table the open seats are what
// the dealer is looking for.
isEmptySeat(player) {
const named = !!(player.name && player.name.trim());
const contributing =
HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount) > 0;
return !named && !player.inHand && !contributing;
},
updatePuckContent(node, player, index) {
const empty = this.isEmptySeat(player);
const nameEl = node.querySelector(".seatPuckName");
// An open chair used to say so, in capitals, on every seat that had nobody in
// it — which on a table a dealer has just opened is the word EMPTY ten times
// across the felt, and it is the first thing they ever see this tool do.
//
// The puck already says it without a word: dashed instead of solid, faded, no
// stack under it. That reads at a glance, which is how a dealer looks at the
// felt, and it reads the same on the one open chair at a full table — which is
// the case the label was actually for. Repeating a word ten times is not
// emphasis, it's noise, and it was drowning the seat numbers underneath it.
nameEl.textContent = empty ? "" : (player.name || "").trim();
nameEl.classList.toggle("seatEmptyLabel", empty);
// Dashed-and-faded is a thing you can see, not a thing you can hear, so the
// word an open chair no longer prints goes here instead. Anyone reading the
// felt by ear gets more than they had: the seat number too, which the sighted
// version has always shown in the corner.
node.setAttribute("aria-label", empty
? `Seat ${index + 1}, empty`
: `Seat ${index + 1}, ${(player.name || "").trim() || "unnamed"}`);
const cardsEl = node.querySelector(".seatPuckCards");
cardsEl.innerHTML = "";
(player.cards || []).filter(Boolean).forEach((card, i) => {
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
const span = document.createElement("span");
if (this.isRedSuit(card)) span.className = "cardRed";
span.textContent = this.formatCard(card);
cardsEl.appendChild(span);
});
// Always this player's total for the hand. What they've just pushed out on this
// street is shown as a chip on the felt instead — the puck used to flip between
// the two meanings depending on the moment, which is a bad thing for a number
// sitting next to a stack size to do.
const handTotal = Betting.handTotal(player);
const total = handTotal > 0
? handTotal
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
const totalEl = node.querySelector(".seatPuckTotal");
totalEl.textContent = total > 0 ? Money.format(total) : "";
// The figure as it is meant to be read, kept beside the one that gets drawn.
// fitAmounts may take the separators out of what is on screen, and the pass
// after it has to start from the grouped figure again or the puck can only ever
// get further from it.
totalEl.dataset.figure = totalEl.textContent;
// A player with nothing behind used to leave this line blank, so the felt drew
// an all-in exactly like a seat whose stack nobody had entered. On a table with
// side pots that's the one distinction the dealer is working from — who can
// still be bet at, and who is already committed for everything they have.
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
const allIn = Betting.isAllIn(this.state, player);
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
stackEl.classList.toggle("seatPuckAllIn", allIn);
stackEl.dataset.figure = stackEl.textContent;
node.classList.toggle("seatAllIn", allIn);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
node.classList.toggle("seatEmpty", empty);
node.classList.toggle("seatInactive", !player.inHand && !empty);
node.classList.toggle("seatFolded", player.folded);
node.classList.toggle("seatLive", player.inHand && !player.folded);
},
// The two steps of type an amount can come down, in order, once taking the
// separators out has not been enough.
AMOUNT_STEPS: ["puckAmountLong", "puckAmountHuge"],
// Puts one amount back to the figure and the size it is meant to be read at, and
// hands back the grouped figure. Everything below measures from here, so a puck that
// once went small can come back — left as it was, this would be a ratchet.
resetAmount(el) {
const figure = el.dataset.figure === undefined ? el.textContent : el.dataset.figure;
if (el.textContent !== figure) el.textContent = figure;
this.AMOUNT_STEPS.forEach(step => el.classList.remove(step));
return figure;
},
// A seat puck is 64px of usable width, and 34 on the tightest ring in the sweep. A
// tournament stack doesn't fit in that and, because a number has no spaces to break
// on, it doesn't wrap or ellipsise either — it draws straight out over the seat next
// to it. Stepping the type down keeps every digit, which matters: a dealer misreading
// a stack is a worse outcome than a small one, so this shrinks rather than
// abbreviating.
//
// Measured, and it used to be counted. The old rule was eight characters for one
// step and ten for the other, which is a guess at how wide a figure is that happens
// to hold at one text size and one puck width. Turn a phone's text up — 20px of root
// text is what an Android slider gives you at 125%, and every size in this
// stylesheet is in `rem` on purpose — and it stops holding: measured at 360x640
// twelve-handed, "$1,200" is six characters, took no step at all, and wanted 38px of
// a 34px puck. The sweep caught it as a stack figure painted over the seat next door
// at every phone in the list. What the puck can hold is a question about the puck,
// so it is asked of the puck.
//
// Written as passes over every amount on the ring rather than a loop per seat: each
// pass writes to all of them and then reads back from all of them, so the browser
// lays the felt out four times whatever the table's size. Interleaved, twelve seats
// would have cost twenty-four layouts, and this runs on every render.
fitAmounts(cells) {
const live = cells.filter(el => el && (el.dataset.figure || el.textContent));
if (!live.length) return;
const figures = new Map(live.map(el => [el, this.resetAmount(el)]));
// The separators go before the type size does. "$1,234,567" is ten characters,
// which used to drop the puck's figures to 7px — and 7px is not a size anybody
// reads a stack off a felt at, so the shrink-rather-than-abbreviate rule above
// was buying a precision the dealer then couldn't see. Without its commas the
// same number is eight characters and fits two steps higher up this ladder.
// Grouping is a reading aid for a column of figures; this is one number on a
// 44px puck, and there the aid costs more than it gives.
const ungroup = live.filter(el => figures.get(el).includes(",") && this.overflowing(el));
ungroup.forEach(el => { el.textContent = figures.get(el).replace(/,/g, ""); });
let carried = null;
this.AMOUNT_STEPS.forEach(step => {
const over = live.filter(el => this.overflowing(el));
const previous = carried;
over.forEach(el => {
if (previous) el.classList.remove(previous);
el.classList.add(step);
});
carried = step;
});
},
// One puck's worth — for refreshPuck, which redraws a single seat.
fitAmount(el) {
this.fitAmounts([el]);
},
// Every amount on the ring, so fitAmounts can do the whole table in four layouts.
seatAmountCells(layer) {
const cells = [];
for (let index = 0; index < layer.childElementCount; index++) {
const node = layer.children[index];
cells.push(node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack"));
}
return cells;
},
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
// (which live in a separate panel) never lose focus.
refreshPuck(index) {
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!node) return;
this.updatePuckContent(node, this.state.seats[index], index);
this.fitAmounts([node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack")]);
},
// ---- Seat gestures ----
//
// One puck, three intents: tap to edit, double-tap to fold, press and hold for
// the quick menu. The single tap waits out the double-tap window before it
// fires — a fifth of a second of latency on opening a form, in exchange for a
// fold that doesn't also fling a sheet up over the table.
LONG_PRESS_MS: 500,
DOUBLE_TAP_MS: 220,
DRAG_SLOP_PX: 8,
pendingTap: null,
wireSeatGestures(node, index) {
let longPressTimer = null;
let longPressFired = false;
let startX = 0;
let startY = 0;
const cancelLongPress = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
node.addEventListener("pointerdown", e => {
longPressFired = false;
startX = e.clientX;
startY = e.clientY;
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
longPressFired = true;
this.cancelPendingTap();
node.classList.remove("seatPressing");
this.hooks.onSeatLongPress(index);
}, this.LONG_PRESS_MS);
node.classList.add("seatPressing");
});
// Moving means they're dragging the seat, not holding it.
node.addEventListener("pointermove", e => {
if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
cancelLongPress();
node.classList.remove("seatPressing");
}
});
["pointerup", "pointercancel", "pointerleave"].forEach(type => {
node.addEventListener(type, () => {
cancelLongPress();
node.classList.remove("seatPressing");
});
});
node.addEventListener("dragstart", () => {
cancelLongPress();
this.cancelPendingTap();
node.classList.remove("seatPressing");
});
node.addEventListener("click", e => {
// The long press already did something; don't also treat the release as a tap.
if (longPressFired) {
longPressFired = false;
e.preventDefault();
e.stopPropagation();
return;
}
this.handleSeatTap(index);
});
},
cancelPendingTap() {
if (this.pendingTap) {
clearTimeout(this.pendingTap.timer);
this.pendingTap = null;
}
},
handleSeatTap(index) {
if (this.pendingTap && this.pendingTap.index === index) {
this.cancelPendingTap();
this.hooks.onSeatDoubleTap(index);
return;
}
this.cancelPendingTap();
const timer = setTimeout(() => {
this.pendingTap = null;
this.toggleSeatEditor(index);
}, this.DOUBLE_TAP_MS);
this.pendingTap = { index, timer };
},
// Tapping the open seat again closes it — on a phone the editor is a sheet over
// the table, and the seat you just tapped is the closest thing to hand for
// dismissing it.
toggleSeatEditor(index) {
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
this.renderSeats();
this.renderSeatEditor();
},
wireDragAndDrop(node, index) {
node.addEventListener("dragstart", e => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
node.classList.add("dragging");
});
node.addEventListener("dragend", () => node.classList.remove("dragging"));
node.addEventListener("dragover", e => {
e.preventDefault();
node.classList.add("dropTarget");
});
node.addEventListener("dragleave", () => node.classList.remove("dropTarget"));
node.addEventListener("drop", e => {
e.preventDefault();
node.classList.remove("dropTarget");
const sourceIndex = Number(e.dataTransfer.getData("text/plain"));
const targetIndex = index;
if (Number.isNaN(sourceIndex) || sourceIndex === targetIndex) return;
const seats = this.state.seats;
[seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
this.hooks.save(this.state);
this.renderSeats();
if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) {
this.renderSeatEditor();
}
});
},
// ---- Seat editor panel (the full form for whichever seat is selected) ----
closeSeatEditor() {
this.selectedSeatIndex = null;
this.renderSeats();
this.renderSeatEditor();
},
renderSeatEditor() {
const title = document.getElementById("seatEditorTitle");
const body = document.getElementById("seatEditorBody");
// Drives the mobile bottom sheet: the panel only slides up over the table
// while a seat is actually being edited.
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
if (this.selectedSeatIndex === null) {
title.textContent = "Select a seat";
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
// Closing is the one size change a ResizeObserver never sees: the sheet
// leaves by transform and visibility, and its box stays exactly as tall as
// it was. Without this the toast would go on holding its place.
if (this.publishSeatSheetHeight) this.publishSeatSheetHeight();
return;
}
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
// Named and numbered. The chair number came off the puck's face this round —
// see HoldemState.seatHeading — and this is one of the two places it went.
title.textContent = HoldemState.seatHeading(player, index);
body.innerHTML = "";
const template = document.getElementById("seatEditorTemplate");
const form = template.content.firstElementChild.cloneNode(true);
this.wireSeatFields(form, player, index);
this.renderChipCounts(form, player, index);
this.renderCustomFields(form, player, index);
this.renderSeatTotal(form, player);
this.renderSeatDisclosures(form, player);
body.appendChild(form);
if (this.publishSeatSheetHeight) this.publishSeatSheetHeight();
},
// Which of the sheet's three folded sections stand open, and what each says while
// it is shut.
//
// The rule is the same for all three and it is about the seat rather than about the
// dealer: a section that has something in it opens, a section that is empty stays a
// line. That is what makes this progressive disclosure rather than a place to hide
// things — nothing a dealer has already entered is ever one tap further away than
// it was, and the sheet only carries the weight of what is actually on the chair.
//
// Hole cards are the one exception, and it is not really one: a table that asked to
// be shown the cards is a table where every seat is about to have some, so the
// slots open before the first card rather than after it.
//
// Called on open and after every commit, so a section fills in as the dealer types
// — the glance beside "What they've put in" is the total, and it is the number the
// boxes underneath are adding up.
renderSeatDisclosures(form, player) {
const set = (selector, hasContent, glance) => {
const panel = form.querySelector(selector);
if (!panel) return;
// Only ever forced open. Once the dealer has opened a section by hand,
// typing in it must not close it under them on the next keystroke, and
// `open = false` on a re-render would do exactly that.
if (hasContent) panel.open = true;
const label = panel.querySelector(".seatGlance");
if (label) label.textContent = glance || "";
};
const cards = (player.cards || []).filter(Boolean);
set(".cardsPanel", cards.length > 0 || !!this.state.trackBoard,
cards.length ? cards.map(card => this.formatCard(card.toUpperCase())).join(" ") : "none yet");
const counted = Object.values(player.chipCounts || {}).some(n => Number(n) > 0);
const contribution = HoldemState.computeContribution(
player, this.state.chipLegend, this.state.basePotAmount);
set(".contributionPanel", counted || player.amountOverride != null,
Money.format(contribution));
const notes = (player.customFields || []).filter(field => field.label || field.value).length;
set(".notesPanel", notes > 0, notes ? `${notes} ${notes === 1 ? "note" : "notes"}` : "");
},
renderSeatTotal(form, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
form.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
commitSeatChange(form, player, index) {
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(form, player);
// The sections' summaries are downstream of nearly every field in the sheet —
// a card picked, a chip counted, an amount typed — so they are refreshed on the
// same path the total is. Safe on every keystroke for the same reason: this
// writes to the summary labels and never to a field the dealer is inside.
this.renderSeatDisclosures(form, player);
this.refreshPuck(index);
// The editor deliberately refreshes narrowly rather than redrawing everything —
// a full render on every keystroke would take the name field out from under the
// dealer mid-word. But the action bar answers "is there a table here at all",
// and seating somebody is exactly what changes that answer. Without this it
// stayed hidden until something else happened to redraw: two players seated, a
// dealable hand, and a blank bar until the page was reloaded.
//
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
// anyway, and nothing inside it can hold focus while the sidebar has it.
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
// And the header answers the same question the bar does — Resolve Pot and Next
// Hand are switched off until somebody is at the table, and typing a name is
// exactly the keystroke that changes that. Caught by walking the first-run flow:
// after naming a player both stayed greyed out, still captioned "nobody is at
// the table yet", because this path deliberately doesn't redraw everything.
this.renderHeaderActions();
// Third thing on this list for the third time the same reason. What a seat has
// bought in for and what it is holding are the two halves of the bank check, so
// this path is the one that moves "Balanced" to "Off by $300" — and the shut
// Standings panel is where a dealer would see it, since the seat editor is over
// the sidebar while they are typing.
this.renderPanelGlances();
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
// Putting a name on an empty chair is somebody sitting down, so they get
// dealt in. It used to be two separate acts, and the second one was easy to
// miss: a dealer setting up typed six names, got six seats that still
// weren't in the hand, and the action bar hid itself because by its reckoning
// nobody was playing. Six people at the table and a blank screen.
//
// Only for a chair that was empty — no name and not dealt in. Renaming
// somebody who is sitting out is a correction, not an arrival, and dealing
// them back in over a typo would be the tool overruling the dealer.
const wasEmpty = !player.name.trim() && !player.inHand;
player.name = nameInput.value;
if (wasEmpty && player.name.trim()) {
player.inHand = true;
player.folded = false;
const inHandBox = form.querySelector(".inHandCheckbox");
if (inHandBox) inHandBox.checked = true;
const foldBox = form.querySelector(".foldCheckbox");
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
}
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
const inHandCheckbox = form.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
this.commitSeatChange(form, player, index);
});
const foldCheckboxRef = form.querySelector(".foldCheckbox");
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
foldCheckboxRef.addEventListener("change", () => {
player.folded = foldCheckboxRef.checked;
this.commitSeatChange(form, player, index);
});
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
slots.forEach((slot, cardIdx) => {
this.paintCardSlot(slot, player.cards[cardIdx]);
slot.addEventListener("click", () => {
CardPicker.open(this.state, {
onPick: (seatIndex, pickedIdx, card) => {
this.paintCardSlot(slots[pickedIdx], card);
this.commitSeatChange(form, player, seatIndex);
},
}, index, cardIdx);
});
});
// What they've handed over tonight, shown next to what's in front of them so a
// cash-out is one subtraction rather than a memory test.
const boughtIn = Number(player.buyIns) || 0;
const boughtInEl = form.querySelector(".seatBuyIns");
boughtInEl.textContent = boughtIn > 0 ? `Bought in ${Money.format(boughtIn)}` : "";
boughtInEl.hidden = boughtIn <= 0;
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
this.commitSeatChange(form, player, index);
});
const amountInput = form.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitSeatChange(form, player, index);
});
const addFieldBtn = form.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
},
renderChipCounts(form, player, index) {
const container = form.querySelector(".chipCounts");
container.innerHTML = "";
const template = document.getElementById("chipCountTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".chipSwatch").style.background = chip.color;
// Written into the row rather than hung off it as a `title`. A tooltip is
// nothing on a touch screen, and this was the only thing telling five
// identical number boxes apart — so the boxes were, in practice, unlabelled,
// to the dealer and to a screen reader alike: the wrapping <label> had a
// coloured dot in it and no text, which is no name at all.
row.querySelector(".chipCountValue").textContent = Money.format(chip.value);
const input = row.querySelector(".chipCountInput");
input.value = player.chipCounts[chip.id] || 0;
input.addEventListener("input", () => {
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
player.chipCounts[chip.id] = count;
this.commitSeatChange(form, player, index);
});
container.appendChild(row);
});
},
renderCustomFields(form, player, index) {
const container = form.querySelector(".customFields");
container.innerHTML = "";
const template = document.getElementById("customFieldTemplate");
player.customFields.forEach((field, fieldIdx) => {
const row = template.content.firstElementChild.cloneNode(true);
const labelInput = row.querySelector(".customFieldLabel");
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
// Same as the chip legend's remove button, and for the same reason: "×" is
// the whole of this control, so unnamed it announces as a button and nothing
// more, in a column of others exactly like it. The label the dealer typed is
// what tells them apart, and it changes as it is typed.
const removeBtn = row.querySelector(".removeFieldBtn");
const nameRemove = () => {
const typed = labelInput.value.trim();
removeBtn.setAttribute("aria-label", typed ? `Remove "${typed}"` : "Remove this note");
};
nameRemove();
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
nameRemove();
this.hooks.save(this.state);
this.renderSeatDisclosures(form, player);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
this.renderSeatDisclosures(form, player);
});
removeBtn.addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
this.renderSeatDisclosures(form, player);
});
container.appendChild(row);
});
},
// ---- Chip legend ----
// The legend itself: what each colour is worth, read left to right.
//
// The panel has always been named for this and has never shown it — what it showed
// was the form for setting it, five rows deep, with the values in number boxes. A
// dealer wanting to know what the green one is worth had to read it out of an input.
// This is the same five facts in two lines, and it sits above the form now.
renderChipLegendStrip() {
const strip = document.getElementById("chipLegendStrip");
if (!strip) return;
strip.innerHTML = "";
(this.state.chipLegend || []).forEach(chip => {
const item = document.createElement("li");
item.className = "chipLegendChip";
const dot = document.createElement("span");
dot.className = "chipSwatch";
dot.style.background = chip.color;
const worth = document.createElement("span");
worth.textContent = Money.format(Number(chip.value) || 0);
item.appendChild(dot);
item.appendChild(worth);
strip.appendChild(item);
});
},
renderChipLegend() {
this.renderChipLegendStrip();
const list = document.getElementById("chipLegendList");
list.innerHTML = "";
const template = document.getElementById("chipLegendRowTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
const colorInput = row.querySelector(".chipColorInput");
const valueInput = row.querySelector(".chipValueInput");
const removeBtn = row.querySelector(".removeChipColorBtn");
colorInput.value = chip.color;
valueInput.value = chip.value;
// "×" is the whole of this button, so without a name it is announced as a
// button and nothing else — and there are five of them in a column, all
// identical, each doing something different and none of them small. The
// value is what tells them apart, and it changes as it is typed, so the
// name is kept up with it rather than set once.
const nameRemove = () => {
const worth = Number(valueInput.value) || 0;
removeBtn.setAttribute("aria-label", worth > 0
? `Remove the ${Money.format(worth)} chip`
: "Remove this chip colour");
};
nameRemove();
colorInput.addEventListener("input", () => {
chip.color = colorInput.value;
this.hooks.save(this.state);
// The strip above is the same fact this field is editing, so it keeps up
// with it. Only the strip: renderChipLegend would write this control's
// own value back into it mid-keystroke, which is the same reason the
// list below calls its neighbours one at a time rather than renderAll.
this.renderChipLegendStrip();
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
nameRemove();
this.renderChipLegendStrip();
this.hooks.save(this.state);
this.renderSeats();
this.renderSeatEditor();
this.updatePotBadge();
// This is a named list of everything a chip's value moves, and the
// legend's own summary is on it: shut, the panel reads "5 colours ·
// $1–$500", and re-pricing the top chip is exactly the edit that
// changes it. Called here for the same reason as the rest of the list:
// renderAll would write this field's own value back into it mid-
// keystroke.
//
// Only that one. The bank check is not downstream of this, which is
// worth saying because it looks as though it should be — a seat's chip
// counts are priced by the legend for the puck and the seat editor's
// total, but bankReconciliation asks chipsHeldBy, which is stackOf, and
// stackOf reads the seat's typed stack or the table's default and never
// opens the legend at all.
this.renderPanelGlances();
});
// Through the hook rather than done here, because what this costs depends on
// what the seats are holding — see removeChipColor in main.js.
removeBtn.addEventListener("click", () => this.hooks.removeChipColor(chip.id));
list.appendChild(row);
});
this.renderColourUp();
},
// Each stack as the chips that make it.
//
// The legend has only ever read chips *into* an amount — counting what's in front
// of somebody. Colouring up at the end of a level is the same question backwards,
// and it's the one the dealer has to answer with a rack in one hand: $205 is four
// fifties and a five. So is counting a stack out for a player who's leaving.
//
// Only when there's a legend to work from and a stack to break down; a table that
// never set its chip colours up gets nothing, which is what it had before.
renderColourUp() {
const host = document.getElementById("colourUp");
const list = document.getElementById("colourUpList");
if (!host || !list) return;
// What each player is holding, asked the same way the felt asks it — which
// includes the table's starting stack standing in for a seat that has never
// been given one. Reading `player.stack` here meant that a table opened the
// ordinary way, with "everyone starts with 200", showed an empty colour-up
// panel: nobody to count out, at the table where every single stack is a
// round number made of the largest chips there are.
const legend = this.state.chipLegend || [];
const rows = this.state.seats
.map((player, index) => ({ player, index, held: HoldemState.chipsHeldBy(this.state, player) }))
.filter(({ held }) => held > 0)
.map(({ player, index, held }) => ({
name: HoldemState.displayName(player, index),
stack: held,
chips: Money.formatChipBreakdown(held, legend),
}))
.filter(row => row.chips);
host.hidden = rows.length === 0;
if (host.hidden) return;
list.innerHTML = "";
rows.forEach(row => {
const term = document.createElement("dt");
term.className = "colourUpWho";
term.textContent = `${row.name} — ${Money.format(row.stack)}`;
const detail = document.createElement("dd");
detail.className = "colourUpChips";
detail.textContent = row.chips;
list.appendChild(term);
list.appendChild(detail);
});
},
// ---- Pot breakdown ----
// Shared by the action bar (live, mid-hand) and the pot wizard (before winners are
// picked), because both are showing the dealer the same announcement.
//
// `maxLayers` caps how many pots get a line of their own, and only the bar passes
// it. The bar is fixed over the felt, so nothing in it may grow with the table —
// and this list did, one row per all-in, with no ceiling. Measured on a 360x640
// phone: five players idle put the bar at 151px, six all-in at 303px, and twelve
// all-in at 411px, which is 64% of the screen and leaves 96px of felt for twelve
// seats. The wizard passes no cap: it is a dialog with room, opened at the point
// the whole list is what you are working from.
buildPotBreakdown(pots, returns, nameFor, heading, { maxLayers, summarise } = {}) {
if (pots.length < 2 && returns.length === 0) return null;
const wrap = document.createElement("div");
wrap.className = "potBreakdown";
if (heading) {
const title = document.createElement("p");
title.className = "potBreakdownHeading";
title.textContent = heading;
wrap.appendChild(title);
}
const addRow = (label, amount, who, className, full) => {
const row = document.createElement("div");
row.className = `potLayer ${className || ""}`;
const labelEl = document.createElement("span");
labelEl.className = "potLayerLabel";
labelEl.textContent = label;
const amountEl = document.createElement("span");
amountEl.className = "potLayerAmount";
amountEl.textContent = Money.format(amount);
const whoEl = document.createElement("span");
whoEl.className = "potLayerWho";
whoEl.textContent = who;
// The line can still run out of room on a phone. A tooltip is not a mechanism
// a keyboard reaches, which is why the count is in the text itself rather
// than only here — this is the extra for anyone with a pointer, not the
// answer to "how many hands am I asking for".
if (full) whoEl.title = full;
row.appendChild(labelEl);
row.appendChild(amountEl);
row.appendChild(whoEl);
wrap.appendChild(row);
};
// While the board is being run out, one line instead of three.
//
// The list earns its height whenever somebody still has a decision: a player
// working out whether to call is owed the side pot they are calling into. Once
// everyone is all-in nobody has a decision at all — the only thing left in the
// hand is the dealer turning cards — and the ledger goes on standing there.
//
// It is not free standing there. Measured at 360x640 with twelve players all-in,
// the bar was 267px of a 640px window: 42%, against the sweep's own "persistent
// chrome under 30%", and it is the felt that pays, on the exact table that has
// the most seats to fit. The main pot is the number a dealer says out loud, so
// it stays; the layering is what the pot wizard is for, and the row has always
// said so in as many words.
if (summarise && pots.length > 1) {
const sides = pots.slice(1);
addRow(
pots[0].label, pots[0].amount,
this.describeEligible(pots[0].eligible, pots[0].eligible, nameFor),
"",
pots[0].eligible.map(nameFor).join(", "));
addRow(
`${sides.length} side pot${sides.length === 1 ? "" : "s"}`,
sides.reduce((sum, layer) => sum + layer.amount, 0),
"listed when you resolve",
"potLayerMore",
sides.map(layer => `${layer.label} ${Money.format(layer.amount)}`).join(", "));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
return wrap;
}
// The two ends of the list are the live ones, and they are live for the same
// reason: pots are paid from the newest side pot back down to the main pot, so
// the top layer is the next thing that happens and the main pot is the last.
// Those are also the two a dealer says out loud while the chips are being
// stacked — "main pot 7,200, side pot 4,500". The layers in between are the
// middle of that count-down, read at showdown rather than mid-hand.
//
// Elide only when it buys something: at the cap itself the middle is one row
// and replacing one row with one row is pure indirection, so the whole list
// stands until there are strictly more pots than the cap.
const middle = maxLayers && pots.length > maxLayers ? pots.slice(1, -1) : [];
const shown = new Set(middle.length ? [pots[0], pots[pots.length - 1]] : pots);
pots.forEach(pot => {
if (!shown.has(pot)) return;
addRow(
pot.label, pot.amount,
this.describeEligible(pot.eligible, pots[0].eligible, nameFor),
"",
pot.eligible.map(nameFor).join(", "));
// Kept in place rather than appended, so the amounts still read in the
// order the pots were made and the gap sits where the gap actually is.
if (middle.length && pot === pots[0]) {
addRow(
`${middle.length} more side pots`,
middle.reduce((sum, layer) => sum + layer.amount, 0),
"listed when you resolve",
"potLayerMore",
middle.map(layer => `${layer.label} ${Money.format(layer.amount)}`).join(", "));
}
});
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
return wrap;
},
// Who can win a pot, said in the fewest names that answer it.
//
// Listing every eligible player spelled the same names out again for every pot, and
// the lists that were too long to fit were the ones nobody needed: pot eligibility
// is strictly nested — the main pot is everyone still live, and each side pot above
// it drops whoever was all-in below — so the main pot's list contains every other
// list, and the side pot a dealer actually announces by name is the short one at the
// top. Measured on an iPhone 14: a single all-in five-handed already clipped the
// main pot's line, and twelve-handed the first row asked for 521px of a 254px slot.
//
// Nesting is what makes the short form safe, and it is a property rather than a
// habit — potCalculator.test.js checks it across 20,000 random tables — so "who is
// in" and "who is out" are complements and the shorter of the two is never more than
// half the table.
//
// The count leads because .potLayerWho is right-aligned with an ellipsis, so it is
// the tail that gets eaten. A dealer who can only read the start of the line still
// learns how many hands to ask for, which is the number the showdown turns on.
describeEligible(eligible, everyone, nameFor) {
const listOf = names => (names.length <= 1
? (names[0] || "")
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`);
if (eligible.length >= everyone.length) return `all ${eligible.length}`;
const inSet = new Set(eligible);
const out = everyone.filter(id => !inSet.has(id));
return out.length < eligible.length
? `${eligible.length} in, not ${listOf(out.map(nameFor))}`
: `${eligible.length} in, ${listOf(eligible.map(nameFor))}`;
},
// ---- Session standings ----
renderStandings() {
const list = document.getElementById("standingsList");
const emptyHint = document.getElementById("standingsEmptyHint");
const table = document.getElementById("standingsTable");
const standings = HoldemState.sessionStandings(this.state);
list.innerHTML = "";
emptyHint.hidden = standings.length > 0;
table.hidden = standings.length === 0;
const template = document.getElementById("standingsRowTemplate");
standings.forEach(row => {
const node = template.content.firstElementChild.cloneNode(true);
// Somebody who has gone home is still on the list — they played, and the
// box took their cash — but the number beside them is money already handed
// over, not chips waiting to be counted. Marked, or a dealer settling up at
// 2am pays them twice.
node.classList.toggle("standingsGone", !!row.cashedOut);
node.querySelector(".standingsName").textContent = row.cashedOut
? `${row.name} · paid out`
: row.name;
node.querySelector(".standingsIn").textContent = Money.format(row.in);
node.querySelector(".standingsWon").textContent = Money.format(row.won);
const netEl = node.querySelector(".standingsNet");
netEl.textContent = Money.formatSigned(row.net);
netEl.classList.toggle("netUp", row.net > 0);
netEl.classList.toggle("netDown", row.net < 0);
// The cash to count out, which is a plain amount and never signed — this
// is the one the banker's hands follow. It sat only in the CSV: on screen
// the column headed "Cash out" held the difference against their buy-in
// instead, so a player up $140 on a $340 stack read as "$140" at the exact
// moment somebody was counting money onto the table.
node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront);
// And whether that leaves them up or down, which is the question the rest
// of the table asks and the one the old column was actually answering.
const cashEl = node.querySelector(".standingsCashOut");
cashEl.textContent = Money.formatSigned(row.cashPosition);
cashEl.classList.toggle("netUp", row.cashPosition > 0);
cashEl.classList.toggle("netDown", row.cashPosition < 0);
list.appendChild(node);
});
this.renderBankReconciliation();
this.renderSettleUp(standings);
this.renderWaitingList();
this.renderStorageWarning();
},
// Who is standing behind the chairs, in the order they turned up. Each one carries
// the two things a dealer does with them: sit them down, or take them off the list
// because they've gone to the bar.
renderWaitingList() {
const list = document.getElementById("waitingList");
const hint = document.getElementById("waitingEmptyHint");
if (!list) return;
const waiting = this.state.waitingList || [];
const open = HoldemState.openSeats(this.state);
list.innerHTML = "";
hint.hidden = waiting.length > 0;
waiting.forEach((entry, position) => {
const row = document.createElement("li");
row.className = "waitingRow";
const name = document.createElement("span");
name.className = "waitingName";
name.textContent = entry.name;
row.appendChild(name);
// Only the person at the front is offered a seat, because a list nobody
// keeps in order is just a pile of names — and jumping the queue is the
// thing the dealer gets shouted at for.
if (position === 0 && open.length > 0) {
const seat = document.createElement("button");
seat.type = "button";
seat.className = "holdemBtn holdemBtnSmall waitingSeatBtn";
seat.textContent = `Seat ${open[0] + 1}`;
seat.title = `Sit ${entry.name} in seat ${open[0] + 1}`;
seat.addEventListener("click", () => this.hooks.seatWaiting(entry.id, open[0]));
row.appendChild(seat);
} else if (position === 0) {
const full = document.createElement("span");
full.className = "waitingNote";
full.textContent = "table full";
row.appendChild(full);
}
const drop = document.createElement("button");
drop.type = "button";
drop.className = "holdemBtn holdemBtnSmall holdemBtnGhost waitingDropBtn";
drop.textContent = "✕";
drop.setAttribute("aria-label", `Take ${entry.name} off the waiting list`);
drop.title = `Take ${entry.name} off the list`;
drop.addEventListener("click", () => this.hooks.dropWaiting(entry.id));
row.appendChild(drop);
list.appendChild(row);
});
},
// A toast is a moment; this table not being saved is a fact about the rest of the
// night. It sits at the top of the panel a dealer opens to check the money, and it
// stays there, because the raise pad dismisses toasts and the one time this warning
// matters is the one time it must not have been missed.
renderStorageWarning() {
const host = document.getElementById("storageWarning");
if (!host) return;
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
if (host.hidden) return;
// Any of these can be true at once — a browser having trouble with storage
// tends to have it in both directions — and the one that explains an empty
// table goes first, because that's the question the dealer is standing there
// asking.
const lines = [];
if (HoldemState.otherTabActive) {
lines.push(
"This table is open in another tab, and both are writing to it — " +
"whichever acts last wins, so work done in the other one can vanish. " +
"Close one, then reload this tab to pick up the table as it stands.");
}
if (HoldemState.loadFailed) {
lines.push(
"The saved table couldn't be read, so this one started empty. " +
"The unreadable copy has been kept rather than written over.");
}
if (HoldemState.saveFailed) {
lines.push(
"This table isn't being saved — the browser refused. It'll keep working, " +
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.");
}
host.textContent = lines.join(" ");
},
// The last step the standings stopped short of. Six signed numbers at 2am is not
// the answer to "who pays whom" — it's the arithmetic the dealer still has to do,
// and it's the part of the night people actually get wrong.
renderSettleUp(standings) {
const host = document.getElementById("settleUp");
if (!host) return;
const plan = SettleUp.plan(standings);
const note = document.getElementById("settleUpNote");
const list = document.getElementById("settleUpList");
// Nothing to show before anyone is up or down. A panel that says "no payments"
// to a table that hasn't played a hand is just noise in the sidebar.
host.hidden = plan.payments.length === 0 && plan.balanced;
if (host.hidden) return;
list.innerHTML = "";
plan.payments.forEach(payment => {
const row = document.createElement("li");
row.className = "settleUpRow";
const who = document.createElement("span");
who.className = "settleUpWho";
// An arrow rather than "pays": the direction is the whole content of the
// line, and it survives being read at a glance from across the table.
who.textContent = `${payment.from} → ${payment.to}`;
row.appendChild(who);
const amount = document.createElement("span");
amount.className = "settleUpAmount";
amount.textContent = Money.format(payment.amount);
row.appendChild(amount);
list.appendChild(row);
});
if (plan.balanced) {
const count = plan.payments.length;
note.textContent = count === 1
? "One payment and the night is square."
: `${count} payments and the night is square.`;
} else if (plan.shortBy > 0) {
note.textContent =
`${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`;
} else {
// If a pot was thrown away on purpose, that's where the gap went, and the
// dealer already answered a dialog about it. Telling them to go and fix it
// would be sending them after their own decision.
const discarded = Math.max(0, Number(this.state.discarded) || 0);
note.textContent = discarded > 0
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
: `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`;
}
note.classList.toggle("settleUpOff", !plan.balanced);
},
// Does the cash box cover the chips on the table? Nothing asked this before, and
// it's the question that decides whether the last two players can be paid.
renderBankReconciliation() {
const host = document.getElementById("bankReconcile");
if (!host) return;
const bank = HoldemState.bankReconciliation(this.state);
// Nothing to reconcile before anybody has handed over money or taken a chip.
host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0;
if (host.hidden) return;
document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn);
document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay);
// An abandoned pot the dealer confirmed throwing away is money that left the
// table on purpose. It gets its own line so the two columns still add up in
// front of them, rather than looking like a shortfall they have to explain.
const discardedRow = document.getElementById("bankDiscardedRow");
if (discardedRow) {
discardedRow.hidden = !(bank.discarded > 0);
if (bank.discarded > 0) {
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
}
}
// Same idea for the house's cut, and the more important of the two: a raked
// game moves chips off the table every single hand, so without this line the
// check reads as a shortfall that grows all night.
const rakedRow = document.getElementById("bankRakedRow");
if (rakedRow) {
rakedRow.hidden = !(bank.raked > 0);
if (bank.raked > 0) {
document.getElementById("bankRaked").textContent = Money.format(bank.raked);
}
}
// Chips handed back to people who have gone home. The third way money leaves
// this table on purpose, and the one a dealer is asked about most: "where did
// Ann's four hundred go?"
const paidRow = document.getElementById("bankPaidOutRow");
if (paidRow) {
paidRow.hidden = !(bank.paidOut > 0);
if (bank.paidOut > 0) {
document.getElementById("bankPaidOut").textContent = Money.format(bank.paidOut);
}
}
const verdict = document.getElementById("bankReconcileVerdict");
// What has left the table on purpose, whichever way it went.
const accountedFor = [
bank.discarded > 0 ? `${Money.format(bank.discarded)} taken off it` : null,
bank.raked > 0 ? `${Money.format(bank.raked)} in the house's cut` : null,
bank.paidOut > 0 ? `${Money.format(bank.paidOut)} paid out to players who have left` : null,
].filter(Boolean);
if (bank.balanced) {
verdict.textContent = accountedFor.length > 0
? `Balanced — the cash covers every chip on the table, once the ${accountedFor.join(" and the ")} is counted.`
: "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
// Name them. "Somebody's rebuy wasn't recorded" is true and useless; the
// dealer's next move is to go to a particular seat, and the tool knows
// which. The commonest cause by far is the table's own starting stack:
// seats holding chips that were never bought, because "everyone starts
// with 200" is how a night opens.
const owing = HoldemState.seatsOwingCash(this.state);
const names = owing.map(row => row.name);
const who = names.length === 1
? names[0]
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
verdict.textContent = owing.length > 0
? `${Money.format(bank.difference)} more in chips than cash taken. ${who} ` +
`${owing.length === 1 ? "is" : "are"} holding chips with no buy-in written down.`
: `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`;
} else {
verdict.textContent =
`${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`;
}
verdict.classList.toggle("bankBalanced", bank.balanced);
verdict.classList.toggle("bankOff", !bank.balanced);
// One tap for the seats that are only holding the table's starting stack —
// which is the dealer's own instruction, said once in the setup panel, rather
// than the tool inventing cash. Anybody else's unrecorded rebuy is named in the
// sentence above and left to them, because only they know what it was.
const record = document.getElementById("bankRecordBuyInsBtn");
if (record) {
const starting = HoldemState.seatsOwingCash(this.state).filter(row => row.fromStartingStack);
const total = Money.round(starting.reduce((sum, row) => sum + row.chips, 0));
record.hidden = starting.length === 0;
if (starting.length > 0) {
record.textContent = `Take ${Money.format(total)} from ${starting.length} ${starting.length === 1 ? "player" : "players"}`;
record.title = starting.length === 1
? `Write down ${Money.format(total)} taken from ${starting[0].name}`
: `Write down the starting stack as cash taken from ${starting.map(row => row.name).join(", ")}`;
}
}
},
// ---- Hand counter + history ----
// toLocaleTimeString is the single most expensive call in the history panel, and a
// logged hand's timestamp never changes, so the answer is worth keeping.
handTimeCache: new Map(),
formatHandTime(timestamp) {
if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp);
let formatted = "";
try {
formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
} catch {
formatted = "";
}
this.handTimeCache.set(timestamp, formatted);
return formatted;
},
// Cheap stand-in for "has the log changed". Entries are immutable once written and
// only ever added at the front, so the length, the counter and the newest row
// between them catch a recorded hand, a cleared history and an undo.
historySignature() {
const history = this.state.handHistory || [];
const newest = history[0];
return [
history.length,
this.state.handNumber || 1,
newest ? newest.handNumber : "",
newest ? newest.timestamp : "",
newest ? newest.pot : "",
// Typing in the filter changes the list without changing the history, and
// the redraw is skipped when the signature matches — leave this out and the
// box takes input and does nothing.
this.historyFilter || "",
].join("|");
},
// What the dealer opens the log for is one hand: the one somebody is arguing
// about. A night runs to sixty or more, five at a time in the panel, so finding it
// meant scrolling and reading every row. A name, an amount, or a hand number
// narrows it instead.
//
// Matching is deliberately loose. "fay" finds Fay whatever case it's typed in;
// "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half
// remembering an amount shouldn't have to know which of those they mean; "#12"
// pins it to the hand number, for when they do.
handMatchesFilter(entry, query) {
if (!query) return true;
const term = query.trim().toLowerCase();
if (!term) return true;
const handNumber = String(entry.handNumber || "");
if (term.startsWith("#")) return handNumber.startsWith(term.slice(1));
// Only what the row itself shows. Matching the hand's full ledger as well was
// the first attempt, on the reasoning that a dealer might hunt for the hand
// that broke somebody by the loser's name — but the ledger holds everyone who
// put a chip in, so at a table where nobody folds it matched every hand.
// Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the
// panel doesn't display is a filter that looks broken even when it's right.
const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" ");
if (labels.includes(term)) return true;
// Amounts, matched on the digits so "$190", "190" and "1,90" all behave.
const digits = term.replace(/[^0-9.]/g, "");
if (digits) {
const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)]
.map(a => String(Money.round(Number(a) || 0)));
if (amounts.some(a => a.startsWith(digits))) return true;
if (handNumber === digits) return true;
}
return false;
},
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
// full re-render runs after every fold, call and raise. The log only changes when a
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
// is skipped unless it actually did change. Everything else on the table reflects
// the current hand and still redraws every time.
renderHandHistory(force = false) {
const signature = this.historySignature();
if (!force && signature === this.lastHistorySignature) return;
this.lastHistorySignature = signature;
const list = document.getElementById("handHistoryList");
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
emptyHint.hidden = history.length > 0;
// This one was greyed out with no explanation anywhere — not even a tooltip —
// so on every device it was a button that did nothing and said nothing about it.
Unavailable.set(
document.getElementById("exportHistoryBtn"),
history.length === 0,
"No hands to export yet — the file fills in as the night is played."
);
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
// How much of the night is being hidden. Without it a filter that matches one
// hand looks exactly like a table that only played one, which is the sort of
// thing a dealer reads wrong at 2am with somebody leaning over the rail.
const count = document.getElementById("handHistoryCount");
if (count) {
count.hidden = !query || history.length === 0;
count.textContent = `${shown.length} of ${history.length}`;
}
const noMatch = document.getElementById("handHistoryNoMatch");
if (noMatch) {
noMatch.hidden = !(query && history.length > 0 && shown.length === 0);
noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`;
}
const filterBox = document.getElementById("handHistoryFilter");
if (filterBox) filterBox.hidden = history.length === 0;
// Built off-document so the panel is laid out once instead of once per row.
const fragment = document.createDocumentFragment();
const template = document.getElementById("handHistoryRowTemplate");
shown.forEach(entry => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`;
row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot);
row.querySelector(".handHistoryTime").textContent = this.formatHandTime(entry.timestamp);
const payoutsEl = row.querySelector(".handHistoryPayouts");
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
fragment.appendChild(row);
});
list.innerHTML = "";
list.appendChild(fragment);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Render };
}