@biohub/scatterplot
v0.3.0
Published
High-performance WebGL scatterplot component for React with pan/zoom and lasso selection
Maintainers
Readme
@biohub/scatterplot
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/scatterplotLocal Development
Build the library
npm run buildThis creates a dist/ directory with:
scatterplot.js(ESM)scatterplot.umd.js(UMD)index.d.ts(TypeScript types)- Source maps
Link for local development
# In this directory
npm link
# In your consuming project directory
npm link @biohub/scatterplotUnlink when done
# In your consuming project
npm unlink @biohub/scatterplot
# In this directory
npm unlinkUsage
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 pointshighContrastTheme- 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
themeprop, 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
useMemoto 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:coverageRunning 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)Production Build (Recommended for Performance Testing)
For accurate performance testing, use the production build:
cd demo
npm run build # Build production bundle
npm run preview # Serve at http://localhost:4173Contributing
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.
- Merge PR to
mainwith conventional commits - release-please automatically creates/updates a "Release PR" with:
- Updated
package.jsonversion - Updated
CHANGELOG.md
- Updated
- When ready to release, merge the Release PR
- Merging triggers:
- GitHub Release creation
- npm publish with quality checks
License
MIT
Support
For issues and questions, please open an issue on GitHub.
