// 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; 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"); dealerChip.classList.toggle("active", index === dealerIndex); dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer); // 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); node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : ""; const stackEl = node.querySelector(".seatPuckStack"); const stack = Betting.stackOf(this.state, player); stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : ""; 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); }, // 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 = '

Click a seat at the table to edit their name, cards, chips and anything else.

'; 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); list.appendChild(node); }); }, // ---- Hand counter + history ---- formatHandTime(timestamp) { try { return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } catch { return ""; } }, renderHandHistory() { document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`; const list = document.getElementById("handHistoryList"); const emptyHint = document.getElementById("handHistoryEmptyHint"); const history = this.state.handHistory || []; list.innerHTML = ""; emptyHint.hidden = history.length > 0; document.getElementById("exportHistoryBtn").disabled = history.length === 0; 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(", "); list.appendChild(row); }); }, }; if (typeof module !== "undefined" && module.exports) { module.exports = { Render }; }