/* flow.jsx — HEMS brain at center, kolumn-layout
Layout (omdesignad 2026-05-29):
Top: Sol (centrerad ovanför brain)
Center: HEMS-brain (styrcentralen)
Vänster (förbrukare): Hus + upp till 2 laddboxar (live.ev_power_list)
Höger (producenter/lagrare): upp till 3 batterier + Elnät
Stöder N batterier (1-3) och M laddboxar (0-2). Kompakt fallback för
1-batteri + 0-laddbox = singel-batteri-default (90% av users).
*/
const EnergyFlow = ({ cur, scenario, soc, soc2, secondBattery, laddbox, evCharging, evNames, evMock }) => {
const [hovered, setHovered] = React.useState(null);
const solar = Math.max(0, cur.solar);
const home = cur.consumption;
// ---------- Live-driven flow magnitudes ----------
// Sign conventions (positive = into brain, negative = out of brain):
// battery_kw > 0 (charging) → brain → batt = out → batBrain negative
// battery_kw < 0 (discharge) → batt → brain = in → batBrain positive
// grid_kw > 0 (import) → grid → brain = in → gridBrain positive
// grid_kw < 0 (export) → brain → grid = out → gridBrain negative
// ev_kw > 0 (charging car) → brain → laddbox → brainLaddbox negative
const battKw = cur.battery_kw != null ? cur.battery_kw : 0;
const gridKwRaw = cur.grid_kw != null ? cur.grid_kw : 0;
const evPower = cur.ev_kw != null ? cur.ev_kw : (laddbox && evCharging ? 7.0 : 0);
// Sun → brain magnitude (always positive when producing). The brain then
// splits this onto home / battery / grid based on what the inverter reports.
const solBrain = solar;
const brainHome = home;
const batBrain = -battKw;
const gridBrain = gridKwRaw;
const brainLaddbox = -evPower;
// Batteri 2 (extern AI) — drives its own flow. HEMS observes only.
// Use live per-battery power if available, else fall back to a plausible
// direction inferred from current action.
const a = cur.action;
let batt2Direction = 0;
if (cur.batt2 && typeof cur.batt2.power_kw === 'number'){
batt2Direction = -cur.batt2.power_kw; // same sign convention as batBrain
} else if (a === 'peak' || a === 'tack_huset') batt2Direction = +1.2;
else if (a === 'ladda_nat') batt2Direction = -1.4;
else if (a === 'ladda_sol') batt2Direction = -0.8;
else batt2Direction = 0;
// ---------- Battery list (any N — HEMS supports up to ~3 per home) ----------
// Live data populates `cur.batteries`. In mock mode we synthesise from
// tweak toggles so the prototype still has something to render.
const batteries = (() => {
if (cur._live && Array.isArray(cur.batteries) && cur.batteries.length){
return cur.batteries.map((b, i) => ({
id: `bat${i}`,
...b,
}));
}
const list = [{
id: 'bat0',
soc: soc,
power_kw: -batBrain,
ai_controlled: false,
action: cur.action,
label: 'Batteri',
}];
if (secondBattery) list.push({
id: 'bat1',
soc: soc2 ?? 62,
power_kw: -batt2Direction,
ai_controlled: true,
action: null,
label: 'Batteri 2',
});
return list;
})();
// ---------- Layout ----------
const liveActive = !!cur._live;
const showBatt2 = liveActive ? batteries.some(b => b.ai_controlled) : !!secondBattery;
// Laddbox-stöd — flera laddboxar via live.ev_power_list (W per box).
// Fallback till skalär ev_power om listan saknas (singel-laddbox).
// Max 2 laddboxar i layouten (vänster kolumn = hus + 2 boxar).
const evList = (() => {
if (liveActive){
const lst = cur._live.ev_power_list;
if (Array.isArray(lst) && lst.length > 0){
return lst.slice(0, 2).map((w, i) => ({
id: `lbox${i}`,
power_kw: (Number(w) || 0) / 1000,
label: (evNames && evNames[i]) || (lst.length > 1 ? `Bil ${i+1}` : 'Laddbox'),
}));
}
// Singel-laddbox via ev_power-skalär
if (cur._live.ev_power != null && Math.abs(evPower) > 0.01){
return [{ id: 'lbox0', power_kw: evPower, label: (evNames && evNames[0]) || 'Laddbox' }];
}
// Per-bil last ur loads[] (2026-07-29) när ev_power-fälten saknas:
// backend ger power_w per box där, och bilen ska synas som last i flödet.
const lds = Array.isArray(cur._live.loads) ? cur._live.loads : null;
if (lds){
const evish = lds.filter(x => x && (x.current_a != null || x.estimated_pct != null || x.next_charge_at != null));
if (evish.length){
return evish.slice(0, 2).map((x, i) => ({
id: `lbox${i}`,
power_kw: (Number(x.power_w) || 0) / 1000,
label: (evNames && evNames[i]) || x.name || (evish.length > 1 ? `Bil ${i+1}` : 'Laddbox'),
}));
}
}
return [];
}
// Mock: §5 flerbils-granskning — explicit lista {power_kw, name} per box.
if (Array.isArray(evMock) && evMock.length > 0){
return evMock.slice(0, 2).map((e, i) => ({
id: `lbox${i}`,
power_kw: Number(e.power_kw) || 0,
label: e.name || (evNames && evNames[i]) || `Bil ${i+1}`,
}));
}
return laddbox ? [{ id: 'lbox0', power_kw: evPower, label: (evNames && evNames[0]) || 'Laddbox' }] : [];
})();
const showLaddbox = evList.length > 0;
// ---------- #91: ev_source_split — varifrån EV-lasten kommer ----------
// Backend ger exakt fördelning av EV-lasten {solar_w, battery_w, grid_w}
// (summerar till ev_power). Används för att färga flödespartiklarna till
// laddboxen per källa + visa fördelningen i tooltip — inte alltid "från nätet".
const totalEvKw = evList.reduce((s, e) => s + Math.abs(e.power_kw || 0), 0);
const evSplit = (() => {
if (totalEvKw < 0.05) return null;
const s = cur._live?.ev_source_split;
if (s && ((s.solar_w||0) + (s.battery_w||0) + (s.grid_w||0)) > 50){
return { solar:(s.solar_w||0)/1000, battery:(s.battery_w||0)/1000, grid:(s.grid_w||0)/1000 };
}
if (!liveActive){
// Mock-illustration: sol-överskott först, resten nät (ingen riktig beräkning)
const fromSolar = Math.min(Math.max(0, solar - home), totalEvKw);
return { solar: fromSolar, battery: 0, grid: totalEvKw - fromSolar };
}
return null;
})();
// Begränsa batterier till 3 (UX-cap — matchar HEMS max 3 batterier per user).
const batList = batteries.slice(0, 3);
// ---------- Levande cirkulär layout ----------
// Alla noder fördelas JÄMNT runt cirkeln, oavsett antal. Sol fixerad i topp
// (kl 12). Övriga noder placeras medurs runt cirkeln i logisk ordning:
// höger-sida (batterier → elnät) först, sen vänster-sida moturs upp
// (laddboxar → hus). Detta ger en levande, balanserad cirkel för alla
// användarsetups — 1 batt + 1 elnät + 1 hus = ruta; Anders 3 batt + 1 elnät
// + 2 ev + 1 hus = oktagon. Cirkeln "andas" efter antal enheter.
const W = 720, H = 720;
const cx = W/2, cy = H/2;
const R = 245; // avstånd från brain till perifera noder
const positions = {};
// Logisk ordning medurs runt cirkeln (start vid sol topp, sweep höger
// halva nedåt, sen vänster halva uppåt tillbaka mot sol):
// sol → batterier → elnät → laddboxar (reversed) → hus
const rightNodes = [...batList.map(b => b.id), 'grid'];
const leftReversed = [...evList.map(e => e.id).reverse(), 'home'];
const orderedNodes = ['sol', ...rightNodes, ...leftReversed];
// Jämn fördelning runt 360°. Sol vid -90° (kl 12 i SVG-koord, y växer nedåt).
const totalSlots = orderedNodes.length;
const stepDeg = 360 / totalSlots;
orderedNodes.forEach((id, idx) => {
const angleDeg = -90 + idx * stepDeg;
const a = angleDeg * Math.PI / 180;
positions[id] = { x: cx + R * Math.cos(a), y: cy + R * Math.sin(a) };
});
// ---------- Edges (peripheral <-> brain) ----------
// En edge per batteri (signerad av batteriets egen power_kw) och per
// laddbox (alltid riktning brain→box). Sol → brain, brain → hus, grid ↔ brain.
const edges = [
{ id:'sol-b', per:'sol', color:'var(--solar)', v: solBrain, visible:true },
{ id:'b-home', per:'home', color:'var(--home)', v: brainHome, visible:true, alwaysOut:true },
...batList.map(b => ({
id: `${b.id}-b`,
per: b.id,
color: b.ai_controlled ? '#C4ADFF' : 'var(--battery)',
v: -(b.power_kw || 0),
visible: true,
external: b.ai_controlled,
})),
{ id:'grid-b', per:'grid', color:'var(--grid)', v: gridBrain, visible:true },
...evList.map(e => {
const boxKw = Math.abs(e.power_kw || 0);
const share = totalEvKw > 0.05 ? boxKw / totalEvKw : 0;
// #91 K2: partikelströmmar färgade per källa enligt ev_source_split
const sources = (evSplit && boxKw > 0.05) ? [
{ color:'var(--solar)', kw: evSplit.solar * share },
{ color:'var(--battery)', kw: evSplit.battery * share },
{ color:'var(--grid)', kw: evSplit.grid * share },
].filter(s => s.kw > 0.05) : null;
return {
id: `b-${e.id}`,
per: e.id,
color: 'var(--violet)',
v: -(e.power_kw || 0), // brain → laddbox (utgående)
visible: true,
alwaysOut: true,
sources,
};
}),
];
return (
Energiflöde just nu
HEMS dirigerar · uppdateras varje sekund
{ACTION_META[cur.action].label}
);
};
const NODE_INFO = (id, ctx) => {
const { solar, home, soc, soc2, cur, gridBrain, evPower, evCharging, batteries, evList, evSplit } = ctx;
const live = cur?._live;
const solarTodayKwh = live?.solar_today_kwh;
const homeTodayKwh = live?.home_today_kwh;
// Dynamic laddbox ids: 'lbox0', 'lbox1'
if (id && id.startsWith('lbox')){
const e = (evList || []).find(x => x.id === id);
if (!e) return { title: id, rows: [] };
const active = Math.abs(e.power_kw) > 0.05;
const fkw = (v) => v.toFixed(1).replace('.', ',') + ' kW';
const rows = [
{ k: 'Status', v: active ? 'Bil laddar' : 'Ingen bil laddar' },
{ k: 'Effekt nu', v: fkw(Math.abs(e.power_kw)) },
];
// #91: visa varifrån laddningen kommer (ev_source_split)
if (active && evSplit){
if (evSplit.solar > 0.05) rows.push({ k: 'Från sol', v: fkw(evSplit.solar) });
if (evSplit.battery > 0.05) rows.push({ k: 'Från batteri', v: fkw(evSplit.battery) });
if (evSplit.grid > 0.05) rows.push({ k: 'Från nät', v: fkw(evSplit.grid) });
}
return { title: e.label, color: '#C4ADFF', rows };
}
// Dynamic battery ids: 'bat0', 'bat1', 'bat2'
if (id && id.startsWith('bat')){
const b = (batteries || []).find(x => x.id === id);
if (!b) return { title: id, rows: [] };
const rows = [
{ k: 'SOC', v: Math.round(b.soc ?? 0) + '%' },
];
if (b.power_kw != null && Math.abs(b.power_kw) > 0.05){
rows.push({ k: b.power_kw > 0 ? 'Laddar' : 'Levererar',
v: Math.abs(b.power_kw).toFixed(2).replace('.', ',') + ' kW' });
}
if (b.ai_controlled){
rows.push({ k: 'Styrning', v: 'Extern AI' });
}
return {
title: b.ai_controlled ? `${b.label} · Extern AI` : `${b.label} · HEMS-styrt`,
color: b.ai_controlled ? '#C4ADFF' : '#3FCC8B',
rows,
foot: b.ai_controlled ? 'HEMS dirigerar inte denna' : undefined,
};
}
switch(id){
case 'sol': return {
title:'Sol', color:'#F4C84B',
rows:[
{k:'Just nu', v: solar.toFixed(2).replace('.',',')+' kW'},
...(solarTodayKwh != null ? [{k:'Idag', v: solarTodayKwh.toFixed(1).replace('.',',')+' kWh'}] : []),
],
};
case 'grid': return {
title:'Elnät', color:'#5BA8FF',
rows:[
{k:'Riktning',
v: gridBrain>0.05 ? 'Köper från nätet'
: gridBrain<-0.05 ? 'Säljer till nätet'
: 'Inaktiv'},
{k:'Effekt', v: Math.abs(gridBrain).toFixed(2).replace('.',',')+' kW'},
{k:'Spotpris', v: Math.round(cur.buy)+' öre/kWh'},
],
};
case 'batt1': return {
title:'Batteri · HEMS-styrt', color:'#3FCC8B',
rows:[
{k:'SOC', v: Math.round(soc)+'%'},
...(cur.batt1?.power_kw != null && Math.abs(cur.batt1.power_kw) > 0.05
? [{k: cur.batt1.power_kw>0?'Laddar':'Levererar',
v: Math.abs(cur.batt1.power_kw).toFixed(2).replace('.',',')+' kW'}]
: []),
],
};
case 'batt2': return {
title:'Batteri 2 · Extern AI', color:'#C4ADFF',
rows:[
{k:'SOC', v: (soc2 ?? 62)+'%'},
{k:'Styrning', v:'Extern AI'},
],
foot:'HEMS dirigerar inte denna',
};
case 'home': return {
title:'Hemmet', color:'#E6ECF7',
rows:[
{k:'Effekt nu', v: home.toFixed(2).replace('.',',')+' kW'},
...(homeTodayKwh != null ? [{k:'Idag förbrukat', v: homeTodayKwh.toFixed(1).replace('.',',')+' kWh'}] : []),
],
};
// 'laddbox'-fallet hanteras numera av lbox*-prefix-block ovan (multi-laddbox-stöd).
}
return { title:id, rows:[] };
};
const FlowNode = ({ x, y, label, v, u, color, icon, gradient, arrow, sub, externalAi, id, onHover, hovered, subKw, dim }) => {
const r = 75; // 2026-05-29: +30% från 58 — bättre läsbarhet vid mobilstorlek
return (
onHover && onHover(id)}
onMouseLeave={()=>onHover && onHover(null)}
opacity={dim && !hovered ? .45 : 1}
style={{cursor:'pointer', transition:'opacity .3s'}}>
{/* invisible hit target */}
{externalAi && (
)}
{u==='%' ? Math.round(v) : v.toFixed(1).replace('.',',')}
{u}
{subKw != null && Math.abs(subKw) > 0.05 && (
0 ? 'var(--c-sell)' : 'var(--c-cover)'}
style={{letterSpacing:'-0.01em'}}>
{subKw > 0 ? '+' : '−'}{Math.abs(subKw).toFixed(1).replace('.',',')} kW
)}
{label}
{sub && (
{externalAi ? (
<>
EXTERN AI
>
) : (
{sub}
)}
)}
{arrow && !sub && (
{arrow}
)}
);
};
const Brain = ({ cx, cy, action }) => {
const r = 64;
const meta = ACTION_META[action];
return (
HEMS
{/* Statusprick ovanför etiketten — centrerad, så långa namn som
"Export (batteri)" får radbrytas utan att pricken hamnar snett. */}
11) ? 13 : 15,
fontWeight:600,lineHeight:1.12,letterSpacing:'-.01em'}}>
{meta.label}
styrcentral
);
};
const __flowStyles = document.createElement('style');
__flowStyles.textContent = `
.flow-body{padding:6px 8px 14px;display:flex;justify-content:center}
.flow-svg{width:100%;height:auto;aspect-ratio:1/1;max-height:760px;display:block}
.flow-tip{
background:rgba(14,21,36,.92);border:1px solid var(--line-2);
border-radius:10px;padding:10px 12px;font-family:Geist;font-size:11.5px;
color:var(--ink-1);min-width:200px;
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
box-shadow:0 10px 30px rgba(0,0,0,.5);
animation:tipIn .14s ease-out;
}
.flow-tip .tip-t{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
margin-bottom:6px}
.flow-tip .tip-r{display:flex;justify-content:space-between;gap:14px;padding:2px 0;font-size:11.5px}
.flow-tip .tip-r .dim{color:var(--ink-3)}
.flow-tip .tip-r .mono{color:var(--ink);font-family:'Geist Mono',ui-monospace,monospace;
font-variant-numeric:tabular-nums}
.flow-tip .tip-f{margin-top:6px;padding-top:6px;border-top:1px solid var(--line);font-size:10.5px}
@keyframes tipIn{ from{opacity:0;transform:translateY(2px)} to{opacity:1;transform:none} }
`;
document.head.appendChild(__flowStyles);
Object.assign(window, { EnergyFlow });