/* schema-chart.jsx — 48h Elpris & Schema, the centerpiece */ const ElprisSchema = ({ slots, nowIdx, onScrub, onNowClick, dense, refreshCount, refreshAt }) => { const W = 1000, H = 320; // viewBox — we letterbox/stretch const padL = 38, padR = 12, padT = 14, padB = 28; const plotW = W - padL - padR; const plotH = H - padT - padB; const N = slots.length; // 192 const slotW = plotW / N; const maxY = 260; const yScale = v => padT + plotH - (v / maxY) * plotH; const xAt = i => padL + i * slotW; // Stats from slots[0..96) (today) const today = slots.slice(0, 96); const lowest = Math.min(...today.map(s=>s.buy)); const highest = Math.max(...today.map(s=>s.buy)); const mean = today.reduce((a,s)=>a+s.buy,0)/today.length; // Sell-price polyline (smooth) const sellPath = (() => { const pts = slots.map((s,i)=>[xAt(i)+slotW/2, yScale(s.sell)]); if (!pts.length) return ''; let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; for (let i=1;i slots.map((_,i)=>{ const a = slots[Math.max(0,i-1)][key]; const b = slots[i][key]; const c = slots[Math.min(slots.length-1,i+1)][key]; return a*0.25 + b*0.5 + c*0.25; }); const solarSeries = smoothed('solar'); const consSeries = smoothed('consumption'); const kwMax = Math.max(1, ...solarSeries, ...consSeries) * 1.15; // Curves occupy ~70% of the plot height; bars still dominate vertically. const kwVisualRange = plotH * 0.70; const yKw = (kw) => padT + plotH - (kw / kwMax) * kwVisualRange; // Smooth bezier path through points (x already centered in slot) const curvePath = (vals) => { if (!vals.length) return ''; const pts = vals.map((v,i)=>[xAt(i)+slotW/2, yKw(v)]); let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; for (let i=1;i 8 ? 4 : kwMax > 4 ? 2 : 1; for (let v = 0; v <= kwMax; v += kwTickStep){ kwTicks.push(v); } // Hover handlers — supports both mouse and touch (mobile tap-and-drag). const svgRef = React.useRef(null); const pickAt = (clientX) => { const r = svgRef.current.getBoundingClientRect(); const x = ((clientX - r.left) / r.width) * W; return Math.max(0, Math.min(N-1, Math.floor((x - padL) / slotW))); }; const handleMove = (e) => setHover(pickAt(e.clientX)); const handleLeave = () => setHover(null); const handleClick = (e) => { if (hover != null && onScrub) onScrub(hover); }; const handleTouchMove = (e) => { const t = e.touches[0]; if (!t) return; e.preventDefault(); setHover(pickAt(t.clientX)); }; const handleTouchEnd = () => { // Keep last selection visible briefly so user can read the tooltip setTimeout(() => setHover(null), 1800); }; const cur = hover != null ? slots[hover] : (slots[nowIdx] || slots[0]); // Legend grouped by intent const legendGroups = [ { label:'Ladda batteri', keys:['ladda_nat','ladda_sol'] }, { label:'Använd batteri', keys:['tack_huset','peak'] }, // K2 (Lennie-fix 2026-06-11): salj_sol (grön) och salj_batteri (teal) har // varsin chip med egen nyans — etiketten "Export (batteri)" skiljer dem åt // så "Sälj sol" aldrig kan förväxlas med batteriexport. Exakt action per // stapel visas dessutom i hover-tooltipen. { label:'Sälj / exportera', keys:['salj_sol','salj_batteri'] }, { label:'Pausad / annat', keys:['vanta','nollexport','ai_styrt'] }, ]; // Confidence track under chart — colored gradient const conf = []; for (let i=0;i<48;i++){ conf.push(0.65 + Math.sin(i*0.37)*0.18 + (i>32?-(i-32)*0.012:0)); } return (

Elpris & Schema (48h)

{(() => { if (refreshAt == null) return 'Schemat visar HEMS-planen per kvart.'; const s = Math.max(0, Math.floor((Date.now()-refreshAt)/1000)); const t = s<20?'Plan uppdaterad just nu' : s<60?`Plan uppdaterad för ${s}s sedan` : s<3600?`Plan uppdaterad för ${Math.floor(s/60)} min sedan` : `Plan uppdaterad för ${Math.floor(s/3600)} h sedan`; return t; })()} {refreshCount != null && }
{fmt.hhmm(cur.i % 96)} {cur.day===0?'idag':'imorgon'} {cur.buy.toFixed(1)} öre {ACTION_META[cur.action].label}
IDAG IMORGON → {/* Per-action vertical gradient — top a touch lighter, bottom saturated. */} {Object.entries(ACTION_META).map(([k, m]) => ( ))} {/* Subtle stripe for "vänta" only — much finer than before */} {/* NU vertical line gradient */} {/* Säljpris line gradient + glow */} {/* Soft chart background */} {/* Solar forecast area fill */} {/* Background tint */} {/* y grid — only 100, 200 */} {yTicks.map(t => ( {t} ))} {/* Day boundary — soft vertical fade */} {/* Bars — rounded top, gradient fill */} {slots.map((s,i) => { const x = xAt(i); const y = yScale(s.buy); const h = padT + plotH - y; let fill = `url(#bar-${s.action})`; if (s.action === 'vanta') fill = 'url(#str-wait)'; const w = Math.max(0.8, slotW - 0.6); const r = Math.min(1.2, w/2.2); // path with rounded top corners only const x0 = x + 0.3; const x1 = x0 + w; const yB = padT + plotH; const d = `M ${x0} ${yB} L ${x0} ${y+r} Q ${x0} ${y} ${x0+r} ${y} L ${x1-r} ${y} Q ${x1} ${y} ${x1} ${y+r} L ${x1} ${yB} Z`; return ( ); })} {/* Imorgon dim */} {/* === Solar + consumption forecast overlay === */} {solarArea && } {solarLine && } {consLine && } {/* Right-side kW axis (faint) */} {kwTicks.map(t => ( {t} ))} kW {/* Säljpris line — soft glow underlay + clean main */} {/* x ticks every 6h */} {xTicks.map(t => ( {String((t.h%24)).padStart(2,'0')}:00 ))} {/* NOW marker — soft glowing dot + vertical fade line */} {nowIdx != null && (() => { const xN = xAt(nowIdx)+slotW/2; return ( ); })()} {/* hover crosshair */} {hover != null && hover !== nowIdx && ( )} {/* Mouse-following tooltip */} {hover != null && (() => { const svg = svgRef.current; if (!svg) return null; const r = svg.getBoundingClientRect(); const xPx = ((xAt(hover) + slotW/2) / W) * r.width; const right = xPx > r.width * 0.6; return (
{fmt.hhmm(slots[hover].i % 96)} {slots[hover].day===0?'idag':'imorgon'}
{slots[hover].buy.toFixed(1)} öre/kWh köp
Sälj: {slots[hover].sell.toFixed(1)} öre
{ACTION_META[slots[hover].action].label}
); })()}
{/* Grouped legend chips */}
Prognos · kW
Solproduktion Förbrukning Säljpris
{legendGroups.map(g => (
{g.label}
{g.keys.map(k => ( {ACTION_META[k].label} ))}
))}
); }; const Stat = ({label, value, accent, hint}) => (
{label}
{value}öre
{hint}
); /* styles inlined here so we don't fragment the css file */ const __schemaStyles = document.createElement('style'); __schemaStyles.textContent = ` .card.schema{border-color:var(--line-2)} .card.schema .schema-head{padding:18px 22px} .card.schema .schema-head h3{font-size:15px;letter-spacing:-.01em} .card.schema .schema-head h3 .ico{width:16px;height:16px} .schema-cur{ display:inline-flex;align-items:center;gap:10px; padding:4px 12px 4px 8px;border-radius:999px; border:1px solid var(--line-2);background:rgba(255,255,255,.02); font-size:11.5px; } .schema-cur .ct{color:var(--ink-2);font-size:11px} .schema-cur .cv b{color:var(--ink);font-weight:600} .schema-cur .cv .u{color:var(--ink-3);margin-left:2px} .schema-cur .ca{display:inline-flex;align-items:center;gap:6px;color:var(--ink-1); padding-left:10px;border-left:1px solid var(--line-2)} .schema-cur .ca .dot{width:7px;height:7px;border-radius:50%} .schema .schema-stats{ display:grid;grid-template-columns:repeat(4,1fr);gap:0; border-bottom:1px solid var(--line); } .schema-stat{padding:16px 22px;position:relative;text-align:left} .schema-stat::after{content:"";position:absolute;right:0;top:24%;bottom:24%; width:1px;background:var(--line)} .schema-stat:last-child::after{display:none} .schema-stat .l{font-size:10px;letter-spacing:.10em;color:var(--ink-3);font-weight:600} .schema-stat .v{margin-top:8px;font-size:30px;line-height:1;display:flex;align-items:baseline;gap:4px} .schema-stat .v b{font-weight:600;letter-spacing:-.02em} .schema-stat .v .u{font-size:11.5px;color:var(--ink-2)} .schema-stat .h{margin-top:6px;font-size:10.5px;letter-spacing:.04em} .schema-wrap{position:relative;padding:22px 22px 14px} .schema-wrap .edgelbl{ position:absolute;top:22px;font-size:10px;letter-spacing:.12em;color:var(--ink-3);font-weight:600; } .schema-wrap .edgelbl.left{left:46px} .schema-wrap .edgelbl.right{right:22px} .schema-svg{display:block;width:100%;height:340px;cursor:crosshair;border-radius:8px} .schema-tip{ position:absolute; pointer-events:none; background:rgba(14,21,36,.94); border:1px solid var(--line-2); border-radius:12px; padding:10px 14px; min-width:190px; box-shadow:0 14px 36px rgba(0,0,0,.55); backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px); font-family:Geist; z-index:5; } .schema-tip .t{font-size:11px;color:var(--ink-2)} .schema-tip .p{font-size:17px;margin-top:3px} .schema-tip .p b{font-weight:600} .schema-tip .p .u{font-size:10.5px;color:var(--ink-3);margin-left:4px} .schema-tip .s{font-size:11px;margin-top:1px} .schema-tip .a{display:inline-flex;align-items:center;gap:6px;margin-top:7px;font-size:11.5px;color:var(--ink-1); padding-top:7px;border-top:1px solid var(--line)} .schema-tip .a .dot{width:8px;height:8px;border-radius:2px} .schema-legend{ display:grid;grid-template-columns:repeat(5,1fr); gap:0; padding:14px 22px 18px; border-top:1px solid var(--line); } .schema-legend .lg-group{padding:0 16px;position:relative} .schema-legend .lg-group + .lg-group::before{ content:"";position:absolute;left:0;top:6px;bottom:6px;width:1px;background:var(--line); } .schema-legend .lg-group:first-child{padding-left:0} .schema-legend .lg-h{ font-size:9.5px;letter-spacing:.10em;text-transform:uppercase;color:var(--ink-3); font-weight:600;margin-bottom:8px; } .schema-legend .lg-chips{display:flex;flex-direction:column;gap:5px} .schema-legend .lg-chip{ display:inline-flex;align-items:center;gap:7px; font-size:11.5px;color:var(--ink-1); } .schema-legend .lg-chip .d{width:8px;height:8px;border-radius:2px;flex:none} .schema-legend .lg-chip .d.sol-sw{ width:14px;height:0;border-top:1.8px solid #F4C84B;border-radius:0; } .schema-legend .lg-chip .d.cons-sw{ width:14px;height:0;border-top:1.6px solid #E6ECF7;border-radius:0;opacity:.85; } .schema-legend .lg-chip .d.sell-sw{ width:14px;height:0;border-top:1.4px solid #ECEFF7;border-radius:0;opacity:.6; } .schema-legend .lg-chip .ln{width:14px;height:0;border-top:1.5px dashed var(--c-sellprice);flex:none} `; document.head.appendChild(__schemaStyles); Object.assign(window, { ElprisSchema });