// 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
}