{"spec_id":"scatter-shot-chart","library":"d3","language":"javascript","code":"// anyplot.ai\n// scatter-shot-chart: Basketball Shot Chart\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 89/100 | Created: 2026-06-21\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// Imprint palette: made = brand green (pos 1), missed = matte red (semantic anchor for loss/error)\nconst COLOR_MADE   = t.palette[0];  // #009E73\nconst COLOR_MISSED = t.palette[4];  // #AE3030\n\n// Deterministic LCG — browser has no seeded Math.random\nfunction makeLcg(seed) {\n  let s = seed >>> 0;\n  return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };\n}\nconst rng = makeLcg(42);\n\nfunction bm() {\n  const u1 = Math.max(rng(), 1e-9), u2 = rng();\n  const mag = Math.sqrt(-2 * Math.log(u1));\n  return [mag * Math.cos(2 * Math.PI * u2), mag * Math.sin(2 * Math.PI * u2)];\n}\n\n// Shot clusters [cx, cy, spreadX, spreadY, madeRate, weight]\n// x = horizontal from court center, y = distance from baseline (ft); basket at (0, 5.25)\nconst CLUSTERS = [\n  [0,    5.5,  1.8, 1.5, 0.62, 0.12],  // at-rim\n  [0,   16.0,  2.0, 1.5, 0.38, 0.07],  // mid-range top\n  [-7,  13.0,  2.5, 2.0, 0.37, 0.07],  // left mid\n  [7,   13.0,  2.5, 2.0, 0.37, 0.07],  // right mid\n  [-21,  6.5,  1.5, 2.5, 0.38, 0.09],  // left corner 3\n  [21,   6.5,  1.5, 2.5, 0.38, 0.09],  // right corner 3\n  [-18, 18.0,  2.5, 2.5, 0.34, 0.10],  // left wing 3\n  [18,  18.0,  2.5, 2.5, 0.34, 0.10],  // right wing 3\n  [0,   28.0,  2.5, 1.5, 0.35, 0.10],  // top of 3-point arc\n  [-12, 23.0,  2.5, 2.0, 0.33, 0.10],  // left 3\n  [12,  23.0,  2.5, 2.0, 0.33, 0.10],  // right 3\n  [0,    9.5,  3.5, 2.5, 0.40, 0.09],  // paint\n];\nconst W_SUM = CLUSTERS.reduce((a, c) => a + c[5], 0);\n\nconst shots = [];\nfor (let i = 0; i < 420; i++) {\n  let pick = rng() * W_SUM, ci = 0, acc = 0;\n  for (let j = 0; j < CLUSTERS.length; j++) {\n    acc += CLUSTERS[j][5];\n    if (pick <= acc) { ci = j; break; }\n  }\n  const [cx, cy, spx, spy, madeRate] = CLUSTERS[ci];\n  const [z0, z1] = bm();\n  const sx = cx + spx * z0;\n  const sy = cy + spy * z1;\n  if (sx < -25 || sx > 25 || sy < 0 || sy > 47) continue;\n  shots.push({ x: sx, y: sy, made: rng() < madeRate });\n}\n\n// Layout\nconst margin = { top: 80, right: 50, bottom: 30, left: 50 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst BKST_X = 0, BKST_Y = 5.25;\n\nconst xSc = d3.scaleLinear().domain([-25, 25]).range([0, iw]);\n// Crop to [0, 32] — removes dead upper-court area; 3-pt arc peaks at ~29ft\nconst ySc = d3.scaleLinear().domain([0, 32]).range([ih, 0]);\n\nconst svg = d3.select(\"#container\").append(\"svg\")\n  .attr(\"width\", width).attr(\"height\", height);\n\n// ClipPath keeps court elements within the plot rectangle\nsvg.append(\"defs\").append(\"clipPath\")\n  .attr(\"id\", \"plotClip\")\n  .append(\"rect\").attr(\"width\", iw).attr(\"height\", ih);\n\nconst g = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`)\n  .attr(\"clip-path\", \"url(#plotClip)\");\n\n// Court floor tint\nconst floorColor = THEME === \"dark\" ? \"#3A2B12\" : \"#D4B483\";\ng.append(\"rect\")\n  .attr(\"x\", 0).attr(\"y\", 0).attr(\"width\", iw).attr(\"height\", ih)\n  .attr(\"fill\", floorColor).attr(\"fill-opacity\", 0.18);\n\n// Arc path helper: math-coord angles (y-axis flip handled by ySc)\nfunction aPath(cx, cy, r, a0, a1, steps = 80) {\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const a = a0 + (a1 - a0) * i / steps;\n    return `${i ? \"L\" : \"M\"}${xSc(cx + r * Math.cos(a)).toFixed(1)},${ySc(cy + r * Math.sin(a)).toFixed(1)}`;\n  }).join(\"\");\n}\n\nconst LC = t.inkSoft;\nconst LW = 2;\n\nfunction cLine(x1, y1, x2, y2) {\n  g.append(\"line\")\n    .attr(\"x1\", xSc(x1)).attr(\"y1\", ySc(y1))\n    .attr(\"x2\", xSc(x2)).attr(\"y2\", ySc(y2))\n    .attr(\"stroke\", LC).attr(\"stroke-width\", LW);\n}\n\n// Court boundary — sidelines extend to y=47 but are clipped at y=32\ncLine(-25, 0, 25, 0);\ncLine(-25, 0, -25, 47);\ncLine(25, 0, 25, 47);\n\n// Paint / key\ncLine(-8, 0, -8, 19);\ncLine(8, 0, 8, 19);\ncLine(-8, 19, 8, 19);\n\n// Free-throw circle (6 ft radius, center at y=19)\ng.append(\"path\").attr(\"d\", aPath(0, 19, 6, Math.PI, 0))\n  .attr(\"fill\", \"none\").attr(\"stroke\", LC).attr(\"stroke-width\", LW);\ng.append(\"path\").attr(\"d\", aPath(0, 19, 6, 0, -Math.PI))\n  .attr(\"fill\", \"none\").attr(\"stroke\", LC).attr(\"stroke-width\", LW)\n  .attr(\"stroke-dasharray\", \"7,5\");\n\n// Backboard and basket\ncLine(-3, 4, 3, 4);\ng.append(\"circle\")\n  .attr(\"cx\", xSc(BKST_X)).attr(\"cy\", ySc(BKST_Y))\n  .attr(\"r\", xSc(BKST_X + 0.75) - xSc(BKST_X))\n  .attr(\"fill\", \"none\").attr(\"stroke\", LC).attr(\"stroke-width\", 2.5);\n\n// Restricted area arc (4 ft radius)\ng.append(\"path\").attr(\"d\", aPath(BKST_X, BKST_Y, 4, Math.PI, 0))\n  .attr(\"fill\", \"none\").attr(\"stroke\", LC).attr(\"stroke-width\", LW);\n\n// Three-point line: straight corner segments + arc at 23.75 ft\ncLine(-22, 0, -22, 14);\ncLine(22, 0, 22, 14);\ng.append(\"path\")\n  .attr(\"d\", aPath(\n    BKST_X, BKST_Y, 23.75,\n    Math.atan2(14 - BKST_Y, -22 - BKST_X),\n    Math.atan2(14 - BKST_Y,  22 - BKST_X),\n  ))\n  .attr(\"fill\", \"none\").attr(\"stroke\", LC).attr(\"stroke-width\", LW);\n\n// Zone FG% annotations — rendered before markers so shots sit on top\nconst ZONE_LABELS = [\n  { x:  0,    y: 30.5, text: \"Top-3 · ~35%\",  anchor: \"middle\" },\n  { x:  14,   y: 14.5, text: \"Mid · ~38%\",    anchor: \"start\" },\n  { x:  0,    y: 3.2,  text: \"At-Rim · ~62%\", anchor: \"middle\" },\n];\nZONE_LABELS.forEach(({ x, y, text, anchor }) => {\n  const tx = xSc(x), ty = ySc(y);\n  const approxW = text.length * 7.2 + 12;\n  const bx = anchor === \"middle\" ? tx - approxW / 2\n            : anchor === \"end\"   ? tx - approxW - 3\n            : tx + 3;\n  g.append(\"rect\")\n    .attr(\"x\", bx).attr(\"y\", ty - 14).attr(\"width\", approxW).attr(\"height\", 18)\n    .attr(\"fill\", t.elevatedBg).attr(\"fill-opacity\", 0.72).attr(\"rx\", 3);\n  g.append(\"text\")\n    .attr(\"x\", tx).attr(\"y\", ty)\n    .attr(\"text-anchor\", anchor)\n    .attr(\"fill\", t.inkSoft).attr(\"fill-opacity\", 0.90)\n    .style(\"font-size\", \"12px\").style(\"font-weight\", \"600\")\n    .text(text);\n});\n\n// CVD accessibility: missed shots rendered as X (cross rotated 45°), made as circles\n// Shape + color dual encoding distinguishes outcome for color-vision-deficient viewers\nconst MARKER_AREA = 180;  // sq px; visually balances r=7 circle (≈154 sq px)\nconst crossPath = d3.symbol().type(d3.symbolCross).size(MARKER_AREA)();\n\nconst madeArr   = shots.filter(d =>  d.made);\nconst missedArr = shots.filter(d => !d.made);\n\n// Missed: X shape\ng.selectAll(\".miss\").data(missedArr).join(\"path\")\n  .attr(\"d\", crossPath)\n  .attr(\"transform\", d => `translate(${xSc(d.x)},${ySc(d.y)}) rotate(45)`)\n  .attr(\"fill\", COLOR_MISSED).attr(\"fill-opacity\", 0.65)\n  .attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 0.8);\n\n// Made: circle (rendered on top)\ng.selectAll(\".make\").data(madeArr).join(\"circle\")\n  .attr(\"cx\", d => xSc(d.x)).attr(\"cy\", d => ySc(d.y))\n  .attr(\"r\", 7)\n  .attr(\"fill\", COLOR_MADE).attr(\"fill-opacity\", 0.85)\n  .attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 0.8);\n\n// Legend — legend group is unclipped so append to svg with absolute position\nconst lgOffX = margin.left + 16, lgOffY = margin.top + 14;\nconst lgW = 192, lgH = 76;\nsvg.append(\"rect\")\n  .attr(\"x\", lgOffX - 10).attr(\"y\", lgOffY - 12)\n  .attr(\"width\", lgW).attr(\"height\", lgH)\n  .attr(\"fill\", t.elevatedBg).attr(\"rx\", 6).attr(\"fill-opacity\", 0.92)\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\nconst lgItems = [\n  { label: `Made (${madeArr.length})`,     color: COLOR_MADE,   shape: \"circle\" },\n  { label: `Missed (${missedArr.length})`, color: COLOR_MISSED, shape: \"cross\" },\n];\nlgItems.forEach((d, i) => {\n  const lx = lgOffX + 6, ly = lgOffY + 8 + i * 30;\n  if (d.shape === \"circle\") {\n    svg.append(\"circle\")\n      .attr(\"cx\", lx).attr(\"cy\", ly)\n      .attr(\"r\", 7).attr(\"fill\", d.color)\n      .attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1);\n  } else {\n    svg.append(\"path\")\n      .attr(\"d\", crossPath)\n      .attr(\"transform\", `translate(${lx},${ly}) rotate(45)`)\n      .attr(\"fill\", d.color).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1);\n  }\n  svg.append(\"text\")\n    .attr(\"x\", lgOffX + 22).attr(\"y\", lgOffY + 13 + i * 30)\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\")\n    .text(d.label);\n});\n\n// Title\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"scatter-shot-chart · javascript · d3 · anyplot.ai\");\n"}