File size: 7,488 Bytes
c993983
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab7559f
c993983
ab7559f
c993983
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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>
  );
}