Spaces:
Sleeping
Sleeping
| 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 >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) => ( | |
| <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> | |
| ); | |
| } | |