Files
Atomizer/atomizer-dashboard/frontend/src/components/ExpandableChart.tsx
Antoine 8cbdbcad78 feat: Add Protocol 13 adaptive optimization, Plotly charts, and dashboard improvements
## 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>
2025-12-04 07:41:54 -05:00

300 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}