Basketball Shot Chart — Chart.js

A basketball shot chart overlays shooting data on a half-court diagram, plotting each shot attempt as a point colored by outcome (made or missed). The court drawing includes the three-point arc, free-throw line, paint/key area, and basket, providing spatial context for analyzing shooting patterns and efficiency. Essential for basketball analytics and player evaluation.

Basketball Shot Chart rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// scatter-shot-chart: Basketball Shot Chart
// Library: chartjs 4.4.7 | JavaScript 22.22.3
// Quality: 87/100 | Created: 2026-06-21

//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;

// --- Seeded LCG for deterministic data ------------------------------------
let seed = 42;
function lcg() {
  seed = (seed * 1664525 + 1013904223) >>> 0;
  return seed / 0x100000000;
}
function randn(mean, std) {
  const u1 = Math.max(lcg(), 1e-10);
  const u2 = lcg();
  return mean + std * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// --- NBA Half-Court Geometry (feet) ---------------------------------------
// Basket at (BX, BY); court: x in [-25, 25], y in [0, 47]
const BX = 0, BY = 4;
const C3X = 22;
const TP_R = 23.75;
const FT_Y = BY + 15;
const C3Y = BY + Math.sqrt(TP_R * TP_R - C3X * C3X); // ~12.95

function arcPts(cx, cy, r, a0, a1, n) {
  const steps = n || 80;
  const pts = [];
  for (let i = 0; i <= steps; i++) {
    const a = a0 + (a1 - a0) * (i / steps);
    pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
  }
  return pts;
}

// Three-point arc: left corner → top → right corner (decreasing angle)
const tpA0 = Math.atan2(C3Y - BY, -C3X);
const tpA1 = Math.atan2(C3Y - BY, C3X);

function courtLine(data, dash) {
  const ds = {
    type: 'line',
    data: data,
    borderColor: t.inkSoft,
    borderWidth: 2,
    backgroundColor: 'transparent',
    fill: false,
    pointRadius: 0,
    pointHoverRadius: 0,
    tension: 0,
    showLine: true,
    order: 0,
  };
  if (dash) ds.borderDash = dash;
  return ds;
}

const courtDatasets = [
  // Outer boundary
  courtLine([{x:-25,y:0},{x:25,y:0},{x:25,y:47},{x:-25,y:47},{x:-25,y:0}]),
  // Paint left/right
  courtLine([{x:-8,y:0},{x:-8,y:FT_Y}]),
  courtLine([{x:8,y:0},{x:8,y:FT_Y}]),
  // Free-throw line
  courtLine([{x:-8,y:FT_Y},{x:8,y:FT_Y}]),
  // FT circle top — solid, faces court
  courtLine(arcPts(BX, FT_Y, 6, 0, Math.PI)),
  // FT circle bottom — dashed, inside key
  courtLine(arcPts(BX, FT_Y, 6, Math.PI, 2 * Math.PI), [5, 5]),
  // Three-point corner straights
  courtLine([{x:-C3X,y:0},{x:-C3X,y:C3Y}]),
  courtLine([{x:C3X,y:0},{x:C3X,y:C3Y}]),
  // Three-point arc
  courtLine(arcPts(BX, BY, TP_R, tpA0, tpA1)),
  // Restricted area arc
  courtLine(arcPts(BX, BY, 4, 0, Math.PI)),
  // Basket ring
  courtLine(arcPts(BX, BY, 0.75, 0, 2 * Math.PI, 40)),
  // Backboard
  courtLine([{x:-3,y:0.5},{x:3,y:0.5}]),
];

// --- Shot Data (350 attempts) ---------------------------------------------
// Marker radius encodes shot type: free-throw=4, 2-pointer=5.5, 3-pointer=7
const TYPE_RADIUS = { 'free-throw': 4, '2-pointer': 5.5, '3-pointer': 7 };

function genShots(n) {
  const shots = [];
  for (let i = 0; i < n; i++) {
    const z = lcg();
    let x, y, made, shot_type;
    if (z < 0.07) {
      // Free throw — clustered at FT line
      x = BX + randn(0, 0.25);
      y = FT_Y + randn(0, 0.25);
      made = lcg() < 0.78;
      shot_type = 'free-throw';
    } else if (z < 0.30) {
      // At rim / paint → 2-pointer
      const d = lcg() * 7, a = lcg() * 2 * Math.PI;
      x = BX + d * Math.cos(a);
      y = Math.max(0.3, BY + d * Math.sin(a));
      made = lcg() < 0.63;
      shot_type = '2-pointer';
    } else if (z < 0.40) {
      // Left corner 3
      x = -(C3X + lcg() * 2.5);
      y = 0.5 + lcg() * 10;
      made = lcg() < 0.39;
      shot_type = '3-pointer';
    } else if (z < 0.50) {
      // Right corner 3
      x = C3X + lcg() * 2.5;
      y = 0.5 + lcg() * 10;
      made = lcg() < 0.39;
      shot_type = '3-pointer';
    } else if (z < 0.63) {
      // Above-the-break 3
      const a = Math.PI * 0.10 + lcg() * Math.PI * 0.80;
      const d = TP_R + randn(0, 0.8);
      x = Math.max(-24.5, Math.min(24.5, BX + d * Math.cos(a)));
      y = Math.max(0.3, Math.min(32, BY + d * Math.sin(a)));
      made = lcg() < 0.35;
      shot_type = '3-pointer';
    } else {
      // Mid-range → 2-pointer
      let tries = 0, x2, y2;
      do {
        const a = Math.PI * 0.08 + lcg() * Math.PI * 0.84;
        const d = 9 + lcg() * 12;
        x2 = BX + d * Math.cos(a);
        y2 = BY + d * Math.sin(a);
        tries++;
      } while (tries < 20 && (Math.abs(x2) < 8.5 && y2 < FT_Y + 1));
      x = Math.max(-24.5, Math.min(24.5, x2));
      y = Math.max(0.3, Math.min(32, y2));
      made = lcg() < 0.43;
      shot_type = '2-pointer';
    }
    shots.push({ x: x, y: y, made: made, shot_type: shot_type });
  }
  return shots;
}

const shots = genShots(350);
const madeShots    = shots.filter(function(s) { return  s.made; });
const missedShots  = shots.filter(function(s) { return !s.made; });
const madeData     = madeShots.map(function(s) { return { x: s.x, y: s.y }; });
const missedData   = missedShots.map(function(s) { return { x: s.x, y: s.y }; });
const madeRadius   = madeShots.map(function(s) { return TYPE_RADIUS[s.shot_type]; });
const missedRadius = missedShots.map(function(s) { return TYPE_RADIUS[s.shot_type]; });

// --- Mount ----------------------------------------------------------------
const canvas = document.createElement('canvas');
document.getElementById('container').appendChild(canvas);

// --- Chart ----------------------------------------------------------------
new Chart(canvas, {
  type: 'scatter',
  data: {
    datasets: courtDatasets.concat([
      {
        label: 'Made',
        data: madeData,
        backgroundColor: t.palette[0] + 'cc',
        borderColor: t.palette[0],
        borderWidth: 1,
        pointRadius: madeRadius,
        pointHoverRadius: madeRadius,
        pointStyle: 'circle',
        order: 1,
      },
      {
        label: 'Missed',
        data: missedData,
        backgroundColor: 'transparent',
        borderColor: t.palette[4],
        borderWidth: 2,
        pointRadius: missedRadius,
        pointHoverRadius: missedRadius,
        pointStyle: 'crossRot',
        order: 2,
      },
    ]),
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    plugins: {
      title: {
        display: true,
        text: 'scatter-shot-chart · javascript · chartjs · anyplot.ai',
        color: t.ink,
        font: { size: 22, weight: '500' },
        padding: { top: 12, bottom: 8 },
      },
      legend: {
        display: true,
        labels: {
          color: t.ink,
          font: { size: 16 },
          usePointStyle: true,
          filter: function(item) { return item.datasetIndex >= courtDatasets.length; },
        },
      },
    },
    scales: {
      x: {
        type: 'linear',
        min: -27,
        max: 27,
        display: false,
        grid: { display: false },
      },
      y: {
        type: 'linear',
        min: -2,
        max: 34,
        display: false,
        grid: { display: false },
      },
    },
    layout: {
      padding: { top: 10, bottom: 20, left: 20, right: 20 },
    },
  },
  plugins: [{
    id: 'background',
    beforeDraw: function(chart) {
      const ctx = chart.ctx;
      ctx.save();
      ctx.fillStyle = t.pageBg;
      ctx.fillRect(0, 0, chart.width, chart.height);
      ctx.restore();
    },
  }],
});

Part of Basketball Shot Chart on anyplot.ai.

Other implementations