Initial commit
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* PDECanvas - Interactive 2D canvas for boundary condition editing and solution display
|
||||
*
|
||||
* Features:
|
||||
* - Draw/erase boundary conditions with mouse
|
||||
* - Visualize input field with colormap
|
||||
* - Display solution from neural operator inference
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback, useState } from "react";
|
||||
import type { ColorMap, BrushTool } from "../../lib/neural-operator-types";
|
||||
import { getColor } from "../../lib/colors";
|
||||
|
||||
interface PDECanvasProps {
|
||||
/** Grid resolution (width/height) */
|
||||
resolution: number;
|
||||
/** Input field values (flattened [H, W]) */
|
||||
inputField: Float32Array;
|
||||
/** Solution field values (optional, for display mode) */
|
||||
solutionField?: Float32Array | null;
|
||||
/** Whether to show solution instead of input */
|
||||
showSolution?: boolean;
|
||||
/** Color map to use */
|
||||
colorMap: ColorMap;
|
||||
/** Whether editing is enabled */
|
||||
editable?: boolean;
|
||||
/** Current brush tool */
|
||||
brushTool?: BrushTool;
|
||||
/** Brush size in pixels */
|
||||
brushSize?: number;
|
||||
/** Brush value for drawing (0-1) */
|
||||
brushValue?: number;
|
||||
/** Callback when input field is modified */
|
||||
onFieldChange?: (field: Float32Array) => void;
|
||||
/** Canvas title/label */
|
||||
title?: string;
|
||||
/** Min/max values for colorbar (auto-computed if not provided) */
|
||||
valueRange?: [number, number];
|
||||
}
|
||||
|
||||
export function PDECanvas({
|
||||
resolution,
|
||||
inputField,
|
||||
solutionField,
|
||||
showSolution = false,
|
||||
colorMap,
|
||||
editable = false,
|
||||
brushTool = 'draw',
|
||||
brushSize = 3,
|
||||
brushValue = 1.0,
|
||||
onFieldChange,
|
||||
title,
|
||||
valueRange,
|
||||
}: PDECanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [canvasSize, setCanvasSize] = useState(400);
|
||||
|
||||
// Get the field to display
|
||||
const displayField = showSolution && solutionField ? solutionField : inputField;
|
||||
|
||||
// Compute value range if not provided
|
||||
const [minVal, maxVal] = valueRange ?? computeRange(displayField);
|
||||
|
||||
// Handle window resize for responsive canvas
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.clientWidth;
|
||||
setCanvasSize(Math.min(width - 20, 500));
|
||||
}
|
||||
};
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
|
||||
// Draw the field on canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const imgData = ctx.createImageData(resolution, resolution);
|
||||
|
||||
for (let i = 0; i < displayField.length; i++) {
|
||||
const val = displayField[i];
|
||||
const normalized = maxVal > minVal
|
||||
? (val - minVal) / (maxVal - minVal)
|
||||
: 0;
|
||||
|
||||
const rgb = getColor(normalized, colorMap);
|
||||
const idx = i * 4;
|
||||
imgData.data[idx] = rgb[0];
|
||||
imgData.data[idx + 1] = rgb[1];
|
||||
imgData.data[idx + 2] = rgb[2];
|
||||
imgData.data[idx + 3] = 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
}, [displayField, resolution, colorMap, minVal, maxVal]);
|
||||
|
||||
// Convert canvas coordinates to grid coordinates
|
||||
const canvasToGrid = useCallback((clientX: number, clientY: number): [number, number] => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return [0, 0];
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = resolution / rect.width;
|
||||
const scaleY = resolution / rect.height;
|
||||
|
||||
const x = Math.floor((clientX - rect.left) * scaleX);
|
||||
const y = Math.floor((clientY - rect.top) * scaleY);
|
||||
|
||||
return [
|
||||
Math.max(0, Math.min(resolution - 1, x)),
|
||||
Math.max(0, Math.min(resolution - 1, y)),
|
||||
];
|
||||
}, [resolution]);
|
||||
|
||||
// Apply brush at position
|
||||
const applyBrush = useCallback((x: number, y: number) => {
|
||||
if (!editable || !onFieldChange) return;
|
||||
|
||||
const newField = new Float32Array(inputField);
|
||||
const halfSize = Math.floor(brushSize / 2);
|
||||
|
||||
for (let dy = -halfSize; dy <= halfSize; dy++) {
|
||||
for (let dx = -halfSize; dx <= halfSize; dx++) {
|
||||
const px = x + dx;
|
||||
const py = y + dy;
|
||||
|
||||
if (px >= 0 && px < resolution && py >= 0 && py < resolution) {
|
||||
// Check if within circular brush
|
||||
if (dx * dx + dy * dy <= halfSize * halfSize) {
|
||||
const idx = py * resolution + px;
|
||||
|
||||
if (brushTool === 'draw') {
|
||||
newField[idx] = brushValue;
|
||||
} else if (brushTool === 'erase') {
|
||||
newField[idx] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onFieldChange(newField);
|
||||
}, [editable, inputField, resolution, brushTool, brushSize, brushValue, onFieldChange]);
|
||||
|
||||
// Fill entire field
|
||||
const fillField = useCallback((value: number) => {
|
||||
if (!editable || !onFieldChange) return;
|
||||
const newField = new Float32Array(resolution * resolution);
|
||||
newField.fill(value);
|
||||
onFieldChange(newField);
|
||||
}, [editable, resolution, onFieldChange]);
|
||||
|
||||
// Mouse event handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!editable) return;
|
||||
|
||||
if (brushTool === 'fill') {
|
||||
fillField(brushValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDrawing(true);
|
||||
const [x, y] = canvasToGrid(e.clientX, e.clientY);
|
||||
applyBrush(x, y);
|
||||
}, [editable, brushTool, brushValue, canvasToGrid, applyBrush, fillField]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDrawing || !editable) return;
|
||||
const [x, y] = canvasToGrid(e.clientX, e.clientY);
|
||||
applyBrush(x, y);
|
||||
}, [isDrawing, editable, canvasToGrid, applyBrush]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDrawing(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsDrawing(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="pde-canvas-container">
|
||||
{title && <h4 className="canvas-title">{title}</h4>}
|
||||
<div className="canvas-wrapper" style={{
|
||||
backgroundColor: '#1a1a2e',
|
||||
borderRadius: '8px',
|
||||
padding: '10px',
|
||||
}}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={resolution}
|
||||
height={resolution}
|
||||
style={{
|
||||
width: `${canvasSize}px`,
|
||||
height: `${canvasSize}px`,
|
||||
imageRendering: 'pixelated',
|
||||
border: '1px solid #333',
|
||||
cursor: editable
|
||||
? brushTool === 'fill' ? 'crosshair' : 'crosshair'
|
||||
: 'default',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Colorbar */}
|
||||
<div className="colorbar" style={{ marginTop: '10px' }}>
|
||||
<div style={{
|
||||
height: '20px',
|
||||
background: generateGradient(colorMap),
|
||||
borderRadius: '4px',
|
||||
}} />
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px',
|
||||
color: '#888',
|
||||
marginTop: '4px',
|
||||
}}>
|
||||
<span>{minVal.toFixed(2)}</span>
|
||||
<span>{((minVal + maxVal) / 2).toFixed(2)}</span>
|
||||
<span>{maxVal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute min/max range of a field */
|
||||
function computeRange(field: Float32Array): [number, number] {
|
||||
if (field.length === 0) return [0, 1];
|
||||
|
||||
let min = field[0];
|
||||
let max = field[0];
|
||||
|
||||
for (let i = 1; i < field.length; i++) {
|
||||
if (field[i] < min) min = field[i];
|
||||
if (field[i] > max) max = field[i];
|
||||
}
|
||||
|
||||
// Ensure non-zero range
|
||||
if (max === min) {
|
||||
return [min - 0.5, max + 0.5];
|
||||
}
|
||||
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
/** Generate CSS gradient for colorbar */
|
||||
function generateGradient(colorMap: ColorMap): string {
|
||||
const stops: string[] = [];
|
||||
const numStops = 10;
|
||||
|
||||
for (let i = 0; i <= numStops; i++) {
|
||||
const t = i / numStops;
|
||||
const rgb = getColor(t, colorMap);
|
||||
stops.push(`rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]}) ${t * 100}%`);
|
||||
}
|
||||
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
export default PDECanvas;
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* PDEControls - Control panel for Neural Operator demo
|
||||
*
|
||||
* Provides controls for:
|
||||
* - PDE type selection
|
||||
* - Resolution settings
|
||||
* - Brush tools for drawing boundary conditions
|
||||
* - Solve button and metrics display
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type {
|
||||
PDEType,
|
||||
ColorMap,
|
||||
BrushTool,
|
||||
ModelInfo,
|
||||
NeuralOperatorMetrics,
|
||||
TrainingConfig,
|
||||
TrainingProgress as TrainingProgressType,
|
||||
} from "../../lib/neural-operator-types";
|
||||
import { PDE_TYPES, TRAINING_PRESETS } from "../../lib/neural-operator-types";
|
||||
import { TrainingProgress } from "./TrainingProgress";
|
||||
|
||||
interface PDEControlsProps {
|
||||
/** Current PDE type */
|
||||
pdeType: PDEType;
|
||||
/** Callback when PDE type changes */
|
||||
onPdeTypeChange: (type: PDEType) => void;
|
||||
/** Current resolution */
|
||||
resolution: number;
|
||||
/** Callback when resolution changes */
|
||||
onResolutionChange: (resolution: number) => void;
|
||||
/** Whether the model is initialized */
|
||||
isInitialized: boolean;
|
||||
/** Whether solve is in progress */
|
||||
isSolving: boolean;
|
||||
/** Callback to initialize model */
|
||||
onInitialize: () => void;
|
||||
/** Callback to solve PDE */
|
||||
onSolve: () => void;
|
||||
/** Callback to reset */
|
||||
onReset: () => void;
|
||||
/** Callback to clear input field */
|
||||
onClearInput: () => void;
|
||||
/** Model info (if initialized) */
|
||||
modelInfo?: ModelInfo | null;
|
||||
/** Performance metrics */
|
||||
metrics?: NeuralOperatorMetrics | null;
|
||||
/** Last inference time */
|
||||
lastInferenceTime?: number;
|
||||
/** Error message */
|
||||
error?: string | null;
|
||||
/** Current brush tool */
|
||||
brushTool: BrushTool;
|
||||
/** Callback when brush tool changes */
|
||||
onBrushToolChange: (tool: BrushTool) => void;
|
||||
/** Current brush size */
|
||||
brushSize: number;
|
||||
/** Callback when brush size changes */
|
||||
onBrushSizeChange: (size: number) => void;
|
||||
/** Current brush value */
|
||||
brushValue: number;
|
||||
/** Callback when brush value changes */
|
||||
onBrushValueChange: (value: number) => void;
|
||||
/** Current colormap */
|
||||
colorMap: ColorMap;
|
||||
/** Callback when colormap changes */
|
||||
onColorMapChange: (colorMap: ColorMap) => void;
|
||||
/** Whether training is in progress */
|
||||
isTraining?: boolean;
|
||||
/** Current training progress */
|
||||
trainingProgress?: TrainingProgressType | null;
|
||||
/** Callback to start training */
|
||||
onStartTraining?: (config: TrainingConfig) => void;
|
||||
/** Callback to cancel training */
|
||||
onCancelTraining?: () => void;
|
||||
}
|
||||
|
||||
/** Available resolution options */
|
||||
const RESOLUTIONS = [32, 64, 128, 256];
|
||||
|
||||
/** Available colormaps */
|
||||
const COLORMAPS: { id: ColorMap; name: string }[] = [
|
||||
{ id: 'viridis', name: 'Viridis' },
|
||||
{ id: 'plasma', name: 'Plasma' },
|
||||
{ id: 'inferno', name: 'Inferno' },
|
||||
{ id: 'magma', name: 'Magma' },
|
||||
{ id: 'coolwarm', name: 'Cool-Warm' },
|
||||
];
|
||||
|
||||
export function PDEControls({
|
||||
pdeType,
|
||||
onPdeTypeChange,
|
||||
resolution,
|
||||
onResolutionChange,
|
||||
isInitialized,
|
||||
isSolving,
|
||||
onInitialize,
|
||||
onSolve,
|
||||
onReset,
|
||||
onClearInput,
|
||||
modelInfo,
|
||||
metrics,
|
||||
lastInferenceTime,
|
||||
error,
|
||||
brushTool,
|
||||
onBrushToolChange,
|
||||
brushSize,
|
||||
onBrushSizeChange,
|
||||
brushValue,
|
||||
onBrushValueChange,
|
||||
colorMap,
|
||||
onColorMapChange,
|
||||
isTraining = false,
|
||||
trainingProgress = null,
|
||||
onStartTraining,
|
||||
onCancelTraining,
|
||||
}: PDEControlsProps) {
|
||||
// Collapsible sections
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showTrainingOptions, setShowTrainingOptions] = useState(false);
|
||||
const [trainingPreset, setTrainingPreset] = useState<'quick' | 'standard' | 'extended'>('quick');
|
||||
|
||||
// Get current PDE info
|
||||
const currentPDE = PDE_TYPES.find((p) => p.id === pdeType);
|
||||
|
||||
return (
|
||||
<div className="control-panel" style={{ width: '280px', flexShrink: 0 }}>
|
||||
{/* PDE Selection */}
|
||||
<div className="panel-section">
|
||||
<h3>PDE Type</h3>
|
||||
<select
|
||||
value={pdeType}
|
||||
onChange={(e) => onPdeTypeChange(e.target.value as PDEType)}
|
||||
disabled={isInitialized}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
{PDE_TYPES.map((pde) => (
|
||||
<option key={pde.id} value={pde.id}>
|
||||
{pde.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{currentPDE && (
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '8px' }}>
|
||||
{currentPDE.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<div className="panel-section">
|
||||
<h3>Resolution</h3>
|
||||
<select
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange(parseInt(e.target.value))}
|
||||
disabled={isInitialized}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
{RESOLUTIONS.map((res) => (
|
||||
<option key={res} value={res}>
|
||||
{res} x {res}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="panel-section">
|
||||
<h3>Simulation</h3>
|
||||
{!isInitialized ? (
|
||||
<button
|
||||
onClick={onInitialize}
|
||||
className="primary-button"
|
||||
style={{ width: '100%', padding: '12px', marginBottom: '8px' }}
|
||||
>
|
||||
Initialize Model
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onSolve}
|
||||
disabled={isSolving}
|
||||
className="primary-button"
|
||||
style={{ width: '100%', padding: '12px', marginBottom: '8px' }}
|
||||
>
|
||||
{isSolving ? 'Solving...' : 'Solve PDE'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClearInput}
|
||||
disabled={isSolving}
|
||||
className="secondary-button"
|
||||
style={{ width: '100%', padding: '8px', marginBottom: '8px' }}
|
||||
>
|
||||
Clear Input
|
||||
</button>
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isSolving}
|
||||
className="danger-button"
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
Reset Model
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brush Tools (when initialized) */}
|
||||
{isInitialized && (
|
||||
<div className="panel-section">
|
||||
<h3>Brush Tools</h3>
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
|
||||
<button
|
||||
onClick={() => onBrushToolChange('draw')}
|
||||
className={brushTool === 'draw' ? 'tool-button active' : 'tool-button'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
backgroundColor: brushTool === 'draw' ? '#4CAF50' : '#2a2a3e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Draw
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onBrushToolChange('erase')}
|
||||
className={brushTool === 'erase' ? 'tool-button active' : 'tool-button'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
backgroundColor: brushTool === 'erase' ? '#f44336' : '#2a2a3e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Erase
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onBrushToolChange('fill')}
|
||||
className={brushTool === 'fill' ? 'tool-button active' : 'tool-button'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
backgroundColor: brushTool === 'fill' ? '#2196F3' : '#2a2a3e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Fill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', color: '#888', marginBottom: '4px' }}>
|
||||
Brush Size: {brushSize}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
value={brushSize}
|
||||
onChange={(e) => onBrushSizeChange(parseInt(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '12px', color: '#888', marginBottom: '4px' }}>
|
||||
Brush Value: {brushValue.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={brushValue}
|
||||
onChange={(e) => onBrushValueChange(parseFloat(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Colormap */}
|
||||
<div className="panel-section">
|
||||
<h3>Colormap</h3>
|
||||
<select
|
||||
value={colorMap}
|
||||
onChange={(e) => onColorMapChange(e.target.value as ColorMap)}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
{COLORMAPS.map((cm) => (
|
||||
<option key={cm.id} value={cm.id}>
|
||||
{cm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Training Section */}
|
||||
{isInitialized && onStartTraining && (
|
||||
<div className="panel-section">
|
||||
<h3>Model Training</h3>
|
||||
{isTraining && trainingProgress ? (
|
||||
<TrainingProgress
|
||||
progress={trainingProgress}
|
||||
onCancel={onCancelTraining || (() => {})}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onStartTraining(TRAINING_PRESETS[trainingPreset])}
|
||||
disabled={isSolving}
|
||||
className="primary-button"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#FF9800',
|
||||
}}
|
||||
>
|
||||
Train Model
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTrainingOptions(!showTrainingOptions)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{showTrainingOptions ? '- Hide' : '+ Show'} Training Options
|
||||
</button>
|
||||
{showTrainingOptions && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<label style={{ display: 'block', fontSize: '12px', color: '#888', marginBottom: '4px' }}>
|
||||
Training Preset
|
||||
</label>
|
||||
<select
|
||||
value={trainingPreset}
|
||||
onChange={(e) => setTrainingPreset(e.target.value as 'quick' | 'standard' | 'extended')}
|
||||
style={{ width: '100%', padding: '8px', marginBottom: '8px' }}
|
||||
>
|
||||
<option value="quick">Quick (10 epochs, 100 samples)</option>
|
||||
<option value="standard">Standard (50 epochs, 1000 samples)</option>
|
||||
<option value="extended">Extended (100 epochs, 5000 samples)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||
<p>Epochs: {TRAINING_PRESETS[trainingPreset].epochs}</p>
|
||||
<p>Batch Size: {TRAINING_PRESETS[trainingPreset].batch_size}</p>
|
||||
<p>Training Samples: {TRAINING_PRESETS[trainingPreset].n_train_samples}</p>
|
||||
<p>Validation Samples: {TRAINING_PRESETS[trainingPreset].n_val_samples}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
{isInitialized && (
|
||||
<div className="panel-section">
|
||||
<h3>Performance</h3>
|
||||
{lastInferenceTime !== undefined && lastInferenceTime > 0 && (
|
||||
<p style={{ color: '#4CAF50' }}>
|
||||
Last solve: {lastInferenceTime.toFixed(2)} ms
|
||||
</p>
|
||||
)}
|
||||
{metrics && (
|
||||
<>
|
||||
<p>Inferences: {metrics.inference_count}</p>
|
||||
<p>Avg time: {metrics.avg_inference_time_ms.toFixed(2)} ms</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Info (Advanced) */}
|
||||
{isInitialized && modelInfo && (
|
||||
<div className="panel-section">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? '- Hide' : '+ Show'} Model Details
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div style={{ fontSize: '12px', color: '#888', marginTop: '8px' }}>
|
||||
<p>Model: {modelInfo.name}</p>
|
||||
<p>Modes: {modelInfo.n_modes[0]} x {modelInfo.n_modes[1]}</p>
|
||||
<p>Width: {modelInfo.model_width}</p>
|
||||
<p>Layers: {modelInfo.n_layers}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="panel-section error-section">
|
||||
<h3>Error</h3>
|
||||
<p style={{ color: '#f44336', fontSize: '12px' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PDEControls;
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* TrainingProgress - Progress display for FNO model training
|
||||
*
|
||||
* Shows:
|
||||
* - Current training status (generating data, training, etc.)
|
||||
* - Epoch and batch progress
|
||||
* - Loss values (current, best, validation)
|
||||
* - ETA and speed metrics
|
||||
* - Mini loss chart
|
||||
* - Cancel button
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { TrainingProgress as TrainingProgressType } from "../../lib/neural-operator-types";
|
||||
|
||||
interface TrainingProgressProps {
|
||||
/** Current training progress */
|
||||
progress: TrainingProgressType;
|
||||
/** Callback to cancel training */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds as human-readable time
|
||||
*/
|
||||
function formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.round((seconds % 3600) / 60);
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status text and color
|
||||
*/
|
||||
function getStatusInfo(status: TrainingProgressType['status']): { text: string; color: string } {
|
||||
switch (status.status) {
|
||||
case 'NotStarted':
|
||||
return { text: 'Not Started', color: '#888' };
|
||||
case 'GeneratingData':
|
||||
return { text: 'Generating Training Data...', color: '#2196F3' };
|
||||
case 'Training':
|
||||
return { text: 'Training Model', color: '#4CAF50' };
|
||||
case 'Complete':
|
||||
return { text: 'Training Complete!', color: '#4CAF50' };
|
||||
case 'Cancelled':
|
||||
return { text: 'Training Cancelled', color: '#FF9800' };
|
||||
case 'Error':
|
||||
return { text: `Error: ${status.details}`, color: '#f44336' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini loss chart component
|
||||
*/
|
||||
function LossChart({ losses, width = 200, height = 60 }: { losses: number[]; width?: number; height?: number }) {
|
||||
const path = useMemo(() => {
|
||||
if (losses.length < 2) return '';
|
||||
|
||||
const maxLoss = Math.max(...losses);
|
||||
const minLoss = Math.min(...losses);
|
||||
const range = maxLoss - minLoss || 1;
|
||||
|
||||
const points = losses.map((loss, i) => {
|
||||
const x = (i / (losses.length - 1)) * width;
|
||||
const y = height - ((loss - minLoss) / range) * height;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return `M${points.join(' L')}`;
|
||||
}, [losses, width, height]);
|
||||
|
||||
if (losses.length < 2) {
|
||||
return (
|
||||
<div style={{ width, height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
<span style={{ fontSize: '11px' }}>Waiting for data...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} style={{ display: 'block' }}>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#4CAF50"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrainingProgress({ progress, onCancel }: TrainingProgressProps) {
|
||||
const statusInfo = getStatusInfo(progress.status);
|
||||
const isActive = progress.status.status === 'GeneratingData' || progress.status.status === 'Training';
|
||||
const progressPercent = progress.total_epochs > 0
|
||||
? (progress.epoch / progress.total_epochs) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#1a1a2e',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #333',
|
||||
}}
|
||||
>
|
||||
{/* Status Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
{isActive && (
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: statusInfo.color,
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ color: statusInfo.color, fontWeight: 500 }}>
|
||||
{statusInfo.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Data Generation Progress */}
|
||||
{progress.status.status === 'GeneratingData' && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#888', marginBottom: '4px' }}>
|
||||
<span>Generating samples</span>
|
||||
<span>
|
||||
{progress.status.details.samples_generated} / {progress.status.details.total_samples}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: '6px', backgroundColor: '#333', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${(progress.status.details.samples_generated / progress.status.details.total_samples) * 100}%`,
|
||||
backgroundColor: '#2196F3',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Training Progress */}
|
||||
{(progress.status.status === 'Training' || progress.status.status === 'Complete') && (
|
||||
<>
|
||||
{/* Epoch Progress */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#888', marginBottom: '4px' }}>
|
||||
<span>Epoch</span>
|
||||
<span>{progress.epoch} / {progress.total_epochs}</span>
|
||||
</div>
|
||||
<div style={{ height: '8px', backgroundColor: '#333', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: '#4CAF50',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loss Values */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
|
||||
<div style={{ padding: '8px', backgroundColor: '#252540', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', marginBottom: '2px' }}>Current Loss</div>
|
||||
<div style={{ fontSize: '14px', color: '#fff', fontFamily: 'monospace' }}>
|
||||
{progress.loss.toExponential(3)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px', backgroundColor: '#252540', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', marginBottom: '2px' }}>Best Loss</div>
|
||||
<div style={{ fontSize: '14px', color: '#4CAF50', fontFamily: 'monospace' }}>
|
||||
{progress.best_loss.toExponential(3)}
|
||||
</div>
|
||||
</div>
|
||||
{progress.val_loss !== null && (
|
||||
<div style={{ padding: '8px', backgroundColor: '#252540', borderRadius: '4px', gridColumn: 'span 2' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', marginBottom: '2px' }}>Validation Loss</div>
|
||||
<div style={{ fontSize: '14px', color: '#2196F3', fontFamily: 'monospace' }}>
|
||||
{progress.val_loss.toExponential(3)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loss Chart */}
|
||||
{progress.loss_history.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', marginBottom: '4px' }}>Loss History</div>
|
||||
<div style={{ padding: '8px', backgroundColor: '#252540', borderRadius: '4px' }}>
|
||||
<LossChart losses={progress.loss_history} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device and LR Info */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
|
||||
<div style={{ padding: '8px', backgroundColor: '#252540', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', marginBottom: '2px' }}>Device</div>
|
||||
<div style={{ fontSize: '12px', color: '#fff', fontFamily: 'monospace' }}>
|
||||
{progress.device || 'CPU'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px', backgroundColor: '#252540', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#888', marginBottom: '2px' }}>Learning Rate</div>
|
||||
<div style={{ fontSize: '12px', color: '#FF9800', fontFamily: 'monospace' }}>
|
||||
{progress.current_lr ? progress.current_lr.toExponential(2) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Stats */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#888', marginBottom: '12px' }}>
|
||||
<span>Speed: {progress.samples_per_sec.toFixed(0)} samples/sec</span>
|
||||
<span>ETA: {formatTime(progress.eta_seconds)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||
Elapsed: {formatTime(progress.elapsed_seconds)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel Button */}
|
||||
{isActive && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '16px',
|
||||
padding: '10px',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #f44336',
|
||||
borderRadius: '4px',
|
||||
color: '#f44336',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f44336';
|
||||
e.currentTarget.style.color = 'white';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#f44336';
|
||||
}}
|
||||
>
|
||||
Cancel Training
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Completion Message */}
|
||||
{progress.status.status === 'Complete' && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#4CAF50', fontSize: '14px' }}>
|
||||
Model trained successfully! Click "Solve" to test.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add CSS animation */}
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrainingProgress;
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Neural Operator Demo Components
|
||||
*/
|
||||
|
||||
export { PDECanvas } from './PDECanvas';
|
||||
export { PDEControls } from './PDEControls';
|
||||
export { TrainingProgress } from './TrainingProgress';
|
||||
Reference in New Issue
Block a user