Files
diagram_ph/Frontend/src/components/PHDiagramModern.tsx
Repo Bot 6ff041d2a8 feat: Add Docker Compose setup for fullstack deployment
- Add Frontend Dockerfile with Next.js standalone build
- Add docker-compose.yml for production deployment
- Add docker-compose.dev.yml for development with hot-reload
- Configure Frontend next.config.js with standalone output
- Add .dockerignore files for both backend and frontend
- Add comprehensive README-DOCKER.md documentation
- Update .gitignore to exclude node_modules and build artifacts
- Remove obsolete component files (CycleCalculator.tsx, PHDiagram.tsx)
- Backend and Frontend communicate via Docker network
- Healthchecks configured for both services
- Environment variables configured for API URL
2025-10-19 12:38:19 +02:00

1499 lines
56 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.
"use client";
import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '@/lib/api-client';
import type { CycleCalculationRequest, CycleCalculationResponse, RefrigerantInfo } from '@/types/api';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LabelList } from 'recharts';
export default function PHDiagramModern() {
const [refrigerants, setRefrigerants] = useState<RefrigerantInfo[]>([]);
const [selectedRefrigerant, setSelectedRefrigerant] = useState('R290');
const [cycleData, setCycleData] = useState<CycleCalculationResponse | null>(null);
const [diagramData, setDiagramData] = useState<any>(null);
const [inputType, setInputType] = useState<'temperature' | 'pressure'>('pressure');
const [evaporatingValue, setEvaporatingValue] = useState('3');
const [condensingValue, setCondensingValue] = useState('12');
const [superheating, setSuperheating] = useState('0');
const [subcooling, setSubcooling] = useState('2');
const [compressorEfficiency, setCompressorEfficiency] = useState('0.7');
const [massFlowRate, setMassFlowRate] = useState('1.0');
const [loading, setLoading] = useState(false);
const [lastMetadata, setLastMetadata] = useState<any | null>(null);
const [resolution, setResolution] = useState<'low' | 'medium' | 'high'>('medium');
const [debounceMs, setDebounceMs] = useState<number>(400);
const [error, setError] = useState<string | null>(null);
const [testPoints, setTestPoints] = useState<Array<{enthalpy: number, pressure: number}> | null>(null);
useEffect(() => {
apiClient.getRefrigerants()
.then(setRefrigerants)
.catch(err => {
console.error('Failed to load refrigerants:', err);
setError('Cannot connect to API on port 8001');
});
}, []);
// small spinner component
const Spinner = () => (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 14, height: 14, borderRadius: 7, border: '2px solid rgba(0,0,0,0.1)', borderTopColor: '#3b82f6', animation: 'spin 1s linear infinite' }} />
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
</div>
);
const handleGenerate = useCallback(async () => {
if (!selectedRefrigerant) return;
setLoading(true);
setError(null);
try {
const request: CycleCalculationRequest = {
refrigerant: selectedRefrigerant,
superheat: parseFloat(superheating),
subcool: parseFloat(subcooling),
compressor_efficiency: parseFloat(compressorEfficiency),
mass_flow: parseFloat(massFlowRate)
};
if (inputType === 'temperature') {
request.evap_temperature = parseFloat(evaporatingValue);
request.cond_temperature = parseFloat(condensingValue);
} else {
request.evap_pressure = parseFloat(evaporatingValue);
request.cond_pressure = parseFloat(condensingValue);
}
const cycleResponse = await apiClient.calculateCycle(request);
setCycleData(cycleResponse);
if (cycleResponse.points && cycleResponse.points.length >= 4) {
const cyclePoints = cycleResponse.points.map(point => ({
enthalpy: point.enthalpy || 0,
pressure: point.pressure
}));
const pressures = cyclePoints.map(p => p.pressure);
const diagramRequest = {
refrigerant: selectedRefrigerant,
pressure_range: {
min: Math.min(...pressures) * 0.5,
max: Math.max(...pressures) * 1.5
},
include_isotherms: true,
cycle_points: cyclePoints,
format: 'json'
};
const diagramResponse = await apiClient.generateDiagram(diagramRequest);
if (diagramResponse.data) {
setDiagramData(diagramResponse.data);
}
setLastMetadata(diagramResponse.metadata || null);
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [
selectedRefrigerant,
inputType,
evaporatingValue,
condensingValue,
superheating,
subcooling,
compressorEfficiency,
massFlowRate,
resolution
]);
// Debounced auto-generate when inputs change (600ms)
useEffect(() => {
if (!selectedRefrigerant) return;
// do not trigger if a manual generation is already in progress
let mounted = true;
const id = setTimeout(() => {
if (mounted) {
handleGenerate();
}
}, debounceMs);
return () => {
mounted = false;
clearTimeout(id);
};
}, [
selectedRefrigerant,
inputType,
evaporatingValue,
condensingValue,
superheating,
subcooling,
compressorEfficiency,
massFlowRate,
debounceMs,
resolution,
]);
// Auto-generate only on initial load
useEffect(() => {
if (refrigerants.length > 0 && selectedRefrigerant && !cycleData) {
handleGenerate();
}
}, [refrigerants.length, selectedRefrigerant]);
// Header controls: resolution + debounce + status
const Controls = () => (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ color: '#64748b', fontSize: 12 }}>Resolution</label>
<select value={resolution} onChange={(e) => setResolution(e.target.value as any)} style={{ padding: '6px', borderRadius: 8 }}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ color: '#64748b', fontSize: 12 }}>Debounce</label>
<select value={debounceMs} onChange={(e) => setDebounceMs(parseInt(e.target.value))} style={{ padding: '6px', borderRadius: 8 }}>
<option value={400}>400ms</option>
<option value={600}>600ms</option>
<option value={800}>800ms</option>
</select>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{loading && <Spinner />}
{lastMetadata && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ color: '#64748b', fontSize: 12 }}>Gen: {lastMetadata.generation_time_ms} ms</div>
{lastMetadata.generation_time_ms === 0 && (
<div style={{ background: '#10b981', color: '#fff', padding: '4px 8px', borderRadius: 8, fontSize: 12 }}>Cache hit</div>
)}
</div>
)}
</div>
</div>
);
// Listen for global cycle events dispatched by CycleCalculator (for dynamic overlay)
useEffect(() => {
const onCycle = (e: any) => {
try {
const detail = e.detail;
if (!detail) return;
if (detail.refrigerant && detail.refrigerant !== selectedRefrigerant) return; // ignore if not matching
const cyclePoints = detail.points as Array<{enthalpy:number, pressure:number}>;
if (!cyclePoints || cyclePoints.length === 0) return;
// Update cycleData and request diagram data for these points
(async () => {
setLoading(true);
setCycleData(prev => ({ ...(prev || {}), points: cyclePoints } as any));
const pressures = cyclePoints.map(p => p.pressure);
const diagramRequest = {
refrigerant: selectedRefrigerant,
pressure_range: { min: Math.min(...pressures) * 0.5, max: Math.max(...pressures) * 1.5 },
include_isotherms: true,
cycle_points: cyclePoints,
format: 'json',
resolution: resolution
};
try {
const diagramResponse = await apiClient.generateDiagram(diagramRequest);
if (diagramResponse.data) setDiagramData(diagramResponse.data);
setLastMetadata(diagramResponse.metadata || null);
} catch (err) {
console.error('Diagram generation (event) failed', err);
} finally {
setLoading(false);
}
})();
} catch (err) {
// ignore
}
};
window.addEventListener('cycle:calculated', onCycle as EventListener);
return () => window.removeEventListener('cycle:calculated', onCycle as EventListener);
}, [selectedRefrigerant, resolution]);
// Listen for global cycle events dispatched by CycleCalculator (for dynamic overlay)
useEffect(() => {
const onCycle = (e: any) => {
try {
const detail = e.detail;
if (!detail) return;
if (detail.refrigerant && detail.refrigerant !== selectedRefrigerant) return; // ignore if not matching
const cyclePoints = detail.points as Array<{enthalpy:number, pressure:number}>;
if (!cyclePoints || cyclePoints.length === 0) return;
// Update cycleData and request diagram data for these points
(async () => {
setLoading(true);
setCycleData(prev => ({ ...(prev || {}), points: cyclePoints } as any));
const pressures = cyclePoints.map(p => p.pressure);
const diagramRequest = {
refrigerant: selectedRefrigerant,
pressure_range: { min: Math.min(...pressures) * 0.5, max: Math.max(...pressures) * 1.5 },
include_isotherms: true,
cycle_points: cyclePoints,
format: 'json'
};
try {
const diagramResponse = await apiClient.generateDiagram(diagramRequest);
if (diagramResponse.data) setDiagramData(diagramResponse.data);
} catch (err) {
console.error('Diagram generation (event) failed', err);
} finally {
setLoading(false);
}
})();
} catch (err) {
// ignore
}
};
window.addEventListener('cycle:calculated', onCycle as EventListener);
return () => window.removeEventListener('cycle:calculated', onCycle as EventListener);
}, [selectedRefrigerant]);
const handleLoadTestData = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.csv';
input.onchange = async (e: any) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const content = event.target?.result as string;
let points: Array<{enthalpy: number, pressure: number}> = [];
if (file.name.endsWith('.json')) {
const data = JSON.parse(content);
points = data.points || data;
} else if (file.name.endsWith('.csv')) {
const lines = content.split('\n').filter(l => l.trim());
const hasHeader = lines[0].toLowerCase().includes('pressure') || lines[0].toLowerCase().includes('enthalpy');
const dataLines = hasHeader ? lines.slice(1) : lines;
points = dataLines.map(line => {
const [pressure, enthalpy] = line.split(/[,;\t]/).map(v => parseFloat(v.trim()));
return { pressure, enthalpy };
}).filter(p => !isNaN(p.pressure) && !isNaN(p.enthalpy));
}
if (points.length > 0) {
setTestPoints(points);
alert(`${points.length} test points loaded successfully!`);
} else {
alert('❌ No valid points found in file');
}
} catch (err) {
alert('❌ Error reading file: ' + err);
}
};
reader.readAsText(file);
};
input.click();
};
const handleClearTestData = () => {
setTestPoints(null);
};
return (
<>
<style jsx>{`
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
`}</style>
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #3a5a77 0%, #4a2c5e 100%)',
display: 'flex',
fontFamily: '"Inter", system-ui, -apple-system, sans-serif'
}}>
{/* LEFT SIDEBAR */}
<div style={{
width: '320px',
background: 'rgba(42, 74, 95, 0.95)',
backdropFilter: 'blur(10px)',
padding: '2rem 1.5rem',
boxShadow: '4px 0 24px rgba(0,0,0,0.4)',
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
overflowY: 'auto',
borderRight: '1px solid rgba(255,255,255,0.1)'
}}>
{/* Header */}
<div style={{
background: 'linear-gradient(135deg, rgba(56, 178, 172, 0.25) 0%, rgba(72, 187, 178, 0.15) 100%)',
padding: '1rem 1.25rem',
borderRadius: '12px',
border: '1px solid rgba(56, 178, 172, 0.4)',
cursor: 'pointer',
transition: 'all 0.3s'
}}>
<h2 style={{
color: '#ffffff',
fontSize: '1.3rem',
fontWeight: '700',
margin: '0',
letterSpacing: '0.3px'
}}>log p-h Diagram</h2>
<div style={{
marginTop: '0.25rem',
fontSize: '0.7rem',
color: 'rgba(255,255,255,0.6)',
fontWeight: '400'
}}>
{cycleData && `COP (Heat Pump) = ${cycleData.performance?.cop.toFixed(2) || '-'} / COP (Refrigerator) = ${(cycleData.performance?.cop ? (cycleData.performance.cop - 1).toFixed(2) : '-')}`}
</div>
</div>
{error && (
<div style={{
padding: '0.75rem 1rem',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '10px',
color: '#fca5a5',
fontSize: '0.85rem',
lineHeight: '1.4'
}}>
{error}
</div>
)}
{/* Select Refrigerant */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.6rem',
letterSpacing: '0.2px'
}}>
Select Refrigerant
</label>
<select
value={selectedRefrigerant}
onChange={(e) => setSelectedRefrigerant(e.target.value)}
style={{
width: '100%',
padding: '0.8rem 1rem',
background: 'rgba(15, 23, 42, 0.9)',
border: '1.5px solid rgba(148, 163, 184, 0.4)',
borderRadius: '10px',
color: '#ffffff',
fontSize: '0.95rem',
fontWeight: '600',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s'
}}
>
{refrigerants.filter(r => r.available).map(ref => (
<option key={ref.name} value={ref.name} style={{ background: '#0f172a', color: '#ffffff' }}>
{ref.name}
</option>
))}
</select>
</div>
{/* Define State Cycle */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.6rem',
letterSpacing: '0.2px'
}}>
Define State Cycle
</label>
<select
value={inputType}
onChange={(e) => setInputType(e.target.value as 'temperature' | 'pressure')}
style={{
width: '100%',
padding: '0.8rem 1rem',
background: 'rgba(15, 23, 42, 0.9)',
border: '1.5px solid rgba(148, 163, 184, 0.4)',
borderRadius: '10px',
color: '#ffffff',
fontSize: '0.95rem',
fontWeight: '600',
cursor: 'pointer',
outline: 'none'
}}
>
<option value="pressure" style={{ background: '#0f172a' }}>Pressure levels (Evap./Cond.)</option>
<option value="temperature" style={{ background: '#0f172a' }}>Temperature levels (Evap./Cond.)</option>
</select>
</div>
{/* Condensing Pressure */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.8rem',
letterSpacing: '0.2px'
}}>
{inputType === 'pressure' ? 'Condensing Pressure' : 'Condensing Temperature'}
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min={inputType === 'pressure' ? '5' : '20'}
max={inputType === 'pressure' ? '30' : '70'}
step="1"
value={condensingValue}
onChange={(e) => setCondensingValue(e.target.value)}
style={{
flex: 1,
height: '5px',
background: 'linear-gradient(90deg, rgba(236, 72, 153, 0.4) 0%, rgba(236, 72, 153, 0.9) 100%)',
borderRadius: '5px',
outline: 'none',
cursor: 'pointer',
appearance: 'none'
}}
/>
<div style={{
minWidth: '90px',
padding: '0.6rem 0.9rem',
background: 'rgba(30, 58, 80, 0.95)',
border: '1.5px solid rgba(148, 163, 184, 0.35)',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.3rem'
}}>
<input
type="number"
value={condensingValue}
onChange={(e) => setCondensingValue(e.target.value)}
step="1"
style={{
width: '50px',
background: 'transparent',
border: 'none',
color: '#ffffff',
fontSize: '1.05rem',
fontWeight: '700',
textAlign: 'right',
outline: 'none',
padding: 0,
margin: 0
}}
/>
<span style={{ fontSize: '0.75rem', color: '#94a3b8', fontWeight: '500' }}>
{inputType === 'pressure' ? 'bar' : '°C'}
</span>
</div>
</div>
</div>
{/* Evaporating Pressure */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.8rem',
letterSpacing: '0.2px'
}}>
{inputType === 'pressure' ? 'Evaporating Pressure' : 'Evaporating Temperature'}
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min={inputType === 'pressure' ? '1' : '-20'}
max={inputType === 'pressure' ? '15' : '20'}
step="1"
value={evaporatingValue}
onChange={(e) => setEvaporatingValue(e.target.value)}
style={{
flex: 1,
height: '5px',
background: 'linear-gradient(90deg, rgba(59, 130, 246, 0.4) 0%, rgba(59, 130, 246, 0.9) 100%)',
borderRadius: '5px',
outline: 'none',
cursor: 'pointer',
appearance: 'none'
}}
/>
<div style={{
minWidth: '90px',
padding: '0.6rem 0.9rem',
background: 'rgba(30, 58, 80, 0.95)',
border: '1.5px solid rgba(148, 163, 184, 0.35)',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.3rem'
}}>
<input
type="number"
value={evaporatingValue}
onChange={(e) => setEvaporatingValue(e.target.value)}
step="1"
style={{
width: '50px',
background: 'transparent',
border: 'none',
color: '#ffffff',
fontSize: '1.05rem',
fontWeight: '700',
textAlign: 'right',
outline: 'none',
padding: 0,
margin: 0
}}
/>
<span style={{ fontSize: '0.75rem', color: '#94a3b8', fontWeight: '500' }}>
{inputType === 'pressure' ? 'bar' : '°C'}
</span>
</div>
</div>
</div>
{/* Superheating */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.8rem',
letterSpacing: '0.2px'
}}>
Superheating
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min="0"
max="20"
step="1"
value={superheating}
onChange={(e) => setSuperheating(e.target.value)}
style={{
flex: 1,
height: '5px',
background: 'linear-gradient(90deg, rgba(168, 85, 247, 0.4) 0%, rgba(168, 85, 247, 0.9) 100%)',
borderRadius: '5px',
outline: 'none',
cursor: 'pointer',
appearance: 'none'
}}
/>
<div style={{
minWidth: '90px',
padding: '0.6rem 0.9rem',
background: 'rgba(30, 58, 80, 0.95)',
border: '1.5px solid rgba(148, 163, 184, 0.35)',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.3rem'
}}>
<input
type="number"
value={superheating}
onChange={(e) => setSuperheating(e.target.value)}
step="1"
style={{
width: '50px',
background: 'transparent',
border: 'none',
color: '#ffffff',
fontSize: '1.05rem',
fontWeight: '700',
textAlign: 'right',
outline: 'none',
padding: 0,
margin: 0
}}
/>
<span style={{ fontSize: '0.75rem', color: '#94a3b8', fontWeight: '500' }}>
°C
</span>
</div>
</div>
</div>
{/* Subcooling */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.8rem',
letterSpacing: '0.2px'
}}>
Subcooling
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min="0"
max="20"
step="1"
value={subcooling}
onChange={(e) => setSubcooling(e.target.value)}
style={{
flex: 1,
height: '5px',
background: 'linear-gradient(90deg, rgba(249, 115, 22, 0.4) 0%, rgba(249, 115, 22, 0.9) 100%)',
borderRadius: '5px',
outline: 'none',
cursor: 'pointer',
appearance: 'none'
}}
/>
<div style={{
minWidth: '90px',
padding: '0.6rem 0.9rem',
background: 'rgba(30, 58, 80, 0.95)',
border: '1.5px solid rgba(148, 163, 184, 0.35)',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.3rem'
}}>
<input
type="number"
value={subcooling}
onChange={(e) => setSubcooling(e.target.value)}
step="1"
style={{
width: '50px',
background: 'transparent',
border: 'none',
color: '#ffffff',
fontSize: '1.05rem',
fontWeight: '700',
textAlign: 'right',
outline: 'none',
padding: 0,
margin: 0
}}
/>
<span style={{ fontSize: '0.75rem', color: '#94a3b8', fontWeight: '500' }}>
°C
</span>
</div>
</div>
</div>
{/* Compressor Efficiency */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.8rem',
letterSpacing: '0.2px'
}}>
Compressor Efficiency
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min="0.5"
max="1"
step="0.05"
value={compressorEfficiency}
onChange={(e) => setCompressorEfficiency(e.target.value)}
style={{
flex: 1,
height: '5px',
background: 'linear-gradient(90deg, rgba(34, 197, 94, 0.4) 0%, rgba(34, 197, 94, 0.9) 100%)',
borderRadius: '5px',
outline: 'none',
cursor: 'pointer'
}}
/>
<div style={{
minWidth: '75px',
padding: '0.6rem 0.9rem',
background: 'rgba(30, 58, 80, 0.95)',
border: '1.5px solid rgba(148, 163, 184, 0.35)',
borderRadius: '10px',
color: '#ffffff',
fontSize: '1.05rem',
fontWeight: '700',
textAlign: 'center'
}}>
{compressorEfficiency} <span style={{ fontSize: '0.75rem', color: '#94a3b8', fontWeight: '500' }}>-</span>
</div>
</div>
</div>
{/* Mass Flow Rate */}
<div>
<label style={{
display: 'block',
color: '#ffffff',
fontSize: '0.9rem',
fontWeight: '600',
marginBottom: '0.8rem',
letterSpacing: '0.2px'
}}>
Mass Flow Rate
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
type="range"
min="0.1"
max="5"
step="0.1"
value={massFlowRate}
onChange={(e) => setMassFlowRate(e.target.value)}
style={{
flex: 1,
height: '5px',
background: 'linear-gradient(90deg, rgba(251, 146, 60, 0.4) 0%, rgba(251, 146, 60, 0.9) 100%)',
borderRadius: '5px',
outline: 'none',
cursor: 'pointer'
}}
/>
<div style={{
minWidth: '75px',
padding: '0.6rem 0.9rem',
background: 'rgba(30, 58, 80, 0.95)',
border: '1.5px solid rgba(148, 163, 184, 0.35)',
borderRadius: '10px',
color: '#ffffff',
fontSize: '1.05rem',
fontWeight: '700',
textAlign: 'center'
}}>
{massFlowRate} <span style={{ fontSize: '0.75rem', color: '#94a3b8', fontWeight: '500' }}>kg/s</span>
</div>
</div>
</div>
{/* Calculate Cycle Button removed - auto-update enabled */}
{/* Load Test Data Button */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button
onClick={handleLoadTestData}
style={{
width: '100%',
padding: '1rem 1.5rem',
background: 'linear-gradient(135deg, rgba(56, 178, 172, 0.3) 0%, rgba(44, 122, 123, 0.4) 100%)',
border: '1.5px solid rgba(56, 178, 172, 0.5)',
borderRadius: '12px',
color: '#ffffff',
fontSize: '0.95rem',
fontWeight: '700',
cursor: 'pointer',
transition: 'all 0.3s',
letterSpacing: '0.5px',
marginTop: '0.5rem',
textTransform: 'uppercase'
}}
>
📁 Load Test Data
</button>
{testPoints && (
<button
onClick={handleClearTestData}
style={{
width: '100%',
padding: '0.75rem 1rem',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.5)',
borderRadius: '8px',
color: '#fca5a5',
fontSize: '0.85rem',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
🗑 Clear Test Data ({testPoints.length} points)
</button>
)}
</div>
{/* Copyright */}
<div style={{
marginTop: 'auto',
paddingTop: '1.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.75rem',
color: 'rgba(255,255,255,0.5)'
}}>
<div style={{
display: 'inline-flex',
padding: '0.3rem 0.5rem',
background: 'rgba(255, 255, 255, 0.15)',
borderRadius: '6px',
gap: '0.25rem',
alignItems: 'center'
}}>
<span style={{ fontWeight: '700' }}>CC</span>
</div>
<span style={{ fontWeight: '500' }}>BY</span>
</div>
</div>
{/* RIGHT CONTENT AREA */}
<div style={{
flex: 1,
padding: '2.5rem',
overflowY: 'auto',
background: '#f8fafc'
}}>
{diagramData && cycleData && cycleData.points && cycleData.points.length > 0 ? (
<div style={{
background: '#ffffff',
borderRadius: '16px',
padding: '2rem',
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
border: '1px solid rgba(0,0,0,0.05)'
}}>
{/* Title with COP */}
<div style={{ marginBottom: '1.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{
color: '#1e293b',
fontSize: '1.5rem',
fontWeight: '700',
margin: 0
}}>
log(p)-h chart {cycleData.refrigerant}
</h3>
<button style={{
padding: '0.6rem 1rem',
background: '#ec4899',
border: 'none',
borderRadius: '50px',
color: '#ffffff',
fontSize: '1.2rem',
fontWeight: '600',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(236, 72, 153, 0.3)'
}}>
</button>
</div>
{cycleData.performance && (
<div style={{
fontSize: '0.85rem',
color: '#64748b',
marginBottom: '1.5rem',
fontWeight: '500'
}}>
COP (Heat Pump) = {cycleData.performance.cop.toFixed(2)} / COP (Refrigerator) = {(cycleData.performance.cop - 1).toFixed(2)}
</div>
)}
{/* Legend */}
<div style={{
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
marginBottom: '1rem',
padding: '0.75rem 1rem',
background: 'rgba(241, 245, 249, 0.8)',
borderRadius: '10px',
fontSize: '0.8rem',
fontWeight: '600'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '20px', height: '3px', background: '#000000', borderRadius: '2px' }}></div>
<span style={{ color: '#1e293b' }}>Saturation</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '20px', height: '2px', background: '#c026d3', borderRadius: '2px', borderTop: '2px dashed #c026d3' }}></div>
<span style={{ color: '#1e293b' }}>Isotherms</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '20px', height: '3px', background: '#00CED1', borderRadius: '2px' }}></div>
<span style={{ color: '#1e293b' }}>Cycle</span>
</div>
{testPoints && testPoints.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '10px', height: '10px', background: '#ff6b35', borderRadius: '50%', border: '2px solid #ffffff' }}></div>
<span style={{ color: '#1e293b' }}>Test Data ({testPoints.length})</span>
</div>
)}
</div>
{/* Chart */}
<ResponsiveContainer width="100%" height={650}>
<LineChart margin={{ top: 20, right: 120, left: 70, bottom: 70 }}>
<defs>
{/* Gradient for labels */}
<linearGradient id="labelGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#ffffff" stopOpacity="1" />
<stop offset="100%" stopColor="#f0f9ff" stopOpacity="1" />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
type="number"
dataKey="enthalpy"
name="Enthalpy"
domain={[
Math.min(...cycleData.points.map(p => p.enthalpy || 0)) - 50,
Math.max(...cycleData.points.map(p => p.enthalpy || 0)) + 100
]}
label={{ value: 'Specific Enthalpy [kJ/kg]', position: 'insideBottom', offset: -15, fill: '#64748b', fontSize: 13, fontWeight: 600 }}
stroke="#94a3b8"
tick={{ fill: '#64748b', fontSize: 11 }}
tickFormatter={(value) => Math.round(value).toString()}
/>
<YAxis
type="number"
dataKey="pressure"
name="Pressure"
scale="log"
domain={[
Math.min(...cycleData.points.map(p => p.pressure)) * 0.7,
Math.max(...cycleData.points.map(p => p.pressure)) * 1.3
]}
label={{ value: 'Pressure [bar]', angle: -90, position: 'insideLeft', offset: 10, fill: '#64748b', fontSize: 13, fontWeight: 600 }}
stroke="#94a3b8"
tick={{ fill: '#64748b', fontSize: 11 }}
tickFormatter={(value) => value.toFixed(value < 1 ? 1 : 0)}
/>
<Tooltip
contentStyle={{ backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '8px', color: '#1e293b', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
labelFormatter={(value) => `h = ${Math.round(Number(value))} kJ/kg`}
/>
{/* Saturation Curve - Black Lines */}
{diagramData.saturation_curve && Array.isArray(diagramData.saturation_curve) && diagramData.saturation_curve.length > 0 && (
<>
<Line
key="saturation-liquid"
data={diagramData.saturation_curve.slice(0, Math.floor(diagramData.saturation_curve.length / 2)).map((p: any) => ({ enthalpy: p.enthalpy, pressure: p.pressure }))}
type="monotone"
dataKey="pressure"
stroke="#000000"
strokeWidth={2.5}
dot={false}
name="Liquid Saturation"
connectNulls
legendType="none"
/>
<Line
key="saturation-vapor"
data={diagramData.saturation_curve.slice(Math.floor(diagramData.saturation_curve.length / 2)).map((p: any) => ({ enthalpy: p.enthalpy, pressure: p.pressure }))}
type="monotone"
dataKey="pressure"
stroke="#000000"
strokeWidth={2.5}
dot={false}
name="Vapor Saturation"
connectNulls
legendType="none"
/>
</>
)}
{/* Isotherms - Purple/Magenta Dashed */}
{diagramData.isotherms && diagramData.isotherms.map((isotherm: any, idx: number) => {
const hue = 280 + (idx * 15);
const color = `hsl(${hue % 360}, 65%, 55%)`;
const tempLabel = `${Math.round(isotherm.temperature)}°C`;
return (
<Line
key={`isotherm-${idx}`}
data={isotherm.points.map((p: any) => ({
enthalpy: p.enthalpy,
pressure: p.pressure,
temperature: isotherm.temperature
}))}
type="monotone"
dataKey="pressure"
stroke={color}
strokeWidth={0.8}
strokeDasharray="4 3"
dot={false}
name={tempLabel}
connectNulls
legendType="none"
>
<LabelList
dataKey="pressure"
position="right"
content={(props: any) => {
const { x, y, index } = props;
if (index !== isotherm.points.length - 1) return null;
return (
<text
x={Number(x) + 10}
y={Number(y)}
fill={color}
fontSize={10}
fontWeight="600"
style={{ textShadow: '0 0 4px #fff' }}
>
{tempLabel}
</text>
);
}}
/>
</Line>
);
})}
{/* Thermodynamic Cycle - Cyan */}
<Line
data={[
...cycleData.points.map(p => ({
enthalpy: p.enthalpy || 0,
pressure: p.pressure,
point: p.point_id,
temperature: p.temperature
})),
{
enthalpy: cycleData.points[0].enthalpy || 0,
pressure: cycleData.points[0].pressure,
point: '1',
temperature: cycleData.points[0].temperature
}
]}
type="linear"
dataKey="pressure"
stroke="#00CED1"
strokeWidth={3}
dot={{ fill: '#00CED1', r: 6, strokeWidth: 2, stroke: '#ffffff' }}
activeDot={{ r: 8 }}
name="Cycle"
connectNulls
legendType="none"
>
<LabelList
content={(props: any) => {
const { x, y, index, value } = props;
if (index >= cycleData.points.length) return null;
const point = cycleData.points[index];
const temp = (point.temperature || 0).toFixed(1);
const pressure = point.pressure.toFixed(2);
// Position labels FAR OUTSIDE the cycle area
// Point 0 (evaporator outlet - bottom left) -> FAR RIGHT
// Point 1 (compressor outlet - top left) -> FAR LEFT
// Point 2 (condenser outlet - top right) -> FAR RIGHT
// Point 3 (expansion valve - bottom right) -> FAR RIGHT
let offsetX = 0;
let offsetY = 0;
if (index === 0) {
// Bottom left point -> push to RIGHT and DOWN
offsetX = 90;
offsetY = 25;
} else if (index === 1) {
// Top left point -> push to LEFT and UP
offsetX = -90;
offsetY = -25;
} else if (index === 2) {
// Top right point -> push to RIGHT and UP
offsetX = 90;
offsetY = -25;
} else if (index === 3) {
// Bottom right point -> push to RIGHT and DOWN
offsetX = 90;
offsetY = 25;
}
return (
<g transform={`translate(${offsetX}, ${offsetY})`}>
{/* Main label box */}
<rect
x={Number(x) - 50}
y={Number(y) - 28}
width="100"
height="50"
fill="white"
stroke="#00CED1"
strokeWidth="2.5"
rx="6"
opacity="0.98"
filter="drop-shadow(0 2px 8px rgba(0,0,0,0.15))"
/>
{/* Temperature */}
<text
x={Number(x)}
y={Number(y) - 8}
textAnchor="middle"
fill="#0f172a"
fontSize="15"
fontWeight="800"
>
{temp}°C
</text>
{/* Pressure */}
<text
x={Number(x)}
y={Number(y) + 10}
textAnchor="middle"
fill="#475569"
fontSize="13"
fontWeight="700"
>
{pressure} bar
</text>
</g>
);
}}
/>
</Line>
{/* Test Points - Orange/Red */}
{testPoints && testPoints.length > 0 && (
<Line
data={testPoints.map(p => ({
enthalpy: p.enthalpy,
pressure: p.pressure
}))}
type="monotone"
dataKey="pressure"
stroke="#ff6b35"
strokeWidth={0}
dot={{ fill: '#ff6b35', r: 5, strokeWidth: 2, stroke: '#ffffff' }}
activeDot={{ r: 7 }}
name="Test Data"
connectNulls={false}
/>
)}
</LineChart>
</ResponsiveContainer>
{/* Performance Section Below Chart - ELEGANT WHITE DESIGN */}
{cycleData.performance && (
<div style={{ marginTop: '2rem' }}>
<h3 style={{
color: '#1e293b',
fontSize: '1.5rem',
fontWeight: '700',
marginBottom: '1.5rem',
marginTop: 0,
letterSpacing: '-0.025em',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ fontSize: '1.75rem' }}></span>
Performance Metrics
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '1.25rem'
}}>
{/* COP Card */}
<div style={{
background: '#ffffff',
padding: '1.5rem',
borderRadius: '16px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
border: '1px solid rgba(0, 0, 0, 0.05)',
transition: 'all 0.3s ease'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '1rem'
}}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)'
}}>
🎯
</div>
<div>
<div style={{
color: '#64748b',
fontSize: '0.75rem',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
COP
</div>
<div style={{
fontSize: '0.875rem',
color: '#94a3b8',
fontWeight: '500'
}}>
Coefficient
</div>
</div>
</div>
<div style={{
fontSize: '2.25rem',
fontWeight: '800',
color: '#10b981',
lineHeight: 1,
letterSpacing: '-0.025em'
}}>
{cycleData.performance.cop.toFixed(2)}
</div>
</div>
{/* Cooling Capacity Card */}
<div style={{
background: '#ffffff',
padding: '1.5rem',
borderRadius: '16px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
border: '1px solid rgba(0, 0, 0, 0.05)',
transition: 'all 0.3s ease'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '1rem'
}}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)'
}}>
</div>
<div>
<div style={{
color: '#64748b',
fontSize: '0.75rem',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Cooling
</div>
<div style={{
fontSize: '0.875rem',
color: '#94a3b8',
fontWeight: '500'
}}>
Capacity
</div>
</div>
</div>
<div style={{
fontSize: '2.25rem',
fontWeight: '800',
color: '#3b82f6',
lineHeight: 1,
letterSpacing: '-0.025em'
}}>
{(cycleData.performance.cooling_capacity || 0).toFixed(1)}
<span style={{
fontSize: '1rem',
color: '#94a3b8',
fontWeight: '600',
marginLeft: '0.5rem'
}}>
kW
</span>
</div>
</div>
{/* Compressor Power Card */}
<div style={{
background: '#ffffff',
padding: '1.5rem',
borderRadius: '16px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
border: '1px solid rgba(0, 0, 0, 0.05)',
transition: 'all 0.3s ease'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '1rem'
}}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.3)'
}}>
</div>
<div>
<div style={{
color: '#64748b',
fontSize: '0.75rem',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Power
</div>
<div style={{
fontSize: '0.875rem',
color: '#94a3b8',
fontWeight: '500'
}}>
Compressor
</div>
</div>
</div>
<div style={{
fontSize: '2.25rem',
fontWeight: '800',
color: '#ef4444',
lineHeight: 1,
letterSpacing: '-0.025em'
}}>
{(cycleData.performance.compressor_power || 0).toFixed(1)}
<span style={{
fontSize: '1rem',
color: '#94a3b8',
fontWeight: '600',
marginLeft: '0.5rem'
}}>
kW
</span>
</div>
</div>
{/* Compression Ratio Card */}
<div style={{
background: '#ffffff',
padding: '1.5rem',
borderRadius: '16px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
border: '1px solid rgba(0, 0, 0, 0.05)',
transition: 'all 0.3s ease'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '1rem'
}}>
<div style={{
width: '48px',
height: '48px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
boxShadow: '0 4px 12px rgba(6, 182, 212, 0.3)'
}}>
📊
</div>
<div>
<div style={{
color: '#64748b',
fontSize: '0.75rem',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Ratio
</div>
<div style={{
fontSize: '0.875rem',
color: '#94a3b8',
fontWeight: '500'
}}>
Compression
</div>
</div>
</div>
<div style={{
fontSize: '2.25rem',
fontWeight: '800',
color: '#06b6d4',
lineHeight: 1,
letterSpacing: '-0.025em'
}}>
{cycleData.performance.compression_ratio.toFixed(2)}
</div>
</div>
</div>
</div>
)}
</div>
) : (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '600px',
background: '#ffffff',
borderRadius: '16px',
border: '2px dashed #cbd5e1',
color: '#94a3b8',
fontSize: '1.1rem',
fontWeight: '600'
}}>
Adjust the sliders to update the P-h diagram automatically
</div>
)}
</div>
</div>
</>
);
}