In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

AFL Top Trumps — Player Card Deck

Skip to content

One Top Trumps card per AFL player — our bespoke TORP / EPR / PSR ratings plus box-score totals. Choose a Season scope — Career (all-time) sums every season, or pick a single year — then a stat pack, and Quick Play a duel, run an endless Higher / Lower streak, or climb the Gaffer's Run roguelike. Headshots are the AFL player images; no-photo players get a monogram. (Age & club are current-day, so past-season decks still show a player's present team.) Click any card for the full profile.

Show code
statsEsc = window.statsEsc
fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL
aflMaps = window.aflTeamMaps

ratingsRaw = fetchParquet(base_url + "afl/ratings.parquet")

// Seasons available (newest first) + a "Career" option, for the season selector.
aflSeasons = ratingsRaw ? ["Career", ...[...new Set(ratingsRaw.map(r => r.season))].filter(Boolean).sort((a, b) => b - a).map(String)] : ["Career"]
RATING_KEYS_AFL = ["torp", "epr", "psr", "osr", "dsr", "recv_epr", "disp_epr", "spoil_epr", "hitout_epr", "gms"]

// Deck rows for the selected season scope (aflSeasonMode). ratings.parquet is one row
// per player per round, so:
//   • a SEASON → each player's final-round row that season (the season's end rating).
//   • CAREER  → sum each rating across the player's seasons (career output; counting
//     box-score totals are summed separately in _ensureAflStats). team/position from
//     the player's most recent season.
ratings = {
  if (!ratingsRaw) return null
  const seasonLatest = new Map()   // "player|season" → that season's final-round row
  for (const r of ratingsRaw) {
    if (!r.player_id) continue
    const k = r.player_id + "|" + r.season
    const c = seasonLatest.get(k)
    if (!c || (r.round || 0) > (c.round || 0)) seasonLatest.set(k, r)
  }
  if (aflSeasonMode !== "Career") {
    const yr = +aflSeasonMode
    return [...seasonLatest.values()].filter(r => r.season === yr)
  }
  const byPlayer = new Map()
  for (const r of seasonLatest.values()) {
    let a = byPlayer.get(r.player_id)
    if (!a) { a = { player_id: r.player_id, player_name: r.player_name, team: r.team, position_group: r.position_group, _maxS: r.season }; for (const k of RATING_KEYS_AFL) a[k] = 0; byPlayer.set(r.player_id, a) }
    if (r.season >= a._maxS) { a._maxS = r.season; a.team = r.team; a.position_group = r.position_group }
    for (const k of RATING_KEYS_AFL) {
      if (k === "gms") a[k] = Math.max(a[k], r[k] || 0)   // gms is cumulative → max = career games
      else a[k] += (r[k] || 0)                            // ratings are per-season → sum = career output
    }
  }
  return [...byPlayer.values()]
}

// Player bio (date of birth → age) from player-details.parquet, keyed by player_id.
aflBio = {
  try {
    const d = await fetchParquet(base_url + "afl/player-details.parquet")
    if (!d) { console.warn("[afl-cards] player-details fetch returned no data"); return {} }
    const latest = window.deduplicateLatest ? window.deduplicateLatest(d) : d
    const m = {}
    for (const r of latest) if (r.player_id) m[r.player_id] = { dob: r.date_of_birth }
    return m
  } catch (e) { console.warn("[afl-cards] bio load failed:", e); return {} }
}
Show code
deckById = ratings ? new Map(ratings.filter(r => r.player_id).map(r => [r.player_id, r])) : new Map()

// Percentile pools (≥1 game) for rating + total stats — for the gallery bars, the
// CPU's stat pick, and roguelike rarity. Built from the deduped ratings rows.
deckPool = {
  if (!ratings) return {}
  const pool = ratings.filter(r => (r.gms || 0) >= 1)
  const col = key => pool.map(r => r[key]).filter(v => v != null).sort((a, b) => a - b)
  const keys = ["torp", "epr", "psr", "osr", "dsr", "recv_epr", "disp_epr", "spoil_epr", "hitout_epr", "gms"]
  const out = {}
  for (const k of keys) out[k] = col(k)
  return out
}
deckPctRank = function (sorted, v, lowGood) {
  if (v == null || !sorted || !sorted.length) return null
  const n = sorted.length
  let lo = 0, hi = n
  while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] < v) lo = m + 1; else hi = m }
  const lb = lo
  lo = 0; hi = n
  while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] <= v) lo = m + 1; else hi = m }
  const ub = lo
  return 100 * (lowGood ? (n - lb) : ub) / n
}
Show code
fmt2 = v => v == null ? "—" : v.toFixed(2)
fInt = v => v == null ? "—" : Math.round(v).toLocaleString()
aflStat = (key, label, fmt) => ({ key, label, lowGood: false, fmt: fmt || fmt2 })

AFL_PACKS = [
  { id: "standard", label: "Standard (10)", needsStats: true, stats: [
    aflStat("torp", "TORP"),
    aflStat("goals", "Goals", fInt), aflStat("disposals", "Disposals", fInt), aflStat("marks", "Marks", fInt),
    aflStat("tackles", "Tackles", fInt), aflStat("clearances", "Clearances", fInt), aflStat("inside50s", "Inside 50s", fInt),
    aflStat("hitouts", "Hitouts", fInt), aflStat("contested_possessions", "Contested", fInt),
    aflStat("gms", "Games", fInt),
  ] },
  { id: "ratings", label: "Ratings (5)", needsStats: false, stats: [
    aflStat("torp", "TORP"), aflStat("epr", "EPR"), aflStat("psr", "PSR"),
    aflStat("osr", "OSR"), aflStat("dsr", "DSR"),
  ] },
]
// game-stats columns to SUM into season totals (display key = same name)
AFL_TOTAL_KEYS = ["goals", "disposals", "marks", "tackles", "clearances", "inside50s", "hitouts", "contested_possessions"]
Show code
teamOpts = {
  if (!ratings) return ["All teams"]
  return ["All teams", ...[...new Set(ratings.map(r => r.team).filter(Boolean))].sort()]
}
posOpts = ["All positions", "KEY_DEFENDER", "MEDIUM_DEFENDER", "MIDFIELDER", "RUCK", "MEDIUM_FORWARD", "KEY_FORWARD"]

viewof aflSeasonMode = Inputs.select(aflSeasons, { label: "Season", value: (aflSeasons.length > 1 ? aflSeasons[1] : "Career"), format: s => s === "Career" ? "Career (all-time)" : s })
viewof aflTeam = Inputs.select(teamOpts, { label: "Team", value: "All teams" })
viewof aflPos = Inputs.select(posOpts, { label: "Position", value: "All positions", format: p => p === "All positions" ? p : p.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) })
viewof aflSort = Inputs.select([["TORP", "torp"], ["EPR", "epr"], ["PSR", "psr"], ["Games", "gms"], ["Name (A–Z)", "name"]], { label: "Sort by", format: x => x[0], value: ["TORP", "torp"] })
viewof aflSearch = Inputs.text({ label: "Search", placeholder: "Player name…", width: 220 })
viewof aflMinGms = Inputs.range([0, 50], { label: "Min games", step: 1, value: 5 })
viewof aflPack = Inputs.select(AFL_PACKS.map(p => p.label), { label: "Game stat pack", value: "Standard (10)" })
Show code
aflPosOf = r => (aflMaps.posColors[r.position_group] || {}).a || r.position_group || "?"
aflColOf = r => (aflMaps.posColors[r.position_group] || {}).c || "#5a9a7a"
aflHeadshot = r => r.player_id ? `${base_url}afl/headshots/${String(r.player_id).replace("CD_I", "")}.webp` : ""

aflAge = r => {
  const dob = aflBio[r.player_id] && aflBio[r.player_id].dob
  if (!dob) return null
  const d = new Date(dob); if (isNaN(d.getTime())) return null
  const a = Math.floor((Date.now() - d.getTime()) / 3.15576e10)
  return (a > 12 && a < 55) ? a : null
}
aflCard = function (r) {
  const name = r.player_name || "?"
  const code = aflPosOf(r), colour = aflColOf(r)
  const initials = name.split(" ").map(w => w[0] || "").join("").slice(0, 2).toUpperCase()
  const photo = aflHeadshot(r)
  const age = aflAge(r)
  const sub = [age ? age + "y" : null, r.team].filter(Boolean).join(" · ")
  const fmt2c = v => v == null ? "—" : v.toFixed(2)
  const rows = [
    { label: "TORP", val: fmt2c(r.torp), pct: deckPctRank(deckPool.torp, r.torp) },
    { label: "EPR", val: fmt2c(r.epr), pct: deckPctRank(deckPool.epr, r.epr) },
    { label: "PSR", val: fmt2c(r.psr), pct: deckPctRank(deckPool.psr, r.psr) },
    { label: "OSR", val: fmt2c(r.osr), pct: deckPctRank(deckPool.osr, r.osr) },
    { label: "Games", val: Math.round(r.gms || 0).toLocaleString(), pct: deckPctRank(deckPool.gms, r.gms) },
  ]
  const rowsHtml = rows.map(s => {
    const w = s.pct == null ? 0 : Math.max(0, Math.min(100, s.pct))
    return `<div class="ttc-row"><span class="ttc-row-label">${s.label}</span><span class="ttc-row-val">${s.val}</span><span class="ttc-row-bar"><i style="width:${w}%"></i></span><span class="ttc-row-pct">${s.pct == null ? "" : Math.round(w)}</span></div>`
  }).join("")
  const a = document.createElement("a")
  a.className = "ttc"
  a.href = "player.html#id=" + encodeURIComponent(r.player_id || "")
  a.style.setProperty("--c", colour)
  a.innerHTML = `
    <div class="ttc-head"><span class="ttc-cat">TORP · AFL</span><span class="ttc-pos">${statsEsc(code)}</span></div>
    <div class="ttc-portrait">${photo ? `<img class="ttc-photo" src="${photo}" alt="" loading="lazy" draggable="false" onerror="this.style.display='none';this.nextElementSibling.style.display=''">` : ""}<span class="ttc-monogram"${photo ? ' style="display:none"' : ''}>${statsEsc(initials)}</span></div>
    <div class="ttc-id"><div class="ttc-name">${statsEsc(name)}</div><div class="ttc-sub">${statsEsc(sub)}</div></div>
    <div class="ttc-stats">${rowsHtml}</div>
    <div class="ttc-foot"><span>In The Game</span><span>TORP ${fmt2c(r.torp)}</span></div>`
  // Pointer-drag into the hand tray (+ the "+ Hand" tap path); a press that never
  // crosses the threshold still navigates to the profile. Mirrors football/deckCard.
  if (r.player_id) {
    a.draggable = false
    a.addEventListener("dragstart", e => e.preventDefault())
    a.addEventListener("pointerdown", e => {
      if (e.pointerType === "mouse" && e.button !== 0) return
      if (e.target.closest(".ttc-add")) return
      if (window._aflBeginCardDrag) window._aflBeginCardDrag(e, r.player_id, name, a)
    })
    a.addEventListener("click", e => { if (window._aflDidDrag) { e.preventDefault(); e.stopPropagation() } })
    const add = document.createElement("button")
    add.type = "button"; add.className = "ttc-add"; add.title = "Add to your hand"; add.textContent = "+ Hand"
    add.addEventListener("click", e => {
      e.preventDefault(); e.stopPropagation()
      if (!window._aflAddToHand) return
      const added = window._aflAddToHand(r.player_id)
      add.textContent = added ? "Added" : "In hand"; add.classList.add("added")
      setTimeout(() => { add.textContent = "+ Hand"; add.classList.remove("added") }, 1000)
    })
    a.querySelector(".ttc-portrait").appendChild(add)
  }
  return a
}
Show code
deckGame = {
  const CG = window.CardsGame
  if (!CG || !ratings) return html``
  window._aflSeasonMode = aflSeasonMode   // "Career" or a year string — used by the aggregator
  window._aflCtx = {
    sport: "afl", byId: deckById, pools: deckPool, esc: statsEsc, baseUrl: base_url, rng: Math.random,
    ratingKey: "torp", bio: aflBio,
    posCode: aflPosOf, posColor: (code, row) => aflColOf(row), headshot: aflHeadshot,
  }
  // Lazy-load game-stats.parquet ONCE (cached on window), then aggregate box-score
  // TOTALS for the CURRENT season mode (Career = all seasons; a year = that season)
  // onto the current deck rows + pools, and rebuild the "has stats" id set. Re-runs
  // per game launch so it always matches the selected season.
  window._ensureAflStats = async () => {
    if (!window._aflRawGS) window._aflRawGS = await fetchParquet(base_url + "afl/game-stats.parquet")
    const gs = window._aflRawGS; if (!gs) return false
    const mode = window._aflSeasonMode
    const agg = new Map()
    for (const r of gs) {
      if (!r.player_id) continue
      if (mode !== "Career" && r.season !== +mode) continue
      let a = agg.get(r.player_id); if (!a) { a = {}; agg.set(r.player_id, a) }
      for (const k of AFL_TOTAL_KEYS) a[k] = (a[k] || 0) + (r[k] || 0)
    }
    const statsIds = new Set()
    for (const [id, row] of deckById) { const a = agg.get(id); if (a) { statsIds.add(id); for (const k of AFL_TOTAL_KEYS) row[k] = a[k] } else { for (const k of AFL_TOTAL_KEYS) row[k] = null } }
    window._aflStatsIds = statsIds
    const rows = [...deckById.values()]
    for (const k of AFL_TOTAL_KEYS) deckPool[k] = rows.map(r => r[k]).filter(v => v != null).sort((a, b) => a - b)
    return true
  }

  window._aflLaunch = async (fn) => {
    const pack = window._aflActivePack
    if (pack && pack.needsStats) {
      const firstLoad = !window._aflRawGS
      let o = null
      if (firstLoad) { o = CG.overlay(); o.modal.innerHTML = '<div class="duel-banner" style="padding:2.4rem 1rem;text-align:center">Loading season stats…</div>' }
      const ok = await window._ensureAflStats()
      if (o) o.close()
      if (!ok) { const rp = AFL_PACKS.find(p => !p.needsStats); if (rp) window._aflPackStats = rp.stats }
    }
    fn()
  }
  const playable = ids => {
    const pack = window._aflActivePack
    const base = (ids && ids.length) ? ids : [...deckById.keys()]
    if (!pack || !pack.needsStats || !window._aflStatsIds) return base
    const f = base.filter(id => window._aflStatsIds.has(id)); return f.length >= 2 ? f : base
  }
  window._aflPool = () => {
    const q = (aflSearch || "").trim().toLowerCase()
    return ratings.filter(r =>
      (r.gms || 0) >= aflMinGms &&
      (aflTeam === "All teams" || r.team === aflTeam) &&
      (aflPos === "All positions" || r.position_group === aflPos) &&
      (!q || (r.player_name || "").toLowerCase().includes(q))
    ).map(r => r.player_id).filter(Boolean)
  }
  window._aflStartQuick = () => window._aflLaunch(() => {
    const a = playable(window._aflPool()); if (a.length < 2) return
    const s = a.slice(); for (let i = s.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1));[s[i], s[j]] = [s[j], s[i]] }
    const n = Math.min(12, s.length - (s.length % 2))
    CG.startDuel(window._aflCtx, s.slice(0, n), { stats: window._aflPackStats })
  })
  window._aflStartHL = () => window._aflLaunch(() => CG.startHigherLower(window._aflCtx, { stats: window._aflPackStats, pool: playable(window._aflPool()) }))
  window._aflStartRun = () => window._aflLaunch(() => CG.startRun(window._aflCtx, { stats: window._aflPackStats, pool: playable(window._aflPool()) }))
  // Play your hand — duel the exact cards in the tray (the user's choice, not filtered).
  window._aflStartDuelHand = (ids) => window._aflLaunch(() => CG.startDuel(window._aflCtx, ids, { stats: window._aflPackStats }))
  // Daily Run — date-seeded (same for everyone), best depth + streak saved locally.
  window._aflStartDaily = () => window._aflLaunch(() => {
    const date = CG.today()
    CG.startRun(window._aflCtx, {
      stats: window._aflPackStats, pool: playable([...deckById.keys()]),
      rng: CG.makeRng("afl-" + date), title: "Daily Run · " + date, noResume: true, daily: true,
      onEnd: (depth) => CG.dailyPanel("afl", date, depth)
    })
  })
  return html``   // controls + hand live in the aflHandTray cell below
}

// Apply the selected pack → window._aflPackStats (no eager stats load).
deckPackApply = {
  const pack = AFL_PACKS.find(p => p.label === aflPack) || AFL_PACKS[0]
  window._aflActivePack = pack
  window._aflPackStats = pack.stats
  return html``
}
Show code
// ── Hand tray (drag-to-build + game launchers) — AFL port of football's tray.
// Shares cards-deck.css (.deck-tray / .tray-card / .deck-ghost / .tray-gap).
aflHandTray = {
  if (!ratings) return html``
  const KEY = "ig-afl-hand", MAX = 30
  let hand = []
  try { hand = (JSON.parse(localStorage.getItem(KEY) || "[]") || []).filter(id => deckById.has(id)).slice(0, MAX) } catch (e) {}
  const save = () => { try { localStorage.setItem(KEY, JSON.stringify(hand)) } catch (e) {} }

  const prior = document.querySelector(".deck-tray"); if (prior) prior.remove()

  const tray = document.createElement("div"); tray.className = "deck-tray"
  const head = document.createElement("div"); head.className = "deck-tray-head"
  const title = document.createElement("span"); title.className = "deck-tray-title"
  const actions = document.createElement("span"); actions.className = "deck-tray-actions"
  const mk = (label, cls, title, fn) => { const b = document.createElement("button"); b.type = "button"; b.className = "deck-tray-btn " + cls; b.textContent = label; if (title) b.title = title; b.addEventListener("click", fn); return b }
  const clearBtn = mk("Clear", "", "", () => { hand = []; save(); render() })
  const playBtn = mk("Play", "play", "Duel your hand as Top Trumps (need 2+ cards)", () => { if (hand.length >= 2 && window._aflStartDuelHand) window._aflStartDuelHand([...hand]) })
  const dailyBtn = mk("Daily", "daily", "Today's seeded Gaffer's Run — same opponents for everyone; best depth + streak saved", () => { if (window._aflStartDaily) window._aflStartDaily() })
  const runBtn = mk("Gaffer's Run", "run", "Roguelike: climb a ladder, draft after each win, lose and it's over", () => { if (window._aflStartRun) window._aflStartRun() })
  const hlBtn = mk("Higher / Lower", "hl", "Endless streak: guess if the next card's stat is higher or lower", () => { if (window._aflStartHL) window._aflStartHL() })
  const quickBtn = mk("Quick Play", "quick", "Deal a random hand from the current filter and duel the CPU", () => { if (window._aflStartQuick) window._aflStartQuick() })
  actions.append(dailyBtn, runBtn, hlBtn, quickBtn, clearBtn, playBtn)
  head.append(title, actions)
  const strip = document.createElement("div"); strip.className = "deck-tray-strip"
  tray.append(head, strip)

  function miniCard(id) {
    const r = deckById.get(id); if (!r) return null
    const name = r.player_name || "?"
    const colour = aflColOf(r)
    const initials = name.split(" ").map(w => w[0] || "").join("").slice(0, 2).toUpperCase()
    const photo = aflHeadshot(r)
    const el = document.createElement("div"); el.className = "tray-card"; el.style.setProperty("--c", colour)
    el.dataset.id = id
    el.innerHTML = `
      <div class="tray-thumb">
        ${photo ? `<img src="${photo}" alt="" draggable="false" onerror="this.style.display='none';this.nextElementSibling.style.display=''">` : ""}
        <span class="tray-mono"${photo ? ' style="display:none"' : ''}>${statsEsc(initials)}</span>
      </div>
      <div class="tray-meta"><span class="tray-name">${statsEsc(name)}</span><span class="tray-panna">${r.torp == null ? "—" : r.torp.toFixed(2)}</span></div>
      <button type="button" class="tray-x" title="Remove">×</button>`
    const goProfile = () => { if (window._aflDidDrag) return; location.href = "player.html#id=" + encodeURIComponent(id) }
    el.querySelector(".tray-thumb").addEventListener("click", goProfile)
    el.querySelector(".tray-meta").addEventListener("click", goProfile)
    el.querySelector(".tray-x").addEventListener("click", e => { e.stopPropagation(); hand = hand.filter(x => x !== id); save(); render() })
    el.style.touchAction = "none"; el.draggable = false
    el.addEventListener("dragstart", e => e.preventDefault())
    el.addEventListener("pointerdown", e => {
      if (e.pointerType === "mouse" && e.button !== 0) return
      if (e.target.closest(".tray-x")) return
      beginDrag(e, id, name, "reorder", el, el)
    })
    return el
  }

  function render() {
    title.textContent = hand.length ? `Your Hand · ${hand.length}${hand.length >= MAX ? " (max)" : ""}` : "Your Hand"
    clearBtn.disabled = !hand.length
    playBtn.disabled = hand.length < 2
    playBtn.title = hand.length < 2 ? "Add 2+ cards to duel" : "Duel your hand as Top Trumps"
    strip.replaceChildren()
    if (!hand.length) {
      const hint = document.createElement("span"); hint.className = "deck-tray-hint"
      hint.textContent = "Drag cards here (or tap “+ Hand”) to build your deck"
      strip.appendChild(hint); return
    }
    for (const id of hand) { const m = miniCard(id); if (m) strip.appendChild(m) }
  }

  window._aflAddToHand = (id) => {
    if (!deckById.has(id) || hand.includes(id) || hand.length >= MAX) return false
    hand.push(id); save(); render(); return true
  }

  // ── Pointer-drag controller (mouse + touch + pen) — identical to football's ──
  const overTray = (x, y) => { const el = document.elementFromPoint(x, y); return !!(el && el.closest(".deck-tray")) }
  const liveCards = () => [...strip.querySelectorAll(".tray-card:not(.dragging)")]
  const gapIndex = clientX => {
    const items = liveCards()
    for (let i = 0; i < items.length; i++) { const b = items[i].getBoundingClientRect(); if (clientX < b.left + b.width / 2) return i }
    return items.length
  }
  let gap = null
  function showGap(idx) {
    if (!gap) { gap = document.createElement("div"); gap.className = "tray-gap" }
    const ref = liveCards()[idx] || null
    if (gap.nextElementSibling !== ref || gap.parentNode !== strip) strip.insertBefore(gap, ref)
    requestAnimationFrame(() => gap && gap.classList.add("open"))
  }
  const hideGap = () => { if (gap) { gap.remove(); gap = null } }

  let drag = null
  function makeGhost(d) {
    if (d.cloneEl) {
      const c = d.cloneEl.cloneNode(true)
      c.className = (d.kind === "add" ? "ttc" : "tray-card") + " deck-ghost-card"
      c.style.setProperty("--c", d.colour)
      c.querySelectorAll(".ttc-add, .tray-x").forEach(b => b.remove())
      if (c.removeAttribute) c.removeAttribute("href")
      return c
    }
    const g = document.createElement("div"); g.className = "deck-ghost"; g.style.setProperty("--c", d.colour); g.textContent = d.name
    return g
  }
  function onMove(e) {
    if (!drag) return
    if (!drag.moved && Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 6) return
    if (!drag.moved) {
      drag.moved = true; window._aflDidDrag = true
      if (drag.sourceEl) drag.sourceEl.classList.add("dragging")
      drag.ghost = makeGhost(drag); document.body.appendChild(drag.ghost)
      document.body.style.userSelect = "none"
    }
    drag.ghost.style.left = e.clientX + "px"; drag.ghost.style.top = e.clientY + "px"
    const inTray = overTray(e.clientX, e.clientY)
    tray.classList.toggle("drop-hot", inTray)
    if (inTray && hand.length) showGap(gapIndex(e.clientX)); else hideGap()
    e.preventDefault()
  }
  function onUp(e) {
    document.removeEventListener("pointermove", onMove)
    tray.classList.remove("drop-hot"); document.body.style.userSelect = ""
    const inTray = drag && drag.moved && overTray(e.clientX, e.clientY)
    const slot = inTray ? gapIndex(e.clientX) : -1
    hideGap()
    if (drag) {
      if (drag.sourceEl) drag.sourceEl.classList.remove("dragging")
      if (drag.ghost) drag.ghost.remove()
      if (drag.moved) {
        if (drag.kind === "add" && inTray) {
          if (window._aflAddToHand(drag.id)) {
            const cur = hand.indexOf(drag.id), to = Math.min(slot, hand.length - 1)
            if (cur !== to && to >= 0) { hand.splice(cur, 1); hand.splice(to, 0, drag.id); save(); render() }
          }
        } else if (drag.kind === "reorder") {
          const from = hand.indexOf(drag.id)
          if (from >= 0 && inTray) { hand.splice(from, 1); hand.splice(Math.max(0, Math.min(hand.length, slot)), 0, drag.id); save(); render() }
          else if (from >= 0 && !inTray) { hand = hand.filter(x => x !== drag.id); save(); render() }
        }
      }
    }
    setTimeout(() => { window._aflDidDrag = false }, 0)
    drag = null
  }
  function beginDrag(e, id, name, kind, sourceEl, cloneEl) {
    drag = { id, name, kind, x0: e.clientX, y0: e.clientY, moved: false, colour: aflColOf(deckById.get(id) || {}), sourceEl: sourceEl || null, cloneEl: cloneEl || null }
    window._aflDidDrag = false
    document.addEventListener("pointermove", onMove, { passive: false })
    document.addEventListener("pointerup", onUp, { once: true })
    document.addEventListener("pointercancel", onUp, { once: true })
  }
  window._aflBeginCardDrag = (e, id, name, cardEl) => beginDrag(e, id, name, "add", null, cardEl)

  render()
  return tray
}
Show code
{
  if (!ratings) return html`<p class="text-muted">Loading the deck…</p>`
  const PAGE = 48
  const q = (aflSearch || "").trim().toLowerCase()
  let rows = ratings.filter(r =>
    (r.gms || 0) >= aflMinGms &&
    (aflTeam === "All teams" || r.team === aflTeam) &&
    (aflPos === "All positions" || r.position_group === aflPos) &&
    (!q || (r.player_name || "").toLowerCase().includes(q))
  )
  const sk = aflSort[1]
  if (sk === "name") rows.sort((a, b) => (a.player_name || "").localeCompare(b.player_name || ""))
  else rows.sort((a, b) => (b[sk] ?? -1e9) - (a[sk] ?? -1e9))

  const wrap = document.createElement("div")
  const info = document.createElement("div"); info.className = "deck-bar"
  const grid = document.createElement("div"); grid.className = "ttc-deck"
  wrap.append(info, grid)
  let page = 0
  const pages = Math.max(1, Math.ceil(rows.length / PAGE))
  function render() {
    grid.replaceChildren()
    for (const r of rows.slice(page * PAGE, page * PAGE + PAGE)) grid.appendChild(aflCard(r))
    info.replaceChildren()
    const c = document.createElement("span"); c.className = "deck-count"
    c.textContent = rows.length ? `${page * PAGE + 1}–${Math.min(rows.length, (page + 1) * PAGE)} of ${rows.length.toLocaleString()} cards` : "No players match these filters"
    info.appendChild(c)
    if (pages > 1) {
      const nav = document.createElement("span"); nav.className = "deck-nav"
      const b = (lbl, dis, fn) => { const x = document.createElement("button"); x.className = "deck-pg"; x.textContent = lbl; x.disabled = dis; if (!dis) x.onclick = () => { fn(); render(); wrap.scrollIntoView({ block: "start", behavior: "smooth" }) }; return x }
      nav.append(b("‹ Prev", page === 0, () => page--), Object.assign(document.createElement("span"), { className: "deck-pgn", textContent: `${page + 1} / ${pages}` }), b("Next ›", page >= pages - 1, () => page++))
      info.appendChild(nav)
    }
  }
  render()
  return wrap
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer