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

@anocca-pub/components

v0.0.52

Published

A React component library providing flexible, interactive laboratory plate components and utilities.

Readme

@anocca-pub/components

A React component library providing flexible, interactive laboratory plate components and utilities.

Installation

bun add @anocca-pub/components

Components

EC50Plot

A publication-ready, interactive dose-response curve plotting component for scientific data visualization. Perfect for displaying EC50/IC50 curves, dose-response relationships, and pharmacological data with multiple series support.

Features

  • SVG-based Rendering: Scalable, crisp plots suitable for publication
  • Multiple Series Support: Display up to 100 series with individual styling
  • Interactive Elements: Hover and click callbacks for all plot elements
  • Scientific Styling: Publication-ready appearance similar to GraphPad Prism
  • Logarithmic X-axis: Proper log-scale concentration plotting
  • Error Bars: T-shaped standard deviation visualization
  • Fitted Curves: Logistic dose-response curve fitting
  • EC50 Lines: Vertical reference lines at EC50 values
  • Flexible Legends: Floating legend panel with custom positioning
  • Axis Customization: Custom labels, tick formatting, and axis reversal
  • Performance Optimized: Memoized series components for smooth rendering

Basic Usage

function BasicEC50Plot() {
  const series = [
    {
      id: "compound-a",
      legend: "Compound A",
      data: [
        { concentration: 0.001, meanResponse: 5, std: 2 },
        { concentration: 0.01, meanResponse: 15, std: 3 },
        { concentration: 0.1, meanResponse: 35, std: 4 },
        { concentration: 1, meanResponse: 50, std: 5 },
        { concentration: 10, meanResponse: 75, std: 4 },
        { concentration: 100, meanResponse: 90, std: 3 },
      ],
      params: {
        EC50: 1.0,
        hillSlope: 1.2,
      },
      showStandardDeviation: true,
      showEC50Line: true,
    },
  ];

  return (
    <EC50Plot
      series={series}
      width={600}
      height={400}
      xAxisLabel="Concentration (μM)"
      yAxisLabel="Response (%)"
      useNormalizedYAxis={true}
      onSeriesClick={(id) => console.log("Series clicked:", id)}
      onSeriesHover={(id) => console.log("Series hovered:", id)}
    />
  );
}

Advanced Usage with Multiple Series

function AdvancedEC50Plot() {
  const [hoveredSeries, setHoveredSeries] = useState<string | null>(null);

  const series = [
    {
      id: "compound-a",
      legend: "Compound A (High Efficacy)",
      data: [
        { concentration: 0.001, meanResponse: 5, std: 2 },
        { concentration: 0.01, meanResponse: 15, std: 3 },
        { concentration: 0.1, meanResponse: 35, std: 4 },
        { concentration: 1, meanResponse: 50, std: 5 },
        { concentration: 10, meanResponse: 75, std: 4 },
        { concentration: 100, meanResponse: 90, std: 3 },
      ],
      params: { EC50: 1.0, hillSlope: 1.2 },
      showStandardDeviation: true,
      showEC50Line: true,
      colors: {
        points: "#2563eb",
        fittedCurve: "#2563eb",
        EC50Line: "#2563eb80",
        standardDeviation: "#2563eb40",
      },
    },
    {
      id: "compound-b",
      legend: "Compound B (Connected Points)",
      data: [
        { concentration: 0.001, meanResponse: 10, std: 3 },
        { concentration: 0.01, meanResponse: 20, std: 4 },
        { concentration: 0.1, meanResponse: 40, std: 5 },
        { concentration: 1, meanResponse: 60, std: 5 },
        { concentration: 10, meanResponse: 80, std: 4 },
        { concentration: 100, meanResponse: 95, std: 2 },
      ],
      params: { EC50: 0.5, hillSlope: 1.5 },
      connectDataPoints: true,
      showFittedCurve: true,
      colors: {
        points: "#dc2626",
        fittedCurve: "#dc2626",
        connectedLine: "#dc262680",
      },
    },
    {
      id: "compound-c",
      legend: "Compound C (Curve Only)",
      data: [
        { concentration: 0.001, meanResponse: 2, std: 1 },
        { concentration: 0.01, meanResponse: 10, std: 2 },
        { concentration: 0.1, meanResponse: 25, std: 3 },
        { concentration: 1, meanResponse: 45, std: 4 },
        { concentration: 10, meanResponse: 70, std: 3 },
        { concentration: 100, meanResponse: 85, std: 2 },
      ],
      params: { EC50: 2.0, hillSlope: 1.0 },
      hideDataPoints: true,
      showFittedCurve: true,
      colors: {
        fittedCurve: "#16a34a",
        legend: "#16a34a",
      },
    },
  ];

  return (
    <div>
      <EC50Plot
        series={series}
        width={800}
        height={600}
        xAxisLabel="Concentration (μM)"
        yAxisLabel="Response (%)"
        useNormalizedYAxis={true}
        reverseXAxis={false}
        onSeriesHover={setHoveredSeries}
        onSeriesClick={(id) => {
          console.log("Clicked series:", id);
          // Handle series selection logic
        }}
        formatXAxisTick={(value) => {
          // Custom scientific notation
          if (value === 0) return "0";
          const exponent = Math.floor(Math.log10(Math.abs(value)));
          if (exponent >= -2 && exponent <= 3) {
            return value.toString();
          } else {
            const mantissa = value / Math.pow(10, exponent);
            return `${mantissa.toFixed(1)}×10^${exponent}`;
          }
        }}
        formatYAxisTick={(value) => `${value}%`}
      />

      {hoveredSeries && (
        <div
          style={{
            marginTop: 16,
            padding: 12,
            backgroundColor: "#f3f4f6",
            borderRadius: 8,
          }}
        >
          <strong>Hovered:</strong>{" "}
          {series.find((s) => s.id === hoveredSeries)?.legend}
        </div>
      )}
    </div>
  );
}

Custom Styling and Colors

function StyledEC50Plot() {
  const series = [
    {
      id: "high-dose",
      legend: "High Dose Treatment",
      data: [
        { concentration: 0.01, meanResponse: 8, std: 3 },
        { concentration: 0.1, meanResponse: 25, std: 4 },
        { concentration: 1, meanResponse: 45, std: 5 },
        { concentration: 10, meanResponse: 70, std: 4 },
        { concentration: 100, meanResponse: 85, std: 3 },
      ],
      params: { EC50: 3.2, hillSlope: 1.1 },
      showStandardDeviation: true,
      showEC50Line: true,
      showFittedCurve: true,
      colors: {
        points: "#7c3aed", // Purple data points
        fittedCurve: "#7c3aed", // Purple fitted curve
        EC50Line: "#7c3aed60", // Semi-transparent purple EC50 line
        standardDeviation: "#7c3aed40", // Light purple error bars
        legend: "#7c3aed", // Purple legend
      },
    },
    {
      id: "low-dose",
      legend: "Low Dose Treatment",
      data: [
        { concentration: 0.001, meanResponse: 12, std: 2 },
        { concentration: 0.01, meanResponse: 28, std: 3 },
        { concentration: 0.1, meanResponse: 52, std: 4 },
        { concentration: 1, meanResponse: 75, std: 3 },
        { concentration: 10, meanResponse: 88, std: 2 },
      ],
      params: { EC50: 0.8, hillSlope: 1.8 },
      connectDataPoints: true,
      showFittedCurve: true,
      colors: {
        points: "#059669", // Emerald data points
        fittedCurve: "#059669", // Emerald fitted curve
        connectedLine: "#05966980", // Semi-transparent emerald connecting lines
        legend: "#059669", // Emerald legend
      },
    },
  ];

  return (
    <EC50Plot
      series={series}
      width={700}
      height={500}
      xAxisLabel="Drug Concentration (nM)"
      yAxisLabel="% Inhibition"
      useNormalizedYAxis={true}
      onSeriesClick={(id) => console.log("Selected:", id)}
    />
  );
}

Interactive Demo with Controls

function InteractiveEC50Demo() {
  const [plotOptions, setPlotOptions] = useState({
    useNormalizedYAxis: true,
    reverseXAxis: false,
  });

  const [seriesOptions, setSeriesOptions] = useState({
    "compound-a": {
      showLegend: true,
      showStandardDeviation: true,
      showFittedCurve: true,
      showEC50Line: true,
      connectDataPoints: false,
      hideDataPoints: false,
    },
  });

  const baseData = {
    id: "compound-a",
    name: "Test Compound",
    data: [
      { concentration: 0.001, meanResponse: 5, std: 2 },
      { concentration: 0.01, meanResponse: 15, std: 3 },
      { concentration: 0.1, meanResponse: 35, std: 4 },
      { concentration: 1, meanResponse: 50, std: 5 },
      { concentration: 10, meanResponse: 75, std: 4 },
      { concentration: 100, meanResponse: 90, std: 3 },
    ],
    params: { EC50: 1.0, hillSlope: 1.2 },
  };

  const series = [
    {
      ...baseData,
      legend: seriesOptions["compound-a"].showLegend
        ? baseData.name
        : undefined,
      showStandardDeviation: seriesOptions["compound-a"].showStandardDeviation,
      showFittedCurve: seriesOptions["compound-a"].showFittedCurve,
      showEC50Line: seriesOptions["compound-a"].showEC50Line,
      connectDataPoints: seriesOptions["compound-a"].connectDataPoints,
      hideDataPoints: seriesOptions["compound-a"].hideDataPoints,
    },
  ];

  return (
    <div>
      {/* Control Panel */}
      <div
        style={{
          marginBottom: 20,
          padding: 16,
          backgroundColor: "#f8f9fa",
          borderRadius: 8,
        }}
      >
        <h4>Plot Controls</h4>

        <div style={{ display: "flex", gap: 16, marginBottom: 12 }}>
          <label>
            <input
              type="checkbox"
              checked={plotOptions.useNormalizedYAxis}
              onChange={(e) =>
                setPlotOptions((prev) => ({
                  ...prev,
                  useNormalizedYAxis: e.target.checked,
                }))
              }
            />
            Normalized Y-Axis (0-100%)
          </label>

          <label>
            <input
              type="checkbox"
              checked={plotOptions.reverseXAxis}
              onChange={(e) =>
                setPlotOptions((prev) => ({
                  ...prev,
                  reverseXAxis: e.target.checked,
                }))
              }
            />
            Reverse X-Axis
          </label>
        </div>

        <h5>Series Options</h5>
        <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
          {Object.entries(seriesOptions["compound-a"]).map(([key, value]) => (
            <label key={key}>
              <input
                type="checkbox"
                checked={value}
                onChange={(e) =>
                  setSeriesOptions((prev) => ({
                    ...prev,
                    "compound-a": {
                      ...prev["compound-a"],
                      [key]: e.target.checked,
                    },
                  }))
                }
              />
              {key
                .replace(/([A-Z])/g, " $1")
                .replace(/^./, (str) => str.toUpperCase())}
            </label>
          ))}
        </div>
      </div>

      {/* Plot */}
      <EC50Plot
        series={series}
        width={800}
        height={600}
        xAxisLabel="Concentration (μM)"
        yAxisLabel="Response (%)"
        useNormalizedYAxis={plotOptions.useNormalizedYAxis}
        reverseXAxis={plotOptions.reverseXAxis}
        onSeriesClick={(id) => console.log("Series clicked:", id)}
        onSeriesHover={(id) => console.log("Series hovered:", id)}
      />
    </div>
  );
}

Props

| Prop | Type | Required | Description | | -------------------- | ------------------------------------ | -------- | ------------------------------------------------- | | series | Series[] | ✅ | Array of data series to plot | | width | number | ❌ | Plot width in pixels (default: 600) | | height | number | ❌ | Plot height in pixels (default: 400) | | xAxisLabel | string | ❌ | Label for the X-axis | | yAxisLabel | string | ❌ | Label for the Y-axis | | useNormalizedYAxis | boolean | ❌ | Use 0-100% Y-axis vs auto-scaled (default: false) | | reverseXAxis | boolean | ❌ | Reverse X-axis direction (default: false) | | formatXAxisTick | (value: number) => string | ❌ | Custom formatter for X-axis tick labels | | formatYAxisTick | (value: number) => string | ❌ | Custom formatter for Y-axis tick labels | | onSeriesClick | (seriesId: string) => void | ❌ | Callback when any series element is clicked | | onSeriesHover | (seriesId: string \| null) => void | ❌ | Callback when any series element is hovered |

Type Definitions

type Point = {
  concentration: number;
  meanResponse: number;
  std: number;
};

type EC50Params = {
  EC50: number;
  hillSlope: number;
};

type Series = {
  id: string;
  data: Point[];
  params: EC50Params;
  legend?: string;
  connectDataPoints?: boolean;
  showStandardDeviation?: boolean;
  showFittedCurve?: boolean;
  hideDataPoints?: boolean;
  showEC50Line?: boolean;
  colors?: {
    legend?: string;
    points?: string;
    fittedCurve?: string;
    connectedLine?: string;
    standardDeviation?: string;
    EC50Line?: string;
  };
};

Series Configuration Options

Each series supports extensive customization:

Visual Elements:

  • showFittedCurve (default: true): Display logistic dose-response curve
  • showStandardDeviation (default: false): Show T-shaped error bars
  • showEC50Line (default: false): Vertical dashed line at EC50 value
  • connectDataPoints (default: false): Dashed lines connecting data points
  • hideDataPoints (default: false): Hide data points, show only curves
  • legend (optional): Text for legend panel

Color Customization:

colors: {
  points: "#2563eb",           // Data point fill color
  fittedCurve: "#2563eb",      // Fitted curve stroke color
  EC50Line: "#2563eb80",       // EC50 line color (with transparency)
  connectedLine: "#2563eb60",  // Connected points line color
  standardDeviation: "#2563eb40", // Error bar color
  legend: "#2563eb",           // Legend text/line color
}

Axis Customization

X-Axis (Logarithmic Scale):

  • Automatically handles log-scale positioning
  • Scientific notation for extreme values
  • Custom tick formatting support
  • Reversible direction

Y-Axis (Linear Scale):

  • Normalized mode: 0-100% scale
  • Auto-scale mode: Fits data range with padding
  • Custom tick formatting support
  • Grid lines for easy reading

Interactive Features

Mouse Interactions:

  • Hover: Highlights series elements and triggers onSeriesHover
  • Click: Selects series elements and triggers onSeriesClick
  • Smooth Interactions: All elements have invisible interaction layers for better usability

Supported Elements:

  • Data points (circles)
  • Fitted curves (smooth lines)
  • EC50 lines (vertical dashed lines)
  • Connected point lines (dashed)
  • Legend items (floating panel)

Scientific Styling

The component follows scientific publication standards:

  • Typography: Clean, readable fonts
  • Grid: Subtle grid lines for easy value reading
  • Colors: Professional color palette with good contrast
  • Error Bars: Proper T-shaped standard deviation representation
  • Legends: Floating legend panel that doesn't interfere with data
  • Axis: Clean axes without arrowheads for modern appearance

Performance Features

  • Memoized Components: Each series is a memoized React component
  • Efficient Rendering: Only re-renders changed series
  • SVG Optimization: Invisible interaction layers for better mouse handling
  • Scalable: Supports up to 100 series with smooth performance

EC50ScatterPlot

A publication-ready scatter plot component specifically designed for EC50 collation data visualization. Features an ordinal Y-axis for series labels and a logarithmic X-axis for concentration data, with support for filled/unfilled points to distinguish between active and inactive compounds.

Features

  • Ordinal Y-axis: Each series is positioned based on its label for clear data organization
  • Logarithmic X-axis: Proper log-scale concentration plotting with scientific notation
  • Dynamic Height: Automatically adjusts height based on the number of series (30px per series)
  • Performance Optimized: Memoized series components for handling 100+ series smoothly
  • Interactive Elements: Hover and click callbacks for data points, Y-axis labels, and legends
  • Filled/Unfilled Points: Visual distinction between active and inactive data points
  • Scientific Styling: GraphPad Prism-inspired design suitable for publication
  • Customizable Legends: Support for both series-specific and global filled/unfilled legends
  • Unique Colors: Each series can have its own color scheme

Basic Usage

function BasicEC50ScatterPlot() {
  const series = [
    {
      id: "compound-a",
      label: "Compound A",
      data: [
        { x: 1e-9, filled: true }, // Active compound
        { x: 1e-7, filled: false }, // Inactive compound
      ],
      colors: {
        points: "#2563eb",
      },
    },
    {
      id: "compound-b",
      label: "Compound B",
      data: [
        { x: 1e-8, filled: true },
        { x: 1e-6, filled: false },
      ],
      colors: {
        points: "#dc2626",
      },
    },
  ];

  return (
    <EC50ScatterPlot
      series={series}
      xAxisLabel="Concentration (M)"
      filledLegend="Active"
      unfilledLegend="Inactive"
      width={800}
      onSeriesClick={(id) => console.log("Series clicked:", id)}
      onSeriesHover={(id) => console.log("Series hovered:", id)}
    />
  );
}

Advanced Usage with Multiple Series

function AdvancedEC50ScatterPlot() {
  const [hoveredSeries, setHoveredSeries] = useState<string | null>(null);
  const [selectedSeries, setSelectedSeries] = useState<string | null>(null);
  const [reverseXAxis, setReverseXAxis] = useState(false);

  // Generate gradient data from high to low concentrations
  const generateSeriesData = (compounds: string[]) => {
    return compounds.map((compound, index) => {
      // Create concentration gradient from Compound A (high) to Compound O (low)
      const position = index / (compounds.length - 1);
      const baseExponent = -4 - position * 6; // Range from 10^-4 to 10^-10
      const baseValue = Math.pow(10, baseExponent);

      return {
        id: `compound-${index}`,
        label: compound,
        data: [
          { x: baseValue * (0.5 + Math.random() * 0.5), filled: true },
          { x: baseValue * (5 + Math.random() * 5), filled: false },
        ],
        legend:
          index % 3 === 0
            ? "Inhibitor"
            : index % 3 === 1
              ? "Agonist"
              : "Antagonist",
        colors: {
          points: `hsl(${(index * 25) % 360}, 65%, 50%)`, // Unique colors
          legend: `hsl(${(index * 25) % 360}, 65%, 50%)`,
        },
      };
    });
  };

  const compounds = React.useMemo(
    () => [
      "Compound A",
      "Compound B",
      "Compound C",
      "Compound D",
      "Compound E",
      "Compound F",
      "Compound G",
      "Compound H",
      "Compound I",
      "Compound J",
    ],
    [],
  );

  const series = React.useMemo(
    () => generateSeriesData(compounds),
    [compounds],
  );

  return (
    <div>
      <div style={{ marginBottom: "16px" }}>
        <label>
          <input
            type="checkbox"
            onChange={(e) => setReverseXAxis(e.target.checked)}
          />
          Reverse X-Axis (High to Low)
        </label>
      </div>

      <div style={{ marginBottom: "16px" }}>
        <strong>Hovered:</strong> {hoveredSeries || "None"} |{" "}
        <strong>Selected:</strong> {selectedSeries || "None"}
      </div>

      <EC50ScatterPlot
        series={series}
        reverseXAxis={reverseXAxis}
        xAxisLabel="Drug Concentration (M)"
        filledLegend="Active Compound"
        unfilledLegend="Inactive Compound"
        width={1000}
        onSeriesHover={setHoveredSeries}
        onSeriesClick={setSelectedSeries}
        formatXAxisTick={(value) => {
          // Custom scientific notation
          const exponent = Math.floor(Math.log10(Math.abs(value)));
          if (exponent >= -2 && exponent <= 3) {
            return value.toString();
          }
          const mantissa = value / Math.pow(10, exponent);
          return `${mantissa.toFixed(1)}×10^${exponent}`;
        }}
      />
    </div>
  );
}

Handling Large Datasets

function LargeDatasetScatterPlot() {
  const largeSeries = React.useMemo(() => {
    const generateLargeDataset = (count: number) => {
      const drugTypes = [
        "Inhibitor",
        "Agonist",
        "Antagonist",
        "Modulator",
        "Blocker",
      ];

      return Array.from({ length: count }, (_, i) => {
        const concentrationBase = Math.pow(10, -10 + Math.random() * 8);

        return {
          id: `series-${i}`,
          label: `Drug ${String.fromCharCode(65 + (i % 26))}${Math.floor(i / 26) + 1}`,
          data: [
            { x: concentrationBase, filled: Math.random() > 0.3 },
            { x: concentrationBase * 10, filled: Math.random() > 0.3 },
          ],
          legend: drugTypes[i % drugTypes.length],
          colors: {
            points: `hsl(${(i * 15) % 360}, 60%, 45%)`,
            legend: `hsl(${(i * 15) % 360}, 60%, 45%)`,
          },
        };
      });
    };
    return generateLargeDataset(75);
  }, []);

  return (
    <EC50ScatterPlot
      series={largeSeries}
      xAxisLabel="Concentration (M)"
      filledLegend="Effective"
      unfilledLegend="Non-effective"
      width={1200}
      onSeriesClick={(id) => {
        // Handle series selection for detailed analysis
        console.log("Analyzing series:", id);
      }}
    />
  );
}

Props

| Prop | Type | Required | Description | | ----------------- | ------------------------------------ | -------- | ------------------------------------------------------ | | series | Series[] | ✅ | Array of series data to plot | | reverseXAxis | boolean | ❌ | Reverse X-axis direction (default: false) | | xAxisLabel | string | ❌ | Label for the X-axis | | filledLegend | string | ❌ | Legend text for filled points (with filled circle) | | unfilledLegend | string | ❌ | Legend text for unfilled points (with unfilled circle) | | formatXAxisTick | (value: number) => string | ❌ | Custom formatter for X-axis tick labels | | width | number | ❌ | Plot width in pixels (default: 600) | | onSeriesClick | (seriesId: string) => void | ❌ | Callback when any series element is clicked | | onSeriesHover | (seriesId: string \| null) => void | ❌ | Callback when any series element is hovered |

Type Definitions

type Point = {
  x: number; // X-axis value (concentration)
  filled?: boolean; // Whether point is filled (default: true)
};

type Series = {
  id: string; // Unique identifier
  label: string; // Y-axis ordinal label
  data: Point[]; // Array of data points
  hideDataPoints?: boolean; // Hide all points for this series
  legend?: string; // Legend text (groups series)
  colors?: {
    legend?: string; // Legend color
    points?: string; // Data point color
  };
};

Series Configuration

Data Points:

  • data: Array of concentration values with filled/unfilled status
  • hideDataPoints: Hide all data points while keeping Y-axis label
  • Each point can be independently filled or unfilled

Styling:

  • colors.points: Color for data points (both fill and stroke)
  • colors.legend: Color for legend items (defaults to points color)
  • Automatic color assignment from professional palette if not specified

Legends:

  • legend: Groups series with same legend text
  • filledLegend/unfilledLegend: Global legends for point types
  • Series-specific legends displayed as colored circles

Coordinate System and Scaling

X-Axis (Logarithmic):

  • Automatic log-scale positioning and tick generation
  • Major ticks at powers of 10 (10^-6, 10^-5, etc.)
  • Minor ticks at 2, 3, 4, 5, 6, 7, 8, 9 multiples
  • Scientific notation for extreme values
  • Reversible direction with reverseXAxis

Y-Axis (Ordinal):

  • Equal spacing between series based on label order
  • Dynamic positioning based on number of series
  • Clickable labels for series interaction
  • Automatic margin adjustment based on longest label

Height Management:

  • Base calculation: series.length * 30px + 150px for margins
  • Minimum height: 300px
  • Maximum height: 1000px (with scrolling for more series)
  • Dynamic left margin based on longest Y-axis label

Interactive Features

Mouse Interactions:

  • Data Points: Click/hover individual points
  • Y-Axis Labels: Click/hover series labels
  • Legends: Click/hover legend items
  • Consistent Callbacks: All interactions trigger same callbacks with series ID

Visual Feedback:

  • Hover effects on all interactive elements
  • Cursor changes to indicate clickable areas
  • Smooth transitions for interactions

Scientific Styling

Publication-ready design following scientific standards:

  • Typography: Arial font family for clarity
  • Grid Lines: Subtle logarithmic grid with major/minor distinctions
  • Axes: Clean axes without arrowheads
  • Colors: Professional palette with good contrast
  • Legends: Clear distinction between filled/unfilled points
  • Spacing: Appropriate margins and padding for publication

Performance Optimizations

  • Memoized Components: Each series is a React.memo component
  • Efficient Calculations: Cached scale functions and tick values
  • SVG Rendering: Optimized for large datasets (100+ series)
  • Event Optimization: useCallback for all event handlers
  • Dynamic Sizing: Efficient height calculation and margin adjustment

Data Visualization Best Practices

The component follows established practices for scientific data visualization:

  • Logarithmic X-axis: Proper for concentration-response data
  • Ordinal Y-axis: Clear organization of multiple compounds/series
  • Point Distinction: Visual separation of active vs inactive compounds
  • Color Coding: Support for categorical grouping via legends
  • Scalability: Handles large datasets without performance degradation

LabPlate

A flexible, interactive laboratory plate component for displaying and manipulating well data in grid layouts (e.g., 96-well plates, 384-well plates).

Features

  • Interactive Selection: Click wells, rows, or columns to select them
  • Multi-Selection: Hold Ctrl/Cmd to select multiple wells
  • Drag Selection: Click and drag to select rectangular areas
  • Keyboard Navigation: Use arrow keys to navigate and Space/Enter to select
  • Cell Merging: Automatically merge adjacent wells with identical data
  • Customizable Styling: Full control over cell appearance and behavior

Basic Usage

function BasicLabPlate() {
  const plateData = new Map([
    ["A1", { sample: "Control", concentration: 100 }],
    ["A2", { sample: "Treatment", concentration: 50 }],
    ["B1", { sample: "Control", concentration: 100 }],
    // ... more well data
  ]);

  return (
    <LabPlate
      layout={{ rows: 8, cols: 12 }}
      cellStyle={{
        width: "48px",
        height: "48px",
        backgroundColor: "#f8fafc",
        borderRadius: "4px",
      }}
      renderWell={(wellLabel) => {
        const data = plateData.get(wellLabel);
        return (
          <div style={{ textAlign: "center", fontSize: "12px" }}>
            {data ? `${data.sample}\n${data.concentration}μM` : wellLabel}
          </div>
        );
      }}
      selection={{
        onStateChange: (state) => {
          console.log("Selection changed:", state);
        },
      }}
    />
  );
}
// Custom styling example
function StyledLabPlate() {
  const plateData = new Map([
    ["A1", { sample: "Control", concentration: 100 }],
    ["A2", { sample: "Treatment", concentration: 50 }],
    ["B1", { sample: "Control", concentration: 100 }],
    // ... more well data
  ]);

  return (
    <LabPlate
      layout={{ rows: 8, cols: 12 }}
      plateContainerStyle={{
        backgroundColor: "#f1f5f9",
        padding: "16px",
        borderRadius: "12px",
        borderWidth: "1px",
        borderStyle: "solid",
        borderColor: "#e2e8f0",
      }}
      cellStyle={{
        borderRadius: "6px",
        width: "48px",
        height: "48px",
      }}
      rowHeaderStyle={{
        backgroundColor: "#1e293b",
        color: "white",
        fontWeight: "bold",
      }}
      colHeaderStyle={{
        backgroundColor: "#0f172a",
        color: "white",
        fontWeight: "bold",
      }}
      renderWell={(wellLabel) => {
        const data = plateData.get(wellLabel);
        return (
          <div
            style={{
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              justifyContent: "center",
              height: "100%",
              fontSize: "10px",
            }}
          >
            <div>{wellLabel}</div>
            {data && (
              <>
                <div>{data.sample}</div>
                <div>{data.concentration}μM</div>
              </>
            )}
          </div>
        );
      }}
    />
  );
}

Advanced Usage with Cell Merging

function CellMergingLabPlate() {
  type WellData = {
    group: string;
    value: number;
  };

  const plateData = new Map<string, WellData>([
    // Group A - top left 2x2
    ["A1", { group: "A", value: 100 }],
    ["A2", { group: "A", value: 100 }],
    ["B1", { group: "A", value: 100 }],
    ["B2", { group: "A", value: 100 }],

    // Group B - vertical strip
    ["A3", { group: "B", value: 200 }],
    ["B3", { group: "B", value: 200 }],
    ["C3", { group: "B", value: 200 }],
    ["D3", { group: "B", value: 200 }],

    // Group C - horizontal strip
    ["C1", { group: "C", value: 300 }],
    ["C2", { group: "C", value: 300 }],
    ["C4", { group: "C", value: 300 }],
    ["C5", { group: "C", value: 300 }],

    // Individual wells
    ["A4", { group: "D", value: 400 }],
    ["A5", { group: "E", value: 500 }],
    ["B4", { group: "F", value: 600 }],
    ["B5", { group: "G", value: 700 }],
  ]);

  const groupColors: Record<string, string> = {
    A: "#fef3c7",
    B: "#dbeafe",
    C: "#dcfce7",
    D: "#fce7f3",
    E: "#e0e7ff",
    F: "#ffedd5",
    G: "#f3e8ff",
  };

  return (
    <LabPlate
      layout={{ rows: 6, cols: 8 }}
      // Merge wells with the same group AND value using well labels
      compareWells={(aLabel, bLabel) => {
        const a = plateData.get(aLabel);
        const b = plateData.get(bLabel);
        if (!a || !b) return false;
        return a.group === b.group && a.value === b.value;
      }}
      renderWell={(wellLabel) => {
        const data = plateData.get(wellLabel);
        if (!data) return null;

        return (
          <div
            style={{
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              justifyContent: "center",
              height: "100%",
              backgroundColor: groupColors[data.group],
              color: "#1f2937",
              padding: "8px",
              fontSize: "14px",
            }}
          >
            <div>Group {data.group}</div>
            <div style={{ fontSize: "12px", marginTop: "4px" }}>
              Value: {data.value}
            </div>
          </div>
        );
      }}
      getWellValue={(wellLabel) => {
        const data = plateData.get(wellLabel);
        return data ? `${data.group}-${data.value}` : "";
      }}
    />
  );
}

Comprehensive Styling Example

This example demonstrates all the styling capabilities including plate container, cell, and header styling:

function ComprehensiveStylingExample() {
  type WellData = {
    sample: string;
    concentration: number;
    status: "active" | "inactive" | "control";
  };

  const plateData = new Map<string, WellData>([
    ["A1", { sample: "Control", concentration: 0, status: "control" }],
    ["A2", { sample: "Sample 1", concentration: 10, status: "active" }],
    ["A3", { sample: "Sample 2", concentration: 25, status: "active" }],
    ["B1", { sample: "Control", concentration: 0, status: "control" }],
    ["B2", { sample: "Sample 3", concentration: 50, status: "inactive" }],
    ["B3", { sample: "Sample 4", concentration: 100, status: "active" }],
  ]);

  return (
    <LabPlate
      layout={{ rows: 4, cols: 6 }}
      // Style the main container
      plateContainerStyle={{
        gap: "24px",
        padding: "20px",
        backgroundColor: "#f8fafc",
        borderRadius: "16px",
        borderWidth: "2px",
        borderStyle: "solid",
        borderColor: "#e2e8f0",
        boxShadow: "0 8px 25px -8px rgba(0, 0, 0, 0.1)",
        maxWidth: "800px",
      }}
      // Style the grid container
      gridStyle={{
        gap: "4px",
        padding: "12px",
        backgroundColor: "#ffffff",
        borderRadius: "12px",
        borderWidth: "1px",
        borderStyle: "solid",
        borderColor: "#cbd5e1",
        boxShadow: "inset 0 2px 4px rgba(0, 0, 0, 0.06)",
      }}
      // Style the laboratory plate container (focusable grid container)
      labPlateStyle={{
        borderRadius: "8px",
        outline: "none",
        backgroundColor: "#fefefe",
        boxShadow: "0 0 0 2px transparent",
        transition: "box-shadow 0.2s ease",
      }}
      // Style individual cells
      cellStyle={{
        width: "60px",
        height: "60px",
        borderRadius: "12px",
        borderWidth: "2px",
        borderStyle: "solid",
        borderColor: "#e2e8f0",
        backgroundColor: "#ffffff",
        fontSize: "11px",
        fontWeight: "500",
        transition: "all 0.2s ease",
      }}
      // Style row headers
      rowHeaderStyle={{
        width: "80px",
        backgroundColor: "#1e293b",
        color: "white",
        fontSize: "16px",
        fontWeight: "bold",
        borderRadius: "12px 0 0 12px",
        borderWidth: "2px",
        borderStyle: "solid",
        borderColor: "#334155",
        transition: "all 0.2s ease",
      }}
      // Style column headers
      colHeaderStyle={{
        height: "60px",
        backgroundColor: "#0f172a",
        color: "white",
        fontSize: "16px",
        fontWeight: "bold",
        borderRadius: "12px 12px 0 0",
        borderWidth: "2px",
        borderStyle: "solid",
        borderColor: "#1f2937",
        transition: "all 0.2s ease",
      }}
      renderWell={(wellLabel) => {
        const data = plateData.get(wellLabel);
        const statusColors = {
          active: "#22c55e",
          inactive: "#ef4444",
          control: "#6b7280",
        };

        return (
          <div
            style={{
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              justifyContent: "center",
              height: "100%",
              color: "#475569",
            }}
          >
            <div style={{ fontWeight: "bold", marginBottom: "2px" }}>
              {wellLabel}
            </div>
            {data && (
              <>
                <div style={{ fontSize: "10px", textAlign: "center" }}>
                  {data.sample}
                </div>
                <div style={{ fontSize: "9px", opacity: 0.8 }}>
                  {data.concentration}µM
                </div>
                <div
                  style={{
                    width: "8px",
                    height: "8px",
                    borderRadius: "50%",
                    backgroundColor: statusColors[data.status],
                    marginTop: "2px",
                  }}
                />
              </>
            )}
          </div>
        );
      }}
      getWellValue={(wellLabel) => {
        const data = plateData.get(wellLabel);
        return data ? `${data.sample} ${data.concentration}µM` : "";
      }}
    />
  );
}

Working with Well Data

Here are common patterns for working with well data in the simplified LabPlate component:

// Example 1: Create data with renderWell function
function createPlateWithData() {
  const sampleData = new Map([
    ["A1", { sample: "Control", concentration: 0 }],
    ["A2", { sample: "Sample 1", concentration: 10 }],
    ["A3", { sample: "Sample 2", concentration: 25 }],
    ["B1", { sample: "Control", concentration: 0 }],
    ["B2", { sample: "Sample 3", concentration: 50 }],
    ["B3", { sample: "Sample 4", concentration: 100 }],
  ]);

  return (
    <LabPlate
      layout={{ rows: 8, cols: 12 }}
      renderWell={(wellLabel) => {
        const data = sampleData.get(wellLabel);
        return (
          <div style={{ textAlign: "center", fontSize: "12px" }}>
            {data ? (
              <>
                <div>{data.sample}</div>
                <div>{data.concentration}μM</div>
              </>
            ) : (
              wellLabel
            )}
          </div>
        );
      }}
      getWellValue={(wellLabel) => {
        const data = sampleData.get(wellLabel);
        return data ? `${data.sample} ${data.concentration}μM` : "";
      }}
    />
  );
}

// Example 2: Generate well patterns programmatically  
function generateWellPattern(samples: string[], rows = 8, cols = 12) {
  const patternData = new Map();

  // Fill controls in first column
  const controls = ["Blank", "Negative", "Positive"];
  for (let row = 0; row < rows; row++) {
    const control = controls[row % controls.length];
    const wellLabel = `${getRowLabel(row)}1`;
    patternData.set(wellLabel, { sample: control, type: "control" });
  }

  // Fill samples in remaining columns
  let sampleIndex = 0;
  for (let col = 1; col < cols && sampleIndex < samples.length; col++) {
    for (let row = 0; row < rows && sampleIndex < samples.length; row++) {
      const wellLabel = `${getRowLabel(row)}${col + 1}`;
      patternData.set(wellLabel, { sample: samples[sampleIndex], type: "sample" });
      sampleIndex++;
    }
  }

  return patternData;
}

Props

| Prop | Type | Required | Description | | --------------------- | --------------------------------------------- | -------- | --------------------------------------------------------------------------------- | | layout | { rows: number; cols: number } | ✅ | Defines the plate dimensions | | renderWell | (wellLabel: string) => ReactNode | ❌ | Custom well renderer | | compareWells | (aLabel: string, bLabel: string) => boolean | ❌ | Function to determine if adjacent wells should be merged using well labels | | getWellValue | (wellLabel: string) => string | ❌ | Function to get the display/edit value for a well | | selection | { onStateChange?, initialState?, state?, effects? } | ❌ | Selection manager configuration | | plateContainerStyle | React.CSSProperties | ❌ | Custom styles for the plate container (holds grid) | | gridStyle | React.CSSProperties | ❌ | Custom styles for the grid container (CSS grid that holds wells and headers) | | labPlateStyle | React.CSSProperties | ❌ | Custom styles for the laboratory plate container (focusable container) | | cellStyle | React.CSSProperties | ❌ | Custom styles for cells | | rowHeaderStyle | React.CSSProperties | ❌ | Custom styles for row headers | | colHeaderStyle | React.CSSProperties | ❌ | Custom styles for column headers | | className | string | ❌ | CSS class name | | style | React.CSSProperties | ❌ | Inline styles for container |

Styling Options

The LabPlate component uses standard React.CSSProperties for all styling, making it intuitive and flexible. You can customize:

Cell Styling with cellStyle:

<LabPlate
  cellStyle={{
    // Any standard CSS properties
    width: "48px",
    height: "48px",
    borderRadius: "8px",
    backgroundColor: "#f8fafc",
    fontSize: "12px",
    borderWidth: "1px",
    borderStyle: "solid",
    borderColor: "#e2e8f0",
    transition: "all 0.2s ease",
  }}
/>

Header Styling with rowHeaderStyle and colHeaderStyle:

<LabPlate
  rowHeaderStyle={{
    width: "60px",
    backgroundColor: "#1e293b",
    color: "white",
    fontSize: "16px",
    fontWeight: "bold",
    borderRadius: "8px 0 0 8px",
  }}
  colHeaderStyle={{
    height: "50px",
    backgroundColor: "#0f172a",
    color: "white",
    fontSize: "16px",
    fontWeight: "bold",
    borderRadius: "8px 8px 0 0",
  }}
/>

Plate Container Styling with plateContainerStyle:

<LabPlate
  plateContainerStyle={{
    gap: "24px",
    padding: "16px",
    backgroundColor: "#f1f5f9",
    borderRadius: "12px",
    borderWidth: "2px",
    borderStyle: "solid",
    borderColor: "#e2e8f0",
    boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
    width: "600px",
    maxWidth: "600px",
  }}
/>

Grid Styling with gridStyle:

<LabPlate
  gridStyle={{
    gap: "2px",
    padding: "8px",
    backgroundColor: "#f8fafc",
    borderRadius: "12px",
    borderWidth: "1px",
    borderStyle: "solid",
    borderColor: "#d1d5db",
    boxShadow: "0 2px 4px rgba(0, 0, 0, 0.05)",
  }}
/>

Laboratory Plate Styling with labPlateStyle:

<LabPlate
  labPlateStyle={{
    borderRadius: "8px",
    outline: "none",
    boxShadow: "0 0 0 2px #3b82f6",
    backgroundColor: "#ffffff",
  }}
/>

Keyboard Controls

  • Arrow Keys: Navigate between wells
  • Space/Enter: Select the focused well
  • Ctrl/Cmd + Click: Multi-select wells
  • Click + Drag: Select rectangular areas
  • Row/Column Headers: Click to select entire rows/columns

Well Labeling

Wells are labeled using standard laboratory plate conventions:

  • Rows: A, B, C, ... Z (supports up to 26 rows)
  • Columns: 1, 2, 3, 4, etc.

Well labels follow the format ${RowLetter}${ColumnNumber}, e.g., "A1", "B12", "H8".

Selection Configuration

The LabPlate component uses an external selection manager. You can configure selection behavior through the selection prop:

<LabPlate
  layout={{ rows: 8, cols: 12 }}
  selection={{
    onStateChange: (state) => {
      // Handle selection state changes
      console.log("Selection state:", state);
    },
    initialState: {
      // Optional: Set initial selection state
      selections: [{ start: { row: 0, col: 0 }, end: { row: 0, col: 0 } }]
    },
    effects: (selectionManager) => {
      // Optional: Set up additional effects
      const cleanup = selectionManager.listenToCopy((isCut) => {
        console.log(isCut ? "Cut" : "Copy", "operation performed");
      });
      return cleanup;
    }
  }}
/>

Utility Functions

The library exports several utility functions to help work with laboratory plates:

getRowLabel(rowIndex: number): string

Converts a numeric row index to its alphabetic label.

import { getRowLabel } from "@anocca-pub/components";

getRowLabel(0); // "A"
getRowLabel(1); // "B"
getRowLabel(25); // "Z"
getColLabel(colIndex: number): string

Converts a numeric column index to its string label.

import { getColLabel } from "@anocca-pub/components";

getColLabel(0); // "1"
getColLabel(11); // "12"
getColLabel(7); // "8"
getWellLabel(row: number, col: number): string

Creates a well label from row and column indices.

import { getWellLabel } from "@anocca-pub/components";

getWellLabel(0, 0); // "A1"
getWellLabel(1, 11); // "B12"
getWellLabel(7, 7); // "H8"
wellLabelToCoords(wellLabel: string): { row: number; col: number }

Converts a well label to row and column indices.

import { wellLabelToCoords } from "@anocca-pub/components";

wellLabelToCoords("A1"); // { row: 0, col: 0 }
wellLabelToCoords("B12"); // { row: 1, col: 11 }
wellLabelToCoords("H8"); // { row: 7, col: 7 }
padWellLabel(wellLabel: string): string

Converts a well label to its padded format (pads column number to 2 digits with leading zeros).

import { padWellLabel } from "@anocca-pub/components";

padWellLabel("A1"); // "A01"
padWellLabel("B12"); // "B12"
padWellLabel("H8"); // "H08"
unpadWellLabel(paddedWellLabel: string): string

Converts a padded well label back to its standard format (removes leading zeros).

import { unpadWellLabel } from "@anocca-pub/components";

unpadWellLabel("A01"); // "A1"
unpadWellLabel("B12"); // "B12"
unpadWellLabel("H08"); // "H8"
generateRowLabels(count: number): string[]

Generates an array of row labels for a given number of rows.

import { generateRowLabels } from "@anocca-pub/components";

generateRowLabels(4); // ["A", "B", "C", "D"]
generateRowLabels(8); // ["A", "B", "C", "D", "E", "F", "G", "H"]
generateColLabels(count: number): string[]

Generates an array of column labels for a given number of columns.

import { generateColLabels } from "@anocca-pub/components";

generateColLabels(4); // ["1", "2", "3", "4"]
generateColLabels(12); // ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]
Usage Example: Converting Between Formats
import {
  wellLabelToCoords,
  getWellLabel,
  getRowLabel,
  getColLabel,
  padWellLabel,
  unpadWellLabel,
} from "@anocca-pub/components";

// Convert well label to coordinates
const coords = wellLabelToCoords("B3"); // { row: 1, col: 2 }

// Create well label from coordinates
const label = getWellLabel(coords.row, coords.col); // "B3"

// Create well label from scratch
const customLabel = `${getRowLabel(3)}${getColLabel(4)}`; // "D5"

// Generate labels for a plate
const rowLabels = generateRowLabels(8); // ["A", "B", "C", "D", "E", "F", "G", "H"]
const colLabels = generateColLabels(12); // ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]

Grid

An infinite, zoomable grid component that can render child components positioned in 2D space. Perfect for creating interactive canvases, diagram editors, or any application that needs to display and manipulate objects in a large coordinate system.

Features

  • Infinite Panning: Mouse drag to pan around the infinite grid
  • Smooth Zooming: Mouse wheel to zoom in/out with smooth scaling
  • Viewport Culling: Only renders children that are visible for optimal performance
  • Persistent Components: Option to keep components mounted even when out of view

Basic Usage

function BasicGrid() {
  const gridChildren: GridChild[] = [
    {
      id: "item1",
      title: "First Item",
      x: 100, // Position in pixels
      y: 200,
      width: 150, // Size in pixels
      height: 100,
      component: "HelloComponent",
    },
    {
      id: "item2",
      title: "Second Item",
      x: 300,
      y: 150,
      width: 200,
      height: 120,
      component: "AnotherComponent",
    },
  ];

  const components = {
    HelloComponent: () => (
      <div
        style={{
          width: "100%",
          height: "100%",
          backgroundColor: "#007bff",
          color: "white",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          borderRadius: "8px",
        }}
      >
        Hello Grid!
      </div>
    ),
    AnotherComponent: () => (
      <div
        style={{
          width: "100%",
          height: "100%",
          backgroundColor: "#28a745",
          color: "white",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          borderRadius: "8px",
        }}
      >
        Another Item
      </div>
    ),
  };

  return (
    <Grid
      children={gridChildren}
      components={components}
      initialVisibleRect={{ x: 50, y: 100, w: 500, h: 300 }} // Show area containing both items
      gridSize={20} // 20px grid cells
      style={{ width: "100%", height: "100%" }}
    />
  );
}

Advanced Usage with Persistent Components

function AdvancedGrid() {
  const ref = React.useCallback((el) => {
    if (el) el.innerHTML = Math.random();
  }, []);

  const items: GridChild[] = [
    {
      id: "persistent-panel",
      title: "Control Panel",
      x: 50,
      y: 50,
      width: 300,
      height: 300,
      persistent: true, // Stays mounted when out of view
      component: "ControlPanel",
    },
  ];

  const components = {
    ControlPanel: () => (
      <div
        style={{
          width: "100%",
          height: "100%",
          backgroundColor: "white",
          borderWidth: "2px",
          borderStyle: "solid",
          borderColor: "#ccc",
          borderRadius: "8px",
          padding: "16px",
          boxShadow: "0 4px 8px rgba(0,0,0,0.1)",
        }}
      >
        <h3>Control Panel</h3>
        <p>
          This panel stays mounted even when scrolled out of view so it will not
          lose state: <span ref={ref} />
        </p>
      </div>
    ),
  };

  return (
    <Grid
      children={items}
      components={components}
      initialVisibleRect={{ x: 0, y: 0, w: 400, h: 400 }} // Show area containing the control panel
      gridSize={25} // Larger grid cells
      style={{
        backgroundColor: "#f8f9fa", // Light background
        width: "100%",
        height: "100%",
      }}
    />
  );
}

Props

| Prop | Type | Required | Description | | -------------------- | ------------------------------------------------------ | -------- | ------------------------------------------------------------------------- | | children | GridChild[] | ✅ | Array of child components to render in the grid | | components | Record<string, GridComponent> | ✅ | Map of component names to their render functions | | style | React.CSSProperties | ❌ | CSS styles for the grid container | | initialVisibleRect | { x: number; y: number; w: number; h: number } | ❌ | Initial visible rectangle in world coordinates (auto-calculates viewport) | | gridSize | number | ❌ | Size of each grid cell in pixels (default: 20) | | selectedChildId | string \| null | ❌ | ID of the currently selected child (controlled mode) | | onChildSelect | React.Dispatch<React.SetStateAction<string \| null>> | ❌ | Callback when a child is selected | | onViewportChange | React.Dispatch<React.SetStateAction<ViewportState>> | ❌ | Callback when viewport changes (controlled mode) | | onChildrenChange | React.Dispatch<React.SetStateAction<GridChild[]>> | ❌ | Callback when children change (controlled mode) | | viewport | ViewportState | ❌ | Controlled viewport state |

GridChild Interface

Each child component is defined by a GridChild object:

interface GridChild {
  id: string; // Unique identifier
  title: string; // Display title
  x: number; // X coordinate in pixels
  y: number; // Y coordinate in pixels
  width: number; // Width in px
  height: number; // Height in px
  component: string; // Name of the component to render
  zoom?: number; // Zoom/scale factor for the child (default: 1)
  minimized?: boolean; // Whether the child is minimized
  initial?: GridChildInitial; // Initial state for reset functionality
  persistent?: boolean; // Keep mounted when out of view (optional)
  props?: any; // Props for the component
}

interface GridChildInitial {
  x: number; // Initial X coordinate in px
  y: number; // Initial Y coordinate in px
  width: number; // Initial width in px
  height: number; // Initial height in px
  zoom?: number; // Initial zoom/scale (default: 1)
}

type GridComponent = (gridChild: GridChild) => ReactNode;

Coordinate System

  • Units: All positions and sizes are specified in pixels
  • Origin: (0, 0) is at the top-left of the grid
  • World Bounds: The grid world spans from -125,000 to +125,000 px in both dimensions

Mouse Controls

  • Pan: Click and drag to pan around the grid
  • Zoom: Mouse wheel to zoom in/out
  • Select: Click child components to select them
  • Move: Drag selected components to move them
  • Resize: Drag resize handles on selected components (hold Shift for proportional scaling)
  • Rulers: Visual rulers show coordinate values at current zoom level

Initial Viewport

The initialVisibleRect prop provides an intuitive way to set the initial view:

<Grid
  initialVisibleRect={{ x: 100, y: 200, w: 400, h: 300 }}
  // This will show a 400x300 area starting at coordinates (100, 200)
  // The grid will automatically calculate the appropriate zoom level and viewport position
/>

This is much more intuitive than manually calculating viewport offsets and zoom levels. The grid will:

  • Calculate the zoom level needed to fit the entire rectangle
  • Center the rectangle in the viewport
  • Add some padding for visual comfort

Spreadsheet

A powerful, Excel-like spreadsheet component with infinite scrolling, zooming, and comprehensive editing capabilities. Perfect for building data entry interfaces, spreadsheet applications, or any tabular data visualization.

Features

  • Infinite Grid: Unlimited rows and columns with smooth scrolling
  • Interactive Editing: Double-click or press Enter/F2 to edit cells
  • Excel-like Selection: Click, drag, Shift+click, and Ctrl+click selection patterns
  • Keyboard Navigation: Arrow keys, Tab, Enter navigation with Excel-like behavior
  • Copy/Cut/Paste Support: Full clipboard integration with Ctrl+C, Ctrl+X, Ctrl+V
  • Undo/Redo: Full history support with Ctrl+Z and Ctrl+Y
  • Smart Zooming: Mouse wheel zoom with normal (10%) and fine-control (Alt+wheel, 5%) modes
  • Column/Row Resizing: Drag headers to resize, double-click to auto-fit
  • File Drop Support: Drag and drop CSV/TSV files to import data
  • Custom Cell Styling: Conditional formatting and custom cell renderers
  • Custom Cell Rendering: Format numbers, dates, or any content with custom renderers
  • Mixed Data Types: Support for both string and numeric cell values
  • Row/Column Selection: Click headers to select entire rows or columns
  • Select All: Ctrl+A to select entire spreadsheet

Basic Usage

// Uncontrolled spreadsheet (manages its own data)
function UncontrolledSpreadsheet() {
  return (
    <Spreadsheet
      style={{ width: "100%", height: "600px" }}
      onSelectionChange={(selection) => {
        console.log("Selection:", selection);
        
        if (selection.isAllSelected) {
          console.log("All cells selected");
        } else if (selection.ranges) {
          console.log("Selected ranges:", selection.ranges);
        } else if (selection.rowIndices) {
          console.log("Selected rows (0-based):", selection.rowIndices);
        } else if (selection.colIndices) {
          console.log("Selected columns (0-based):", selection.colIndices);
        }
      }}
    />
  );
}
// Controlled spreadsheet with mixed data types
function ControlledSpreadsheet() {
  const [cellData, setCellData] = React.useState<Map<string, string | number>>(
    new Map([
      ["A1", "Product"],
      ["B1", "Price"],
      ["C1", "Quantity"],
      ["D1", "Total"],
      ["A2", "Widget A"],
      ["B2", 29.99],
      ["C2", 100],
      ["D2", 2999],
      ["A3", "Widget B"],
      ["B3", 39.99],
      ["C3", 75],
      ["D3", 2999.25],
    ]),
  );

  return (
    <Spreadsheet
      style={{ width: "100%", height: "600px" }}
      cellData={cellData}
      onCellDataChange={setCellData}
      onSelectionChange={(selection) => {
        console.log("Selection changed:", selection);
      }}
    />
  );
}

Advanced Usage with Custom Cell Rendering

function FormattedSpreadsheet() {
  const [cellData, setCellData] = React.useState<Map<string, string | number>>(
    new Map([
      ["A1", "Product"],
      ["B1", "Price"],
      ["C1", "Quantity"],
      ["D1", "Revenue"],
      ["A2", "Premium Widget"],
      ["B2", 129.99],
      ["C2", 1500],
      ["D2", 194985],
      ["A3", "Standard Widget"],
      ["B3", 49.99],
      ["C3", 2300],
      ["D3", 114977],
    ]),
  );

  // Custom renderer for formatting different data types
  const customRenderer = (cell: ConditionalStyleCallbackData) => {
    // Format currency in Price column (B)
    if (cell.col === "B" && typeof cell.value === "number") {
      return new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(cell.value);
    }

    // Format large numbers with commas in Quantity and Revenue columns
    if (
      (cell.col === "C" || cell.col === "D") &&
      typeof cell.value === "number"
    ) {
      return cell.value.toLocaleString("en-US");
    }

    // Header styling
    if (cell.row === 1) {
      return <strong>{cell.value}</strong>;
    }

    // Default rendering
    return cell.value;
  };

  return (
    <Spreadsheet
      style={{
        width: "100%",
        height: "600px",
        fontFamily: "Inter, sans-serif",
      }}
      cellData={cellData}
      onCellDataChange={setCellData}
      customCellRenderer={customRenderer}
      customCellStyle={(cell) => {
        const isHeader = cell.row === 1;
        const isNumeric = typeof cell.value === "number";

        return {
          backgroundColor: isHeader
            ? "#1f2937"
            : cell.isSelected
              ? "#3b82f6"
              : isNumeric
                ? "#f8fafc"
                : "transparent",
          color: isHeader || cell.isSelected ? "white" : "#374151",
          fontWeight: isHeader ? "bold" : "normal",
          textAlign: isNumeric && !isHeader ? "right" : "left",
          fontSize: "14px",
          padding: "8px",
          transition: "all 0.2s ease",
        };
      }}
    />
  );
}

Data Type Parsing with parseValue

The parseValue prop allows you to automatically convert user input into appropriate data types. This is particularly useful for numeric data, dates, or any custom formatting.

// Example: Date parsing function with cell context
function ParsedSpreadsheet() {
  function parseDatesAndNumbers(
    value: string,
    cell: {
      id: string;
      row: number;
      col: string;
      rowIndex: number;
      colIndex: number;
    },
  ): string | number | Date {
    const cleaned = value.trim();

    // Parse dates only in specific columns (e.g., column D for dates)
    if (cell.col === "D") {
      const dateRegex =
        /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}-\d{2}-\d{4}$/;
      if (dateRegex.test(cleaned)) {
        const date = new Date(cleaned);
        if (!isNaN(date.getTime())) {
          return date;
        }
      }
    }

    // Parse numbers in numeric columns (B, C, etc.)
    if (cell.col.match(/^[B-Z]+$/)) {
      const numberCleaned = cleaned.replace(/[\s,;]+/g, "");
      const numberRegex = /^-?\d+(\.\d+)?$/;
      if (numberRegex.test(numberCleaned)) {
        return Number(numberCleaned);
      }
    }

    return value;
  }

  const [cellData, setCellData] = React.useState<Map<string, string | number>>(
    new Map([
      ["A1", "Item"],
      ["B1", "Price"],
      ["C1", "Quantity"],
      ["D1", "Total"],
      ["A2", "Widget"],
      // Initial numeric values will stay as numbers
      ["B2", 29.99],
      ["C2", 100],
      ["D2", 2999],
    ]),
  );

  // Custom parser that handles different number formats based on column
  const parseValue = (
    value: string,
    cell: {
      id: string;
      row: number;
      col: string;
      rowIndex: number;
      colIndex: number;
    },
  ): string | number => {
    // Skip parsing for header row
    if (cell.row === 1) {
      return value;
    }

    // Column-specific parsing
    switch (cell.col) {
      case "B": // Price column - handle currency
        const cleaned = value.replace(/[\s,;_$€£¥]+/g, "");
        const numberRegex = /^-?\d+(\.\d+)?$/;
        if (numberRegex.test(cleaned)) {
          return Number(cleaned);
        }
        return value;

      case "C": // Quantity column - parse as integer
        const quantityCleaned = value.replace(/[\s,;_]+/g, "");
        const intRegex = /^-?\d+$/;
        if (intRegex.test(quantityCleaned)) {
          return Number(quantityCleaned);
        }
        return value;

      case "D": // Total column - handle currency and percentages
        const totalCleaned = value.replace(/[\s,;_]+/g, "");

        // Handle percentage notation
        if (totalCleaned.endsWith("%")) {
          const numStr = totalCleaned.slice(0, -1);
          const num = Number(numStr);
          if (!isNaN(num)) {
            return num / 100; // Convert percentage to decimal
          }
        }

        // Handle currency notation
        const currencyRegex = /^[$€£¥]+([0-9.,]+)$/;
        const currencyMatch = totalCleaned.match(currencyRegex);
        if (currencyMatch) {
          const num = Number(currencyMatch[1].replace(/,/g, ""));
          if (!isNaN(num)) {
            return num;
          }
        }

        // Standard number parsing
        const stdNumberRegex = /^-?\d+(\.\d+)?$/;
        if (stdNumberRegex.test(totalCleaned)) {
          return Number(totalCleaned);
        }
        return value;

      default:
        // For other columns, return as-is
        return value;
    }
  };

  // Format numbers for display
  const customRenderer = (cell: ConditionalStyleCallbackData) => {
    // Format price column as currency
    if (cell.col === "B" && typeof cell.value === "number") {
      return new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(cell.value);
    }

    // Format quantity with commas
    if (cell.col === "C" && typeof cell.value === "number") {
      return cell.value.toLocaleString();
    }

    // Format total as currency
    if (cell.col === "D" && typeof cell.value === "number") {
      return new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(cell.value);
    }

    return cell.value;
  };

  return (
    <div>
      <div
        style={{
          marginBottom: "16px",
          padding: "12px",
          backgroundColor: "#f8fafc",
          borderRadius: "8px",
        }}
      >
        <h4>Try entering these values to see automatic parsing:</h4>
        <ul style={{ fontSize: "14px", marginTop: "8px" }}>
          <li>
            <strong>Numbers:</strong> 1234, 1,234, 12.34, -45.67
          </