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 Age Curves

Skip to content

AFL > Age Curves

AFL · Age Curves · Career Trajectories

When does each player actually peak?

One line per player — each point a TORP rating at that age. Pick up to four players to compare side-by-side; the bold line is a Gaussian smooth that decouples genuine trajectory from sparse early- or late-career rounds.

Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>After every round</strong></span>
  <span><a href="../blog/2026-04-24-understanding-torp/">Methodology &darr;</a></span>
  <span><a href="player-ratings.html">Player Ratings &nearr;</a></span>
  <span>&approx; 4 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
statsEsc = window.statsEsc
fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL

ratings = fetchParquet(base_url + "afl/ratings.parquet")
details = fetchParquet(base_url + "afl/player-details.parquet")
Show code
_maps = {
  if (!window.aflTeamMaps) throw new Error("AFL team mappings failed to load.")
  return window.aflTeamMaps
}
aflTeamColors2 = _maps.aflTeamColors2
teamLogo = _maps.teamLogo
predToFull = _maps.predToFull
Show code
playerLookup = {
  if (!ratings) return new Map()
  const latest = new Map()
  for (const d of ratings) {
    const prev = latest.get(d.player_id)
    if (!prev || d.season > prev.season || (d.season === prev.season && (d.round || 0) > (prev.round || 0))) {
      latest.set(d.player_id, d)
    }
  }
  return latest
}

playerList = {
  if (!playerLookup || playerLookup.size === 0) return []
  return [...playerLookup.values()]
    .map(d => ({ id: d.player_id, name: d.player_name, team: predToFull[d.team] || d.team, position_group: d.position_group }))
    .sort((a, b) => a.name.localeCompare(b.name))
}

dobByPid = {
  const m = new Map()
  if (!details) return m
  for (const d of details) {
    if (d.player_id && d.date_of_birth) m.set(d.player_id, d.date_of_birth)
  }
  return m
}
Show code
_initPlayers = {
  const ids = []
  for (let i = 1; i <= 4; i++) {
    const id = window._getHashParam("p" + i)
    if (id && playerLookup.has(id)) ids.push(id)
  }
  return ids
}
Show code
viewof selectedPlayers = {
  if (!ratings || playerList.length === 0) return html`<p class="text-muted">Loading player data...</p>`

  const MAX_PLAYERS = 4
  const PALETTE = ["#e8b84b", "#5dadec", "#e06666", "#6fcf97", "#c084fc", "#f4845f", "#7dd3fc", "#fbbf24"]
  const state = [..._initPlayers]

  const container = document.createElement("div")
  container.value = [...state]

  const cardsRow = document.createElement("div")
  cardsRow.style.cssText = "display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:0.75rem"
  container.appendChild(cardsRow)

  const searchWrap = document.createElement("div")
  searchWrap.style.cssText = "position:relative;max-width:320px;z-index:50"
  const input = document.createElement("input")
  input.type = "text"
  input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
  input.className = "form-control"
  input.style.cssText = "background:var(--site-surface-bg);color:var(--site-body-color);border:1px solid var(--site-surface-border);padding:0.4rem 0.6rem;font-size:0.9rem;width:100%"
  searchWrap.appendChild(input)
  const dropdown = document.createElement("div")
  dropdown.style.cssText = "position:absolute;top:100%;left:0;right:0;background:var(--site-surface-raised);border:1px solid var(--site-surface-border);border-top:none;max-height:240px;overflow-y:auto;display:none;z-index:100;border-radius:0 0 6px 6px"
  searchWrap.appendChild(dropdown)
  container.appendChild(searchWrap)

  function getPlayerColor(idx) {
    const pid = state[idx]
    const p = playerLookup.get(pid)
    const teamColor = p ? aflTeamColors2[predToFull[p.team] || p.team] : null
    return teamColor || PALETTE[idx % PALETTE.length]
  }

  function renderCards() {
    while (cardsRow.firstChild) cardsRow.removeChild(cardsRow.firstChild)
    for (let i = 0; i < state.length; i++) {
      const pid = state[i]
      const p = playerLookup.get(pid)
      if (!p) continue
      const team = predToFull[p.team] || p.team
      const color = getPlayerColor(i)

      const card = document.createElement("div")
      card.style.cssText = "display:flex;align-items:center;gap:0.4rem;padding:0.35rem 0.6rem;background:" + color + "18;border:1px solid " + color + "50;border-radius:6px;font-size:0.85rem"

      const logo = teamLogo(team)
      if (logo) {
        const img = document.createElement("img")
        img.src = logo
        img.alt = ""
        img.style.cssText = "width:20px;height:20px"
        card.appendChild(img)
      }

      const nameEl = document.createElement("a")
      nameEl.href = "player.html#id=" + encodeURIComponent(pid)
      nameEl.textContent = p.player_name
      nameEl.style.cssText = "color:" + color + ";font-weight:600;text-decoration:none"
      card.appendChild(nameEl)

      const removeBtn = document.createElement("button")
      removeBtn.textContent = "\u00d7"
      removeBtn.style.cssText = "background:none;border:none;color:var(--site-muted-color);cursor:pointer;font-size:1.1rem;padding:0 0.2rem;margin-left:0.2rem"
      ;(function(index) {
        removeBtn.addEventListener("click", function() {
          state.splice(index, 1)
          renderCards()
          updateHash()
          input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
          input.disabled = state.length >= MAX_PLAYERS
          container.value = [...state]
          container.dispatchEvent(new Event("input", { bubbles: true }))
        })
      })(i)
      card.appendChild(removeBtn)
      cardsRow.appendChild(card)
    }
    if (typeof suggestWrap !== "undefined") {
      suggestWrap.style.display = state.length === 0 ? "block" : "none"
    }
  }

  function updateHash() {
    const params = state.map(function(id, i) { return "p" + (i + 1) + "=" + encodeURIComponent(id) }).join("&")
    const metric = window._getHashParam("metric")
    const hash = params + (metric ? "&metric=" + metric : "")
    history.replaceState(null, "", "#" + hash)
  }

  function showDropdown(query) {
    while (dropdown.firstChild) dropdown.removeChild(dropdown.firstChild)
    if (!query || query.length < 2) { dropdown.style.display = "none"; return }
    const q = query.toLowerCase()
    const matches = playerList.filter(function(p) { return !state.includes(p.id) && p.name.toLowerCase().includes(q) }).slice(0, 12)
    if (matches.length === 0) { dropdown.style.display = "none"; return }

    for (const p of matches) {
      const item = document.createElement("div")
      item.style.cssText = "padding:0.4rem 0.6rem;cursor:pointer;display:flex;align-items:center;gap:0.4rem;font-size:0.85rem"
      item.addEventListener("mouseenter", function() { item.style.background = "rgba(var(--site-overlay-rgb), 0.08)" })
      item.addEventListener("mouseleave", function() { item.style.background = "none" })

      const logo = teamLogo(p.team)
      if (logo) {
        const img = document.createElement("img")
        img.src = logo
        img.alt = ""
        img.style.cssText = "width:18px;height:18px"
        item.appendChild(img)
      }

      const nameSpan = document.createElement("span")
      nameSpan.style.color = "var(--site-body-color)"
      nameSpan.textContent = p.name
      item.appendChild(nameSpan)

      const teamSpan = document.createElement("span")
      teamSpan.style.cssText = "color:var(--site-muted-color);font-size:0.8rem;margin-left:auto"
      teamSpan.textContent = p.team
      item.appendChild(teamSpan)

      ;(function(playerId) {
        item.addEventListener("click", function() {
          if (state.length >= MAX_PLAYERS) return
          state.push(playerId)
          input.value = ""
          dropdown.style.display = "none"
          renderCards()
          updateHash()
          input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
          input.disabled = state.length >= MAX_PLAYERS
          container.value = [...state]
          container.dispatchEvent(new Event("input", { bubbles: true }))
        })
      })(p.id)
      dropdown.appendChild(item)
    }
    dropdown.style.display = "block"
  }

  input.addEventListener("input", function() { showDropdown(input.value) })
  input.addEventListener("focus", function() { showDropdown(input.value) })
  document.addEventListener("click", function(e) { if (!searchWrap.contains(e.target)) dropdown.style.display = "none" })

  // Suggestion chips: career-leading TORPs so empty-state has a starting point
  var suggestWrap = document.createElement("div")
  suggestWrap.style.cssText = "margin-top:0.75rem;display:none"
  const suggestLabel = document.createElement("div")
  suggestLabel.textContent = "Try comparing career arcs:"
  suggestLabel.style.cssText = "color:var(--site-muted-color);font-size:0.8rem;margin-bottom:0.4rem"
  suggestWrap.appendChild(suggestLabel)
  const suggestRow = document.createElement("div")
  suggestRow.style.cssText = "display:flex;flex-wrap:wrap;gap:0.35rem"
  suggestWrap.appendChild(suggestRow)
  container.appendChild(suggestWrap)

  const topPlayers = [...playerLookup.values()]
    .filter(d => (d.torp || 0) > 0 && (d.gms || 0) >= 50)
    .sort((a, b) => (b.torp || 0) - (a.torp || 0))
    .slice(0, 6)

  for (const tp of topPlayers) {
    const chip = document.createElement("button")
    chip.textContent = tp.player_name
    chip.style.cssText = "background:var(--site-surface-raised);border:1px solid var(--site-surface-border);color:var(--bs-primary);padding:0.3rem 0.6rem;border-radius:14px;font-size:0.8rem;cursor:pointer"
    ;(function(pid) {
      chip.addEventListener("click", function() {
        if (state.length >= MAX_PLAYERS) return
        state.push(pid)
        renderCards()
        updateHash()
        input.placeholder = state.length >= MAX_PLAYERS ? "Max 4 players" : "Search for a player..."
        input.disabled = state.length >= MAX_PLAYERS
        suggestWrap.style.display = state.length === 0 ? "block" : "none"
        container.value = [...state]
        container.dispatchEvent(new Event("input", { bubbles: true }))
      })
    })(tp.player_id)
    suggestRow.appendChild(chip)
  }

  input.disabled = state.length >= MAX_PLAYERS
  renderCards()

  return container
}
Show code
viewof metric = {
  // PSR/OSR/DSR omitted until torp#88 ships — currently only the latest snapshot value
  // ends up on every historical row, so the chart shows misleading flat lines.
  const metrics = ["torp", "epr", "recv_epr", "disp_epr", "spoil_epr", "hitout_epr"]
  const labels = {
    torp: "TORP", epr: "EPR",
    recv_epr: "Reception", disp_epr: "Disposal", spoil_epr: "Spoil", hitout_epr: "Hitout"
  }
  const initMetric = window._getHashParam("metric") || "torp"

  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.style.marginTop = "0.5rem"
  wrap.value = metrics.includes(initMetric) ? initMetric : "torp"

  for (const m of metrics) {
    const btn = document.createElement("button")
    btn.type = "button"
    btn.className = "epv-toggle-btn" + (m === wrap.value ? " active" : "")
    btn.textContent = labels[m]
    btn.addEventListener("click", function() {
      wrap.querySelectorAll(".epv-toggle-btn").forEach(function(b) { b.classList.remove("active") })
      btn.classList.add("active")
      wrap.value = m
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
      var currentHash = window.location.hash.replace(/([&?]?)metric=[^&]*/g, "").replace(/^#/, "")
      history.replaceState(null, "", "#" + currentHash + (currentHash ? "&" : "") + "metric=" + m)
    })
    wrap.appendChild(btn)
  }
  return wrap
}
Show code
// Main chart: per-player age trajectory
{
  if (!ratings || !details) return html`<p class="text-muted">Loading…</p>`
  if (!selectedPlayers || selectedPlayers.length === 0) {
    return html`<div style="text-align:center;padding:3rem 1rem;color:var(--site-muted-color)">
      <p style="font-size:1.1rem">Pick a player above to trace their rating trajectory.</p>
      <p style="font-size:0.85rem">Add up to four to compare career arcs side by side.</p>
    </div>`
  }

  const labels = {
    torp: "TORP", epr: "EPR",
    recv_epr: "Reception", disp_epr: "Disposal", spoil_epr: "Spoil", hitout_epr: "Hitout"
  }
  const PALETTE = ["#e8b84b", "#5dadec", "#e06666", "#6fcf97", "#c084fc", "#f4845f", "#7dd3fc", "#fbbf24"]

  // For each player → one point per round (all rating rows, not just season-end)
  const YEAR_MS = 365.25 * 24 * 3600 * 1000
  const series = []
  const usedColors = []
  selectedPlayers.forEach(function(pid, idx) {
    const rows = ratings.filter(function(d) { return d.player_id === pid })
    if (rows.length === 0) return

    const dob = dobByPid.get(pid)
    if (!dob) return
    const dobDate = dob instanceof Date ? dob : new Date(dob)
    if (isNaN(dobDate.getTime())) return

    const p = playerLookup.get(pid)
    const team = p ? predToFull[p.team] || p.team : ""
    let color = aflTeamColors2[team] || PALETTE[idx % PALETTE.length]
    // Fall back to any unused palette entry if the team-derived color collides
    // or the first fallback also collides (e.g. 4 players with the same team).
    if (usedColors.indexOf(color) !== -1) {
      for (let k = 0; k < PALETTE.length; k++) {
        const candidate = PALETTE[(idx + k) % PALETTE.length]
        if (usedColors.indexOf(candidate) === -1) { color = candidate; break }
      }
    }
    usedColors.push(color)

    const points = rows
      .map(function(r) {
        // AFL round 1 starts ~March 20, ~7 days per round → approximate match date
        const rnd = r.round || 1
        const roundDate = new Date(r.season, 2, 20 + (rnd - 1) * 7)
        const age = (roundDate - dobDate) / YEAR_MS
        return { age, season: r.season, round: rnd, value: r[metric], gms: r.gms || 0 }
      })
      .filter(function(p) { return p.value != null && !isNaN(p.value) && p.age >= 15 && p.age <= 45 })
      .sort(function(a, b) { return a.age - b.age })

    if (points.length === 0) return

    series.push({
      pid, name: p ? p.player_name : pid, team, color, points
    })
  })

  if (series.length === 0) {
    return html`<p class="text-muted">No age data available for the selected players.</p>`
  }

  // Scales
  const allAges = series.flatMap(s => s.points.map(p => p.age))
  const allVals = series.flatMap(s => s.points.map(p => p.value))
  const xMin = Math.max(16, Math.floor(Math.min(...allAges)) - 1)
  const xMax = Math.min(42, Math.ceil(Math.max(...allAges)) + 1)
  const vMin = Math.min(...allVals), vMax = Math.max(...allVals)
  const vPad = (vMax - vMin) * 0.1 || 0.5
  const yMin = vMin - vPad, yMax = vMax + vPad

  const W = 900, H = 440
  const PAD = { l: 55, r: 120, t: 30, b: 50 }
  const plotW = W - PAD.l - PAD.r
  const plotH = H - PAD.t - PAD.b
  const xScale = v => PAD.l + ((v - xMin) / (xMax - xMin)) * plotW
  const yScale = v => PAD.t + plotH - ((v - yMin) / (yMax - yMin)) * plotH

  const ns = "http://www.w3.org/2000/svg"
  const mk = (tag, attrs, text) => {
    const el = document.createElementNS(ns, tag)
    for (const k in attrs) el.setAttribute(k, attrs[k])
    if (text != null) el.textContent = text
    return el
  }

  const svg = document.createElementNS(ns, "svg")
  svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
  svg.setAttribute("style", "width:100%;height:auto;max-height:520px")

  // Plot background
  // Chart bg + gridlines + axis labels use theme-aware tokens via inline style=
  // (see chart-helpers.js scatter for the same pattern).
  svg.appendChild(mk("rect", {
    x: PAD.l, y: PAD.t, width: plotW, height: plotH, rx: 4,
    style: "fill: rgba(var(--site-overlay-rgb), 0.04)"
  }))

  // X gridlines + tick labels
  for (let a = xMin; a <= xMax; a++) {
    const x = xScale(a)
    svg.appendChild(mk("line", {
      x1: x, y1: PAD.t, x2: x, y2: PAD.t + plotH, "stroke-width": "0.5",
      style: "stroke: rgba(var(--site-overlay-rgb), 0.1)"
    }))
    if (a % 2 === 0 || xMax - xMin <= 14) {
      svg.appendChild(mk("text", {
        x, y: PAD.t + plotH + 16, "text-anchor": "middle",
        "font-size": "11", "font-family": "var(--bs-font-monospace)",
        style: "fill: var(--site-muted-strong)"
      }, String(a)))
    }
  }
  // Y gridlines
  const yTicks = 6
  for (let i = 0; i <= yTicks; i++) {
    const v = yMin + (yMax - yMin) * (i / yTicks)
    const y = yScale(v)
    svg.appendChild(mk("line", {
      x1: PAD.l, y1: y, x2: PAD.l + plotW, y2: y, "stroke-width": "0.5",
      style: "stroke: rgba(var(--site-overlay-rgb), 0.12)"
    }))
    svg.appendChild(mk("text", {
      x: PAD.l - 8, y: y + 3, "text-anchor": "end",
      "font-size": "11", "font-family": "var(--bs-font-monospace)",
      style: "fill: var(--site-muted-strong)"
    }, v.toFixed(Math.abs(yMax - yMin) < 5 ? 1 : 0)))
  }
  // Zero line
  if (yMin < 0 && yMax > 0) {
    const y0 = yScale(0)
    svg.appendChild(mk("line", {
      x1: PAD.l, y1: y0, x2: PAD.l + plotW, y2: y0,
      "stroke-width": "1", "stroke-dasharray": "3,3",
      style: "stroke: rgba(var(--site-overlay-rgb), 0.32)"
    }))
  }

  // Axis labels
  svg.appendChild(mk("text", {
    x: PAD.l + plotW / 2, y: H - 8, "text-anchor": "middle",
    "font-size": "12",
    style: "fill: var(--site-muted-color)"
  }, "Age"))
  svg.appendChild(mk("text", {
    x: 16, y: PAD.t + plotH / 2, "text-anchor": "middle",
    "font-size": "12",
    transform: `rotate(-90,16,${PAD.t + plotH / 2})`,
    style: "fill: var(--site-muted-color)"
  }, labels[metric]))

  // Gaussian kernel smooth (bandwidth in years) sampled on a dense grid
  function kernelSmooth(pts, bandwidth, step) {
    if (pts.length < 2) return []
    const aMin = pts[0].age, aMax = pts[pts.length - 1].age
    const out = []
    const invTwoBw2 = 1 / (2 * bandwidth * bandwidth)
    for (let a = aMin; a <= aMax + 1e-9; a += step) {
      let num = 0, den = 0
      for (const p of pts) {
        const dA = p.age - a
        if (dA < -4 * bandwidth || dA > 4 * bandwidth) continue
        const w = Math.exp(-dA * dA * invTwoBw2)
        num += w * p.value
        den += w
      }
      if (den > 0) out.push({ age: a, value: num / den })
    }
    return out
  }

  function pathFrom(smoothPts) {
    if (smoothPts.length === 0) return ""
    return smoothPts
      .map((p, i) => `${i === 0 ? "M" : "L"}${xScale(p.age).toFixed(2)},${yScale(p.value).toFixed(2)}`)
      .join(" ")
  }

  const SMOOTH_BW = 0.75
  const SMOOTH_STEP = 0.1

  for (const s of series) {
    if (s.points.length === 0) continue

    // Faint dots for raw per-round values
    for (const p of s.points) {
      svg.appendChild(mk("circle", {
        cx: xScale(p.age), cy: yScale(p.value),
        r: 2, fill: s.color, opacity: 0.35,
        "data-player": s.name, "data-team": s.team,
        "data-age": p.age.toFixed(1),
        "data-season": p.season,
        "data-round": p.round,
        "data-value": p.value.toFixed(2),
        "data-gms": p.gms,
        class: "age-dot"
      }))
    }

    // Smoothed trend line
    if (s.points.length >= 2) {
      const smoothed = kernelSmooth(s.points, SMOOTH_BW, SMOOTH_STEP)
      if (smoothed.length >= 2) {
        svg.appendChild(mk("path", {
          d: pathFrom(smoothed),
          fill: "none", stroke: s.color, "stroke-width": "2.5", opacity: "0.95",
          "stroke-linejoin": "round", "stroke-linecap": "round"
        }))
        s._smoothEnd = smoothed[smoothed.length - 1]
      }
    }
  }

  // Right-side end labels (anchor on smoothed line end when available)
  const labelPositions = series.map(s => {
    const end = s._smoothEnd || s.points[s.points.length - 1]
    return { name: s.name.split(" ").slice(-1)[0], color: s.color, y: yScale(end.value), xEnd: xScale(end.age) }
  }).sort((a, b) => a.y - b.y)
  const minGap = 14
  for (let i = 1; i < labelPositions.length; i++) {
    if (labelPositions[i].y - labelPositions[i - 1].y < minGap) {
      labelPositions[i].y = labelPositions[i - 1].y + minGap
    }
  }
  for (const lp of labelPositions) {
    svg.appendChild(mk("text", {
      x: PAD.l + plotW + 8, y: lp.y + 4,
      fill: lp.color, "font-size": "12", "font-weight": "600",
      "font-family": "var(--bs-font-monospace)"
    }, lp.name))
  }

  // Tooltip
  const wrap = document.createElement("div")
  wrap.style.position = "relative"
  const tip = document.createElement("div")
  tip.className = "field-tooltip"
  wrap.appendChild(svg)
  wrap.appendChild(tip)

  svg.addEventListener("mousemove", (e) => {
    const dot = e.target.closest(".age-dot")
    if (!dot) { tip.classList.remove("visible"); return }
    if (window.chartHelpers?.buildFieldTooltip) {
      window.chartHelpers.buildFieldTooltip(tip, dot.getAttribute("data-player"), [
        ["Team", dot.getAttribute("data-team")],
        ["Season", dot.getAttribute("data-season")],
        ["Round", dot.getAttribute("data-round")],
        ["Age", dot.getAttribute("data-age")],
        [labels[metric], dot.getAttribute("data-value")],
        ["Career Gms", dot.getAttribute("data-gms")]
      ])
    }
    const r = wrap.getBoundingClientRect()
    tip.style.left = (e.clientX - r.left) + "px"
    tip.style.top = (e.clientY - r.top - 14) + "px"
    tip.style.transform = "translate(-50%, -100%)"
    tip.classList.add("visible")
  })
  svg.addEventListener("mouseleave", () => tip.classList.remove("visible"))

  return wrap
}
Show code
// Summary table — peak rating & age per selected player at current metric
{
  if (!ratings || !details || !selectedPlayers || selectedPlayers.length === 0) return html``

  const labels = {
    torp: "TORP", epr: "EPR",
    recv_epr: "Reception", disp_epr: "Disposal", spoil_epr: "Spoil", hitout_epr: "Hitout"
  }
  const rows = []
  for (const pid of selectedPlayers) {
    const p = playerLookup.get(pid)
    if (!p) continue
    const dob = dobByPid.get(pid)
    const dobDate = dob ? new Date(dob) : null

    const playerRows = ratings.filter(r => r.player_id === pid)
    const bySeason = new Map()
    for (const r of playerRows) {
      const prev = bySeason.get(r.season)
      if (!prev || (r.round || 0) > (prev.round || 0)) bySeason.set(r.season, r)
    }

    let peak = null, latest = null
    for (const r of bySeason.values()) {
      const v = r[metric]
      if (v == null || isNaN(v)) continue
      if (!peak || v > peak.value) {
        const age = dobDate ? (new Date(r.season, 5, 15) - dobDate) / (365.25 * 24 * 3600 * 1000) : null
        peak = { value: v, age, season: r.season }
      }
      if (!latest || r.season > latest.season) latest = r
    }
    if (!peak) continue

    const currentAge = dobDate ? (Date.now() - dobDate.getTime()) / (365.25 * 24 * 3600 * 1000) : null

    rows.push({
      _pid: pid,
      player: p.player_name,
      team: predToFull[p.team] || p.team,
      position: p.position_group || "",
      currentAge: currentAge != null ? currentAge.toFixed(1) : "—",
      peakAge: peak.age != null ? peak.age.toFixed(1) : "—",
      peakSeason: peak.season,
      peakValue: +peak.value.toFixed(2),
      currentValue: latest ? +(latest[metric] || 0).toFixed(2) : 0
    })
  }

  if (rows.length === 0) return html``

  return html`<h3 style="margin-top:1.5rem;font-size:1.1rem">Peak vs Current · ${labels[metric]}</h3>
  ${window.statsTable(rows, {
    columns: ["player", "team", "position", "currentAge", "peakAge", "peakSeason", "peakValue", "currentValue"],
    header: {
      player: "Player", team: "Team", position: "Pos",
      currentAge: "Age Now", peakAge: "Peak Age", peakSeason: "Peak Yr",
      peakValue: "Peak", currentValue: "Current"
    },
    heatmap: { peakValue: "high-good", currentValue: "high-good" },
    render: {
      player: function(v, row) {
        return '<a href="player.html#id=' + encodeURIComponent(row._pid || "") + '" class="player-link" style="font-weight:600">' + statsEsc(v) + '</a>'
      }
    },
    sort: "peakValue",
    reverse: true,
    rows: 10
  })}`
}

Method: Every rating row from ratings.parquet is used — one faint dot per player per round (the cumulative career rating at that point). Age at a given round is approximated as new Date(season, March 20 + (round-1)×7 days) − date_of_birth. The bold line is a Gaussian kernel smooth (bandwidth 0.75 yr) — it decouples how wiggly the trend is from how many rounds the player has played, so sparse early-career or late-career tails don’t dominate.

Show code
// ── Source attribution row ──────────────────────────────────
{
  const src = document.createElement("div"); src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/torpdata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "torpdata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Pete Owen · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = "One dot per (player, round) · Bold line = Gaussian smooth bw=0.75 yr"
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock, btnTile } = window.editorial

  // Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "After every round"
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Trajectories regenerate from the daily "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "torpdata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline."))
  upd.appendChild(updP); inner.appendChild(upd)

  // BTN — by-the-numbers framing
  const btn = railBlock("By the Numbers")
  const grid = document.createElement("div"); grid.className = "btn-block"
  grid.appendChild(btnTile("0.75 yr", [
    { text: "Smoother bandwidth", bold: true },
    { text: " · Gaussian kernel — narrow enough to track real change, wide enough to ignore single-round noise" }
  ]))
  grid.appendChild(btnTile("18–35", [
    { text: "Typical career span", bold: true },
    { text: " · debut to retirement on the AFL list" }
  ]))
  grid.appendChild(btnTile("4", [
    { text: "Compare up to four", bold: true },
    { text: " · pick players from the dropdown" }
  ]))
  btn.appendChild(grid); inner.appendChild(btn)

  // How to read this
  const how = railBlock("How to read this"); how.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Each faint dot is one round's "))
  const s = document.createElement("strong"); s.textContent = "cumulative career rating"; p1.appendChild(s)
  p1.appendChild(document.createTextNode(", plotted against age at that round."))
  how.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("The bold line is a "))
  const s2 = document.createElement("strong"); s2.textContent = "Gaussian smooth"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" — bandwidth 0.75 yr. Steep ascending segments = breakouts. Plateaus around the late 20s = a player at their ceiling. Falling lines from 30+ = the standard decline pattern."))
  how.appendChild(p2)
  inner.appendChild(how)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "player-ratings.html", title: "Player Ratings", meta: "Where TORP comes from" },
    { href: "compare.html", title: "Player Comparison", meta: "Side-by-side player tool" },
    { href: "player-stats.html", title: "Player Stats", meta: "Per-match box scores" },
    { href: "../blog/2026-04-24-understanding-torp/", title: "Understanding TORP", meta: "Blog · Methodology" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer