Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
T
jheapsandClaude Opus 5 784315f2ea Put one dealer button on the table
A poker table has one dealer button. This one drew nine.

Every seat carried a puck: the real one in gold, and eight ghosts at 0.32 opacity
that existed to be pressed. Measured on a nine-seat table -- eight pale discs, all
eight showing the letter D, one of them the button. Finding it meant comparing nine
identical shapes for the one that's gold, when it should be the only thing of its
kind on the felt.

The ghosts also sat on their neighbours. Every remaining "something important is
covered" offender on a crowded table was a live seat covered by the seat-next-door's
ghost puck: 844x390, 915x412, 360x640 and 640x360 each reported one, and they were
all the same thing.

And a screen reader heard nine buttons named "D".

So a seat without the button doesn't draw one. Hovering a seat fades its slot in --
a preview of where the button would go, one click as before -- and on touch, where
there is no hover, "Give the button" has been in the seat's long-press menu all
along. Keyboard reaches it by focus, where the slot appears too.

pointer-events matters as much as the opacity: an invisible puck that still took
taps would be worse than a visible one.

  visible pucks    9 -> 1 at every size
  layout offenders 915x412 1->0, 360x640 1->0, 844x390 3->2, 640x360 3->2
  clean viewports  4 of 9 -> 6 of 9
  labels           nine "D" -> "Ann has the dealer button" and
                              "Give the dealer button to Ben"

The audit now checks it, because this is a rule about the table rather than about
CSS: one puck drawn, and no two announcing themselves the same way. Verified against
the old behaviour put back -- it reports 9 and 9.

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

829 lines
35 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.renderAll();
},
renderAll() {
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
this.selectedSeatIndex = null;
}
this.renderSeats();
this.renderSeatEditor();
this.renderChipLegend();
this.renderNotes();
this.updatePotBadge();
this.renderSetupFields();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
},
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("smallBlindInput").value = this.state.smallBlind ?? "";
document.getElementById("bigBlindInput").value = this.state.bigBlind ?? "";
document.getElementById("defaultStackInput").value = this.state.defaultStack ?? "";
},
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 = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
},
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) ----
seatPosition(index, count) {
return this.positionOnRing(index, count, 44, 42);
},
// 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.
betPosition(index, count) {
return this.positionOnRing(index, count, 27, 25);
},
positionOnRing(index, count, rx, ry) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
return {
left: 50 + rx * Math.cos(radians),
top: 50 + ry * Math.sin(radians),
};
},
// 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";
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;
},
// 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.
renderBets() {
const layer = document.getElementById("betsLayer");
if (!layer) return;
layer.innerHTML = "";
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
this.state.seats.forEach((player, index) => {
const bet = Betting.committed(player);
if (bet <= 0) return;
const chip = this.makeBetChip(bet);
const pos = this.betPosition(index, this.state.seatCount);
if (anchorToSeat) {
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!seat) return;
const seatPos = this.seatPosition(index, this.state.seatCount);
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
seat.appendChild(chip);
return;
}
chip.style.left = `${pos.left}%`;
chip.style.top = `${pos.top}%`;
layer.appendChild(chip);
});
},
puckLabel(player, seatIndex) {
return HoldemState.displayName(player, seatIndex);
},
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
this.state.seats.forEach((player, index) => {
const node = template.content.firstElementChild.cloneNode(true);
const pos = this.seatPosition(index, this.state.seatCount);
node.style.left = `${pos.left}%`;
node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index);
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
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}`;
const chipLabel = hasButton
? `${who} has the dealer button`
: `Give the dealer button to ${who}`;
dealerChip.setAttribute("aria-label", chipLabel);
dealerChip.title = chipLabel;
// 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.
dealerChip.addEventListener("click", e => {
e.stopPropagation();
this.hooks.onGiveButton(index);
});
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);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
this.wireSeatGestures(node, index);
this.wireDragAndDrop(node, index);
layer.appendChild(node);
});
this.renderBets();
},
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) : "+";
},
// 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");
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
nameEl.classList.toggle("seatEmptyLabel", empty);
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) : "";
this.fitAmount(totalEl);
const stackEl = node.querySelector(".seatPuckStack");
const stack = Betting.stackOf(this.state, player);
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
this.fitAmount(stackEl);
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);
},
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
// because the number has no spaces to break on, it doesn't wrap or ellipsise
// either — it just draws straight out over the neighbouring seat. Stepping the
// font 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.
fitAmount(el) {
const length = (el.textContent || "").length;
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
el.classList.toggle("puckAmountHuge", length >= 10);
},
// 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) this.updatePuckContent(node, this.state.seats[index], index);
},
// ---- 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>';
return;
}
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
title.textContent = this.puckLabel(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);
body.appendChild(form);
},
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);
this.refreshPuck(index);
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
player.name = nameInput.value;
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;
row.title = `$${chip.value} chips`;
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;
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
container.appendChild(row);
});
},
// ---- Chip legend ----
renderChipLegend() {
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");
colorInput.value = chip.color;
valueInput.value = chip.value;
colorInput.addEventListener("input", () => {
chip.color = colorInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
this.hooks.save(this.state);
this.renderSeats();
this.renderSeatEditor();
this.updatePotBadge();
});
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
this.hooks.save(this.state);
this.renderAll();
});
list.appendChild(row);
});
},
// ---- 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.
buildPotBreakdown(pots, returns, nameFor, heading) {
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) => {
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;
row.appendChild(labelEl);
row.appendChild(amountEl);
row.appendChild(whoEl);
wrap.appendChild(row);
};
pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", ")));
returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn"));
return wrap;
},
// ---- 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);
node.querySelector(".standingsName").textContent = 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);
// What they'd actually be handed if the night ended now: chips in front of
// them less the cash they gave the banker. This was computed and written to
// the CSV all along, but never shown to the person doing the paying out.
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();
},
// 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);
const verdict = document.getElementById("bankReconcileVerdict");
if (bank.balanced) {
verdict.textContent = "Balanced — the cash covers every chip on the table.";
} else if (bank.difference > 0) {
verdict.textContent =
`${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);
},
// ---- 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 : "",
].join("|");
},
// 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;
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
const list = document.getElementById("handHistoryList");
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = 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");
history.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 };
}