// 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(); }, 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; }, renderTableHint() { const hint = document.getElementById("tableHint"); hint.textContent = this.state.dedicatedDealer ? "Drag a seat onto another seat to swap players." : "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button."; }, renderNotes() { const notesEl = document.getElementById("tableNotes"); if (document.activeElement !== notesEl) notesEl.value = this.state.notes || ""; }, updatePotBadge() { const total = HoldemState.totalPot(this.state); document.getElementById("potBadgeAmount").textContent = Money.format(total); }, // ---- Seats around the table (compact pucks) ---- seatPosition(index, count) { const angle = -90 + (360 / count) * index; const radians = (angle * Math.PI) / 180; const rx = 44; const ry = 42; const left = 50 + rx * Math.cos(radians); const top = 50 + ry * Math.sin(radians); return { left, top }; }, puckLabel(player, seatIndex) { return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`; }, renderSeats() { const layer = document.getElementById("seatsLayer"); layer.innerHTML = ""; const template = document.getElementById("seatTemplate"); const { dealerIndex, sbIndex, bbIndex } = 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); dealerChip.addEventListener("click", e => { e.stopPropagation(); this.state.dealerSeatIndex = index; this.hooks.save(this.state); this.renderSeats(); }); node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex); node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex); this.updatePuckContent(node, player, index); node.classList.toggle("seatSelected", index === this.selectedSeatIndex); node.addEventListener("click", () => { this.selectedSeatIndex = index; this.renderSeats(); this.renderSeatEditor(); }); this.wireDragAndDrop(node, index); layer.appendChild(node); }); }, isRedSuit(card) { return /[HD]$/i.test(card || ""); }, updatePuckContent(node, player, index) { node.querySelector(".seatPuckName").textContent = player.name && player.name.trim() ? player.name.trim() : ""; 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 = card; cardsEl.appendChild(span); }); const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount); node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : ""; node.classList.toggle("seatInactive", !player.inHand); node.classList.toggle("seatFolded", 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); }, 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) ---- renderSeatEditor() { const title = document.getElementById("seatEditorTitle"); const body = document.getElementById("seatEditorBody"); 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 card1 = form.querySelector(".card1Input"); const card2 = form.querySelector(".card2Input"); card1.value = player.cards[0] || ""; card2.value = player.cards[1] || ""; [card1, card2].forEach((input, cardIdx) => { input.classList.toggle("cardRed", this.isRedSuit(input.value)); input.addEventListener("input", () => { input.value = input.value.toUpperCase(); input.classList.toggle("cardRed", this.isRedSuit(input.value)); player.cards[cardIdx] = input.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); }); }, }; const Money = { format(amount) { const value = Number(amount) || 0; return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }, }; if (typeof module !== "undefined" && module.exports) { module.exports = { Render, Money }; }