HeatTransPlan / frontend /src /components /analysis /StreamBubbleChart.tsx
drzg15's picture
chaning background color dark mode
ab7559f
import { useMemo, useState } from 'react';
import type { ProcessNode } from '../../types/process';
import { extractStreamInfo } from '../../utils/streamInfo';
interface Props {
processes: ProcessNode[];
groups: number[][];
groupNames: string[];
}
interface BubbleData {
id: string;
label: string; // stream name
process: string; // process group name
subprocess: string; // subprocess name
tin: number | null;
tout: number | null;
Q: number | null;
CP: number | null;
mdot: number | null;
cp: number | null;
type: 'hot' | 'cold' | null;
}
/** Return an RGBA fill color matching the Streamlit logic:
* hot stream → red tones, cold → blue tones; intensity by max temp */
function getBubbleColor(b: BubbleData): { fill: string; stroke: string } {
if (b.tin == null || b.tout == null || b.type == null) {
return { fill: 'rgba(150,150,150,0.55)', stroke: '#666' };
}
const maxTemp = Math.max(b.tin, b.tout);
const strong = maxTemp > 100;
if (b.type === 'hot') {
return strong
? { fill: 'rgba(255,40,40,0.75)', stroke: '#b00' }
: { fill: 'rgba(255,130,130,0.75)', stroke: '#c44' };
} else {
return strong
? { fill: 'rgba(40,80,255,0.75)', stroke: '#00b' }
: { fill: 'rgba(120,160,255,0.75)', stroke: '#44a' };
}
}
export default function StreamBubbleChart({ processes, groups, groupNames }: Props) {
const [hovered, setHovered] = useState<string | null>(null);
const bubbles = useMemo<BubbleData[]>(() => {
const result: BubbleData[] = [];
groups.forEach((subIdxs, gIdx) => {
const gName = groupNames[gIdx] || `Process ${gIdx + 1}`;
subIdxs.forEach((si) => {
const sub = processes[si];
if (!sub) return;
const processStreams = (streamList: ProcessNode['streams'], subName: string) => {
(streamList || []).forEach((s, sIdx) => {
const info = extractStreamInfo(s as Record<string, any>);
result.push({
id: `g${gIdx}-s${si}-${sIdx}`,
label: s.name || `Stream ${sIdx + 1}`,
process: gName,
subprocess: subName,
tin: info.tin,
tout: info.tout,
Q: info.Q,
CP: info.CP,
mdot: info.mdot,
cp: info.cp,
type: info.type,
});
});
};
processStreams(sub.streams, sub.name);
(sub.children || []).forEach((child) => {
processStreams(child.streams, `${sub.name} > ${child.name}`);
});
});
});
return result;
}, [processes, groups, groupNames]);
// Global min-max Q scaling for radius (matching Streamlit: r_min=6, r_max=20px)
const { qMin, qRange } = useMemo(() => {
const Qs = bubbles.map((b) => b.Q ?? 0).filter((q) => q > 0);
const qMin = Qs.length ? Math.min(...Qs) : 0;
const qMax = Qs.length ? Math.max(...Qs) : 1;
return { qMin, qRange: qMax === qMin ? 1 : qMax - qMin };
}, [bubbles]);
const R_MIN = 8;
const R_MAX = 28;
const getRadius = (Q: number | null) => {
if (!Q || Q <= 0) return R_MIN;
return R_MIN + Math.round(((Q - qMin) / qRange) * (R_MAX - R_MIN));
};
if (bubbles.length === 0) return null;
// ---- Layout: pack bubbles in a horizontal strip, grouped by process ----
const GAP = 12;
const PADDING = 20;
const HEIGHT = 120;
// Compute x positions
let cx = PADDING + R_MAX;
const positioned = bubbles.map((b, i) => {
const r = getRadius(b.Q);
const x = cx;
cx += r * 2 + GAP;
return { ...b, r, x, y: HEIGHT / 2, i };
});
const svgWidth = cx + PADDING;
const hoveredBubble = hovered ? positioned.find((b) => b.id === hovered) : null;
return (
<div className="card mt-md" style={{ overflowX: 'auto' }}>
<h4 style={{ fontSize: 12, marginBottom: 4 }}>
Stream Power Overview
<span style={{ fontWeight: 400, fontSize: 11, color: 'var(--color-muted)', marginLeft: 8 }}>
circle size = heat power Q (kW) · color = stream type
</span>
</h4>
{/* Legend */}
<div style={{ display: 'flex', gap: 16, marginBottom: 6, flexWrap: 'wrap' }}>
{[
{ label: 'Hot &gt;100°C', fill: 'rgba(255,40,40,0.75)', stroke: '#b00' },
{ label: 'Hot ≤100°C', fill: 'rgba(255,130,130,0.75)', stroke: '#c44' },
{ label: 'Cold &gt;100°C', fill: 'rgba(40,80,255,0.75)', stroke: '#00b' },
{ label: 'Cold ≤100°C', fill: 'rgba(120,160,255,0.75)', stroke: '#44a' },
{ label: 'Unknown', fill: 'rgba(150,150,150,0.55)', stroke: '#666' },
].map((l) => (
<span key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
<svg width={12} height={12}>
<circle cx={6} cy={6} r={5} fill={l.fill} stroke={l.stroke} strokeWidth={1} />
</svg>
<span dangerouslySetInnerHTML={{ __html: l.label }} />
</span>
))}
</div>
<svg
width={svgWidth}
height={HEIGHT}
style={{ display: 'block', minWidth: svgWidth }}
>
{positioned.map((b) => {
const { fill, stroke } = getBubbleColor(b);
const isHov = hovered === b.id;
return (
<g
key={b.id}
onMouseEnter={() => setHovered(b.id)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: 'pointer' }}
>
<circle
cx={b.x}
cy={b.y}
r={b.r + (isHov ? 3 : 0)}
fill={fill}
stroke={isHov ? '#fff' : stroke}
strokeWidth={isHov ? 2 : 1}
style={{ transition: 'r 0.15s, stroke 0.15s' }}
/>
</g>
);
})}
</svg>
{/* Tooltip / detail panel */}
{hoveredBubble && (
<div
style={{
marginTop: 6,
padding: '8px 12px',
background: 'var(--bg)',
borderRadius: 6,
border: '1px solid var(--border)',
fontSize: 11,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))',
gap: '4px 16px',
}}
>
<strong style={{ gridColumn: '1/-1', fontSize: 12 }}>
{hoveredBubble.label}
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--color-muted)' }}>
{hoveredBubble.process} / {hoveredBubble.subprocess}
</span>
</strong>
<span>Tin: <b>{hoveredBubble.tin != null ? `${hoveredBubble.tin.toFixed(1)} °C` : '—'}</b></span>
<span>Tout: <b>{hoveredBubble.tout != null ? `${hoveredBubble.tout.toFixed(1)} °C` : '—'}</b></span>
{hoveredBubble.CP != null && (
<span>CP: <b>{hoveredBubble.CP.toFixed(2)} kW/K</b></span>
)}
{hoveredBubble.mdot != null && (
<span>ṁ: <b>{hoveredBubble.mdot.toFixed(2)}</b></span>
)}
{hoveredBubble.cp != null && (
<span>cp: <b>{hoveredBubble.cp.toFixed(3)}</b></span>
)}
<span>
Q: <b style={{ color: hoveredBubble.Q != null ? 'var(--color-primary, #6c8ebf)' : undefined }}>
{hoveredBubble.Q != null ? `${hoveredBubble.Q.toFixed(1)} kW` : 'N/A'}
</b>
</span>
</div>
)}
</div>
);
}