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

@mikemarr/play-designer

v0.2.1

Published

Embeddable multi-sport play & drill diagram editor for React: drag icons, notation arrows (cut/dribble/pass/screen/shot), phase engine with animation, and a pluggable play store.

Readme

@mikemarr/play-designer

An embeddable React component for drawing sports plays and drills — basketball, soccer, football, and hockey out of the box, with a registry for adding your own surfaces.

Features

  • Drag-and-drop icons: numbered offensive players, ✕ defenders with subscript numbers, cones, coaches, and a ball marker
  • Standard diagram notation arrows: cut/move (solid), dribble (squiggle), pass (dashed), screen (⊥ terminator), shot (circled ✕) — every arrow snaps to icons and carries one optional bend handle for curves
  • Full arrow editing: drag endpoints to re-aim/re-snap, drag the body to move, drag the ◆ midpoint to curve
  • Phase engine: "next phase" moves each icon to the end of its arrow and hands the ball to pass receivers; animation plays the phases back with icons traveling along their curves and the ball flying the pass path
  • Undo, three-level clear (arrows / phase / play), NFHS-accurate basketball courts
  • Surface variants per sport — every built-in ships half and full versions (court, pitch, field, rink) with a toggle in the top bar. Full surfaces are portrait, drawn at the same scale as the half surfaces so icons stay the same workable size; the variant list is open-ended for future additions (zones, small-sided areas, ...)
  • A play is one plain JSON document — trivial to persist anywhere
  • PlayViewer: a read-only companion component for embedding a finished play — animated playback plus a wrapping row of per-phase thumbnail cards

Install

npm install @mikemarr/play-designer

React ≥17 is a peer dependency.

Quick start

import PlayDesigner from "@mikemarr/play-designer";

export default function App() {
  return (
    <div style={{ height: "100vh" }}>
      <PlayDesigner />
    </div>
  );
}

The component fills its container (give it a sized parent; it has a 640px minimum height). With no props you get all registered sports and an in-memory library seeded with an example pick & roll.

Props

| Prop | Type | Default | Description | |---|---|---|---| | sports | (string \| SportConfig)[] | all registered | Which sport tabs to show. Strings are registry ids; objects are registered on the fly. One sport hides the tab bar entirely. | | defaultSport | string | first enabled | Sport for new plays. | | store | PlayStore | in-memory | Where the library reads/writes plays. See Persistence. | | library | boolean | true | Show the Library button, list, and JSON import UI. Save still works when hidden. | | initialPlay | Play \| string | — | A play (object or JSON) to open on mount. | | onSave | (play) => void | — | Fires after a successful store save. | | onChange | (play) => void | — | Fires on every edit — use for autosave/drafts. | | onError | (err) => void | console + toast | Store failures land here. | | className, style | — | — | Styling passthrough for the outer wrapper. |

Configuring sports

// Only show two of the built-ins:
<PlayDesigner sports={["basketball", "soccer"]} />

// Lock it to a single sport (tab bar disappears):
<PlayDesigner sports={["basketball"]} />

Adding a custom sport

A sport is a config object with an SVG surface that draws into the fixed 800×560 viewBox:

import { registerSport } from "@mikemarr/play-designer";

registerSport({
  id: "lacrosse",
  label: "Lacrosse",
  icon: "🥍",
  ink: "#1a2b1f",                            // icon stroke color that reads on your surface
  ball: { fill: "#f5f5f5", stroke: "#333" }, // ball marker colors
  Surface: () => (
    <g>
      <rect x="0" y="0" width="800" height="560" fill="#3f8a4f" />
      {/* draw your field lines here */}
    </g>
  ),
});

<PlayDesigner sports={["lacrosse", "basketball"]} />

You can also pass the config object directly in the sports array. Because play coordinates live in the viewBox (not pixels), plays for custom sports serialize and render identically everywhere.

Surface variants

Each sport can offer multiple playing surfaces, and each variant can bring its own viewBox. The built-ins ship two each — e.g. basketball has half ("Half court", 800×560) and full ("Full court", portrait 800×1206 at the identical 12.4 units/ft scale). When the active sport has more than one variant, a toggle appears in the top bar next to the sport tabs.

Switching surfaces keeps the current diagram, and because the full surfaces reuse the half surfaces' end markings at identical coordinates, a half-court set lands exactly on the matching end of the full court — draw your set on the half court, toggle to full, and add the press break in the backcourt. Only switching sports clears the canvas.

The chosen variant is stored on the play as play.surface, so saved plays reopen on the right surface. Plays saved before this feature (no surface field) render on the sport's default variant.

import { registerSport } from "@mikemarr/play-designer";

registerSport({
  id: "futsal",
  label: "Futsal",
  icon: "🥅",
  surfaces: [
    { id: "half", label: "Half court", Surface: FutsalHalf },                    // 800×560 default
    { id: "full", label: "Full court", Surface: FutsalFull, width: 800, height: 1080 }, // portrait
    // add more variants later — the toggle grows with the list
  ],
  defaultSurface: "half",
  ink: "#10231a",
  ball: { fill: "#f5f5f5", stroke: "#222" },
});

A config with a single top-level Surface (the pre-0.2 shape) still works — it's wrapped into a one-entry surfaces list and no toggle is shown. resolveSurface(sport, surfaceId) is exported if you need to look up a variant yourself.

Persistence

The library is backed by a PlayStore — any object with three async methods:

interface PlayStore {
  list(): Promise<Play[]>;
  save(play: Play): Promise<Play>;   // upsert; may return the stored record
  remove(id: string): Promise<void>;
}

Two adapters ship with the package:

In-memory (default)

import { createMemoryStore, examplePlay } from "@mikemarr/play-designer";
<PlayDesigner store={createMemoryStore([examplePlay])} />

REST

import { createRestStore } from "@mikemarr/play-designer";

const store = createRestStore({
  baseUrl: "/api/plays",
  headers: { Authorization: `Bearer ${token}` },
  // fetchImpl: customFetch,   // optional
});

<PlayDesigner store={store} onSave={(p) => toast(`Saved ${p.name}`)} />

Server contract:

GET    /api/plays        → 200, JSON array of plays
PUT    /api/plays/:id    → upsert; body is the full play JSON
DELETE /api/plays/:id    → 200/204

Rails example

A play is one JSON document, so the whole backend is a jsonb column:

# migration
create_table :plays, id: false do |t|
  t.string :id, primary_key: true
  t.jsonb :doc, null: false
  t.timestamps
end

# routes.rb
resources :plays, only: [:index, :update, :destroy]

# plays_controller.rb
class PlaysController < ApplicationController
  def index
    render json: Play.order(updated_at: :desc).pluck(:doc)
  end

  def update # PUT /plays/:id — upsert
    play = Play.find_or_initialize_by(id: params[:id])
    play.doc = JSON.parse(request.raw_post)
    play.save!
    render json: play.doc
  end

  def destroy
    Play.find_by(id: params[:id])&.destroy
    head :no_content
  end
end

Scope by user_id/team_id and add auth as your app requires — the component doesn't care, it only speaks the store interface.

Custom store

Anything satisfying the interface works — localStorage wrapper, GraphQL, Firebase, offline queue:

const localStore = {
  async list() { return JSON.parse(localStorage.getItem("plays") || "[]"); },
  async save(play) {
    const plays = await this.list();
    const i = plays.findIndex((p) => p.id === play.id);
    if (i >= 0) plays[i] = play; else plays.unshift(play);
    localStorage.setItem("plays", JSON.stringify(plays));
    return play;
  },
  async remove(id) {
    localStorage.setItem("plays", JSON.stringify((await this.list()).filter((p) => p.id !== id)));
  },
};

Displaying a play (read-only)

For embedding a diagrammed play where viewers shouldn't be able to edit it — a game plan page, a scouting report, a shared link — use PlayViewer instead of PlayDesigner. It renders the same icons/arrows/ball as the editor, an Animate button that plays the phases back in sequence, and every phase laid out below as a row of clickable thumbnail cards that wrap to the container's width.

import { PlayViewer, examplePlay } from "@mikemarr/play-designer";

export default function ScoutingReport() {
  return <PlayViewer play={examplePlay} />;
}

| Prop | Type | Default | Description | |---|---|---|---| | play | Play \| string | — | The play to display (object or JSON string). Required. | | sport | SportConfig | looked up by play.sport | Override the sport surface directly, useful if the sport isn't globally registered. | | showPhaseCards | boolean | true | Show the horizontal, wrapping row of per-phase thumbnails below the main viewer. SVG mode only. | | format | "svg" \| "png" | "svg" | "png" rasterizes every phase into one static grid image instead of the live SVG — no Animate control, since a PNG can't play back. | | pngPhaseSize | { width?, height? } | { width: 220 } | Pixel size of each phase cell in PNG mode. Height is derived from the fixed 800x560 aspect ratio if omitted. | | pngTotalSize | { width?, height? } | grid's natural size | Overrides the final PNG's raster dimensions; the composed grid is scaled to fit inside it (letterboxed, centered) rather than changing the grid layout. | | pngColumns | number | all phases in one row | Phases per row in PNG mode. | | pngShowLabels | boolean | true | Show "Phase N" labels under each cell in PNG mode. | | pngTheme | "dark" \| "light" | "dark" | Color theme for the composited grid and its wrapper in PNG mode. | | className, style | — | — | Styling passthrough for the outer wrapper. |

Clicking a phase card jumps the main viewer to that phase (and stops any running animation). The Animate button always plays the full phase sequence from the start, the same "next phase" playback used by the editor's own Animate control — so a play designed in PlayDesigner looks identical when embedded read-only.

For static contexts that can't run React/SVG — email, print, a chat preview — switch to a rasterized grid instead:

<PlayViewer
  play={examplePlay}
  format="png"
  pngPhaseSize={{ width: 220 }}
  pngColumns={2}
  pngTotalSize={{ width: 700, height: 500 }}
  pngTheme="light"
/>

This renders every phase into one PNG <img> arranged in a grid (pngColumns per row), with pngPhaseSize controlling each cell and pngTotalSize controlling the final image's overall pixel dimensions independently — the grid is scaled to fit pngTotalSize, letterboxed and centered, rather than reflowed. Each cell renders as a plain, square-cornered bordered tile so it doesn't fight the court's own rectangular shape; pngTheme swaps the background/border/label colors between a dark and a light palette.

Data model

Play = {
  id, name, sport,
  phases: [{
    entities: [{ id, kind, label, x, y }],   // kind: 'offense'|'defense'|'cone'|'coach'
    ballOwnerId: string | null,
    arrows: [{
      id, type,                              // 'move'|'dribble'|'pass'|'screen'|'shot'
      start: {x,y}, end: {x,y},
      ctrl: {x,y} | null,                    // optional bend point the curve passes through
      fromId, toId                           // entity anchors (endpoints follow the icons)
    }]
  }]
}

Coordinates are viewBox units (800×560), so a play renders identically at any size and on any device. normalizePlay migrates the legacy freeform-points arrow format automatically on import/load.

Utilities are exported for working with plays outside the component:

import {
  newPlay, computeNextPhase, serializePlay, deserializePlay, normalizePlay,
} from "@mikemarr/play-designer";

Development

npm install
npm run build   # esbuild → dist/index.js (ESM) + dist/index.cjs
npm test        # headless jsdom regression suite with deterministic rAF

The test suite mounts the component under React.StrictMode, drives it with real DOM events, and pumps a fake requestAnimationFrame frame-by-frame — animation and interaction bugs reproduce deterministically instead of flaking.

License

MIT