Training Load Performance Management Chart — Apache ECharts

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 Apache ECharts

JavaScript source (Apache ECharts)

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

const t = window.ANYPLOT_TOKENS;
const isDark = window.ANYPLOT_THEME === "dark";

// Deterministic data (xorshift RNG — no Math.random())
let seed = 12345;
const rand = () => {
  seed ^= seed << 13;
  seed ^= seed >> 17;
  seed ^= seed << 5;
  return (seed >>> 0) / 0x100000000;
};

// Generate 180 days starting Dec 16 2025, ending ~Jun 13 2026
const N = 180;
const dates = [];
const tssArr = [];
const ctlArr = [];
const atlArr = [];
const tsbArr = [];

let ctl = 38;
let atl = 42;

for (let i = 0; i < N; i++) {
  const d = new Date(2025, 11, 16 + i); // Dec 16 2025 + i days (JS handles overflow)
  dates.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric" }));

  // TSB = yesterday's CTL - yesterday's ATL (form going into the day)
  tsbArr.push(+(ctl - atl).toFixed(1));

  // Training phase determines TSS distribution
  const phase = i / N;
  const dow = d.getDay(); // 0 = Sunday

  let tss = 0;
  if (dow !== 0) {
    const r = rand();
    const restChance = dow === 6 ? 0.3 : 0.15;
    if (r >= restChance) {
      let baseTSS;
      if (phase < 0.35) {
        baseTSS = 55 + phase * 70;           // base build: 55 → 79
      } else if (phase < 0.65) {
        baseTSS = 79 + (phase - 0.35) * 130; // intensity: 79 → 118
      } else if (phase < 0.82) {
        baseTSS = 118 + (phase - 0.65) * 30; // peak: 118 → 123
      } else {
        baseTSS = 123 * Math.max(0.22, 1 - (phase - 0.82) * 4.3); // taper
      }
      tss = Math.round(baseTSS * (0.65 + rand() * 0.70));
    } else {
      rand(); // consume RNG slot to keep sequence consistent
    }
  }

  tssArr.push(tss);

  // EWMA: CTL ≈ 42-day constant, ATL ≈ 7-day constant
  ctl = ctl + (tss - ctl) / 42;
  atl = atl + (tss - atl) / 7;
  ctlArr.push(+ctl.toFixed(1));
  atlArr.push(+atl.toFixed(1));
}

// Two-tone TSB: positive clipped to [0, ∞) and negative clipped to (-∞, 0]
const tsbPos = tsbArr.map(v => Math.max(v, 0));
const tsbNeg = tsbArr.map(v => Math.min(v, 0));

// Phase boundary indices (matching data generation thresholds)
const baseEnd = Math.floor(N * 0.35) - 1;      // day 62
const intensityEnd = Math.floor(N * 0.65) - 1; // day 116
const peakEnd = Math.floor(N * 0.82) - 1;      // day 147

// Peak CTL date for annotation
let peakCtlIdx = 0;
for (let i = 1; i < ctlArr.length; i++) {
  if (ctlArr[i] > ctlArr[peakCtlIdx]) peakCtlIdx = i;
}

// Very subtle phase background shading, theme-adaptive
const phaseShade = isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)";

const chart = echarts.init(document.getElementById("container"));

const titleText =
  "Training Load PMC · line-training-load-pmc · javascript · echarts · anyplot.ai";
const titleFontSize = Math.max(14, Math.round(22 * 67 / titleText.length));

chart.setOption({
  animation: false,
  backgroundColor: "transparent",

  title: {
    text: titleText,
    left: "center",
    top: 14,
    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: "bold" },
  },

  legend: {
    data: ["Fitness (CTL)", "Fatigue (ATL)", "Form (TSB)", "Daily TSS"],
    bottom: 14,
    textStyle: { color: t.inkSoft, fontSize: 14 },
    itemWidth: 24,
    itemHeight: 12,
  },

  grid: { left: 80, right: 90, top: 80, bottom: 62 },

  xAxis: {
    type: "category",
    data: dates,
    boundaryGap: false,
    axisLabel: { color: t.inkSoft, fontSize: 13, interval: 13 },
    axisLine: { lineStyle: { color: t.inkSoft } },
    axisTick: { show: false },
    splitLine: { show: false },
  },

  yAxis: [
    {
      // Primary — CTL, ATL, raw TSS bars
      name: "Load (TSS units)",
      nameTextStyle: { color: t.inkSoft, fontSize: 13, padding: [0, 0, 6, 44] },
      type: "value",
      min: 0,
      max: 185,
      axisLabel: { color: t.inkSoft, fontSize: 13 },
      axisLine: { show: true, lineStyle: { color: t.inkSoft } },
      axisTick: { show: false },
      splitLine: { lineStyle: { color: t.grid } },
    },
    {
      // Secondary — TSB oscillates around zero
      name: "Form (TSB)",
      nameTextStyle: { color: t.inkSoft, fontSize: 13, padding: [0, 36, 6, 0] },
      type: "value",
      position: "right",
      axisLabel: { color: t.inkSoft, fontSize: 13 },
      axisLine: { show: true, lineStyle: { color: t.inkSoft } },
      axisTick: { show: false },
      splitLine: { show: false },
    },
  ],

  series: [
    // --- Primary axis series ---
    {
      name: "Fitness (CTL)",
      type: "line",
      data: ctlArr,
      smooth: 0.4,
      symbol: "none",
      // palette[0] = brand green: first categorical series (hard rule) + fitness/progress reads as green
      lineStyle: { width: 3.5, color: t.palette[0] },
      itemStyle: { color: t.palette[0] },
      yAxisIndex: 0,
      z: 5,
      // Annotate peak fitness moment for data storytelling
      markLine: {
        silent: true,
        symbol: ["none", "none"],
        lineStyle: { color: t.palette[0], opacity: 0.5, width: 1.5, type: "dashed" },
        label: {
          show: true,
          formatter: "Peak",
          position: "insideStart",
          fontSize: 10,
          color: t.inkSoft,
        },
        data: [{ xAxis: dates[peakCtlIdx] }],
      },
    },
    {
      name: "Fatigue (ATL)",
      type: "line",
      data: atlArr,
      smooth: 0.3,
      symbol: "none",
      lineStyle: { width: 2.5, color: t.palette[1] }, // lavender — acute fatigue
      itemStyle: { color: t.palette[1] },
      yAxisIndex: 0,
      z: 4,
    },
    {
      // Raw daily training load — muted vertical bars; carries training phase markArea (z:1, below data)
      name: "Daily TSS",
      type: "bar",
      data: tssArr,
      yAxisIndex: 0,
      barMaxWidth: 4,
      itemStyle: { color: t.inkSoft, opacity: 0.35 },
      z: 1,
      // Subtle phase shading with italic labels for data storytelling
      markArea: {
        silent: true,
        itemStyle: { color: phaseShade },
        label: {
          show: true,
          position: "insideTop",
          fontSize: 10,
          color: t.inkSoft,
          fontStyle: "italic",
        },
        data: [
          [{ name: "Base", xAxis: dates[0] }, { xAxis: dates[baseEnd] }],
          [{ name: "Intensity", xAxis: dates[baseEnd + 1] }, { xAxis: dates[intensityEnd] }],
          [{ name: "Peak", xAxis: dates[intensityEnd + 1] }, { xAxis: dates[peakEnd] }],
          [{ name: "Taper", xAxis: dates[peakEnd + 1] }, { xAxis: dates[N - 1] }],
        ],
      },
    },

    // --- Secondary axis: TSB two-tone area ---
    {
      // Positive TSB (fresh / peak form) — blue fill (spec: "positive form = fresh/blue")
      name: "Form (TSB)",
      type: "line",
      data: tsbPos,
      smooth: 0.4,
      symbol: "none",
      lineStyle: { width: 0 },
      areaStyle: { color: t.palette[2], opacity: 0.45 }, // blue — fresh/positive form
      itemStyle: { color: t.palette[2] },
      yAxisIndex: 1,
      z: 3,
      // Idiomatic ECharts zero reference via markLine (avoids phantom series)
      markLine: {
        silent: true,
        symbol: ["none", "none"],
        lineStyle: { color: t.inkSoft, opacity: 0.55, width: 1 },
        data: [{ yAxis: 0 }],
      },
    },
    {
      // Negative TSB (fatigued) — matte red fill
      name: "_tsb_neg",
      type: "line",
      data: tsbNeg,
      smooth: 0.4,
      symbol: "none",
      lineStyle: { width: 0 },
      areaStyle: { color: "#AE3030", opacity: 0.40 }, // matte red — fatigued
      itemStyle: { color: "#AE3030" },
      yAxisIndex: 1,
      z: 3,
      legendHoverLink: false,
      tooltip: { show: false },
    },
  ],
});

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

Other implementations