← poker-tutor
import SwiftUI
import PokerCore
/// Where the seats sit, as pure arithmetic.
///
/// Separated from the drawing because it is the part that can be wrong in a way nobody notices
/// until a nine-seat ring is drawn on a phone: dots overlapping each other, or the outer ones
/// hanging off the edge of the space the diagram claimed. A test checks both, at every table size
/// the app deals, which is not something a SwiftUI view can be asked.
public struct SeatRing: Sendable {
/// The whole picture is this wide, and nearly as tall: nine seats on a ring only stop
/// crowding each other at the top and the bottom once the ring is close to round.
public let side: CGFloat
public let count: Int
public init(side: CGFloat, count: Int) {
self.side = side
self.count = min(9, max(2, count))
}
public var height: CGFloat { side * 0.90 }
/// A seat counter, and the space it needs once the highlight ring is round it.
public var dot: CGFloat { side * 0.15 }
public var footprint: CGFloat { dot * 1.3 }
/// Seat 1 sits at the top and the numbers run clockwise from there, which puts the dealer —
/// always the last number — immediately anticlockwise of the small blind, exactly where it
/// sits at a real table.
public func point(_ index: Int) -> CGPoint {
let angle = -Double.pi / 2 + 2 * Double.pi * Double(index) / Double(count)
return CGPoint(x: cos(angle) * side * 0.36, y: sin(angle) * height * 0.33)
}
public var points: [CGPoint] { (0..<count).map(point) }
}
/// A small picture of the table: every seat as a numbered dot in a ring, the dealer button and the
/// two blinds marked, and one seat lit up.
///
/// The whole reason it exists is that "UTG+1" is a fact about a table nobody can see. A ring with
/// the seats numbered on it answers the question the name was standing in for — where am I sitting,
/// and how many people are behind me — without anyone having to be taught the word first.
///
/// Drawn entirely with shapes, so it costs nothing to ship and grows with the text-size setting
/// rather than pixelating. It reads the same in both palettes: the only colours in it are the
/// palette's own.
struct TableDiagram: View {
/// How many were dealt in. Two through nine, which is every table this app deals.
let players: Int
/// The seat to light up, if any.
var highlight: Position?
/// The width the whole picture is drawn at before the text-size setting is applied.
var base: CGFloat = 148
private var count: Int { min(9, max(2, players)) }
/// The seats in numbered order, which is also the order they sit in: the small blind first and
/// the button last. Sorted by the number rather than by the naming, because heads-up the two
/// disagree — see `Position.number(inTableOf:)`.
private var seats: [Position] {
Position.names(forPlayerCount: count)
.sorted { ($0.number(inTableOf: count) ?? 0) < ($1.number(inTableOf: count) ?? 0) }
}
var body: some View {
let ring = SeatRing(side: Theme.size(base), count: count)
return ZStack {
Ellipse()
.fill(Theme.feltInner.opacity(0.5))
.overlay(Ellipse().stroke(Theme.rail, lineWidth: max(2, ring.side * 0.026)))
.frame(width: ring.side * 0.48, height: ring.height * 0.30)
ForEach(Array(seats.enumerated()), id: \.offset) { index, seat in
dot(seat, ring: ring)
.offset(x: ring.point(index).x, y: ring.point(index).y)
}
}
.frame(width: ring.side, height: ring.height)
.oneAccessibleElement()
.accessibilityLabel(Text(accessibilityText))
}
/// One seat: a numbered counter, with the dealer button or a blind's chip clipped to the
/// corner of it. Both marks are the shapes they are at a real table — a "D" disc for the
/// dealer, a chip for money that is already in — so the picture needs no key to read.
private func dot(_ seat: Position, ring: SeatRing) -> some View {
let lit = seat == highlight
let side = ring.side
let diameter = ring.dot
let mark = diameter * 0.52
return ZStack {
Circle()
.fill(lit ? Theme.accent : Theme.panelRaised)
.frame(width: diameter, height: diameter)
Circle()
.stroke(lit ? Theme.accent : Color.white.opacity(0.14),
lineWidth: lit ? max(2, side * 0.02) : 1)
.frame(width: diameter * 1.3, height: diameter * 1.3)
Text("\(seat.number(inTableOf: count) ?? 0)")
.numeric(side * 0.078, capped: true)
.foregroundStyle(lit ? Theme.background : Theme.text)
if seat == .button {
ZStack {
Circle().fill(Theme.gold)
Text("D").label(side * 0.05, capped: true).foregroundStyle(Theme.background)
}
.frame(width: mark, height: mark)
.offset(x: diameter * 0.46, y: diameter * 0.46)
} else if seat.isBlind {
Circle()
.fill(Theme.gold)
.overlay(Circle().stroke(Theme.background.opacity(0.6), lineWidth: 1))
.frame(width: mark * 0.72, height: mark * 0.72)
.offset(x: diameter * 0.46, y: diameter * 0.46)
}
}
}
private var accessibilityText: String {
guard let highlight, let number = highlight.number(inTableOf: count) else {
return "A \(count) seat table."
}
return "A \(count) seat table with seat \(number), the \(highlight.fullName), marked."
}
}
/// The picture with the words that go under it: what the highlighted seat is, and what its number
/// actually buys you.
struct SeatExplainer: View {
let players: Int
let seat: Position?
@Environment(\.metrics) var metrics
@State var showing: GlossaryTerm?
var body: some View {
let layout = metrics.compact
return VStack(alignment: .leading, spacing: 10) {
if layout {
TableDiagram(players: players, highlight: seat)
words
} else {
HStack(alignment: .top, spacing: 16) {
TableDiagram(players: players, highlight: seat)
words
}
}
DefinitionSlot(showing: $showing)
}
}
/// The traditional name is the one part of this that has to be learned rather than read, so it
/// is the part that can be tapped.
private var words: some View {
VStack(alignment: .leading, spacing: 5) {
if let seat, let number = seat.number(inTableOf: players) {
HStack(alignment: .firstTextBaseline, spacing: 5) {
Text("Seat \(number) of \(players) —").heading().foregroundStyle(Theme.text)
TermChip(seat.fullName, showing: $showing, size: 13, colour: Theme.text)
}
GlossaryText(seat.actOrderNote(inTableOf: players), showing: $showing)
}
GlossaryText(Copy.seatsPayBeforeCards,
size: 11, colour: Theme.dim.opacity(0.85), showing: $showing)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}