@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/componentsComponents
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 curveshowStandardDeviation(default:false): Show T-shaped error barsshowEC50Line(default:false): Vertical dashed line at EC50 valueconnectDataPoints(default:false): Dashed lines connecting data pointshideDataPoints(default:false): Hide data points, show only curveslegend(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 statushideDataPoints: 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 textfilledLegend/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 + 150pxfor 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.memocomponent - Efficient Calculations: Cached scale functions and tick values
- SVG Rendering: Optimized for large datasets (100+ series)
- Event Optimization:
useCallbackfor 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
</