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(null); const bubbles = useMemo(() => { 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); 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 (

Stream Power Overview circle size = heat power Q (kW) · color = stream type

{/* Legend */}
{[ { label: 'Hot >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 >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) => ( ))}
{positioned.map((b) => { const { fill, stroke } = getBubbleColor(b); const isHov = hovered === b.id; return ( setHovered(b.id)} onMouseLeave={() => setHovered(null)} style={{ cursor: 'pointer' }} > ); })} {/* Tooltip / detail panel */} {hoveredBubble && (
{hoveredBubble.label} {hoveredBubble.process} / {hoveredBubble.subprocess} Tin: {hoveredBubble.tin != null ? `${hoveredBubble.tin.toFixed(1)} °C` : '—'} Tout: {hoveredBubble.tout != null ? `${hoveredBubble.tout.toFixed(1)} °C` : '—'} {hoveredBubble.CP != null && ( CP: {hoveredBubble.CP.toFixed(2)} kW/K )} {hoveredBubble.mdot != null && ( ṁ: {hoveredBubble.mdot.toFixed(2)} )} {hoveredBubble.cp != null && ( cp: {hoveredBubble.cp.toFixed(3)} )} Q: {hoveredBubble.Q != null ? `${hoveredBubble.Q.toFixed(1)} kW` : 'N/A'}
)}
); }