Skip to content

Instantly share code, notes, and snippets.

@oaustegard
Created April 12, 2026 12:05
Show Gist options
  • Select an option

  • Save oaustegard/b515c674c35f43753e9cc1150125dded to your computer and use it in GitHub Desktop.

Select an option

Save oaustegard/b515c674c35f43753e9cc1150125dded to your computer and use it in GitHub Desktop.
<spray-chart> Web Component — zero-dependency SVG spray chart for static baseball sites

<spray-chart> Web Component

A zero-dependency, drop-in SVG spray chart for static baseball sites. Designed to match the BCC dark theme (Barlow Condensed, dark backgrounds, gold/red/blue accents).

spray-chart no deps

Quick Start

  1. Copy spray-chart.js into your repo (same directory as your HTML files, or a components/ folder).

  2. Add the script tag to any HTML page — just before </body>:

<script src="spray-chart.js"></script>
  1. Add the element wherever you want a chart:
<spray-chart fence-line="325" fence-center="360" id="spray1"></spray-chart>
  1. Feed it data — either inline JSON or via JavaScript:
<script>
document.getElementById('spray1').data = [
  { distance: 280, hitType: "line_drive", result: "double",   spray: 25,  pitch: "4-seam", speed: 62 },
  { distance: 100, hitType: "ground_ball",result: "out",      spray: -15, pitch: "curve",   speed: 54 },
  { distance: 340, hitType: "fly_ball",   result: "home_run", spray: 18,  pitch: "4-seam",  speed: 65 },
  { distance: 155, hitType: "line_drive", result: "single",   spray: -38, pitch: "slider",  speed: 60 },
];
</script>

That's it. No npm, no build step, no React.

Attributes

Attribute Default Description
fence-line 325 Fence distance (ft) down the foul lines
fence-center 360 Fence distance (ft) to center field
data [] JSON string of hit array (alternative to .data property)

Data Format

Each hit object supports these fields:

Field Type Required Values
distance number Batted ball distance in feet
hitType string "ground_ball", "line_drive", "fly_ball", "popup"
result string "home_run", "triple", "double", "single", "out"
spray number Spray angle in degrees. Negative = left field, positive = right field, 0 = center. Range roughly ±45°.
pitch string Pitch type: "4-seam", "2-seam", "curve", "slider", "change", "cutter", etc.
speed number Pitch speed in mph

Estimating Spray Angle

If you don't have exact spray angle data, estimate from the fielding zone:

Zone Approx spray angle
Left field line -40° to -45°
Left field -25° to -40°
Left-center -10° to -25°
Center -10° to +10°
Right-center +10° to +25°
Right field +25° to +40°
Right field line +40° to +45°

Integration with BCC Site

The component inherits CSS custom properties from your site when available:

  • --color-carbon (background)
  • --color-steel (borders)
  • --color-graphite (info panel background)
  • --color-mist (legend text)
  • --color-silver (info text)
  • --color-void (chip text)
  • --radius-md (border radius)

Since the BCC site already defines these in :root, the chart automatically picks up the right colors.

Where to place it

Scout pages (scout-gambrill.html, etc.): Add a spray chart per batter in their scouting section, after the existing spray-bar stats. This gives a visual map of tendencies alongside the L/C/R percentages you already show.

Game pages (game1.html, etc.): Add a team-wide spray chart showing all batted balls from the game, color-coded by result.

Example: adding to a scout page

<!-- After the player's stat table -->
<div class="section">
  <div class="section-label">Batted ball locations</div>
  <div class="section-title">Spray <span class="gold">Chart</span></div>
  <spray-chart fence-line="325" fence-center="360" id="player-spray"></spray-chart>
</div>

<script>
document.getElementById('player-spray').data = [
  // Paste player's batted ball data here
];
</script>

LLM Integration Instructions

If you're using an LLM (Claude, ChatGPT, etc.) to generate or update BCC pages, here's how to include spray charts:

Prompt context for the LLM

The BCC site uses a <spray-chart> web component (loaded via spray-chart.js).
To add a spray chart, include the element and set its .data property.

The component needs:
- fence-line="325" fence-center="360" (BCC field dimensions)
- Each hit: { distance, hitType, result, spray, pitch (optional), speed (optional) }
- hitType: "ground_ball" | "line_drive" | "fly_ball" | "popup"
- result: "home_run" | "triple" | "double" | "single" | "out"
- spray: degrees, negative = left field, positive = right field

It inherits the site's CSS custom properties automatically.
Place it inside a <div class="section"> block with section-label and section-title.
Make sure <script src="spray-chart.js"></script> is included before </body>.

When generating hit data from game notes or box scores:
- Use the fielding position to estimate spray angle (SS area ≈ -15°, 2B area ≈ +15°, etc.)
- Use hit description to set hitType (grounder→ground_ball, liner→line_drive, fly→fly_ball, pop→popup)
- Estimate distance: infield singles ~130ft, outfield singles ~190ft, doubles ~260ft, triples ~330ft, HR ~370ft, groundouts ~110ft, flyouts ~250ft

Example: LLM generates game data from play-by-play notes

Input notes:

"Smith grounded out to short. Jones lined a single to left. Williams hit a fly ball HR to right-center."

Generated data:

document.getElementById('game-spray').data = [
  { distance: 110, hitType: "ground_ball", result: "out",      spray: -12, pitch: "4-seam", speed: 63 },
  { distance: 185, hitType: "line_drive",  result: "single",   spray: -30, pitch: "curve",  speed: 55 },
  { distance: 375, hitType: "fly_ball",    result: "home_run", spray: 15,  pitch: "4-seam", speed: 64 },
];
// ══════════════════════════════════════════════════════════════════
// <spray-chart> Web Component
// Drop-in 2D SVG spray chart for static HTML sites.
// No dependencies. No build step. No framework.
//
// Usage:
// <script src="spray-chart.js"></script>
// <spray-chart fence-line="325" fence-center="360" id="my-chart"></spray-chart>
// <script>
// document.getElementById('my-chart').data = [
// { distance: 280, hitType: "line_drive", result: "double", spray: 25, pitch: "4-seam", speed: 62 },
// ];
// </script>
//
// Attributes:
// fence-line Fence distance down the foul lines in feet (default: 325)
// fence-center Fence distance to center field in feet (default: 360)
// data JSON string of hit array (alternative to .data property)
//
// Data format per hit:
// distance (number, ft) — recorded distance of batted ball
// hitType (string) — "ground_ball" | "line_drive" | "fly_ball" | "popup"
// result (string) — "home_run" | "triple" | "double" | "single" | "out"
// spray (number, deg) — spray angle: negative = left field, positive = right field, 0 = center
// pitch (string, opt) — pitch type: "4-seam", "curve", "slider", "change", etc.
// speed (number, opt) — pitch speed in mph
// ══════════════════════════════════════════════════════════════════
class SprayChart extends HTMLElement {
static get observedAttributes() { return ['fence-line', 'fence-center', 'data']; }
constructor() {
super();
this._data = [];
this._selected = null;
this.attachShadow({ mode: 'open' });
}
set data(v) {
this._data = Array.isArray(v) ? v : [];
this.render();
}
get data() { return this._data; }
connectedCallback() {
if (!this._data.length && this.hasAttribute('data')) {
try { this._data = JSON.parse(this.getAttribute('data')); } catch(e) {}
}
this.render();
}
attributeChangedCallback(name) {
if (name === 'data' && this.hasAttribute('data')) {
try { this._data = JSON.parse(this.getAttribute('data')); } catch(e) {}
}
this.render();
}
get fenceLine() { return parseFloat(this.getAttribute('fence-line')) || 325; }
get fenceCenter() { return parseFloat(this.getAttribute('fence-center')) || 360; }
fenceR(deg) {
return this.fenceLine + (this.fenceCenter - this.fenceLine) * Math.cos((deg * Math.PI) / 90);
}
fieldToSvg(distance, sprayDeg) {
const rad = (sprayDeg * Math.PI) / 180;
return { x: distance * Math.sin(rad), y: -distance * Math.cos(rad) };
}
static COLORS = {
home_run: '#C0272D',
triple: '#F5A623',
double: '#00AEEF',
single: '#4CAF50',
out: '#6B6B6B',
};
static LABELS = {
home_run: 'HR', triple: '3B', double: '2B', single: '1B', out: 'Out',
};
static HIT_LABELS = {
ground_ball: 'GB', line_drive: 'LD', fly_ball: 'FB', popup: 'PU', home_run: 'FB',
};
render() {
const C = SprayChart.COLORS;
const L = SprayChart.LABELS;
const HL = SprayChart.HIT_LABELS;
const N = 64;
const B = 63.64;
let fencePath = '';
for (let i = 0; i <= N; i++) {
const deg = -45 + (90 * i) / N;
const r = this.fenceR(deg);
const p = this.fieldToSvg(r, deg);
fencePath += (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}
let grassPath = 'M0,0 ';
for (let i = 0; i <= N; i++) {
const deg = -45 + (90 * i) / N;
const r = this.fenceR(deg) - 12;
const p = this.fieldToSvg(r, deg);
grassPath += 'L' + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}
grassPath += 'Z';
let wtFillPath = fencePath;
for (let i = N; i >= 0; i--) {
const deg = -45 + (90 * i) / N;
const r = this.fenceR(deg) - 12;
const p = this.fieldToSvg(r, deg);
wtFillPath += 'L' + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}
wtFillPath += 'Z';
let dirtPath = 'M0,0 ';
for (let i = 0; i <= N; i++) {
const deg = -45 + (90 * i) / N;
const p = this.fieldToSvg(95, deg);
dirtPath += 'L' + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}
dirtPath += 'Z';
let igPath = '';
const igCx = 0, igCy = -B;
for (let i = 0; i <= 48; i++) {
const a = (2 * Math.PI * i) / 48;
igPath += (i === 0 ? 'M' : 'L') + (igCx + 50 * Math.cos(a)).toFixed(1) + ',' + (igCy + 52 * Math.sin(a)).toFixed(1);
}
igPath += 'Z';
const bases = [[0, 0], [B, -B], [0, -2 * B], [-B, -B]];
const bpLines = bases.map((b, i) => {
const n = bases[(i + 1) % 4];
return `<line x1="${b[0]}" y1="${b[1]}" x2="${n[0]}" y2="${n[1]}" stroke="#9A9A9A" stroke-width="0.6" opacity="0.5"/>`;
}).join('');
const fl1 = this.fieldToSvg(this.fenceR(-45), -45);
const fl2 = this.fieldToSvg(this.fenceR(45), 45);
const markers = [100, 200, 300].map(d => {
let arc = '';
for (let i = 0; i <= 32; i++) {
const deg = -45 + (90 * i) / 32;
const p = this.fieldToSvg(d, deg);
arc += (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}
const lbl = this.fieldToSvg(d, 0);
return `<path d="${arc}" fill="none" stroke="#2C2C2C" stroke-width="0.5" stroke-dasharray="3,4"/>
<text x="${lbl.x}" y="${lbl.y + 5}" text-anchor="middle" fill="#4A4A4A" font-size="8" font-family="'Barlow Condensed',sans-serif">${d}'</text>`;
}).join('');
const fLabels = [-45, -20, 0, 20, 45].map(deg => {
const r = this.fenceR(deg);
const p = this.fieldToSvg(r + 10, deg);
return `<text x="${p.x}" y="${p.y}" text-anchor="middle" fill="#4A4A4A" font-size="7" font-family="'Barlow Condensed',sans-serif">${Math.round(r)}'</text>`;
}).join('');
const dots = this._data.map((h, i) => {
const p = this.fieldToSvg(h.distance, h.spray || h.sprayAngle || 0);
const c = C[h.result] || '#6B6B6B';
const r = this._selected === i ? 6 : 4;
const op = this._selected !== null && this._selected !== i ? 0.3 : 0.85;
return `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${r}" fill="${c}" opacity="${op}"
stroke="${this._selected === i ? '#F0F0F0' : 'none'}" stroke-width="1.2"
data-idx="${i}" class="hit-dot" style="cursor:pointer;transition:all 150ms"/>`;
}).join('');
const baseSquares = bases.map((b, i) => {
if (i === 0) return `<polygon points="0,-0.7 0.7,-0.3 0.7,0.5 -0.7,0.5 -0.7,-0.3" fill="#C8C8C8" opacity="0.8"/>`;
return `<rect x="${b[0]-1}" y="${b[1]-1}" width="2" height="2" fill="#C8C8C8" transform="rotate(45,${b[0]},${b[1]})" opacity="0.7"/>`;
}).join('');
const sel = this._selected !== null ? this._data[this._selected] : null;
const infoHtml = sel ? `
<div class="info">
<span class="info-chip" style="background:${C[sel.result]}">${L[sel.result]}</span>
<span class="info-item">${HL[sel.hitType] || '—'}</span>
<span class="info-item">${Math.round(sel.distance)}ft</span>
${sel.pitch ? `<span class="info-item">${sel.pitch}</span>` : ''}
${sel.speed ? `<span class="info-item">${sel.speed}mph</span>` : ''}
</div>` : '';
const legend = Object.entries(C).map(([k, c]) =>
`<span class="leg-item"><span class="leg-dot" style="background:${c}"></span>${L[k]}</span>`
).join('');
const pad = 40;
const maxR = this.fenceCenter + pad;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.wrap { position: relative; background: var(--color-carbon, #0D0D0D); border: 1px solid var(--color-steel, #2C2C2C); border-radius: var(--radius-md, 4px); overflow: hidden; }
svg { display: block; width: 100%; height: auto; }
.legend { display: flex; gap: 10px; justify-content: center; padding: 8px 12px; border-top: 1px solid var(--color-steel, #2C2C2C); flex-wrap: wrap; }
.leg-item { display: flex; align-items: center; gap: 4px; font-family: 'Barlow Condensed', Impact, sans-serif; font-size: 0.7rem; font-weight: 600; color: var(--color-mist, #9A9A9A); text-transform: uppercase; letter-spacing: 0.1em; }
.leg-dot { width: 7px; height: 7px; border-radius: 50%; }
.info { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-top: 1px solid var(--color-steel, #2C2C2C); background: var(--color-graphite, #1A1A1A); justify-content: center; flex-wrap: wrap; }
.info-chip { font-family: 'Barlow Condensed', Impact, sans-serif; font-size: 0.7rem; font-weight: 700; color: var(--color-void, #080808); padding: 1px 8px; border-radius: 2px; text-transform: uppercase; letter-spacing: 0.1em; }
.info-item { font-family: 'Barlow', 'Helvetica Neue', sans-serif; font-size: 0.78rem; color: var(--color-silver, #C8C8C8); font-weight: 400; }
.hit-dot:hover { filter: brightness(1.3); }
</style>
<div class="wrap">
<svg viewBox="${-maxR} ${-maxR} ${maxR * 2} ${maxR}" xmlns="http://www.w3.org/2000/svg">
<path d="${grassPath}" fill="#1a3a1a"/>
<path d="${wtFillPath}" fill="#3d2b0f"/>
<path d="${fencePath}" fill="none" stroke="#2C2C2C" stroke-width="2"/>
<path d="${dirtPath}" fill="#4a3510" opacity="0.7"/>
<path d="${igPath}" fill="#1f4a1f"/>
${bpLines}
<line x1="0" y1="0" x2="${fl1.x}" y2="${fl1.y}" stroke="#9A9A9A" stroke-width="0.5" opacity="0.4"/>
<line x1="0" y1="0" x2="${fl2.x}" y2="${fl2.y}" stroke="#9A9A9A" stroke-width="0.5" opacity="0.4"/>
${markers}
${fLabels}
<circle cx="0" cy="-60.5" r="4.5" fill="#8A6F2E" opacity="0.5"/>
${baseSquares}
${dots}
</svg>
<div class="legend">${legend}</div>
${infoHtml}
</div>`;
this.shadowRoot.querySelectorAll('.hit-dot').forEach(dot => {
dot.addEventListener('click', (e) => {
const i = parseInt(e.target.dataset.idx);
this._selected = this._selected === i ? null : i;
this.render();
});
});
this.shadowRoot.querySelector('svg').addEventListener('click', (e) => {
if (!e.target.classList.contains('hit-dot') && this._selected !== null) {
this._selected = null;
this.render();
}
});
}
}
customElements.define('spray-chart', SprayChart);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment