## Protocol 13: Adaptive Multi-Objective Optimization - Iterative FEA + Neural Network surrogate workflow - Initial FEA sampling, NN training, NN-accelerated search - FEA validation of top NN predictions, retraining loop - adaptive_state.json tracks iteration history and best values - M1 mirror study (V11) with 103 FEA, 3000 NN trials ## Dashboard Visualization Enhancements - Added Plotly.js interactive charts (parallel coords, Pareto, convergence) - Lazy loading with React.lazy() for performance - Code splitting: plotly.js-basic-dist (~1MB vs 3.5MB) - Chart library toggle (Recharts default, Plotly on-demand) - ExpandableChart component for full-screen modal views - ConsoleOutput component for real-time log viewing ## Documentation - Protocol 13 detailed documentation - Dashboard visualization guide - Plotly components README - Updated run-optimization skill with Mode 5 (adaptive) ## Bug Fixes - Fixed TypeScript errors in dashboard components - Fixed Card component to accept ReactNode title - Removed unused imports across components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
/**
|
||
* ExpandableChart - Wrapper component for charts that can be expanded to full-screen
|
||
*
|
||
* Features:
|
||
* - Expand button to open chart in full-screen modal
|
||
* - Interactive zoom and pan controls in expanded view
|
||
* - Download as PNG/SVG
|
||
* - Reset view button
|
||
*/
|
||
|
||
import React, { useState, useRef, useCallback } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
interface ExpandableChartProps {
|
||
title: string;
|
||
subtitle?: string;
|
||
children: React.ReactNode;
|
||
// Render function for the expanded version with larger dimensions
|
||
renderExpanded?: (width: number, height: number) => React.ReactNode;
|
||
className?: string;
|
||
}
|
||
|
||
export function ExpandableChart({
|
||
title,
|
||
subtitle,
|
||
children,
|
||
renderExpanded,
|
||
className = ''
|
||
}: ExpandableChartProps) {
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [zoom, setZoom] = useState(1);
|
||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||
const [isPanning, setIsPanning] = useState(false);
|
||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const expandedContentRef = useRef<HTMLDivElement>(null);
|
||
|
||
const handleExpand = () => {
|
||
setIsExpanded(true);
|
||
setZoom(1);
|
||
setPan({ x: 0, y: 0 });
|
||
};
|
||
|
||
const handleClose = () => {
|
||
setIsExpanded(false);
|
||
};
|
||
|
||
const handleZoomIn = () => {
|
||
setZoom(prev => Math.min(prev * 1.25, 5));
|
||
};
|
||
|
||
const handleZoomOut = () => {
|
||
setZoom(prev => Math.max(prev / 1.25, 0.25));
|
||
};
|
||
|
||
const handleResetView = () => {
|
||
setZoom(1);
|
||
setPan({ x: 0, y: 0 });
|
||
};
|
||
|
||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||
setZoom(prev => Math.min(Math.max(prev * delta, 0.25), 5));
|
||
}, []);
|
||
|
||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||
if (e.button === 0) { // Left click
|
||
setIsPanning(true);
|
||
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||
}
|
||
}, [pan]);
|
||
|
||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||
if (isPanning) {
|
||
setPan({
|
||
x: e.clientX - panStart.x,
|
||
y: e.clientY - panStart.y
|
||
});
|
||
}
|
||
}, [isPanning, panStart]);
|
||
|
||
const handleMouseUp = useCallback(() => {
|
||
setIsPanning(false);
|
||
}, []);
|
||
|
||
const handleDownload = useCallback((format: 'png' | 'svg') => {
|
||
const content = expandedContentRef.current;
|
||
if (!content) return;
|
||
|
||
const svg = content.querySelector('svg');
|
||
if (!svg) {
|
||
alert('No SVG found to download');
|
||
return;
|
||
}
|
||
|
||
if (format === 'svg') {
|
||
const svgData = new XMLSerializer().serializeToString(svg);
|
||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${title.replace(/\s+/g, '_')}.svg`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} else {
|
||
// PNG export
|
||
const svgData = new XMLSerializer().serializeToString(svg);
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const img = new Image();
|
||
|
||
// Get actual SVG dimensions
|
||
const svgWidth = svg.width.baseVal.value || 800;
|
||
const svgHeight = svg.height.baseVal.value || 600;
|
||
|
||
canvas.width = svgWidth * 2; // 2x for better quality
|
||
canvas.height = svgHeight * 2;
|
||
|
||
img.onload = () => {
|
||
if (ctx) {
|
||
ctx.fillStyle = 'white';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
|
||
const pngUrl = canvas.toDataURL('image/png');
|
||
const a = document.createElement('a');
|
||
a.href = pngUrl;
|
||
a.download = `${title.replace(/\s+/g, '_')}.png`;
|
||
a.click();
|
||
}
|
||
};
|
||
|
||
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData)));
|
||
}
|
||
}, [title]);
|
||
|
||
// Calculate expanded dimensions
|
||
const expandedWidth = typeof window !== 'undefined' ? window.innerWidth - 80 : 1200;
|
||
const expandedHeight = typeof window !== 'undefined' ? window.innerHeight - 200 : 800;
|
||
|
||
const modal = isExpanded ? createPortal(
|
||
<div
|
||
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center"
|
||
onClick={handleClose}
|
||
>
|
||
<div
|
||
className="bg-white rounded-xl shadow-2xl max-w-[95vw] max-h-[95vh] overflow-hidden flex flex-col"
|
||
onClick={e => e.stopPropagation()}
|
||
style={{ width: expandedWidth + 40, height: expandedHeight + 160 }}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||
{subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* Zoom controls */}
|
||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg px-2 py-1">
|
||
<button
|
||
onClick={handleZoomOut}
|
||
className="p-1.5 hover:bg-gray-200 rounded text-gray-700"
|
||
title="Zoom out"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||
</svg>
|
||
</button>
|
||
<span className="text-sm font-medium text-gray-700 min-w-[3rem] text-center">
|
||
{Math.round(zoom * 100)}%
|
||
</span>
|
||
<button
|
||
onClick={handleZoomIn}
|
||
className="p-1.5 hover:bg-gray-200 rounded text-gray-700"
|
||
title="Zoom in"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Reset button */}
|
||
<button
|
||
onClick={handleResetView}
|
||
className="p-2 hover:bg-gray-200 rounded-lg text-gray-700"
|
||
title="Reset view"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Download dropdown */}
|
||
<div className="relative group">
|
||
<button
|
||
className="p-2 hover:bg-gray-200 rounded-lg text-gray-700"
|
||
title="Download"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||
</svg>
|
||
</button>
|
||
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 hidden group-hover:block min-w-[120px]">
|
||
<button
|
||
onClick={() => handleDownload('png')}
|
||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 text-gray-700"
|
||
>
|
||
Download PNG
|
||
</button>
|
||
<button
|
||
onClick={() => handleDownload('svg')}
|
||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 text-gray-700"
|
||
>
|
||
Download SVG
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Close button */}
|
||
<button
|
||
onClick={handleClose}
|
||
className="p-2 hover:bg-red-100 rounded-lg text-gray-700 hover:text-red-600 ml-2"
|
||
title="Close (Esc)"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chart content with zoom/pan */}
|
||
<div
|
||
className="flex-1 overflow-hidden bg-white cursor-grab active:cursor-grabbing"
|
||
onWheel={handleWheel}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseUp}
|
||
>
|
||
<div
|
||
ref={expandedContentRef}
|
||
className="w-full h-full flex items-center justify-center p-4"
|
||
style={{
|
||
transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
|
||
transformOrigin: 'center center',
|
||
transition: isPanning ? 'none' : 'transform 0.1s ease-out'
|
||
}}
|
||
>
|
||
{renderExpanded ? renderExpanded(expandedWidth, expandedHeight) : children}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer with instructions */}
|
||
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 text-xs text-gray-500 flex items-center gap-4">
|
||
<span>🖱️ Scroll to zoom</span>
|
||
<span>✋ Drag to pan</span>
|
||
<span>⌨️ Press Esc to close</span>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
) : null;
|
||
|
||
// Handle Esc key
|
||
React.useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape' && isExpanded) {
|
||
handleClose();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [isExpanded]);
|
||
|
||
return (
|
||
<div ref={containerRef} className={`relative ${className}`}>
|
||
{/* Expand button */}
|
||
<button
|
||
onClick={handleExpand}
|
||
className="absolute top-2 right-2 z-10 p-1.5 bg-white/90 hover:bg-white rounded-lg shadow-sm border border-gray-200 text-gray-600 hover:text-gray-900 transition-colors"
|
||
title="Expand chart"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Original chart content */}
|
||
{children}
|
||
|
||
{/* Modal portal */}
|
||
{modal}
|
||
</div>
|
||
);
|
||
}
|