Building an Interactive Opensees Material Configurer with React

Jeremy Atkinson
Developer

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:

  1. Type Safety is Critical: With complex parameter interdependencies, TypeScript prevented numerous runtime errors
  2. Client-side Validation: Immediate feedback improves user experience significantly over server-side-only validation
  3. Debouncing is Essential: Real-time updates need careful management to prevent performance issues
  4. Progressive Disclosure: Complex interfaces benefit from guided user experiences (user manual modal)
  5. 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

Comments (0)

Loading comments...