Building an Interactive Opensees Material Configurer with React
A deep dive into the frontend architecture of an OpenSees Material Tester - from dynamic parameter forms to real-time hysteresis visualization
Building interactive engineering applications presents unique challenges that differ significantly from typical web development. When I set out to create an OpenSees Material Tester, I needed to handle complex material parameters, real-time data visualization, and provide an intuitive interface for structural engineers. This post explores the frontend architecture and React patterns that made this application possible.
Project Overview
The OpenSees Material Tester is an interactive web application that allows engineers to:
- Configure material models with dynamic parameter forms
- Design loading protocols (cyclic, monotonic, or custom)
- Visualize material hysteresis behavior in real-time
- Compare multiple material configurations
- Export analysis data for further use
The frontend needed to handle 15+ different material types, each with unique parameters, while providing immediate feedback and preventing invalid configurations.
Frontend Architecture
Next.js App Router Setup
The application leverages Next.js 15 with the App Router, providing excellent TypeScript integration and component organization:
// src/app/demos/opensees-uniaxial-material-tester/page.jsx
"use client";
import { BlogHeader } from "@/components/blog/BlogHeader";
import materials from "./materials.json";
import { useState, useEffect, useMemo } from "react";
import { HysteresisChart } from "./HysteresisChart.jsx";
import { LoadingChart } from "./LoadingChart.jsx";
import { MaterialInfoCard } from "./MaterialInfoCard.jsx";
import { openSeesClient } from '@/lib/opensees-client';
The key architectural decisions include:
- Client-side state management for responsive UI updates
- TypeScript interfaces for type safety with material definitions
- Component composition for reusable UI elements
- JSON-driven configuration for material parameters
Dynamic Material Parameter Management
One of the most complex aspects was handling the diverse parameter requirements across different material types. Each material has unique parameters with different types, defaults, and validation rules:
const handleMaterialChange = (materialName) => {
setSelectedMaterial(materialName);
const newParams = {};
const materialDef = materials[materialName];
// First pass: set all non-string defaults
materialDef.parameters.forEach(param => {
if (param.default !== null && typeof param.default !== 'string') {
newParams[param.symbol] = param.default;
}
});
// Second pass: resolve string references to other parameters
materialDef.parameters.forEach(param => {
if (param.default !== null && typeof param.default === 'string') {
// Handle string references like "s2p", "e2p", "epsyP"
const refValue = newParams[param.default];
if (refValue !== undefined) {
newParams[param.symbol] = refValue;
} else {
console.warn(`String reference '${param.default}' not found for parameter '${param.symbol}', using fallback`);
newParams[param.symbol] = param.type === 'float' ? 0.0 : 0;
}
}
});
setMaterialParams(newParams);
};
This approach handles complex parameter interdependencies where some parameters reference others by default (common in structural engineering materials).
Real-time Syntax Generation
Engineers need to see the actual OpenSees command syntax as they configure parameters. This required a reactive system that updates in real-time:
// Generate live syntax with debounced parameter values
const liveSyntax = useMemo(() => {
const params = currentMaterial.parameters.map(param => {
const value = debouncedParams[param.symbol];
if (value !== undefined && value !== '') {
return param.type === "string" ? `'${value}'` : value;
}
return param.default !== null ? param.default : `<${param.symbol}>`;
});
return `uniaxialMaterial('${selectedMaterial}', ${params.join(', ')})`;
}, [debouncedParams, selectedMaterial, currentMaterial.parameters]);
The useMemo
hook ensures the syntax only recalculates when necessary, while debounced parameters prevent excessive updates during typing.
Interactive UI Components
Dynamic Parameter Forms
The parameter form system needed to handle different input types while providing validation and real-time feedback:
const handleParameterChange = (symbol, value) => {
// Find parameter definition to determine type
const paramDef = currentMaterial.parameters.find(p => p.symbol === symbol);
let processedValue = value;
if (paramDef && paramDef.type !== 'string') {
// For numeric parameters, convert to number if valid
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
processedValue = numValue;
} else if (value === '') {
processedValue = '';
}
}
setMaterialParams(prev => ({
...prev,
[symbol]: processedValue
}));
};
Each parameter input includes contextual information and validation based on the material definition:
{currentMaterial.parameters.map(param => (
<FormField
key={param.symbol}
label={`${param.symbol} ${param.default !== null ? `(${param.default})` : ''}`}
note={param.description}
>
<Input
type={param.type === "string" ? "text" : "number"}
value={materialParams[param.symbol] !== undefined ? materialParams[param.symbol] : ""}
onChange={(e) => handleParameterChange(param.symbol, e.target.value)}
placeholder={typeof param.default === 'string' ? `ref: ${param.default}` : (param.default || "")}
step={param.type === "float" ? "0.001" : "1"}
title={param.description}
/>
</FormField>
))}
Chart Layer Management System
The application needed to support comparing multiple material configurations simultaneously. This required a sophisticated layer management system:
const [chartSeries, setChartSeries] = useState([
{
id: 1,
name: "Current Material",
visible: true,
type: "current",
material: "Steel01",
params: {},
color: "#3b82f6"
}
]);
const freezeCurrentSeries = () => {
const currentSeries = chartSeries.find(series => series.type === 'current');
if (!currentSeries || !seriesData[currentSeries.id]) return;
const newId = Math.max(...chartSeries.map(s => s.id), 0) + 1;
const newSeries = {
id: newId,
name: `${selectedMaterial} (Saved)`,
visible: true,
type: "frozen",
material: selectedMaterial,
params: { ...materialParams },
color: `hsl(${(newId * 137.5) % 360}, 70%, 50%)`
};
// Copy the current series data to the new frozen series
setSeriesData(prev => ({
...prev,
[newId]: prev[currentSeries.id]
}));
setChartSeries(prev => [...prev, newSeries]);
};
This allows users to save interesting configurations and compare them visually, with automatic color assignment using HSL color space for good visual separation.
Loading Protocol Designer
The application supports three types of loading protocols: cyclic, monotonic, and custom file uploads. The client-side protocol generation ensures immediate preview:
// loadingProtocols.js
function generateCyclicProtocol({ max_strain, num_cycles, num_steps = 200, strain_increment = 'evenly_per_cycle' }) {
// Validate inputs
const validMaxStrain = Math.abs(parseFloat(max_strain) || 2.0);
const validNumCycles = Math.max(1, parseInt(num_cycles) || 3);
const validNumSteps = Math.max(10, parseInt(num_steps) || 200);
if (strain_increment === 'evenly_per_cycle') {
return generateEvenlySpacedCyclicProtocol(validMaxStrain, validNumCycles, validNumSteps);
} else {
return generateBasicCyclicProtocol(validMaxStrain, validNumCycles, validNumSteps);
}
}
Custom file uploads are handled with robust error handling:
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (file) {
setCustomFile(file);
setError(null);
try {
const content = await readFileAsText(file);
const strains = generateLoadingProtocol('custom', {}, content);
setCustomStrains(strains);
console.log('Successfully processed custom file:', strains.length, 'strain points');
} catch (error) {
console.error('Error processing custom file:', error);
setCustomStrains([]);
setError(`Failed to process custom file: ${error.message}`);
}
}
};
Data Visualization with Recharts
The visualization layer uses Recharts for responsive, interactive charts. The hysteresis chart needed to handle multiple series with different colors and visibility states:
// HysteresisChart.jsx component structure
const HysteresisChart = ({ chartSeries, seriesData, backboneData, loading, error }) => {
const visibleSeries = chartSeries.filter(series => series.visible);
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="strain"
type="number"
scale="linear"
tickFormatter={(value) => `${(value * 100).toFixed(2)}%`}
/>
<YAxis
dataKey="stress"
type="number"
scale="linear"
/>
<Tooltip formatter={(value, name) => [
typeof value === 'number' ? value.toFixed(2) : value,
name
]} />
{visibleSeries.map(series => (
<Line
key={series.id}
dataKey="stress"
data={series.type === 'backbone' ? backboneData[series.id] : seriesData[series.id]}
stroke={series.color}
strokeWidth={series.type === 'current' ? 3 : 2}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
);
};
Performance Optimization Patterns
Debounced Parameter Updates
To prevent excessive API calls while users are typing, the application uses debounced parameter updates:
// Debounce materialParams
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedParams(materialParams);
}, 300);
return () => clearTimeout(timer);
}, [materialParams, selectedMaterial]);
Optimized Loading Protocol Generation
Loading protocols are generated client-side and cached to prevent recalculation:
// Generate loading protocol for preview whenever parameters change
useEffect(() => {
try {
if (loadingProtocol === 'custom') {
if (customStrains.length > 0) {
setCurrentLoadingStrains(customStrains);
} else {
setCurrentLoadingStrains([]);
}
} else {
const strains = generateLoadingProtocol(loadingProtocol, loadingParams);
setCurrentLoadingStrains(strains);
// Clear any previous errors when successful
if (error && error.includes('loading protocol')) {
setError(null);
}
}
} catch (error) {
console.error('Error generating loading preview:', error);
setCurrentLoadingStrains([]);
setError(`Loading protocol error: ${error.message}`);
}
}, [loadingProtocol, loadingParams, customStrains]);
Error Handling and User Experience
The application provides comprehensive error handling with user-friendly messages:
const updateMaterialData = async () => {
setLoading(true);
setError(null);
try {
// Check if server is available first
await openSeesClient.healthCheck();
// Generate loading protocol on client side
let strains;
if (loadingProtocol === 'custom' && customStrains.length > 0) {
strains = customStrains;
} else {
if (loadingProtocol === 'custom' && (!customFile || customStrains.length === 0)) {
throw new Error('Please upload a custom loading protocol file first');
}
strains = generateLoadingProtocol(loadingProtocol, loadingParams);
}
const response = await openSeesClient.analyzeMaterial({
material_type: selectedMaterial,
parameters: materialParams,
strain_history: strains
});
// Process successful response...
} catch (err) {
console.error('Failed to fetch hysteresis data:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
Key Lessons Learned
Building this interactive engineering application taught several valuable lessons:
- Type Safety is Critical: With complex parameter interdependencies, TypeScript prevented numerous runtime errors
- Client-side Validation: Immediate feedback improves user experience significantly over server-side-only validation
- Debouncing is Essential: Real-time updates need careful management to prevent performance issues
- Progressive Disclosure: Complex interfaces benefit from guided user experiences (user manual modal)
- Data Export is Crucial: Engineers need to export data for further analysis - this should never be an afterthought
Conclusion
Creating an interactive material testing interface required balancing engineering domain complexity with modern web development patterns. The React ecosystem, combined with thoughtful state management and real-time visualization, enabled a powerful tool that brings OpenSees analysis to the browser.
The key to success was understanding the engineering workflow and translating complex material behavior into intuitive user interactions. Each technical decision - from parameter debouncing to chart layer management - directly supports the engineer's analytical process.
In the next post, I'll explore the backend architecture that makes this possible, including OpenSees integration, process pool management, and production deployment considerations.
Jeremy Atkinson
Jeremy is a structural engineer, researcher, and developer from BC. He works on Calcs.app and writes at Kinson.io