{"spec_id":"scatter-pitch-events","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// scatter-pitch-events: Soccer Pitch Event Map\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 95/100 | Created: 2026-06-21\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Pitch surface (green turf)\nconst GRASS  = window.ANYPLOT_THEME === 'light' ? '#2e7d3a' : '#1b4a22';\nconst STRIPE = window.ANYPLOT_THEME === 'light' ? '#347540' : '#214d28';\nconst LINE_C = 'rgba(255,255,255,0.88)';\n\n// Imprint palette positions 1-4 via tokens (data colors theme-independent)\nconst PASS_C   = t.palette[0]; // #009E73 — passes\nconst SHOT_C   = t.palette[1]; // #C475FD — shots\nconst TACKLE_C = t.palette[2]; // #4467A3 — tackles\nconst INT_C    = t.palette[3]; // #BD8233 — interceptions\n\nfunction hexRgba(hex, a) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r},${g},${b},${a})`;\n}\n\n// Deterministic LCG (seed 42) — browser has no seeded RNG\nlet _s = 42;\nfunction rand() {\n  _s = (Math.imul(1664525, _s) + 1013904223) >>> 0;\n  return _s / 4294967296;\n}\n\n// Arrow store: {x1,y1,x2,y2,color,alpha} in pitch metres\nconst arrows = [];\n\n// ---- Data generation -------------------------------------------------------\n\n// Passes (circles) — midfield and attacking third; arrows show forward direction\nconst passSucPts = [], passUnsPts = [];\nfor (let i = 0; i < 52; i++) {\n  const x = 18 + rand() * 80, y = 3 + rand() * 62;\n  const dx = 6 + rand() * 18, dy = (rand() - 0.5) * 14;\n  arrows.push({ x1: x, y1: y, x2: x + dx, y2: y + dy, color: PASS_C, alpha: 0.58 });\n  passSucPts.push({ x, y });\n}\nfor (let i = 0; i < 16; i++) {\n  const x = 22 + rand() * 68, y = 3 + rand() * 62;\n  const dx = 4 + rand() * 14, dy = (rand() - 0.5) * 12;\n  arrows.push({ x1: x, y1: y, x2: x + dx, y2: y + dy, color: PASS_C, alpha: 0.18 });\n  passUnsPts.push({ x, y });\n}\n\n// Shots (stars) — attacking zone; arrows point toward goal (x=105)\nconst shotSucPts = [], shotUnsPts = [];\nfor (let i = 0; i < 7; i++) {\n  const x = 76 + rand() * 24, y = 22 + rand() * 24;\n  const x2 = 103 + rand() * 2, y2 = 30 + rand() * 8;\n  arrows.push({ x1: x, y1: y, x2, y2, color: SHOT_C, alpha: 0.88 });\n  shotSucPts.push({ x, y });\n}\nfor (let i = 0; i < 13; i++) {\n  const x = 68 + rand() * 32, y = 14 + rand() * 40;\n  const x2 = 96 + rand() * 8, y2 = 18 + rand() * 32;\n  arrows.push({ x1: x, y1: y, x2: Math.min(104, x2), y2, color: SHOT_C, alpha: 0.28 });\n  shotUnsPts.push({ x, y });\n}\n\n// Tackles (triangles) — mostly defensive half\nconst tackleSucPts = Array.from({ length: 20 }, () => ({ x: 5 + rand() * 62, y: 4 + rand() * 60 }));\nconst tackleUnsPts = Array.from({ length: 10 }, () => ({ x: 5 + rand() * 66, y: 4 + rand() * 60 }));\n\n// Interceptions (diamonds) — spread across the pitch\nconst intSucPts = Array.from({ length: 15 }, () => ({ x: 10 + rand() * 80, y: 4 + rand() * 60 }));\nconst intUnsPts = Array.from({ length: 8  }, () => ({ x: 10 + rand() * 75, y: 4 + rand() * 60 }));\n\n// Clamp arrow endpoints inside pitch bounds\narrows.forEach(a => {\n  a.x2 = Math.max(0.5, Math.min(104.5, a.x2));\n  a.y2 = Math.max(0.5, Math.min(67.5,  a.y2));\n});\n\n// ---- Mount -----------------------------------------------------------------\nconst canvas = document.createElement('canvas');\ndocument.getElementById('container').appendChild(canvas);\n\n// ---- Plugins ---------------------------------------------------------------\n\nconst pitchPlugin = {\n  id: 'pitch',\n  beforeDraw(chart) {\n    const { ctx, scales: { x: xs, y: ys }, chartArea: ca } = chart;\n    const px = v => xs.getPixelForValue(v);\n    const py = v => ys.getPixelForValue(v);\n\n    // Derived pitch dimensions in canvas pixels\n    const ptx = px(0);\n    const pty = py(68);           // top of pitch (y=68 in pitch = top of canvas)\n    const pw  = px(105) - px(0);\n    const ph  = py(0)   - py(68); // positive (canvas y is inverted vs. pitch y)\n\n    ctx.save();\n\n    // Grass fill for entire chart area (represents the playing surface)\n    ctx.fillStyle = GRASS;\n    ctx.fillRect(ca.left, ca.top, ca.width, ca.height);\n\n    // Alternating 10m stripe bands within pitch bounds\n    const stripeW = px(10) - px(0);\n    ctx.fillStyle = STRIPE;\n    for (let i = 0; i < 11; i += 2) {\n      const bx = px(i * 10);\n      const bw = Math.min(stripeW, px(105) - px(i * 10));\n      if (bw > 0) ctx.fillRect(bx, pty, bw, ph);\n    }\n\n    ctx.strokeStyle = LINE_C;\n    ctx.lineWidth   = 2.5;\n    ctx.lineJoin    = 'miter';\n    ctx.lineCap     = 'butt';\n\n    // Pitch outline\n    ctx.strokeRect(ptx, pty, pw, ph);\n\n    // Halfway line\n    ctx.beginPath(); ctx.moveTo(px(52.5), pty); ctx.lineTo(px(52.5), pty + ph); ctx.stroke();\n\n    // Helper: stroke a rectangle in pitch coordinates (y1 < y2)\n    const pRect = (x1, y1, x2, y2) =>\n      ctx.strokeRect(px(x1), py(y2), px(x2) - px(x1), py(y1) - py(y2));\n\n    // Left penalty area (16.5m deep, 40.32m wide, centred on goal y=34)\n    pRect(0,    13.84, 16.5, 54.16);\n    // Left goal area (5.5m deep, 18.32m wide)\n    pRect(0,    24.84, 5.5,  43.16);\n\n    // Right penalty area\n    pRect(88.5, 13.84, 105,  54.16);\n    // Right goal area\n    pRect(99.5, 24.84, 105,  43.16);\n\n    // Goals — 7.32m wide (y = 34 ± 3.66), thick stroke on goal line\n    ctx.lineWidth = 4.5;\n    ctx.beginPath(); ctx.moveTo(px(0),   py(30.34)); ctx.lineTo(px(0),   py(37.66)); ctx.stroke();\n    ctx.beginPath(); ctx.moveTo(px(105), py(30.34)); ctx.lineTo(px(105), py(37.66)); ctx.stroke();\n    ctx.lineWidth = 2.5;\n\n    // Center circle (r=9.15m; rendered as ellipse because chart area is wider than 105:68)\n    const crx = Math.abs(px(52.5 + 9.15) - px(52.5));\n    const cry = Math.abs(py(34  + 9.15) - py(34));\n    ctx.beginPath();\n    ctx.ellipse(px(52.5), py(34), crx, cry, 0, 0, 2 * Math.PI);\n    ctx.stroke();\n\n    // Spots: centre, left penalty (11m), right penalty (94m)\n    ctx.fillStyle = LINE_C;\n    [[52.5, 34], [11, 34], [94, 34]].forEach(([mx, my]) => {\n      ctx.beginPath(); ctx.arc(px(mx), py(my), 4, 0, 2 * Math.PI); ctx.fill();\n    });\n\n    // Corner arcs (r=1m)\n    // Canvas angle convention: 0=E, π/2=S (canvas down), π=W, 3π/2=N (canvas up)\n    // Pitch y is inverted vs canvas y, so pitch bottom-left = canvas bottom-left\n    ctx.lineWidth = 2;\n    const mX = Math.abs(px(1) - px(0)); // 1m in x-pixels\n    const mY = Math.abs(py(1) - py(0)); // 1m in y-pixels\n    const cArc = (mx, my, s, e) => {\n      ctx.beginPath();\n      ctx.ellipse(px(mx), py(my), mX, mY, 0, s, e, false);\n      ctx.stroke();\n    };\n    cArc(0,   0,  3 * Math.PI / 2, 2 * Math.PI);   // bottom-left  → NE in canvas\n    cArc(105, 0,  Math.PI,         3 * Math.PI / 2); // bottom-right → NW\n    cArc(0,   68, 0,               Math.PI / 2);     // top-left     → SE\n    cArc(105, 68, Math.PI / 2,     Math.PI);         // top-right    → SW\n\n    ctx.restore();\n  },\n};\n\nconst arrowPlugin = {\n  id: 'arrows',\n  afterDatasetsDraw(chart) {\n    const { ctx, scales: { x: xs, y: ys }, chartArea: ca } = chart;\n    ctx.save();\n\n    // Clip to chart area so arrows don't bleed outside\n    ctx.beginPath();\n    ctx.rect(ca.left, ca.top, ca.width, ca.height);\n    ctx.clip();\n\n    ctx.lineCap = 'round';\n    const HEAD = 9;\n\n    arrows.forEach(({ x1, y1, x2, y2, color, alpha }) => {\n      const cx1 = xs.getPixelForValue(x1), cy1 = ys.getPixelForValue(y1);\n      const cx2 = xs.getPixelForValue(x2), cy2 = ys.getPixelForValue(y2);\n      const ang = Math.atan2(cy2 - cy1, cx2 - cx1);\n\n      ctx.globalAlpha = alpha;\n      ctx.strokeStyle = color;\n      ctx.lineWidth   = 1.8;\n\n      ctx.beginPath();\n      ctx.moveTo(cx1, cy1);\n      ctx.lineTo(cx2, cy2);\n      ctx.stroke();\n\n      // Arrowhead barbs\n      ctx.beginPath();\n      ctx.moveTo(cx2, cy2);\n      ctx.lineTo(cx2 - HEAD * Math.cos(ang - Math.PI / 7), cy2 - HEAD * Math.sin(ang - Math.PI / 7));\n      ctx.moveTo(cx2, cy2);\n      ctx.lineTo(cx2 - HEAD * Math.cos(ang + Math.PI / 7), cy2 - HEAD * Math.sin(ang + Math.PI / 7));\n      ctx.stroke();\n    });\n\n    ctx.globalAlpha = 1;\n    ctx.restore();\n  },\n};\n\n// ---- Chart -----------------------------------------------------------------\nnew Chart(canvas, {\n  type: 'scatter',\n  plugins: [pitchPlugin, arrowPlugin],\n  data: {\n    datasets: [\n      {\n        label: 'Pass (complete)',\n        data: passSucPts,\n        pointStyle: 'circle',\n        pointRadius: 6,\n        backgroundColor: hexRgba(PASS_C, 0.85),\n        borderColor: 'rgba(255,255,255,0.70)',\n        borderWidth: 2,\n      },\n      {\n        label: 'Pass (incomplete)',\n        data: passUnsPts,\n        pointStyle: 'circle',\n        pointRadius: 5,\n        backgroundColor: hexRgba(PASS_C, 0.22),\n        borderColor: hexRgba(PASS_C, 0.45),\n        borderWidth: 1,\n      },\n      {\n        label: 'Shot (on target)',\n        data: shotSucPts,\n        pointStyle: 'star',\n        pointRadius: 10,\n        backgroundColor: hexRgba(SHOT_C, 0.92),\n        borderColor: 'rgba(255,255,255,0.6)',\n        borderWidth: 1.5,\n      },\n      {\n        label: 'Shot (off target)',\n        data: shotUnsPts,\n        pointStyle: 'star',\n        pointRadius: 8,\n        backgroundColor: hexRgba(SHOT_C, 0.25),\n        borderColor: hexRgba(SHOT_C, 0.5),\n        borderWidth: 1,\n      },\n      {\n        label: 'Tackle (won)',\n        data: tackleSucPts,\n        pointStyle: 'triangle',\n        pointRadius: 8,\n        backgroundColor: hexRgba(TACKLE_C, 0.85),\n        borderColor: 'rgba(255,255,255,0.55)',\n        borderWidth: 1.5,\n      },\n      {\n        label: 'Tackle (lost)',\n        data: tackleUnsPts,\n        pointStyle: 'triangle',\n        pointRadius: 7,\n        backgroundColor: hexRgba(TACKLE_C, 0.25),\n        borderColor: hexRgba(TACKLE_C, 0.45),\n        borderWidth: 1,\n      },\n      {\n        label: 'Interception (won)',\n        data: intSucPts,\n        pointStyle: 'rectRot',\n        pointRadius: 8,\n        backgroundColor: hexRgba(INT_C, 0.85),\n        borderColor: 'rgba(255,255,255,0.55)',\n        borderWidth: 1.5,\n      },\n      {\n        label: 'Interception (lost)',\n        data: intUnsPts,\n        pointStyle: 'rectRot',\n        pointRadius: 7,\n        backgroundColor: hexRgba(INT_C, 0.25),\n        borderColor: hexRgba(INT_C, 0.45),\n        borderWidth: 1,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: 'scatter-pitch-events · javascript · chartjs · anyplot.ai',\n        color: t.ink,\n        font: { size: 22, weight: '500' },\n        padding: { top: 10, bottom: 14 },\n      },\n      legend: {\n        display: true,\n        position: 'bottom',\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          padding: 20,\n          usePointStyle: true,\n          pointStyleWidth: 16,\n        },\n      },\n    },\n    scales: {\n      x: { type: 'linear', min: -1, max: 106, display: false, grid: { display: false } },\n      y: { type: 'linear', min: -1, max: 69,  display: false, grid: { display: false } },\n    },\n    layout: {\n      padding: { left: 16, right: 16, top: 6, bottom: 6 },\n    },\n  },\n});\n"}