npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

shotchart.ts

v0.2.0

Published

A TypeScript framework-agnostic basketball shotchart primitives built on D3. Renders to any SVG element. Supports NBA and college court dimensions.

Readme

shotchart.ts

Framework-agnostic basketball shotchart primitives built on D3. Renders to any SVG element — works in React, Vue, Svelte, vanilla JS, anything that can hand you a <svg>.

  • Six court presets — NBA, WNBA, NCAA-M, NCAA-W, FIBA, NFHS High School
  • Five charts — halfcourt, fullcourt, 14-zone shotchart, per-shot scatter, hexbin density
  • Procedural hardwood floor — planks, seams and grain drawn in SVG, no image assets
  • Targeted updatessetData(), setTheme(), setStyle(), setRadius() mutate the existing DOM instead of rebuilding
  • Dual CJS + ESM, full .d.ts declarations, d3 externalized
  • Zero framework dependencies

Install

pnpm add shotchart.ts
# or
npm install shotchart.ts
# or
yarn add shotchart.ts

D3 modules (d3-selection, d3-scale, d3-shape, d3-array, d3-hexbin) are declared as runtime dependencies and installed automatically.

Stylesheet

The chart relies on a small stylesheet for court strokes, floor paint and zone label sizing. Import it once at your app entry:

import "shotchart.ts/styles.css";

Every chart mounts this structure into your <svg>, so you can target any layer with CSS:

<g class="shotchart">
  <defs/>                         <!-- floor pattern / grain filter / clipPath -->
  <g class="shot-chart-floor"/>   <!-- hardwood or solid background -->
  <g class="shot-chart-court"/>   <!-- lines, zones, labels -->
  <g class="shot-chart-overlay"/> <!-- shots, hexbins -->
</g>

Halfcourt

A static court diagram, no data.

import { createHalfcourt } from "shotchart.ts";

const svg = document.querySelector<SVGSVGElement>("#my-court");
const chart = createHalfcourt(svg!, { courtType: "nba", floor: "maple" });

// later
chart.destroy();

createHalfcourt(svg, options?)

| Option | Type | Default | Notes | | ---------------- | ------------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------- | | courtType | "nba" \| "wnba" \| "ncaam" \| "ncaaw" \| "fiba" \| "nfhs" | "nba" | Picks the dimension preset (see Court types). | | leagueSettings | LeagueSettings | — | Override the preset with a custom league. Wins over courtType. | | extent | "arc" \| "half" | "arc" | "arc" crops just past the 3-pt line; "half" shows the whole half court with the division line and center circle. | | floor | FloorInput | "none" | Hardwood / solid background (see Court floor). |

Returns { destroy(): void }.

These four options are shared by every halfcourt-based chart below (createZonedShotchart, createShotScatter, createHexbinShotchart).

Court types

| courtType | Court size | 3-pt radius | Notes | | ----------- | ----------- | ------------------------ | --------------------------------------------------------------------- | | "nba" | 94' × 50' | 23'9" (corner 22') | Default. Dashed inner half on FT circle. | | "wnba" | 94' × 50' | 22'1.75" (corner 21'8") | Identical to NBA except the 3-point arc. | | "ncaam" | 94' × 50' | 22'1.75" (since 2019) | Solid FT circle, narrower 12' paint. | | "ncaaw" | 94' × 50' | 22'1.75" (since 2021-22) | Identical to NCAA-M. | | "fiba" | 28m × 15m | 6.75m (≈22'1.75") | EuroLeague spec. Metric values converted to feet internally. | | "nfhs" | 84' × 50' | 19'9" (5'3" straight) | 10' shorter court. No painted restricted-area arc on the court. |

Units

All LeagueSettings values are in feet, and so is the SVG: the viewBox is set so 1 user unit = 1 foot. FIBA's metric dimensions (28m × 15m, 6.75m arc, 1.25m restricted area) are converted to feet (91.86' × 49.21', 22.146', 4.1') at the settings boundary.

Court floor

Every factory accepts a floor option. Pass a preset name or an object:

createHalfcourt(svg, { floor: "walnut" });

createFullcourt(svg, {
  floor: { color: "#3b5b8f", plankWidth: 0.75, grain: false, keyColor: "#2c4670" },
});
type FloorInput = "none" | "wood" | "maple" | "walnut" | "dark" | FloorOptions;

interface FloorOptions {
  type?: "none" | "solid" | "wood"; // default "wood"
  preset?: "maple" | "walnut" | "dark";
  color?: string;        // base tone (the solid color, or the wood's base)
  plankWidth?: number;   // feet, default 0.5
  plankLength?: number;  // feet, default 6
  grain?: boolean;       // feTurbulence overlay, default true
  seamOpacity?: number;  // default 0.25
  paintKey?: boolean;    // fill the lane, default true
  keyColor?: string;     // lane paint (defaults to a preset-derived tone)
  lineColor?: string;    // court line paint (defaults to white via CSS)
}

The floor is a <pattern> of staggered plank <rect>s plus an optional feTurbulence grain filter, all sized in feet so it scales with the chart. When a floor is present the court group gets a shot-chart-court--on-floor class: lines become 2" white paint, the rim turns orange and the lane is painted. Override any of it in CSS.

Fullcourt

Both ends, sidelines, division line and center circle, for all six courts.

import { createFullcourt } from "shotchart.ts";

const court = createFullcourt(svg, { courtType: "fiba", orientation: "horizontal", floor: "maple" });
court.destroy();

createFullcourt(svg, options?)

| Option | Type | Default | Notes | | ---------------- | ---------------------------- | -------------- | ----------------------------------------------------------- | | courtType | CourtType | "nba" | | | leagueSettings | LeagueSettings | — | Override the preset. | | orientation | "horizontal" \| "vertical" | "horizontal" | Horizontal = baskets left and right; viewBox is courtLength × courtWidth. | | floor | FloorInput | "none" | |

Returns { destroy(): void }. Each half is its own <g class="shot-chart-court shot-chart-court--near|--far">.

Zoned shotchart

A court with 14 shooting zones colored by percentile and labeled with FGM / FGA + FG%.

import { createZonedShotchart, type ZoneData } from "shotchart.ts";
import "shotchart.ts/styles.css";

const data: ZoneData[] = [
  { bucket: "RIM",   fgm: 18, fga: 24, percentile: 88 },
  { bucket: "R-C3",  fgm: 4,  fga: 11, percentile: 42 },
  { bucket: "M-ATB", fgm: 7,  fga: 19, percentile: 61 },
  // ...one entry per zone you have stats for
];

const chart = createZonedShotchart(svg, {
  courtType: "nba",
  theme: "red-green",
  backgroundTheme: "light",
  data,
});

// Update without rebuilding the DOM
chart.setData(newData);
chart.setTheme("blue-orange");
chart.setBackground("dark");

// Clean up
chart.destroy();

createZonedShotchart(svg, options)

| Option | Type | Default | Notes | | ----------------- | ------------------------------ | ------------- | --------------------------------------------------------------------- | | data | ZoneData[] | required | One entry per zone you have stats for. Missing zones render as empty. | | theme | "red-green" \| "blue-orange" | "red-green" | Color palette for percentile fills. | | backgroundTheme | "dark" \| "light" | "light" | Drives text styling for empty zones so labels remain legible. | | + halfcourt options | courtType, leagueSettings, extent, floor | | NFHS still has a logical 4' RIM zone for analytics even though no arc is drawn. |

Returns { setData, setTheme, setBackground, destroy }.

ZoneData

interface ZoneData {
  bucket: ShotchartZone;  // see codes below
  fgm: number;
  fga: number;
  percentile: number;     // 0–100, or -1 for "no data"
}

Zone codes

| Code | Zone | | -------- | ------------------------------- | | R-C3 | Right corner three | | L-C3 | Left corner three | | R-ATB | Right above-the-break three | | L-ATB | Left above-the-break three | | M-ATB | Middle above-the-break three | | RB-MR | Right baseline midrange | | LB-MR | Left baseline midrange | | RW-MR | Right wing midrange | | LW-MR | Left wing midrange | | M-MR | Middle midrange | | R-FL | Right floater range | | L-FL | Left floater range | | M-FL | Middle floater range | | RIM | Restricted area / rim |

Shot data

The scatter and hexbin charts take individual attempts:

interface Shot {
  x: number;      // feet from basket center, +x to the right as drawn
  y: number;      // feet from basket center, +y toward halfcourt
  made: boolean;
}

That is the NBA Stats API convention divided by ten, so shotchartdetail rows map directly:

const shots: Shot[] = rows.map((r) => ({
  x: r.LOC_X / 10,
  y: r.LOC_Y / 10,
  made: r.SHOT_MADE_FLAG === 1,
}));

Shots are league-independent — the same list renders on any court. If your source looks mirrored, negate x. Extra fields are fine: the factories are generic over T extends Shot, so setData() keeps your type.

Shot scatter

One mark per attempt: filled dots for makes, hollow rings (or X's) for misses.

import { createShotScatter } from "shotchart.ts";

const chart = createShotScatter(svg, {
  courtType: "nba",
  floor: "maple",
  data: shots,
  style: { missedMarker: "x", radius: 0.5 },
});

chart.setData(nextShots);            // d3 join — only changed marks touch the DOM
chart.setStyle({ madeColor: "#0f0" });
chart.destroy();

createShotScatter(svg, options)

| Option | Type | Default | Notes | | ------- | --------------------------- | ------- | ------------------------------------------------------ | | data | Shot[] | required | | | style | Partial<ShotScatterStyle> | — | See below. | | clip | boolean | true | Clip marks to the court rectangle (hides heaves). | | + halfcourt options | courtType, leagueSettings, extent, floor | | |

interface ShotScatterStyle {
  radius: number;        // feet, default 0.6
  madeColor: string;     // default "#22c55e"
  missedColor: string;   // default "#ef4444"
  missedMarker: "ring" | "x"; // default "ring"
  opacity: number;       // default 0.85
  strokeWidth: number;   // feet, default 0.15
}

Returns { setData, setStyle, destroy }. Each mark is <g class="shot shot--made|shot--missed" data-made>.

Hexbin shotchart

Shots aggregated into hexagons: color = FG% on the theme's palette, size = attempt frequency.

import { createHexbinShotchart } from "shotchart.ts";

const chart = createHexbinShotchart(svg, {
  courtType: "nba",
  data: shots,
  radius: 1.5,           // feet
  theme: "red-green",
  colorDomain: [0.25, 0.65],
  minAttempts: 2,
});

chart.setData(nextShots);
chart.setTheme("blue-orange");
chart.setRadius(2);
chart.bins();            // HexbinDatum[] — the live aggregation
chart.destroy();

createHexbinShotchart(svg, options)

| Option | Type | Default | Notes | | -------------- | ------------------------------- | -------------- | ------------------------------------------------------------ | | data | Shot[] | required | | | radius | number | 1.5 | Hex radius in feet. Binning happens in feet, so cells mean the same thing on every court. | | theme | "red-green" \| "blue-orange" | "red-green" | | | colorDomain | [number, number] | [0.25, 0.65] | FG% range (0–1) mapped across the palette. | | minAttempts | number | 1 | Hide cells with fewer attempts. | | sizeScale | "sqrt" \| "linear" \| "none" | "sqrt" | How attempt count drives hex size. | | minSizeRatio | number | 0.3 | Smallest hex as a fraction of radius. | | opacity | number | 0.9 | | | outline | boolean | true | Faint full-radius outline of every cell, so low-frequency cells still show their footprint. | | + halfcourt options | courtType, leagueSettings, extent, floor | | |

Returns { setData, setTheme, setRadius, bins, destroy }. The pure aggregation is also exported as binShots(shots, settings, radius): HexbinDatum[]:

interface HexbinDatum { x: number; y: number; fga: number; fgm: number; fgPct: number }

Coordinates

createShotchartSettings(leagueSettings, extent?) returns the derived SVG layout the renderers use (basketCenterY, visibleCourtLength(), court width, …), and shotToSvg / svgToShot convert between basket-relative feet and SVG coordinates. Use them for your own overlays:

import { select } from "d3-selection";
import { createHalfcourt, createShotchartSettings, nbaSettings, shotToSvg } from "shotchart.ts";

createHalfcourt(svg, { courtType: "nba" });
const settings = createShotchartSettings(nbaSettings);

// Append outside the court group so plain fills apply.
const overlay = select(svg).append("g");
const { x, y } = shotToSvg({ x: -22, y: 1 }, settings); // right corner
overlay.append("circle").attr("cx", x).attr("cy", y).attr("r", 0.5).attr("fill", "gold");

React wrapper

The library doesn't ship a React wrapper, but writing one is straightforward:

import { useEffect, useRef } from "react";
import {
  createZonedShotchart,
  type ZoneData,
  type ZonedShotchartInstance,
} from "shotchart.ts";
import "shotchart.ts/styles.css";

interface Props {
  data: ZoneData[];
  courtType?: "nba" | "wnba" | "ncaam" | "ncaaw" | "fiba" | "nfhs";
  theme?: "red-green" | "blue-orange";
  backgroundTheme?: "dark" | "light";
}

export function ZonedShotchart(props: Props) {
  const svgRef = useRef<SVGSVGElement>(null);
  const chartRef = useRef<ZonedShotchartInstance | null>(null);

  // Create once per court type — structural changes only
  useEffect(() => {
    if (!svgRef.current) return;
    chartRef.current = createZonedShotchart(svgRef.current, props);
    return () => chartRef.current?.destroy();
  }, [props.courtType]);

  // Cheap updates
  useEffect(() => { chartRef.current?.setData(props.data); }, [props.data]);
  useEffect(() => { props.theme && chartRef.current?.setTheme(props.theme); }, [props.theme]);
  useEffect(() => {
    props.backgroundTheme && chartRef.current?.setBackground(props.backgroundTheme);
  }, [props.backgroundTheme]);

  return <svg ref={svgRef} />;
}

Other exports

For custom integrations — legend rendering, alternate palettes, etc:

import {
  // presets (one per courtType)
  nbaSettings,
  wnbaSettings,
  ncaamSettings,
  ncaawSettings,
  fibaSettings,
  nfhsSettings,

  // layout + projection
  createShotchartSettings,
  shotToSvg,
  svgToShot,

  // aggregation
  binShots,

  // palettes
  redGreenPalette,
  orangeBluePalette,

  // color helpers
  createColorScale,
  zoneColor,

  // pure utilities
  polygonCentroid,
  formatPercentage,
} from "shotchart.ts";

Migration from 0.x

Version 1.0.0 expands the supported court types from 2 to 6 and reshapes LeagueSettings to carry every court-dependent dimension (so adding a new court is data, not code).

  • CourtType is now "nba" | "wnba" | "ncaam" | "ncaaw" | "fiba" | "nfhs". The old "college" value is removed — use "ncaam".
  • collegeSettings is removed — use ncaamSettings.
  • LeagueSettings now requires courtLength, courtWidth, basketProtrusionLength, basketDiameter, basketWidth, freeThrowLineLength, freeThrowCircleRadius, freeThrowCircleStyle, restrictedAreaRadius, and centerCircleRadius. The previous leftThreeInside / rightThreeInside points are no longer required — the renderer derives them from arc geometry.
  • If you were passing a custom leagueSettings object, populate the new fields. The shipped presets cover the common cases.
  • The court group is now nested: svg > g.shotchart > g.shot-chart-court. Class names are unchanged, but selectors that assumed the court group was a direct child of the <svg> need updating.
  • Halfcourts now draw sidelines (.shot-chart-court-sideline).
  • The stylesheet's label rule is scoped to .shotchart text instead of a bare text selector, so it no longer restyles other SVG text on your page.

Development

pnpm install
pnpm build       # tsup → dist/
pnpm typecheck   # tsc --noEmit
pnpm test        # vitest (happy-dom)
pnpm lint        # biome check --write

Roadmap

  • [x] Customizable court backgrounds
  • [x] Individual shot plotting (one dot per shot)
  • [x] Hexbin density shotchart
  • [x] Fullcourt
  • [ ] Team designs & customizable courts
  • [ ] Player tracking for Fullcourt

License

ISC © Michael Mirandi