{"spec_id":"line-training-load-pmc","library":"d3","language":"javascript","code":"// anyplot.ai\n// line-training-load-pmc: Training Load Performance Management Chart\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 93/100 | Created: 2026-06-13\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\nconst margin = { top: 80, right: 90, bottom: 70, left: 75 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// Seeded LCG RNG (browser has no seeded Math.random)\nlet _seed = 42;\nconst rng = () => { _seed = (1664525 * _seed + 1013904223) >>> 0; return _seed / 4294967296; };\n\n// --- Data: 180-day cycling season (Oct 2025 – Mar 2026) -------------------\nconst N = 180;\nconst DAY_MS = 86400000;\nconst startMs = new Date(2025, 9, 1).getTime(); // Oct 1, 2025\nconst dates = Array.from({ length: N }, (_, i) => new Date(startMs + i * DAY_MS));\n\n// Periodized TSS: 6-wk build → 8-wk peak → 6-wk race prep → 6-wk taper\nconst tssRaw = Array.from({ length: N }, (_, i) => {\n  const week = Math.floor(i / 7);\n  const dow = i % 7; // 0=Mon … 6=Sun\n  if (dow === 6) return 0; // Sunday always rest\n  if (dow === 2 && rng() < 0.45) return 0; // occasional Wednesday rest\n\n  let base;\n  if (week < 6)       base = 55 + week * 6;         // build:   55 → 85\n  else if (week < 14) base = 88 + (week - 6) * 4;   // peak:    88 → 116\n  else if (week < 20) base = 112 + (week - 14) * 3; // race:   112 → 127\n  else                base = 130 - (week - 20) * 17; // taper:  130 → 45\n\n  const dayMult = [0.85, 1.25, 0.7, 1.1, 0.75, 1.35, 0][dow];\n  const jitter = (rng() - 0.5) * 20;\n  return Math.max(0, Math.round(base * dayMult + jitter));\n});\n\n// EWMA: CTL (42-day fitness) and ATL (7-day fatigue)\nconst ctl = [0];\nconst atl = [0];\nfor (let i = 1; i < N; i++) {\n  ctl.push(ctl[i - 1] + (tssRaw[i - 1] - ctl[i - 1]) / 42);\n  atl.push(atl[i - 1] + (tssRaw[i - 1] - atl[i - 1]) / 7);\n}\n// TSB = yesterday's CTL − yesterday's ATL\nconst tsb = ctl.map((_, i) => (i === 0 ? 0 : ctl[i - 1] - atl[i - 1]));\n\nconst data = dates.map((date, i) => ({\n  date, tss: tssRaw[i], ctl: ctl[i], atl: atl[i], tsb: tsb[i],\n}));\n\n// --- SVG mount -------------------------------------------------------------\nconst svg = d3.select(\"#container\")\n  .append(\"svg\").attr(\"width\", width).attr(\"height\", height)\n  .style(\"font-family\", \"system-ui, -apple-system, sans-serif\");\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// Clip path for data layers\nsvg.append(\"defs\").append(\"clipPath\").attr(\"id\", \"clip\")\n  .append(\"rect\").attr(\"width\", iw).attr(\"height\", ih);\n\n// --- Scales ----------------------------------------------------------------\nconst x = d3.scaleTime().domain([dates[0], dates[N - 1]]).range([0, iw]);\n\nconst ctlAtlMax = d3.max(data, (d) => Math.max(d.ctl, d.atl)) * 1.12;\nconst yLeft = d3.scaleLinear().domain([0, ctlAtlMax]).nice().range([ih, 0]);\n\nconst tsbExtent = Math.max(\n  Math.abs(d3.min(data, (d) => d.tsb)),\n  d3.max(data, (d) => d.tsb)\n) * 1.25;\nconst yRight = d3.scaleLinear().domain([-tsbExtent, tsbExtent]).nice().range([ih, 0]);\n\nconst tssMax = d3.max(data, (d) => d.tss) || 1;\nconst yTSS = d3.scaleLinear().domain([0, tssMax]).range([ih, ih * 0.76]); // bottom 24%\n\n// --- Gridlines (y-left ticks, horizontal) ---------------------------------\nconst yTicks = yLeft.ticks(6);\ng.append(\"g\").selectAll(\"line.grid\").data(yTicks).join(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", (d) => yLeft(d)).attr(\"y2\", (d) => yLeft(d))\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\n// --- TSB zero reference line ----------------------------------------------\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", yRight(0)).attr(\"y2\", yRight(0))\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"5,5\").attr(\"opacity\", 0.55);\n\n// --- TSS bars (raw daily load, behind all other elements) -----------------\nconst barW = Math.max(1.5, (iw / N) * 0.85);\nconst tssLayer = g.append(\"g\").attr(\"clip-path\", \"url(#clip)\");\ntssLayer.selectAll(\"rect\").data(data).join(\"rect\")\n  .attr(\"x\", (d) => x(d.date) - barW / 2)\n  .attr(\"y\", (d) => yTSS(d.tss))\n  .attr(\"width\", barW)\n  .attr(\"height\", (d) => ih - yTSS(d.tss))\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"opacity\", 0.28);\n\n// --- TSB filled area (two-toned: blue = fresh/positive, red = fatigued/negative)\nconst curve = d3.curveMonotoneX;\nconst areaPos = d3.area().curve(curve)\n  .x((d) => x(d.date)).y0(yRight(0)).y1((d) => yRight(Math.max(0, d.tsb)));\nconst areaNeg = d3.area().curve(curve)\n  .x((d) => x(d.date)).y0(yRight(0)).y1((d) => yRight(Math.min(0, d.tsb)));\nconst lineTSB = d3.line().curve(curve)\n  .x((d) => x(d.date)).y((d) => yRight(d.tsb));\n\nconst tsbLayer = g.append(\"g\").attr(\"clip-path\", \"url(#clip)\");\ntsbLayer.append(\"path\").datum(data)\n  .attr(\"d\", areaPos)\n  .attr(\"fill\", t.palette[2])   // #4467A3 blue = fresh\n  .attr(\"opacity\", 0.32);\ntsbLayer.append(\"path\").datum(data)\n  .attr(\"d\", areaNeg)\n  .attr(\"fill\", t.palette[4])   // #AE3030 red = fatigued\n  .attr(\"opacity\", 0.22);\ntsbLayer.append(\"path\").datum(data)\n  .attr(\"d\", lineTSB)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[2])\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"opacity\", 0.65);\n\n// --- CTL line (fitness, Imprint pos 0 = brand green #009E73) -------------\nconst lineCTL = d3.line().curve(curve)\n  .x((d) => x(d.date)).y((d) => yLeft(d.ctl));\nconst lineATL = d3.line().curve(curve)\n  .x((d) => x(d.date)).y((d) => yLeft(d.atl));\n\nconst lineLayer = g.append(\"g\").attr(\"clip-path\", \"url(#clip)\");\nlineLayer.append(\"path\").datum(data)\n  .attr(\"d\", lineCTL)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])   // #009E73 brand green = Fitness (CTL)\n  .attr(\"stroke-width\", 3);\nlineLayer.append(\"path\").datum(data)\n  .attr(\"d\", lineATL)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[1])   // #C475FD lavender = Fatigue (ATL)\n  .attr(\"stroke-width\", 2.5);\n\n// --- Axes ------------------------------------------------------------------\nconst dateFmt = d3.timeFormat(\"%b '%y\");\n\nconst xAx = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(d3.timeMonth.every(1)).tickFormat(dateFmt));\nxAx.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\");\nxAx.selectAll(\"line\").attr(\"stroke\", t.grid);\nxAx.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nconst yAxL = g.append(\"g\").call(d3.axisLeft(yLeft).ticks(6));\nyAxL.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\");\nyAxL.selectAll(\"line\").attr(\"stroke\", t.grid);\nyAxL.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nconst yAxR = g.append(\"g\").attr(\"transform\", `translate(${iw},0)`)\n  .call(d3.axisRight(yRight).ticks(6));\nyAxR.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\");\nyAxR.selectAll(\"line\").attr(\"stroke\", t.grid);\nyAxR.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// --- Axis labels -----------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(margin.top + ih / 2))\n  .attr(\"y\", 20)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(\"Training Load (TSS)\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", \"rotate(90)\")\n  .attr(\"x\", margin.top + ih / 2)\n  .attr(\"y\", -(width - 22))\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(\"Form / TSB\");\n\n// --- Legend ---------------------------------------------------------------\nconst lItems = [\n  { label: \"Fitness (CTL)\", type: \"line\", stroke: t.palette[0], sw: 3 },\n  { label: \"Fatigue (ATL)\", type: \"line\", stroke: t.palette[1], sw: 2.5 },\n  { label: \"Form+ / Fresh\",  type: \"rect\", fill: t.palette[2], opacity: 0.5 },\n  { label: \"Form− / Fatigued\", type: \"rect\", fill: t.palette[4], opacity: 0.55 },\n  { label: \"Daily TSS\",     type: \"rect\", fill: t.inkSoft,    opacity: 0.4 },\n];\nconst lW = 248;\nconst lH = lItems.length * 26 + 20;\nconst legendX = margin.left + iw - lW - 8;\nconst legendY = margin.top + 18;\n\nconst leg = svg.append(\"g\").attr(\"transform\", `translate(${legendX},${legendY})`);\nleg.append(\"rect\")\n  .attr(\"width\", lW).attr(\"height\", lH)\n  .attr(\"fill\", t.elevatedBg).attr(\"rx\", 5)\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\nlItems.forEach((item, i) => {\n  const iy = 14 + i * 26;\n  if (item.type === \"line\") {\n    leg.append(\"line\")\n      .attr(\"x1\", 12).attr(\"x2\", 42).attr(\"y1\", iy + 6).attr(\"y2\", iy + 6)\n      .attr(\"stroke\", item.stroke).attr(\"stroke-width\", item.sw);\n  } else {\n    leg.append(\"rect\")\n      .attr(\"x\", 12).attr(\"y\", iy).attr(\"width\", 30).attr(\"height\", 13)\n      .attr(\"fill\", item.fill).attr(\"opacity\", item.opacity);\n  }\n  leg.append(\"text\")\n    .attr(\"x\", 52).attr(\"y\", iy + 10)\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\")\n    .text(item.label);\n});\n\n// --- Title ----------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"line-training-load-pmc · javascript · d3 · anyplot.ai\");\n"}