{"spec_id":"scatter-pitch-events","library":"d3","language":"javascript","code":"// anyplot.ai\n// scatter-pitch-events: Soccer Pitch Event Map\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-21\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// Deterministic LCG for reproducible data\nlet _s = 12345;\nfunction rand() { _s = (_s * 1664525 + 1013904223) >>> 0; return _s / 4294967295; }\nfunction rr(lo, hi) { return lo + rand() * (hi - lo); }\n\n// Theme\nconst isDark = window.ANYPLOT_THEME === \"dark\";\nconst PITCH_BG       = isDark ? \"#1e3a28\" : \"#3a7d44\";\nconst PITCH_LINE_CLR = isDark ? \"rgba(255,255,255,0.82)\" : \"rgba(255,255,255,0.92)\";\nconst GOAL_BG        = isDark ? \"#152b1e\" : \"#2d5e34\";\n\n// Event colors — Imprint palette positions 1–4\nconst EC = {\n  pass:         t.palette[0], // #009E73\n  shot:         t.palette[1], // #C475FD\n  tackle:       t.palette[2], // #4467A3\n  interception: t.palette[3], // #BD8233\n};\n\n// Data: 120 events (deterministic)\nconst events = [];\n\n// 60 passes with end positions (arrows show direction)\nfor (let i = 0; i < 60; i++) {\n  const x = rr(5, 95), y = rr(3, 65);\n  const ang = rr(-0.65, 0.65);\n  const dist = rr(8, 26);\n  events.push({\n    type: \"pass\", x, y,\n    ex: Math.min(103, Math.max(2, x + Math.cos(ang) * dist)),\n    ey: Math.min(66, Math.max(2, y + Math.sin(ang) * dist)),\n    ok: rand() > 0.22,\n  });\n}\n\n// 25 shots toward right goal (arrows point to goal mouth)\nfor (let i = 0; i < 25; i++) {\n  const x = rr(60, 103), y = rr(14, 54);\n  events.push({ type: \"shot\", x, y, ex: 105, ey: rr(29.5, 38.5), ok: rand() > 0.72 });\n}\n\n// 20 tackles (no arrows)\nfor (let i = 0; i < 20; i++) {\n  events.push({ type: \"tackle\", x: rr(5, 100), y: rr(3, 65), ok: rand() > 0.4 });\n}\n\n// 15 interceptions (no arrows)\nfor (let i = 0; i < 15; i++) {\n  events.push({ type: \"interception\", x: rr(5, 100), y: rr(3, 65), ok: rand() > 0.35 });\n}\n\n// Layout — maintain 105:68 pitch aspect ratio within the drawing area\nconst margin = { top: 70, right: 50, bottom: 100, left: 50 };\nconst drawW = width - margin.left - margin.right;\nconst drawH = height - margin.top - margin.bottom;\n\nconst PITCH_RATIO = 105 / 68;\nlet pitchW, pitchH;\nif (drawW / drawH > PITCH_RATIO) {\n  pitchH = drawH;\n  pitchW = pitchH * PITCH_RATIO;\n} else {\n  pitchW = drawW;\n  pitchH = pitchW / PITCH_RATIO;\n}\nconst offX = (drawW - pitchW) / 2;\nconst offY = (drawH - pitchH) / 2;\nconst mPx  = pitchW / 105; // pixels per metre (same in x and y due to aspect ratio lock)\n\n// Scales (pitch metres → SVG px within g)\nconst px = d3.scaleLinear().domain([0, 105]).range([0, pitchW]);\nconst py = d3.scaleLinear().domain([0, 68]).range([0, pitchH]);\n\n// SVG root\nconst svg = d3.select(\"#container\").append(\"svg\")\n  .attr(\"width\", width).attr(\"height\", height);\n\nconst g = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left + offX},${margin.top + offY})`);\n\n// D3 line generator for pitch outlines — idiomatic D3 shape generator\nconst lineGen = d3.line().x(d => px(d[0])).y(d => py(d[1]));\n\n// Pitch drawing helpers\nfunction prect(x, y, w, h, fill) {\n  g.append(\"rect\")\n    .attr(\"x\", px(x)).attr(\"y\", py(y))\n    .attr(\"width\", px(x + w) - px(x)).attr(\"height\", py(y + h) - py(y))\n    .attr(\"fill\", fill || \"none\").attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 1.5);\n}\nfunction parc(x1, y1, x2, y2, r, sweep) {\n  const rp = r * mPx;\n  g.append(\"path\")\n    .attr(\"d\", `M${px(x1)},${py(y1)} A${rp},${rp} 0 0,${sweep} ${px(x2)},${py(y2)}`)\n    .attr(\"fill\", \"none\").attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 1.5);\n}\n\n// Pitch background\ng.append(\"rect\").attr(\"x\", 0).attr(\"y\", 0)\n  .attr(\"width\", pitchW).attr(\"height\", pitchH).attr(\"fill\", PITCH_BG);\n\n// Goals — 7.32 m wide, 2.44 m deep; extend beyond pitch boundary\nconst gy1 = (68 - 7.32) / 2, gy2 = gy1 + 7.32;\ng.append(\"path\")\n  .attr(\"d\", `M${px(0)},${py(gy1)} L${px(-2.44)},${py(gy1)} L${px(-2.44)},${py(gy2)} L${px(0)},${py(gy2)}`)\n  .attr(\"fill\", GOAL_BG).attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 2);\ng.append(\"path\")\n  .attr(\"d\", `M${px(105)},${py(gy1)} L${px(107.44)},${py(gy1)} L${px(107.44)},${py(gy2)} L${px(105)},${py(gy2)}`)\n  .attr(\"fill\", GOAL_BG).attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 2);\n\n// Pitch outline via d3.line() — closed polygon from coordinate array\ng.append(\"path\")\n  .attr(\"d\", lineGen([[0, 0], [105, 0], [105, 68], [0, 68], [0, 0]]))\n  .attr(\"fill\", \"none\").attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 1.5);\n\n// Halfway line via d3.line()\ng.append(\"path\")\n  .attr(\"d\", lineGen([[52.5, 0], [52.5, 68]]))\n  .attr(\"fill\", \"none\").attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 1.5);\n\n// Centre circle and spot\ng.append(\"circle\").attr(\"cx\", px(52.5)).attr(\"cy\", py(34)).attr(\"r\", 9.15 * mPx)\n  .attr(\"fill\", \"none\").attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 1.5);\ng.append(\"circle\").attr(\"cx\", px(52.5)).attr(\"cy\", py(34)).attr(\"r\", 3.5)\n  .attr(\"fill\", PITCH_LINE_CLR);\n\n// Penalty areas (FIFA: 16.5 m deep, 40.3 m wide; goal area: 5.5 m deep, 18.3 m wide)\nprect(0, 13.85, 16.5, 40.3);\nprect(0, 24.85, 5.5, 18.3);\nprect(88.5, 13.85, 16.5, 40.3);\nprect(99.5, 24.85, 5.5, 18.3);\n\n// Penalty spots\ng.append(\"circle\").attr(\"cx\", px(11)).attr(\"cy\", py(34)).attr(\"r\", 3).attr(\"fill\", PITCH_LINE_CLR);\ng.append(\"circle\").attr(\"cx\", px(94)).attr(\"cy\", py(34)).attr(\"r\", 3).attr(\"fill\", PITCH_LINE_CLR);\n\n// Penalty arcs via d3.arc() — shape generator with transform for positioning\n// d3.arc() angles are clockwise from north; for point (dx,dy) in SVG: angle = atan2(dx, -dy)\nconst ady_m = Math.sqrt(9.15 * 9.15 - 5.5 * 5.5); // vertical reach at the penalty area edge\nconst pArcGen = d3.arc().innerRadius(9.15 * mPx).outerRadius(9.15 * mPx);\n\n// Left arc: CW from upper-right intersection to lower-right (visible right of penalty spot)\ng.append(\"path\")\n  .attr(\"d\", pArcGen.startAngle(Math.atan2(5.5, ady_m)).endAngle(Math.atan2(5.5, -ady_m))())\n  .attr(\"transform\", `translate(${px(11)},${py(34)})`)\n  .attr(\"fill\", \"none\").attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 1.5);\n\n// Right arc: CW from lower-left intersection to upper-left (visible left of penalty spot)\nconst rArcStart = ((Math.atan2(-5.5, -ady_m) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);\nconst rArcEnd   = ((Math.atan2(-5.5,  ady_m) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);\ng.append(\"path\")\n  .attr(\"d\", pArcGen.startAngle(rArcStart).endAngle(rArcEnd)())\n  .attr(\"transform\", `translate(${px(94)},${py(34)})`)\n  .attr(\"fill\", \"none\").attr(\"stroke\", PITCH_LINE_CLR).attr(\"stroke-width\", 1.5);\n\n// Corner arcs (radius 1 m); sweep directions keep the arc inside the pitch\nparc(1, 0, 0, 1, 1, 1);\nparc(104, 0, 105, 1, 1, 0);\nparc(0, 67, 1, 68, 1, 1);\nparc(105, 67, 104, 68, 1, 0);\n\n// Arrow marker definitions for passes and shots\nconst defs = svg.append(\"defs\");\nfor (const tp of [\"pass\", \"shot\"]) {\n  defs.append(\"marker\").attr(\"id\", `arr-${tp}`)\n    .attr(\"viewBox\", \"0 -4 8 8\").attr(\"refX\", 7).attr(\"refY\", 0)\n    .attr(\"markerWidth\", 5).attr(\"markerHeight\", 5).attr(\"orient\", \"auto\")\n    .append(\"path\").attr(\"d\", \"M0,-4L8,0L0,4Z\")\n    .attr(\"fill\", EC[tp]).attr(\"opacity\", 0.9);\n}\n\n// Arrow lines — uniform 1.2px; wider opacity split (0.7/0.18) reduces shot-zone clutter\ng.selectAll(\".ev-arr\")\n  .data(events.filter(e => e.type === \"pass\" || e.type === \"shot\"))\n  .join(\"line\").attr(\"class\", \"ev-arr\")\n  .attr(\"x1\", d => px(d.x)).attr(\"y1\", d => py(d.y))\n  .attr(\"x2\", d => px(d.ex)).attr(\"y2\", d => py(d.ey))\n  .attr(\"stroke\", d => EC[d.type])\n  .attr(\"stroke-width\", 1.2)\n  .attr(\"opacity\", d => d.ok ? 0.7 : 0.18)\n  .attr(\"marker-end\", d => `url(#arr-${d.type})`);\n\n// Marker shape path builders\nfunction starP(cx, cy, r) {\n  let d = \"\";\n  for (let i = 0; i < 10; i++) {\n    const a = i * Math.PI / 5 - Math.PI / 2;\n    const rad = i % 2 === 0 ? r : r * 0.42;\n    d += (i === 0 ? \"M\" : \"L\") + (cx + rad * Math.cos(a)) + \",\" + (cy + rad * Math.sin(a));\n  }\n  return d + \"Z\";\n}\nfunction triP(cx, cy, r) {\n  return `M${cx},${cy - r}L${cx + r * 0.866},${cy + r * 0.5}L${cx - r * 0.866},${cy + r * 0.5}Z`;\n}\nfunction diaP(cx, cy, r) {\n  return `M${cx},${cy - r}L${cx + r * 0.65},${cy}L${cx},${cy + r}L${cx - r * 0.65},${cy}Z`;\n}\n\n// Draw event markers — filled = successful, hollow = unsuccessful\nconst MR = mPx * 0.9;\nfor (const ev of events) {\n  const cx = px(ev.x), cy = py(ev.y), col = EC[ev.type];\n  let el;\n  if (ev.type === \"pass\") {\n    el = g.append(\"circle\").attr(\"cx\", cx).attr(\"cy\", cy).attr(\"r\", MR * 0.85);\n  } else if (ev.type === \"shot\") {\n    el = g.append(\"path\").attr(\"d\", starP(cx, cy, MR * 1.1));\n  } else if (ev.type === \"tackle\") {\n    el = g.append(\"path\").attr(\"d\", triP(cx, cy, MR * 1.05));\n  } else {\n    el = g.append(\"path\").attr(\"d\", diaP(cx, cy, MR * 1.1));\n  }\n  el.attr(\"fill\", ev.ok ? col : \"none\")\n    .attr(\"stroke\", col).attr(\"stroke-width\", 1.8)\n    .attr(\"opacity\", ev.ok ? 0.9 : 0.55);\n}\n\n// Legend — two rows centred under the pitch\nconst pitchLeft = margin.left + offX;\nconst pitchBot  = margin.top + offY + pitchH;\nconst pcX       = pitchLeft + pitchW / 2;\nconst lgY1      = pitchBot + 24;\nconst lgY2      = lgY1 + 30;\nconst LR        = 9;\n\nconst typeItems = [\n  { label: \"Pass\",         tp: \"pass\",         shape: \"circle\" },\n  { label: \"Shot\",         tp: \"shot\",          shape: \"star\" },\n  { label: \"Tackle\",       tp: \"tackle\",        shape: \"tri\" },\n  { label: \"Interception\", tp: \"interception\",  shape: \"diam\" },\n];\nconst tSp = 185;\nconst tX0 = pcX - tSp * 1.5;\n\nfor (let i = 0; i < typeItems.length; i++) {\n  const { label, tp, shape } = typeItems[i];\n  const lx = tX0 + i * tSp, col = EC[tp];\n  const lg = svg.append(\"g\");\n  if (shape === \"circle\") {\n    lg.append(\"circle\").attr(\"cx\", lx).attr(\"cy\", lgY1).attr(\"r\", LR).attr(\"fill\", col).attr(\"opacity\", 0.9);\n  } else if (shape === \"star\") {\n    lg.append(\"path\").attr(\"d\", starP(lx, lgY1, LR)).attr(\"fill\", col).attr(\"opacity\", 0.9);\n  } else if (shape === \"tri\") {\n    lg.append(\"path\").attr(\"d\", triP(lx, lgY1, LR)).attr(\"fill\", col).attr(\"opacity\", 0.9);\n  } else {\n    lg.append(\"path\").attr(\"d\", diaP(lx, lgY1, LR)).attr(\"fill\", col).attr(\"opacity\", 0.9);\n  }\n  lg.append(\"text\").attr(\"x\", lx + LR + 7).attr(\"y\", lgY1 + 5)\n    .attr(\"fill\", t.ink).style(\"font-size\", \"14px\").text(label);\n}\n\n// Outcome row — filled circle uses palette[0] to communicate the encoding clearly\nconst oSp = 220;\nconst oX0 = pcX - oSp / 2;\nsvg.append(\"circle\").attr(\"cx\", oX0).attr(\"cy\", lgY2).attr(\"r\", LR)\n  .attr(\"fill\", t.palette[0]).attr(\"opacity\", 0.9);\nsvg.append(\"text\").attr(\"x\", oX0 + LR + 7).attr(\"y\", lgY2 + 5)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"14px\").text(\"Successful (filled)\");\nsvg.append(\"circle\").attr(\"cx\", oX0 + oSp).attr(\"cy\", lgY2).attr(\"r\", LR)\n  .attr(\"fill\", \"none\").attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1.8).attr(\"opacity\", 0.9);\nsvg.append(\"text\").attr(\"x\", oX0 + oSp + LR + 7).attr(\"y\", lgY2 + 5)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"14px\").text(\"Unsuccessful (hollow)\");\n\n// Title\nsvg.append(\"text\").attr(\"x\", width / 2).attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"scatter-pitch-events · javascript · d3 · anyplot.ai\");\n"}