/* 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}
{['solar','grid','battery','home','laddbox','batt2'].map(k => ( ))} {/* Brain-routed edges — base rail + flying particles The particle direction makes the flow direction unambiguous. */} {edges.filter(e=>e.visible).map(e => { const p = positions[e.per]; const mag = Math.abs(e.v); if (mag < 0.04) { return ; } const weight = Math.min(7, 1.6 + mag*0.85); const dur = Math.max(1.6, 4.6 - mag*0.45); // Direction: alwaysOut edges always animate brain → peripheral. const inward = e.alwaysOut ? false : (e.v > 0); // Trim line endpoints so particles fade away just before reaching nodes. const dx = cx - p.x, dy = cy - p.y; const len = Math.hypot(dx, dy); const ux = dx/len, uy = dy/len; const periphEdge = { x: p.x + ux*44, y: p.y + uy*44 }; const centerEdge = { x: cx - ux*70, y: cy - uy*70 }; // Source → destination const src = inward ? periphEdge : centerEdge; const dst = inward ? centerEdge : periphEdge; const baseOpacity = e.external ? .14 : .26; const partR = e.external ? 2.5 : Math.min(4, 2 + mag*0.25); return ( {/* Static rail — visible "pipe" between nodes */} {/* Staggered particles flying source → destination. #91: EV-edges får partiklar FÄRGADE PER KÄLLA (sol/batteri/nät) enligt ev_source_split — linjen går via styrcentralen men färgen visar varifrån energin kommer. */} {(e.sources && e.sources.length ? e.sources.flatMap((s, si, arr) => [0, 0.5].map((ph) => ({ color: s.color, r: Math.min(4, 2 + s.kw * 0.4), begin: ((ph + si / (arr.length * 2)) % 1) * dur, }))) : [0, 0.5].map((ph) => ({ color: e.color, r: partR, begin: ph * dur })) ).map((pt, idx) => ( {/* Fade in/out at endpoints */} ))} ); })} {/* Nodes */} 0.05?'PRODUCERAR':null}/> {batList.map((b) => ( 0.05 ? 'in' : (b.power_kw || 0) < -0.05 ? 'out' : null}/> ))} 0.05?'FÖRBRUKAR':null}/> {evList.map((e) => { const active = Math.abs(e.power_kw || 0) > 0.05; return ( ); })} 0.05?'KÖPER':gridBrain<-0.05?'SÄLJER':null}/> {/* HEMS Brain */} {/* Hover tooltip */} {hovered && (() => { const p = positions[hovered]; if (!p) return null; const w = 230, h = 130; const offset = 78; // distance from node center to tooltip near edge const dx = p.x - cx, dy = p.y - cy; const len = Math.hypot(dx, dy) || 1; const ux = dx/len, uy = dy/len; const anchorX = p.x + ux * offset; const anchorY = p.y + uy * offset; let tx, ty; if (Math.abs(ux) > Math.abs(uy)){ tx = ux > 0 ? anchorX : anchorX - w; ty = anchorY - h/2; } else { tx = anchorX - w/2; ty = uy > 0 ? anchorY : anchorY - h; } // Clamp within SVG bounds (W x H) tx = Math.max(8, Math.min(W - w - 8, tx)); ty = Math.max(8, Math.min(H - h - 8, ty)); const info = NODE_INFO(hovered, { solar, home, soc, soc2, cur, gridBrain, evPower, evCharging, batteries: batList, evList, evSplit }); return (
{info.title}
{info.rows.map((r,i)=>(
{r.k} {r.v}
))} {info.foot &&
{info.foot}
}
); })()}
); }; 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 });