/* gravio-livechart.jsx — GravioLiveChart: 96-kvarts plan-graf med energibalans. Portad från Gravio-konceptet (PDF-spec), kopplad mot riktig slots-data. - 96 kvartstaplar (prisstaplar färgade per beslut) - Sol-våg uppifrån, SOC-area underifrån, förbrukningslinje (klassisk vy) - Energi-balans-vy: heatmap (sol vs förbrukning) + per-timme %-självförsörjning - Drag-scrub: dra fingret över grafen → flytande tooltip ovanför - NU-markör. Helt responsiv (viewBox + width 100%). Ändrar inget befintligt; egen fil, eget kort. */ const GLC_COLORS = { ladda_nat: { fill:'rgba(79,140,255,0.85)', stroke:'#4F8CFF', label:'Ladda (nät)', icon:'⚡' }, ladda_sol: { fill:'rgba(245,181,68,0.95)', stroke:'#F5B544', label:'Ladda (sol)', icon:'☀' }, tack_huset: { fill:'rgba(255,138,61,0.85)', stroke:'#FF8A3D', label:'Täck huset', icon:'⌂' }, salj_sol: { fill:'rgba(46,212,122,0.90)', stroke:'#2ED47A', label:'Sälj sol', icon:'↗' }, // Lennie-fix 2026-06-11: adaptern mappar backend `export` → `salj_batteri`. // Utan denna nyckel föll batteri-export tillbaka på grå "Vänta" i staplar+tooltip. salj_batteri: { fill:'rgba(0,212,216,0.95)', stroke:'#00D4D8', label:'Export (batteri)', icon:'⇈' }, export: { fill:'rgba(0,212,216,0.95)', stroke:'#00D4D8', label:'Export', icon:'⇈' }, peak: { fill:'rgba(255,77,109,0.90)', stroke:'#FF4D6D', label:'Peak shaving', icon:'▲' }, vanta: { fill:'rgba(124,135,152,0.6)', stroke:'#7C8798', label:'Vänta', icon:'·' }, nollexport: { fill:'rgba(168,85,247,0.85)', stroke:'#A855F7', label:'Nollexport', icon:'⊘' }, }; const glcColor = (a) => GLC_COLORS[a] || GLC_COLORS.vanta; const GravioLiveChart = ({ slots, nowIdx, refreshAt, show, onToggleSeries, dayView, onDayView, hasTomorrow, quarterHistory }) => { const [selIdx, setSelIdx] = React.useState(null); const [mode, setMode] = React.useState('classic'); // 'classic' | 'heatmap' const [dragging, setDragging] = React.useState(false); const areaRef = React.useRef(null); const vis = { solar: show ? show.solar !== false : true, soc: show ? show.soc !== false : true, cons: show ? show.cons !== false : true, buy: show ? !!show.buy : false, sell: show ? !!show.sell : false, solBands: show ? !!show.solBands : false, consBands: show ? !!show.consBands : false, }; // day 0 = idag (slots 0..95), day 1 = imorgon (96..191) const dv = dayView ? 1 : 0; const today = dv === 1 ? (slots || []).slice(96, 192) : (slots || []).slice(0, 96); const N = today.length; if (!N) return null; const showNow = dv === 0; const maxPrice = Math.max(120, ...today.map(s=>s.buy||0)); const maxPower = Math.max(6, ...today.map(s=>Math.max(s.solar||0, s.consumption||0))); // geometry in 0..100 space (preserveAspectRatio none) const xAt = i => ((i + 0.5) / N) * 100; const smooth = (pts) => { if (!pts.length) return ''; let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)} `; for (let i=1;i({x:xAt(i), y:(((s.solar||0)/maxPower)*50)})); // K1 (Lennie-fix 2026-06-11): passerade kvartar (i < nu, dagsvy 0) ritar // FAKTISK SOC (s.actualSoc från /api/history), framtida kvartar planens // projektion (s.soc). Samma mönster som forecast.jsx (facit). const socSplit = showNow && nowIdx!=null ? Math.max(0, Math.min(N-1, nowIdx)) : -1; const socValAt = (s,i) => (socSplit>=0 && i({x:xAt(i), y:100-socValAt(s,i)})); const loadPts = today.map((s,i)=>({x:xAt(i), y:(((s.consumption||0)/maxPower)*50)})); const solarArea = `M 0 0 L 0 ${solarPts[0].y.toFixed(2)} ` + solarPts.slice(1).map((p,idx)=>{const pr=solarPts[idx],cx=(pr.x+p.x)/2; return `Q ${cx.toFixed(2)} ${pr.y.toFixed(2)}, ${p.x.toFixed(2)} ${p.y.toFixed(2)}`;}).join(' ') + ` L 100 ${solarPts[solarPts.length-1].y.toFixed(2)} L 100 0 Z`; const solarLine = smooth(solarPts); const socArea = `M 0 100 L 0 ${socPts[0].y.toFixed(2)} ` + socPts.slice(1).map((p,idx)=>{const pr=socPts[idx],cx=(pr.x+p.x)/2; return `Q ${cx.toFixed(2)} ${pr.y.toFixed(2)}, ${p.x.toFixed(2)} ${p.y.toFixed(2)}`;}).join(' ') + ` L 100 ${socPts[socPts.length-1].y.toFixed(2)} L 100 100 Z`; const socLine = smooth(socPts); // Skarven utfall→plan: heldragen bakåt, mer transparent/streckad framåt. const socPastLine = socSplit>0 ? smooth(socPts.slice(0, socSplit+1)) : ''; const socFutLine = socSplit>=0 ? smooth(socPts.slice(socSplit)) : ''; const loadLine = smooth(loadPts); // buy/sell price lines — share the bar price scale (0 = bottom, maxPrice = top) const buyPts = today.map((s,i)=>({x:xAt(i), y:100-(((s.buy||0)/maxPrice)*100)})); const sellPts = today.map((s,i)=>({x:xAt(i), y:100-(((s.sell||0)/maxPrice)*100)})); const buyLine = smooth(buyPts); const sellLine = smooth(sellPts); // forecast vs actual deviation bands (past quarters only, day 0) const lastPast = showNow && nowIdx!=null ? Math.min(N-1, nowIdx) : -1; const bandPath = (fKey, aKey, scale) => { if (lastPast < 1) return ''; const top = [], bot = []; for (let i=0;i<=lastPast;i++){ const s=today[i]; const fc=100-(((s[fKey]||0)/scale)*50); const av=(s[aKey]!=null?s[aKey]:(s[fKey]||0)); const ac=100-((av/scale)*50); top.push({x:xAt(i),y:Math.min(fc,ac)}); bot.push({x:xAt(i),y:Math.max(fc,ac)}); } const up = top.map((p,i)=>`${i?'L':'M'} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' '); const dn = bot.slice().reverse().map(p=>`L ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' '); return `${up} ${dn} Z`; }; const solBandPath = bandPath('solar','actualSolar', maxPower); const consBandPath = bandPath('consumption','actualConsumption', maxPower); // energy balance let solK=0,loadK=0,covK=0; today.forEach(s=>{const sk=(s.solar||0)*0.25,lk=(s.consumption||0)*0.25;solK+=sk;loadK+=lk;covK+=Math.min(sk,lk);}); const selfSuff = loadK>0?Math.round(covK/loadK*100):0; const avgPrice = today.reduce((a,s)=>a+(s.buy||0),0)/N/100; const savedKr = Math.round(covK*avgPrice); const f1 = n => n.toFixed(1).replace('.',','); const hhmm = (h,q)=>`${String(h).padStart(2,'0')}:${String(q*15).padStart(2,'0')}`; const updateFromPointer = (e) => { if (!areaRef.current) return; const r = areaRef.current.getBoundingClientRect(); const cx = e.clientX ?? (e.touches && e.touches[0]?.clientX); if (cx==null) return; const ratio = Math.max(0, Math.min(1, (cx - r.left)/r.width)); setSelIdx(Math.min(N-1, Math.floor(ratio*N))); }; const sel = selIdx!=null ? today[selIdx] : null; const selC = sel ? glcColor(sel.action) : null; const selH = sel ? Math.floor(selIdx/4) : 0, selQ = sel ? selIdx%4 : 0; const ageMin = refreshAt ? Math.floor((Date.now()-refreshAt)/60000) : null; return (
{/* header: live + day toggle + mode toggle */}
{showNow?'LIVE':'PLAN'} {hasTomorrow && (
)}
{/* energy-balance summary (heatmap mode) */} {mode==='heatmap' && (
{selfSuff}%Självförsörjning
{f1(solK)}kWh sol
{savedKr} krSparat
)} {/* floating tooltip */}
{sel ? (
{selC.icon}
{hhmm(selH,selQ)} {sel===today[nowIdx] && NU}
{selC.label}
{mode==='classic' ? (
Pris{Math.round(sel.buy)} öre
Sol{f1(sel.solar||0)} kW
Batteri{Math.round(((socSplit>=0 && selIdx
) : (
Sol{f1(sel.solar||0)}
Hus{f1(sel.consumption||0)}
{(sel.solar||0)>=(sel.consumption||0)?'Överskott':'Brist'} =(sel.consumption||0)?'#2ED47A':'#FF4D6D'}}> {((sel.solar||0)-(sel.consumption||0)>=0?'+':'')}{f1((sel.solar||0)-(sel.consumption||0))}
)}
) : (
Dra fingret över grafen för att utforska kvartarna
)}
{/* chart */}
{[maxPrice,maxPrice*0.75,maxPrice*0.5,maxPrice*0.25,0].map((v,i)=>( {Math.round(v)} ))}
{/* solar wave bg */} {/* heatmap surplus zones */} {mode==='heatmap' && (
{today.map((s,i)=>{ const surplus=(s.solar||0)-(s.consumption||0); const inten=Math.min(1,Math.abs(surplus)/4); let bg='transparent'; if(surplus>0.2) bg=`rgba(46,212,122,${inten*0.25})`; else if(surplus<-0.2) bg=`rgba(255,77,109,${inten*0.22})`; return
; })}
)} {/* price bars (drag area) */}
{e.currentTarget.setPointerCapture(e.pointerId);setDragging(true);updateFromPointer(e);}} onPointerMove={(e)=>{if(dragging)updateFromPointer(e);}} onPointerUp={(e)=>{try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(x){}setDragging(false);}} onPointerCancel={()=>setDragging(false)} style={{touchAction:'pan-y'}}> {today.map((s,i)=>{ const barH=Math.max(3,(s.buy/maxPrice)*100); const past=showNow && nowIdx!=null && i ({ c: glcColor((window.hemsAdapter && window.hemsAdapter.mapAction) ? window.hemsAdapter.mapAction(seg.action) : seg.action), proportion: seg.proportion, })); const c = segs[0].c; // primär (översta) för markering/glow const mixed = segs.length > 1; // tunn mörk delare mellan staplar så likafärgade kvartar inte flyter ihop const prevSame = !mixed && i>0 && today[i-1].action===s.action; return (
{mixed && segs.map((seg, j) => (
0 ? '1px solid rgba(7,6,13,0.55)' : 'none', }}/> ))}
); })}
{/* SOC wave over bars */} {vis.soc && ( {socSplit>=0 ? ( {socFutLine && } {socPastLine && } ) : ( )} )} {/* load line (classic only) */} {mode==='classic' && vis.cons && ( )} {/* load line — alltid synlig (prognos streckad) */} {vis.cons && ( {vis.consBands && consBandPath && } )} {/* sol-avvikelse band */} {vis.solar && vis.solBands && solBandPath && ( )} {/* köppris line */} {vis.buy && ( )} {/* säljpris line */} {vis.sell && ( )} {/* NU marker */} {showNow && nowIdx!=null && nowIdx
)}
{[0,6,12,18,23].map(h=>{String(h).padStart(2,'0')}:00)}
{/* legend — klickbara toggles */}
{mode==='heatmap' && (
Sol täcker huset Drar från nät/batteri
)}
); }; const __glcStyles = document.createElement('style'); __glcStyles.textContent = ` .glc{padding:4px 2px 2px} .glc-top{display:flex;align-items:center;gap:12px;margin-bottom:14px} .glc-live{display:inline-flex;align-items:center;gap:6px;font-size:10.5px;letter-spacing:.14em;color:var(--c-sell);font-weight:600} .glc-live .d{width:6px;height:6px;border-radius:50%;background:var(--c-sell);animation:glcPulse 2s ease-out infinite} @keyframes glcPulse{0%{box-shadow:0 0 0 0 rgba(63,204,139,.5)}70%{box-shadow:0 0 0 7px rgba(63,204,139,0)}100%{box-shadow:0 0 0 0 rgba(63,204,139,0)}} .glc-modes{margin-left:auto;display:inline-flex;gap:2px;background:rgba(0,0,0,.25);border:1px solid var(--line);border-radius:9px;padding:2px} .glc-modes button{appearance:none;border:0;background:transparent;color:var(--ink-2);font:inherit;font-size:11.5px;padding:5px 11px;border-radius:7px;cursor:pointer} .glc-modes button.on{background:rgba(255,255,255,.07);color:var(--ink)} .glc-balance{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border-radius:11px;overflow:hidden;margin-bottom:12px} .glc-balance>div{background:var(--bg-1);padding:12px 14px;display:flex;flex-direction:column;gap:3px;align-items:center} .glc-balance .v{font-size:24px;font-weight:300;letter-spacing:-.02em;font-variant-numeric:tabular-nums} .glc-balance .l{font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3)} .glc-tip-slot{min-height:60px;margin-bottom:10px} .glc-tip{background:rgba(10,15,24,.7);border:1px solid var(--line-2);border-radius:12px;padding:9px 12px;display:flex;align-items:center;gap:14px;backdrop-filter:blur(10px)} .glc-tip .ic{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;font-size:16px;flex:none} .glc-tip .meta .t{font-size:11px;color:var(--ink-2)} .glc-tip .meta .t .nu{background:#FF8A3D;color:#000;font-size:8px;padding:1px 4px;border-radius:3px;margin-left:5px} .glc-tip .meta .a{font-size:13px;font-weight:600;margin-top:2px} .glc-tip .vals{flex:1;display:grid;grid-template-columns:repeat(3,1fr);gap:8px;text-align:center} .glc-tip .vals span{display:block;font-size:9px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3)} .glc-tip .vals b{font-size:13px;font-weight:500;color:var(--ink);margin-top:2px;display:block} .glc-hint{font-size:11.5px;color:var(--ink-3);text-align:center;padding:18px 0} .glc-plot{display:flex;gap:6px;height:300px} .glc-yax{display:flex;flex-direction:column;justify-content:space-between;font-size:9px;font-family:"Geist Mono",monospace;color:var(--ink-4);padding-bottom:4px;width:26px;text-align:right;flex:none} .glc-canvas{position:relative;flex:1;min-width:0} .glc-bg,.glc-soc,.glc-load{position:absolute;inset:0;width:100%;height:100%;pointer-events:none} .glc-heat{position:absolute;inset:0;display:flex;pointer-events:none} .glc-bars{position:absolute;inset:0;display:flex;align-items:flex-end;cursor:crosshair} .glc-bar-cell{flex:1;height:100%;display:flex;align-items:flex-end;justify-content:center;padding:0 .9px} .glc-bar{width:100%;border-radius:1.5px;transition:opacity .15s} .glc-now{position:absolute;top:0;bottom:0;width:2px;background:linear-gradient(180deg,#FF8A3D,rgba(255,138,61,.25));box-shadow:0 0 8px rgba(255,138,61,.7);pointer-events:none} .glc-now span{position:absolute;top:-4px;left:-4px;width:11px;height:11px;border-radius:50%;background:#FF8A3D;box-shadow:0 0 10px 2px rgba(255,138,61,.8)} .glc-xax{display:flex;justify-content:space-between;font-size:9.5px;font-family:"Geist Mono",monospace;color:var(--ink-4);margin:6px 0 0 32px} .glc-leg{display:flex;flex-wrap:wrap;justify-content:center;gap:16px;margin-top:12px;font-size:11.5px;color:var(--ink-2)} .glc-leg span,.glc-leg .glc-chip{display:inline-flex;align-items:center;gap:7px} .glc-chip{appearance:none;border:1px solid var(--line);background:rgba(255,255,255,.02);color:var(--ink-2);font:inherit;font-size:11.5px;padding:4px 9px;border-radius:7px;cursor:pointer;transition:opacity .15s} .glc-chip.off{opacity:.42;filter:grayscale(.6)} .glc-chip.off .sw{filter:grayscale(.7)} .glc-leg .sw{width:14px;height:8px;border-radius:2px;flex:none} .glc-leg .sw.ln{height:0;border-top:1.5px dashed #FF8AC6} .glc-leg .sw.ln.sell{border-top:1.5px dashed #FF6FB3} @media(max-width:640px){ .glc-plot{height:230px} .glc-balance .v{font-size:19px} .glc-tip .vals{grid-template-columns:repeat(3,1fr);gap:4px} .glc-tip .vals b{font-size:11.5px} } `; document.head.appendChild(__glcStyles); window.GravioLiveChart = GravioLiveChart;