Training Load Performance Management Chart — MUI X Charts

A Performance Management Chart (PMC), popularized by TrainingPeaks, summarizes an endurance athlete's training history on a single shared time axis. Two exponentially-smoothed lines track Chronic Training Load (CTL, "fitness", ~42-day time constant) and Acute Training Load (ATL, "fatigue", ~7-day time constant), while a filled area or band shows Training Stress Balance (TSB, "form" = CTL − ATL) above and below a zero baseline. Daily Training Stress Score (TSS) values appear as points or thin vertical bars, giving the raw workout intensity behind the smoothed trends. The chart reveals whether an athlete is building fitness, accumulating fatigue, or freshening up (positive form) for a target event.

Training Load Performance Management Chart rendered with MUI X Charts

JavaScript source (MUI X Charts)

// anyplot.ai
// line-training-load-pmc: Training Load Performance Management Chart
// Library: muix 7.29.1 | JavaScript 22.22.3
// Quality: 91/100 | Created: 2026-06-13

import { ChartContainer } from "@mui/x-charts/ChartContainer";
import { AreaPlot, LinePlot } from "@mui/x-charts/LineChart";
import { ScatterPlot } from "@mui/x-charts/ScatterChart";
import { ChartsXAxis } from "@mui/x-charts/ChartsXAxis";
import { ChartsYAxis } from "@mui/x-charts/ChartsYAxis";
import { ChartsLegend } from "@mui/x-charts/ChartsLegend";
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
import { ChartsReferenceLine } from "@mui/x-charts/ChartsReferenceLine";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";

const t = window.ANYPLOT_TOKENS;

// --- Deterministic LCG for reproducible synthetic data ---
function lcg(seed) {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(1664525, s) + 1013904223) >>> 0;
    return s / 4294967295;
  };
}
const rand = lcg(42);

// --- Generate 180-day PMC training data (6-month endurance build to race) ---
const DAYS = 180;
const start = new Date(2025, 1, 3); // 3 Feb 2025
// Taper begins at week 22 (day 154) — where positive form zone opens
const taperDate = new Date(start.getTime() + 154 * 24 * 60 * 60 * 1000);

const dates = [];
const tssData = [];
const ctlData = [];
const atlData = [];
const tsbPos = []; // TSB when form is positive (fresh)
const tsbNeg = []; // TSB when form is negative (fatigued)

let ctl = 36; // starting chronic training load
let atl = 34; // starting acute training load

for (let i = 0; i < DAYS; i++) {
  const d = new Date(start);
  d.setDate(start.getDate() + i);
  dates.push(d);

  const week = Math.floor(i / 7);
  const dow = i % 7; // 0 = Mon … 6 = Sun

  // Phase-structured TSS with realistic training block
  let tss;
  const noise = (rand() - 0.5) * 18;

  if (dow === 6) {
    // Easy Sunday recovery
    tss = Math.round(Math.max(0, 22 + noise * 0.4));
  } else if (week < 6) {
    // Base phase: low-moderate volume, progressive
    tss = Math.round(Math.max(0, 50 + week * 4 + (dow % 2 === 0 ? 28 : 12) + noise));
  } else if (week < 15) {
    // Build phase: progressive overload with recovery every 4th week
    const buildProg = (week - 6) / 9;
    const isRec = (week - 6) % 4 === 3;
    const base = 80 + buildProg * 55;
    const variation = dow % 3 === 0 ? 50 : dow % 2 === 0 ? 28 : 12;
    tss = Math.round(Math.max(0, (base + variation + noise) * (isRec ? 0.5 : 1)));
  } else if (week < 22) {
    // Peak phase: high load with mini-recovery cycles
    const isRec = (week - 15) % 4 === 3;
    tss = Math.round(Math.max(0, (120 + (dow % 2 === 0 ? 48 : 22) + noise) * (isRec ? 0.48 : 1)));
  } else {
    // Taper: 4 weeks of declining load before race
    const tapProg = Math.min(1, (week - 22) / 3.5);
    tss = Math.round(Math.max(10, 95 * (1 - tapProg * 0.72) + noise * 0.5));
  }

  // TSB = previous-day CTL minus previous-day ATL (form available today)
  const tsb = Math.round((ctl - atl) * 10) / 10;

  // Update EWMA with today's TSS (42-day fitness constant, 7-day fatigue constant)
  ctl += (tss - ctl) / 42;
  atl += (tss - atl) / 7;

  tssData.push(tss);
  ctlData.push(Math.round(ctl * 10) / 10);
  atlData.push(Math.round(atl * 10) / 10);
  // Split TSB into positive (fresh) and negative (fatigued) for two-toned area
  tsbPos.push(tsb >= 0 ? tsb : null);
  tsbNeg.push(tsb < 0 ? tsb : null);
}

// TSS as scatter points: {x: Date, y: tss, id} format for ScatterPlot
const tssScatter = dates.map((d, i) => ({ x: d, y: tssData[i], id: i }));

const TITLE = "line-training-load-pmc · javascript · muix · anyplot.ai";
const TITLE_H = 58;
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];

export default function Chart() {
  const W = window.ANYPLOT_SIZE.width;
  const H = window.ANYPLOT_SIZE.height;

  return (
    <Box
      sx={{
        width: W,
        height: H,
        bgcolor: t.pageBg,
        display: "flex",
        flexDirection: "column",
      }}
    >
      {/* Title */}
      <Box
        sx={{
          height: TITLE_H,
          display: "flex",
          alignItems: "center",
          px: "40px",
          pt: "10px",
        }}
      >
        <Typography
          sx={{ color: t.ink, fontSize: "24px", fontWeight: 500, lineHeight: 1 }}
        >
          {TITLE}
        </Typography>
      </Box>

      {/* PMC chart */}
      <ChartContainer
        skipAnimation
        width={W}
        height={H - TITLE_H}
        sx={{
          "& .MuiAreaElement-series-tsbPos": { fillOpacity: 0.3 },
          "& .MuiAreaElement-series-tsbNeg": { fillOpacity: 0.3 },
        }}
        xAxis={[{
          id: "date",
          data: dates,
          scaleType: "time",
          valueFormatter: (v) => {
            const d = new Date(v);
            return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
          },
          tickNumber: 6,
        }]}
        yAxis={[
          // Primary left axis: CTL and ATL training load values
          { id: "load", min: 0, max: 190 },
          // Secondary right axis: TSB form (oscillates around 0)
          { id: "tsb",  min: -90, max: 90 },
          // Hidden axis for TSS scatter (scaled so points cluster at bottom quarter)
          { id: "tss",  min: 0, max: 760 },
        ]}
        series={[
          // CTL is first series → receives brand green (Imprint palette position 1)
          {
            id: "ctl",
            type: "line",
            data: ctlData,
            yAxisId: "load",
            label: "CTL – Fitness",
            color: t.palette[0], // #009E73 brand green
            showMark: false,
            curve: "monotoneX",
          },
          {
            id: "atl",
            type: "line",
            data: atlData,
            yAxisId: "load",
            label: "ATL – Fatigue",
            color: t.palette[1], // #C475FD lavender
            showMark: false,
            curve: "monotoneX",
          },
          // TSB split into two-toned areas: blue for fresh, red for fatigued
          {
            id: "tsbPos",
            type: "line",
            data: tsbPos,
            yAxisId: "tsb",
            label: "Form (Fresh)",
            color: t.palette[2], // #4467A3 blue
            area: true,
            showMark: false,
            curve: "monotoneX",
          },
          {
            id: "tsbNeg",
            type: "line",
            data: tsbNeg,
            yAxisId: "tsb",
            label: "Form (Fatigued)",
            color: t.palette[4], // #AE3030 matte red (semantic: bad/fatigue)
            area: true,
            showMark: false,
            curve: "monotoneX",
          },
          // TSS as scatter points — daily raw load shown as light dots near the bottom
          {
            id: "tss",
            type: "scatter",
            data: tssScatter,
            xAxisId: "date",
            yAxisId: "tss",
            label: "Daily TSS",
            color: t.palette[3], // #BD8233 ochre
            markerSize: 3,
          },
        ]}
        margin={{ top: 20, bottom: 100, left: 90, right: 90 }}
      >
        <ChartsGrid horizontal />
        <AreaPlot />
        <LinePlot />
        <ScatterPlot />
        <ChartsXAxis
          axisId="date"
          tickLabelStyle={{ fill: t.inkSoft, fontSize: 14 }}
        />
        <ChartsYAxis
          axisId="load"
          label="Training Load (AU)"
          tickLabelStyle={{ fill: t.inkSoft, fontSize: 13 }}
          labelStyle={{ fill: t.inkSoft, fontSize: 15 }}
        />
        <ChartsYAxis
          axisId="tsb"
          position="right"
          label="Form / TSB (AU)"
          tickLabelStyle={{ fill: t.inkSoft, fontSize: 13 }}
          labelStyle={{ fill: t.inkSoft, fontSize: 15 }}
        />
        {/* TSB = 0 horizontal reference — separates fresh from fatigued zones */}
        <ChartsReferenceLine
          y={0}
          yAxisId="tsb"
          lineStyle={{
            stroke: t.ink,
            strokeOpacity: 0.28,
            strokeDasharray: "6 3",
            strokeWidth: 1.5,
          }}
        />
        {/* Taper start — marks the positive-form window before race day */}
        <ChartsReferenceLine
          x={taperDate}
          xAxisId="date"
          label="↑ Taper"
          labelAlign="start"
          labelStyle={{ fill: t.inkSoft, fontSize: 12, fontStyle: "italic" }}
          lineStyle={{
            stroke: t.inkSoft,
            strokeOpacity: 0.4,
            strokeDasharray: "4 4",
            strokeWidth: 1,
          }}
        />
        <ChartsLegend
          position={{ vertical: "bottom", horizontal: "middle" }}
          slotProps={{
            legend: {
              labelStyle: { fill: t.inkSoft, fontSize: 14 },
              itemMarkWidth: 20,
              itemMarkHeight: 4,
            },
          }}
        />
      </ChartContainer>
    </Box>
  );
}

Part of Training Load Performance Management Chart on anyplot.ai.

Other implementations