JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 188
  • Score
    100M100P100Q100338F
  • License MIT

High-performance WebGL scatterplot component for React with pan/zoom and lasso selection

Package Exports

  • @biohub/scatterplot
  • @biohub/scatterplot/styles.css

Readme

@biohub/scatterplot

CI Coverage Size

High-performance WebGL scatterplot component for React with support for datasets up to 10M+ points.

Features

  • GPU-accelerated rendering - WebGL2-based for smooth 60fps performance
  • Interactive pan & zoom - Mouse wheel zoom-to-cursor, drag panning, configurable zoom limits
  • Lasso selection - Select multiple points with custom polygons
  • Controlled camera - External camera control for syncing with OpenSeadragon or similar viewers
  • Customizable styling - Theming system with point colors, sizes, states, and presets
  • Responsive - Auto-adapts to container size
  • React hooks - Composable selection and interaction hooks
  • Zero heavy dependencies - No D3, no regl, just React and WebGL

Installation

npm install @biohub/scatterplot

Local Development

Build the library

npm run build

This creates a dist/ directory with:

  • scatterplot.js (ESM)
  • scatterplot.umd.js (UMD)
  • index.d.ts (TypeScript types)
  • Source maps
# In this directory
npm link

# In your consuming project directory
npm link @biohub/scatterplot
# In your consuming project
npm unlink @biohub/scatterplot

# In this directory
npm unlink

Usage

Styles are bundled with the library: the package uses vite-plugin-lib-inject-css so the main JS bundle imports the CSS at build time. When you import { Scatterplot } from '@biohub/scatterplot', your bundler will process the CSS import (emit a link tag, inline it, etc.)—no extra configuration needed, and SSR-friendly. To load the CSS explicitly, use import '@biohub/scatterplot/styles.css'.

Basic Example

import { Scatterplot } from '@biohub/scatterplot';

function MyChart() {
  const points = [
    { x: 10, y: 20, color: '#ff0000' },
    { x: 30, y: 40, color: '#00ff00' },
    { x: 50, y: 60, color: '#0000ff' },
  ];

  return (
    <Scatterplot
      points={points}
      width={800}
      height={600}
    />
  );
}

With Selection Handling

import { Scatterplot } from '@biohub/scatterplot';

function InteractiveChart() {
  const points = generateYourData(); // Array of {x, y, color?}

  return (
    <Scatterplot
      points={points}
      width={800}
      height={600}
      enableLasso={true}
      onSelectionChange={(indices) => {
        console.log('Selected points:', indices);
      }}
    />
  );
}

Controlled Camera

import { useState } from 'react';
import { Scatterplot, type Camera, DEFAULT_CAMERA } from '@biohub/scatterplot';

function ControlledChart() {
  const points = generateYourData(); // Array of {x, y, color?}
  const [camera, setCamera] = useState<Camera>(DEFAULT_CAMERA);

  return (
    <div>
      <p>Zoom: {camera.zoom.toFixed(2)}</p>
      <button onClick={() => setCamera(DEFAULT_CAMERA)}>Reset</button>

      <Scatterplot
        points={points}
        width={800}
        height={600}
        controlled={{ camera, onCameraChange: setCamera }}
        maxZoom={50}
        minZoom={0.5}
      />
    </div>
  );
}

Syncing with External Viewers

Use the controlled prop (or camera + onCameraChange on ScatterplotGL) to keep an external viewer in sync. Derive the external viewer's viewport from the scatterplot camera state inside your onCameraChange handler — this avoids drift that can occur with per-event forwarding:

import { useState, useCallback } from 'react';
import { Scatterplot, type Camera, DEFAULT_CAMERA } from '@biohub/scatterplot';

function SyncedChart({ externalViewer, points }) {
  const [camera, setCamera] = useState<Camera>(DEFAULT_CAMERA);

  const handleCameraChange = useCallback(
    (update: Camera | ((prev: Camera) => Camera)) => {
      setCamera((prev) => {
        const next = typeof update === 'function' ? update(prev) : update;

        // Derive external viewer state from scatterplot camera
        externalViewer.syncToCamera(next.zoom, next.pan);

        return next;
      });
    },
    [externalViewer],
  );

  return (
    <Scatterplot
      points={points}
      controlled={{ camera, onCameraChange: handleCameraChange }}
    />
  );
}

Responsive Sizing

Omit width/height to auto-fill the container, or use the built-in useContainerSize hook:

import { useRef } from 'react';
import { Scatterplot, useContainerSize } from '@biohub/scatterplot';

function ResponsiveChart() {
  const ref = useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(ref);

  return (
    <div ref={ref} style={{ width: '100%', height: '100vh' }}>
      <Scatterplot
        points={points}
        width={width}
        height={height}
      />
    </div>
  );
}

API Reference

<Scatterplot>

High-level component with built-in state management for camera, selection, and lasso. For spatial mode features (pointSize, dataBounds), use <ScatterplotGL> directly.

Props

Prop Type Default Description
points Point[] required Array of {x, y, color?} data points
width number container width Canvas width in CSS pixels
height number container height Canvas height in CSS pixels
initialCamera Camera {zoom: 1, pan: {x:0, y:0}} Initial camera state
theme ScatterplotTheme lightTheme Theme configuration
pixelRatio number devicePixelRatio Device pixel ratio for high-DPI
enableLasso boolean true Enable lasso selection mode
enablePanZoom boolean true Enable pan and zoom interactions
debug boolean false Show debug panel with performance metrics
onSelectionChange (indices: Set<number>) => void - Callback when selection changes
lassoRealtimeThreshold number 1_000_000 Point count above which realtime lasso highlighting is disabled
controlled {camera, onCameraChange?} - External camera control (see Controlled Camera)
maxZoom number Infinity Maximum zoom level
minZoom number 0.5 Minimum zoom level

<ScatterplotGL>

Low-level rendering component for advanced use cases. Accepts raw Float32Array buffers instead of Point[].

Props

Prop Type Default Description
positions Float32Array required Interleaved [x, y, x, y, ...] in data space
colors Uint8Array required Interleaved [r, g, b, a, ...] in 0-255 range
width number container width Canvas width in CSS pixels
height number container height Canvas height in CSS pixels
flags Uint8Array - Per-point selection/highlight state (use createFlagBuffer())
dataBounds DataBounds auto-computed Override normalization bounds (e.g., {xMin: -1, xMax: 1, yMin: -1, yMax: 1})
camera Camera internal Controlled camera state
onCameraChange (camera | updater) => void - Camera change callback (supports functional updates)
panZoomEnabled boolean true Enable pan and zoom interactions
lassoEnabled boolean false Enable lasso selection mode
onLassoComplete (indices: Set<number>) => void - Lasso completion callback
onLassoUpdate (indices: Set<number>) => void - Real-time lasso highlight callback
onPointClick (index: number | null) => void - Point click callback
pointSize number - Override point size in CSS pixels
maxZoom number Infinity Maximum zoom level
minZoom number 0.5 Minimum zoom level
theme ScatterplotTheme lightTheme Theme configuration
pixelRatio number devicePixelRatio Device pixel ratio
debug boolean false Show debug panel
className string - CSS class for the wrapper div

Types

Point

interface Point {
  x: number;
  y: number;
  color?: string; // Hex color (e.g., '#ff0000'), defaults to '#3498db'
}

Camera

interface Camera {
  zoom: number;           // 1.0 = no zoom
  pan: { x: number; y: number }; // NDC offset (-1 to 1)
}

DataBounds

interface DataBounds {
  xMin: number;
  xMax: number;
  yMin: number;
  yMax: number;
}

Hooks

useSelection()

Hook for managing point selection state.

const {
  selectedIndices,   // Set<number> — current selection
  handlePointClick,  // (index: number | null) => void — click handler
  setSelection,      // (indices: Set<number>) => void — set selection
  clearSelection,    // () => void — clear all
  isSelected,        // (index: number) => boolean — check membership
} = useSelection();

useContainerSize()

Hook for responsive container sizing.

const containerRef = useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);

Utilities

createFlagBuffer(count, selectedIndices?, highlightedIndices?, backgroundIndices?)

Create a per-point flag buffer for selection/highlight/background state.

createTheme(overrides, baseTheme?)

Create a theme by merging partial overrides into a base theme (defaults to lightTheme).

findClosestPointRaw(mouseX, mouseY, positions, count, width, height, camera, maxDistance, dataBounds, dataPadding)

Find the closest point to mouse coordinates using raw buffers.

findPointsInLassoRaw(polygon, positions, count, width, height, camera, dataBounds, dataPadding)

Find all points within a lasso polygon using raw buffers.

Theming

Built-in Presets

import { Scatterplot, lightTheme, darkTheme, highContrastTheme } from '@biohub/scatterplot';

<Scatterplot points={points} theme={darkTheme} />

Available presets:

  • lightTheme - Light background, blue points (default)
  • darkTheme - Dark background, lighter blue points
  • highContrastTheme - Black background, white points, yellow lasso

Custom Themes

Use createTheme() to customize specific properties:

import { Scatterplot, createTheme } from '@biohub/scatterplot';

const myTheme = createTheme({
  canvas: { background: '#1a1a2e', dataPadding: 0 },
  points: { size: 8, opacity: 0.8 },
});

<Scatterplot points={points} theme={myTheme} />

Extend any base theme:

import { createTheme, darkTheme } from '@biohub/scatterplot';

const myDarkTheme = createTheme({ points: { size: 10 } }, darkTheme);

Theme Structure

interface ScatterplotTheme {
  canvas: {
    background: string;      // Canvas background color (default: '#ffffff')
    dataPadding: number;     // Padding around data in pixels (default: 20)
  };
  points: {
    defaultColor: string;    // Fallback point color (default: '#3498db')
    size: number;            // Point diameter in pixels (default: 5)
    opacity: number;         // Base opacity 0-1 (default: 1.0)
    backgroundOpacity: number;    // Opacity for background points (default: 0.5)
    highlightBrightness: number;  // Brightness multiplier for highlights (default: 1.4)
    highlightSizeScale: number;   // Size multiplier for highlights (default: 1.3)
    unselectedSizeScale: number;  // Size multiplier for unselected points (default: 0.2)
  };
  lasso: {
    fill: string;            // Lasso fill color (default: 'rgba(59, 130, 246, 0.1)')
    stroke: string;          // Lasso stroke color (default: 'rgb(59, 130, 246)')
    strokeWidth: number;     // Stroke width in pixels (default: 2)
    strokeDasharray: string; // SVG dash pattern (default: '5,5')
  };
  debug: {
    background: string;      // Debug panel background (default: 'rgba(0, 0, 0, 0.8)')
    color: string;           // Debug panel text color (default: '#00ff00')
    fontFamily: string;      // Debug panel font (default: 'monospace')
    fontSize: string;        // Debug panel font size (default: '12px')
  };
}

CSS Custom Properties

DOM elements (lasso overlay, debug panel) expose CSS custom properties for external styling.

Naming convention: --scatterplot-{section}-{kebab-case-property}

Section Properties
lasso --scatterplot-lasso-fill, --scatterplot-lasso-stroke, --scatterplot-lasso-stroke-width, --scatterplot-lasso-stroke-dasharray
debug --scatterplot-debug-background, --scatterplot-debug-color, --scatterplot-debug-font-family, --scatterplot-debug-font-size

Example:

.my-chart {
  --scatterplot-lasso-stroke: red;
  --scatterplot-lasso-fill: rgba(255, 0, 0, 0.1);
}
<ScatterplotGL positions={positions} colors={colors} className="my-chart" />

Note: Canvas background and point properties are WebGL-only and must be configured via the theme prop, not CSS.

Performance Tips

  • Large datasets (>100K points): Rendering is optimized for WebGL, should maintain 60fps
  • Colors: Pre-calculate colors instead of computing on each render
  • Selection: Use useMemo to avoid re-creating selection arrays
  • Responsive: Debounce resize events for better performance

Browser Requirements

  • WebGL2 support required (all modern browsers since ~2017)
  • Chrome 56+, Firefox 51+, Safari 15+, Edge 79+

Troubleshooting

"WebGL context lost" error

This can happen with very large datasets or after leaving tab inactive for long periods. The component will attempt to recover automatically.

Types not found

Ensure your tsconfig.json includes:

{
  "compilerOptions": {
    "moduleResolution": "node",
    "types": ["node"]
  }
}

Development

# Build library
npm run build

# Run tests
npm test

# Run tests in watch mode
npm run test:watch

# Run tests with coverage
npm run test:coverage

Running the Demo

The demo app is in the demo/ directory and links to the local library build.

# First, build and link the library
npm run build
npm link

# Then run the demo
cd demo
npm install
npm link @biohub/scatterplot
npm run dev          # Development server (http://localhost:5173)

For accurate performance testing, use the production build:

cd demo
npm run build        # Build production bundle
npm run preview      # Serve at http://localhost:4173

Contributing

This project uses Conventional Commits and release-please for automated versioning.

Commit Format

type(scope): description
Type Description Release
feat New feature Minor (0.1.0 → 0.2.0)
fix Bug fix Patch (0.1.0 → 0.1.1)
docs Documentation only No release
style Code style (formatting) No release
refactor Code refactoring No release
perf Performance improvement Patch
test Adding tests No release
chore Maintenance tasks No release

Breaking changes: Add ! after type or include BREAKING CHANGE: in footer for major release (0.1.0 → 1.0.0).

feat!: redesign API
# or
feat: redesign API

BREAKING CHANGE: The `data` prop now requires typed arrays instead of Point[].

Release Flow

This project uses release-please for automated releases.

  1. Merge PR to main with conventional commits
  2. release-please automatically creates/updates a "Release PR" with:
    • Updated package.json version
    • Updated CHANGELOG.md
  3. When ready to release, merge the Release PR
  4. Merging triggers:
    • GitHub Release creation
    • npm publish with quality checks

License

MIT

Support

For issues and questions, please open an issue on GitHub.