/* forecast.jsx — Solar + consumption forecast over 48h */ // Returns the colour/meta for a HEMS action code (raw API code or already-mapped // Swedish key). Anders' guidance: always look up by HEMS code, never lose the // per-segment variant by mapping ahead of time. function metaForApiAction(code){ const mapped = (window.hemsAdapter && window.hemsAdapter.mapAction) ? window.hemsAdapter.mapAction(code) : code; return ACTION_META[mapped] || ACTION_META.vanta; } // Per-quarter segments. Future = single LP-plan segment. // Past with mixed actions = array of {action, proportion}. // Past with single/missing = single plan-coloured segment. function getSegments(quarterIdx, isPast, qhRoot, plannedAction){ if (!isPast) return [{ action: plannedAction, proportion: 1.0 }]; const q = qhRoot?.quarters?.[String(quarterIdx)]; if (!q || !Array.isArray(q.actions) || q.actions.length === 0){ return [{ action: plannedAction, proportion: 1.0 }]; } return q.actions.map(a => ({ action: a.action, proportion: a.proportion ?? 1/q.actions.length })); } const ForecastCard = ({ slots, nowIdx, show, onToggleSeries, refreshCount, refreshAt, quarterHistory, evLoad, tomorrowPrelim }) => { const svgRef = React.useRef(null); const [hover, setHover] = React.useState(null); // quarter index 0..191 const [showEv, setShowEv] = React.useState(true); // EV-band ovanpå förbrukning // Default everything visible if no `show` prop passed const visible = { solar: show ? show.solar !== false : true, cons: show ? show.cons !== false : true, solBands: show ? show.solBands !== false : true, consBands: show ? show.consBands !== false : true, soc: show ? show.soc !== false : true, buy: show ? show.buy !== false : (show ? show.price !== false : true), sell: show ? show.sell !== false : (show ? show.price !== false : true), }; // Relative time for "Uppdaterad just nu / för X min sedan" const ageSec = refreshAt ? Math.max(0, Math.floor((Date.now() - refreshAt)/1000)) : null; const relText = ageSec == null ? 'SMHI · uppdaterad nyligen' : ageSec < 20 ? 'Uppdaterad just nu' : ageSec < 60 ? `Uppdaterad för ${ageSec}s sedan` : ageSec < 3600 ? `Uppdaterad för ${Math.floor(ageSec/60)} min sedan` : `Uppdaterad för ${Math.floor(ageSec/3600)} h sedan`; const W = 1800, H = 300; const padL = 44, padR = 56, padT = 18, padB = 74; const plotW = W - padL - padR; const plotH = H - padT - padB; const N = slots.length; const slotW = plotW / N; // Display-förbrukning = bas + planerade laster/EV (enhetlighet 2026-07-24). // Live: backend har redan vikt in lasterna i consumption_kwh_per_hour // (slots.consIncludesLoads=true) → lägg INTE till igen (dubbelräkning). Mock/ // cold-start (bara basprognos): rekonstruera ur SAMMA plan-band som lanerna // ritar (evLoad) så kurvan höjs exakt där lasterna/elbilen ligger planerade. const consIncludesLoads = slots.consIncludesLoads === true; /* EV är en EGEN fråga (3/9). Se hems-adapter.jsx: LP:s cons-serie innehåller de skjutbara lasterna men ALDRIG elbilen — den går in i solvern som ev_demand. Att läsa consIncludesLoads här karvade ut EV ur en kurva som inte innehöll den. Flaggan är false tills backend börjar injicera EV. */ const consIncludesEv = slots.consIncludesEv === true; // SKJUTBARA laster bakas in i själva förbrukningskurvan (hus + laster = consFc). // Live: backend har redan vikt in dem i consumption_kwh_per_hour (flagga true) // → lägg INTE till igen. Mock/cold-start (bara bas): rekonstruera ur samma // plan-band som lanerna ritar. ELBILEN ingår INTE här — den ritas som eget // stackat band ovanpå (nedan) så den aldrig dubbelräknas. const loadAddQ = new Array(N).fill(0); if (!consIncludesLoads && evLoad){ (evLoad.loads || []).forEach(l => (l.bands || []).forEach(b => { for (let i = Math.max(0, b.i0); i <= Math.min(N-1, b.i1); i++) loadAddQ[i] += (l.power_kw || 0); })); } slots.forEach((s,i)=>{ s.consFc = s.consumption + loadAddQ[i]; }); // Elbilen = husets största last → EGET stackat band OVANPÅ förbrukningen // (ev_plan.intervals via evLoad.ev; kW = kwh_per_quarter × 4). Alltid synligt // när plan finns, i Elbil-färg. const evKwQ = new Array(N).fill(0); (evLoad?.ev || []).forEach(g => (g.parts || []).forEach(p => { for (let i = Math.max(0, p.i0); i <= Math.min(N-1, p.i1); i++) evKwQ[i] += (p.kwhPerQ || 0) * 4; })); const evAny = evKwQ.some(v => v > 0); // Sessioner ur modellen (ev-loads). Fallback: en enda session över alla band // (äldre modeller utan evSessions). const evSessions = (evLoad && evLoad.evSessions && evLoad.evSessions.length) ? evLoad.evSessions : (evLoad && (evLoad.ev || []).length ? [{ i0: evLoad.ev[0].i0, i1: evLoad.ev[evLoad.ev.length-1].i1, groups: evLoad.ev, planKwh: evLoad.evTotalKwh || 0, actKwh: null, missedKwh: 0, missRanges: [], done: false, active: false }] : []); // Baslinje för EV-bandet (fix 2026-08-01, rättad 2026-09-03). Bakar backend // in elbilen i consumption_kwh_per_hour ska bandet KARVAS UT ur kurvan // (topp = kurvan, botten = kurvan − EV), annars dubbelräknas elbilen och // bandet kan ritas ovanför solkurvan trots att planen per kvart alltid // ligger under sol − hus i sol-läge. Gör den inte det — vilket är fallet i // dag, se consIncludesEv — staplas bandet ovanpå som förr. const evTopKw = new Array(N), evBaseKw = new Array(N); slots.forEach((s,i) => { if (consIncludesEv){ evTopKw[i] = s.consFc; evBaseKw[i] = Math.max(0, s.consFc - evKwQ[i]); } else { evTopKw[i] = s.consFc + evKwQ[i]; evBaseKw[i] = s.consFc; } }); // Solar + consumption share the SAME kW scale so the visual height matches // the actual value. Previously they had independent maxes which made a small // consumption value render visually taller than a larger solar value. const kwMax = Math.max(2.5, ...slots.map((s,i) => Math.max(s.solar, s.actualSolar, s.frozenSolar ?? 0, evTopKw[i], s.actualConsumption))); const solarMax = kwMax; const consMax = kwMax; const xAt = i => padL + i * slotW + slotW/2; const ySolar = v => padT + plotH - (v / kwMax) * plotH; const yCons = v => padT + plotH - (v / kwMax) * plotH; // smooth bezier path through points const pathThrough = (pts) => { if (!pts.length) return ''; let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; for (let i=1;i d + ` L ${(xAt(N-1)).toFixed(1)} ${yBase.toFixed(1)}` + ` L ${(xAt(0)).toFixed(1)} ${yBase.toFixed(1)} Z`; const solarPts = slots.map((s,i)=>[xAt(i), ySolar(s.solar)]); const consPts = slots.map((s,i)=>[xAt(i), yCons(s.consFc)]); const solarLine = pathThrough(solarPts); const consLine = pathThrough(consPts); const solarArea = closeArea(solarLine, padT+plotH); const consArea = closeArea(consLine, padT+plotH); // EV-band mellan evBaseKw och evTopKw (se ovan) — samma kW-skala som allt // annat. Kollapsar till noll höjd där EV=0. const evTopPts = slots.map((s,i)=>[xAt(i), yCons(evTopKw[i])]); const evBotPts = slots.map((s,i)=>[xAt(i), yCons(evBaseKw[i])]); const evBandPath = evAny ? pathThrough(evTopPts) + ' L ' + evBotPts.slice().reverse().map(p=>`${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ') + ' Z' : ''; // ---- Actual (past only) ---- const lastPast = Math.max(0, Math.min(N-1, nowIdx ?? 0)); const actualSolarPts = slots.slice(0, lastPast+1).map((s,i)=>[xAt(i), ySolar(s.actualSolar)]); const actualConsPts = slots.slice(0, lastPast+1).map((s,i)=>[xAt(i), yCons(s.actualConsumption)]); const actualSolarLine = pathThrough(actualSolarPts); const actualConsLine = pathThrough(actualConsPts); const actualSolarArea = actualSolarPts.length > 1 ? actualSolarLine + ` L ${actualSolarPts[actualSolarPts.length-1][0].toFixed(1)} ${(padT+plotH).toFixed(1)}` + ` L ${actualSolarPts[0][0].toFixed(1)} ${(padT+plotH).toFixed(1)} Z` : ''; // ---- Diff bands between forecast and actual (past only) ---- // Build sign-grouped polygons so positive (better) and negative (worse) deltas // fill in distinct colours. const buildDiffBands = (yFc, yAct, invert) => { // invert: when true, actual > forecast counts as "worse" (consumption case) const pos = [], neg = []; // arrays of point-arrays let segPos = null, segNeg = null; const flush = () => { if (segPos && segPos.length>3) pos.push(segPos); if (segNeg && segNeg.length>3) neg.push(segNeg); segPos = null; segNeg = null; }; for (let i=0; i<=lastPast; i++){ const yfc = yFc(slots[i]); const ya = yAct(slots[i]); const better = invert ? ya > yfc : ya < yfc; // y inverted in SVG → larger y = smaller value // For solar: actual > forecast (better) means ya < yfc. // For cons: actual < forecast (better) means ya > yfc. const x = xAt(i); if (better) { if (segNeg){ segNeg.push([x, yfc]); flush(); } if (!segPos) segPos = [[x, yfc]]; segPos.push([x, ya]); } else { if (segPos){ segPos.push([x, yfc]); flush(); } if (!segNeg) segNeg = [[x, yfc]]; segNeg.push([x, ya]); } } if (segPos){ segPos.push([xAt(lastPast), yFc(slots[lastPast])]); pos.push(segPos); } if (segNeg){ segNeg.push([xAt(lastPast), yFc(slots[lastPast])]); neg.push(segNeg); } // Close each polygon by going back along forecast line. const polyToPath = (pts) => { if (pts.length < 3) return ''; // top: actual line forward; bottom: forecast line backward. // We've stored: [start fc], [act @ start], [act @ next], ..., [act @ end], [end fc] // Need to add the forecast back path between start fc and end fc. // Simpler: just close with Z — the polygon already has fc anchors at both ends // and actual in between, plus we connect [end fc] -> [start fc] via the segments // we computed inline. Re-walk forecast between the bounds of this segment. const xs = pts.map(p=>p[0]); const xMin = Math.min(...xs), xMax = Math.max(...xs); // Backwalk forecast between xMin and xMax const back = []; for (let j=lastPast; j>=0; j--){ const x = xAt(j); if (x>=xMin-0.5 && x<=xMax+0.5) back.push([x, yFc(slots[j])]); } const all = [...pts, ...back]; return 'M ' + all.map(p=>`${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ') + ' Z'; }; return { posPath: pos.map(polyToPath).join(' '), negPath: neg.map(polyToPath).join(' '), }; }; const solarBands = {posPath:'', negPath:''}; // (ersatt av frozen-band nedan) const consBands = lastPast > 1 ? buildDiffBands(s=>yCons(s.consFc), s=>yCons(s.actualConsumption), true) : {posPath:'', negPath:''}; // ---- Fryst prognos "inför timmen" + låst zon (solprognos-diff-spec 2026-07) ---- // Backend fryser prognosen inför varje timme och skriver aldrig om den. Grafen // ritar tre zoner över dygnets klocktimmar: // Dåtid (i < nu): utfall (heldragen) + fryst prognos (punktad) + diff-band. // Låst (innevarande + nästa timme): fryst prognos punktad, står stilla; utfallet // växer in. Zonen får en svag bakgrundston. // Framtid (> nu+1h): levande prognos (punktad, s.solar). const nowHour = Math.floor((nowIdx ?? 0) / 4); const lockZoneStart = nowHour * 4; // början innevarande timme const lockedEndIdx = Math.min(95, lockZoneStart + 7); // slut "nästa timme" (inkl.) const lockZoneEnd = Math.min(96, lockZoneStart + 8); // exkl. — för bakgrundsrect // Fryst värde per slot (idag), null bevaras som gap (ingen linje, inget band). const frozenAt = i => (i < 96 && slots[i] && slots[i].frozenSolar != null) ? slots[i].frozenSolar : null; // Bygg en punktad linje som BRYTS vid null (gap, inte nolla). Ritas t.o.m. loEnd. const buildBrokenLine = (loEnd, valOf) => { const segs = []; let cur = []; for (let i = 0; i <= loEnd; i++){ const v = valOf(i); if (v == null){ if (cur.length > 1) segs.push(cur); cur = []; continue; } cur.push([xAt(i), ySolar(v)]); } if (cur.length > 1) segs.push(cur); return segs.map(pathThrough).join(' '); }; const frozenSolarLine = buildBrokenLine(lockedEndIdx, frozenAt); // Levande framtidsprognos — från slutet av låsta zonen och framåt (inkl imorgon). const livingSolarPts = []; for (let i = lockedEndIdx + 1; i < N; i++) livingSolarPts.push([xAt(i), ySolar(slots[i].solar)]); const livingSolarLine = pathThrough(livingSolarPts); // Diff-band: UTFALL vs FRYST prognos (inte s.solar). Grönt där utfall > fryst // (solen slog prognosen), amber där utfall < fryst. null-timmar → bandet bryts. const buildFrozenBands = () => { const pos = [], neg = []; let segPos = null, segNeg = null; const flush = () => { if (segPos && segPos.length > 3) pos.push(segPos); if (segNeg && segNeg.length > 3) neg.push(segNeg); segPos = null; segNeg = null; }; for (let i = 0; i <= lastPast; i++){ const fz = frozenAt(i); if (fz == null){ flush(); continue; } // gap → bryt bandet const yfz = ySolar(fz); const ya = ySolar(slots[i].actualSolar); const better = ya < yfz; // utfall > fryst (y inverterad) const x = xAt(i); if (better){ if (segNeg){ segNeg.push([x, yfz]); flush(); } if (!segPos) segPos = [[x, yfz]]; segPos.push([x, ya]); } else { if (segPos){ segPos.push([x, yfz]); flush(); } if (!segNeg) segNeg = [[x, yfz]]; segNeg.push([x, ya]); } } flush(); const polyToPath = (pts) => { if (pts.length < 3) return ''; const xs = pts.map(p => p[0]); const xMin = Math.min(...xs), xMax = Math.max(...xs); const back = []; for (let j = lastPast; j >= 0; j--){ const fz = frozenAt(j); if (fz == null) continue; const x = xAt(j); if (x >= xMin - 0.5 && x <= xMax + 0.5) back.push([x, ySolar(fz)]); } const all = [...pts, ...back]; return 'M ' + all.map(p => `${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ') + ' Z'; }; return { posPath: pos.map(polyToPath).join(' '), negPath: neg.map(polyToPath).join(' '), }; }; const solarFrozenBands = lastPast > 1 ? buildFrozenBands() : {posPath:'', negPath:''}; // x ticks const ticks = []; for (let h=0; h<=48; h+=6) ticks.push({h, x: padL + (h*4)*slotW}); // ---- SOC (battery state of charge) — own scale 0..100% ---- const ySoc = (pct) => padT + plotH - ((pct ?? 0) / 100) * plotH; const socPts = slots.map((s,i)=>[xAt(i), ySoc(s.soc ?? 50)]); const actualSocPts = slots.slice(0, lastPast+1).map((s,i)=>[xAt(i), ySoc(s.actualSoc ?? s.soc ?? 50)]); const socLine = pathThrough(socPts); const actualSocLine = pathThrough(actualSocPts); // ---- Price (öre/kWh) — own scale 0..maxPrice ---- // Nordpool only publishes tomorrow's prices ~13:00 CET. Detect when the // tomorrow values are an exact copy of today and hide the tomorrow line so // we never imply prices we don't have. const todayPrices = slots.slice(0, 96).map(s => s.buy); const tomorrowPrices = slots.slice(96).map(s => s.buy); const tomorrowIsCopy = tomorrowPrices.length === 96 && todayPrices.every((p, i) => Math.abs(p - tomorrowPrices[i]) < 0.1); // Preliminär morgondag (SPEC_IMORGON_PRELIMINART_PRIS 2026-06-13): backend- // flaggan tomorrow_prices_available===false (prop) ELLER en upptäckt // dagskopia ⇒ morgondagens priser är inte släppta (~13:00). Dämpa allt som // rör imorgon (kvart ≥96) och rita aldrig en exakt prislinje in i morgondagen. const prelim = tomorrowPrelim === true || tomorrowIsCopy; const priceVisibleN = prelim ? 96 : slots.length; const PRELIM_TITLE = 'Preliminärt — morgondagens elpris släpps runt kl 13:00, planen uppdateras då.'; const priceMax = Math.max(50, ...slots.slice(0, priceVisibleN).map(s => s.buy || 0)) * 1.05; const yPrice = (ore) => padT + plotH - ((ore || 0) / priceMax) * plotH; const buyPts = slots.slice(0, priceVisibleN).map((s,i) => [xAt(i), yPrice(s.buy)]); const sellPts = slots.slice(0, priceVisibleN).map((s,i) => [xAt(i), yPrice(s.sell)]); const buyLine = pathThrough(buyPts); const sellLine = pathThrough(sellPts); const today = slots.slice(0, 96); const tomorrow = slots.slice(96); const sumSolarToday = today.reduce((a,s)=>a+s.solar*0.25, 0); const sumSolarTomorrow = tomorrow.reduce((a,s)=>a+s.solar*0.25, 0); const sumConsToday = today.reduce((a,s)=>a+s.consFc*0.25, 0); const sumConsTomorrow = tomorrow.reduce((a,s)=>a+s.consFc*0.25, 0); const actualSolarSoFar = today.slice(0, lastPast+1).reduce((a,s)=>a+s.actualSolar*0.25, 0); const fcSolarSoFar = today.slice(0, lastPast+1).reduce((a,s)=>a+s.solar*0.25, 0); const actualConsSoFar = today.slice(0, lastPast+1).reduce((a,s)=>a+s.actualConsumption*0.25, 0); const fcConsSoFar = today.slice(0, lastPast+1).reduce((a,s)=>a+s.consFc*0.25, 0); // Lennie-fix 2026-05-31: KVARVARANDE solöverskott (sol kvar idag − förbrukning // kvar idag) är vad som är actionable. Heldagssiffran (sumSolar−sumCons) krockar // med verkligheten kl 12 (kvar-överskott ≈ 0). Vi visar "kvar idag" som primärt // tal, heldag som sekundärt. Räknas från NU (nowIdx) till midnatt. const remStart = Math.min(96, (nowIdx ?? 0) + 1); const remSolarToday = today.slice(remStart).reduce((a,s)=>a+s.solar*0.25, 0); const remConsToday = today.slice(remStart).reduce((a,s)=>a+s.consFc*0.25, 0); const remSurplus = remSolarToday - remConsToday; const fullDaySurplus = sumSolarToday - sumConsToday; // Sol-/förbr-topp visar IDAG (André 2026-06-04): tidigare Math.max över ALLA // 192 slots (48h) → hittade morgondagens topp och visade "kl 34:00 imorgon" // (kvart-index 136 → 34:00). Begränsa till today (slots 0-95). const peakSolar = Math.max(...today.map(s=>s.solar)); const peakSolarHour = today.indexOf(today.reduce((a,s)=>s.solar>a.solar?s:a, today[0])); const peakCons = Math.max(...today.map(s=>s.consFc)); const peakConsHour = today.indexOf(today.reduce((a,s)=>s.consFc>a.consFc?s:a, today[0])); return (

Prognos 48 timmar

{relText}{refreshCount != null && }
{/* Kvarvarande solöverskott (primärt) — vad som är kvar att hantera resten av dygnet. Heldagssiffran står som dim sekundärtext. */} =0?'var(--c-sell)':'var(--c-peak)'}}/> {remSurplus>=0?'Solöverskott kvar ':'Underskott kvar '}{(remSurplus>=0?'+':'')}{remSurplus.toFixed(1).replace('.',',')} kWh · heldag {(fullDaySurplus>=0?'+':'')}{fullDaySurplus.toFixed(1).replace('.',',')}
{/* Stats row — clock-style pip cards */}
{/* Sticky info panel — lives OUTSIDE the scroll-container so the user always sees the current touch readout, even when scrolled to tomorrow. Updates live from hover state; falls back to a hint when idle so the layout never collapses/shifts. */}
{(() => { const di = hover != null ? hover : (nowIdx ?? 0); const isNow = hover == null; const s = slots[di]; if (!s) return null; const past = di <= (nowIdx ?? 0); const tomorrow = di >= 96; return ( <>
{isNow && NU}{fmt.hhmm(di % 96)} {tomorrow ? 'imorgon' : 'idag'}{past && !tomorrow ? ' · utfall' : ' · prognos'}
{visible.solar && (
Sol {(past ? s.actualSolar : s.solar).toFixed(1).replace('.',',')} kW
)} {/* Passerade timmar: fryst prognos vs utfall (solprognos-diff-spec). Utfallet summeras \u00f6ver klocktimmens 4 kvartar (kWh); fryst prognos \u00e4r redan kWh/timme. Ingen rad n\u00e4r frozen \u00e4r null (gap). */} {visible.solar && past && di < 96 && s.frozenSolar != null && (() => { const h0 = Math.floor(di / 4) * 4; const hq = slots.slice(h0, h0 + 4); const actualKwh = hq.reduce((a, x) => a + (x.actualSolar || 0), 0) / (hq.length || 1); const frozenKwh = s.frozenSolar; const dev = actualKwh - frozenKwh; const pct = frozenKwh > 0.05 ? (dev / frozenKwh * 100) : null; const sign = dev >= 0 ? '+' : '\u2212'; const col = dev >= 0 ? 'var(--c-sell)' : 'var(--c-unchanged)'; const n1 = v => v.toFixed(1).replace('.', ','); return (
Prognos inför timmen {n1(frozenKwh)} kWh Utfall {n1(actualKwh)} kWh · Avvikelse{' '} {sign}{n1(Math.abs(dev))} kWh{pct != null ? ` (${sign}${Math.abs(pct).toFixed(0)} %)` : ''}
); })()} {visible.cons && (
Förbrukning {(past ? s.actualConsumption : s.consumption).toFixed(1).replace('.',',')} kW
)} {visible.soc && s.soc != null && (
Batteri (SOC) {Math.round(past ? (s.actualSoc ?? s.soc) : s.soc)}%
)} {visible.buy && (
Köppris {Math.round(s.buy)} öre
)} {visible.sell && (
Säljpris {Math.round(s.sell)} öre
)} {/* EV + skjutbara laster i readouten när kvarten ingår i ett band. Effekten här är kvartens VERKLIGA (kwh_per_quarter × 4). */} {(() => { const g = (evLoad?.ev || []).find(g => di >= g.i0 && di <= g.i1); if (!g) return null; const part = (g.parts || []).find(p => di >= p.i0 && di <= p.i1); const kw = part ? part.kwhPerQ * 4 : null; // Facit finns (SPEC_EV_ACTUAL_HISTORIK) → readouten visar // planerat vs levererat i stället för bara planen. const act = part && part.actKwh != null ? part.actKwh * 4 : null; const n1 = (v) => `${(Math.round(v*10)/10).toString().replace('.',',')} kW`; const label = act == null ? 'Elbil laddar' : part.cls === 'extra' ? 'Elbil · utanför plan' : part.cls === 'miss' ? 'Elbil · ingen laddning' : 'Elbil laddade'; const val = act == null ? (kw != null ? n1(kw) : '') : part.cls === 'extra' ? n1(act) : part.cls === 'miss' ? `planerat ${n1(kw)}` : `${n1(act)} av ${n1(kw)}`; return (
{label} {val}
); })()} {(evLoad?.loads || []).filter(l => (l.bands || []).some(b => di >= b.i0 && di <= b.i1)).map((l, k) => (
{l.name}{l.solar_only && } {(l.power_kw || 0).toFixed(1).replace('.',',')} kW
))} {ACTION_META[s.action] && (() => { // Backend-genererad mänsklig förklaring per kvart // (SPEC_pause_reason 2026-07-02). Fältet i /ml/analysis bär // ANTINGEN en kurerad svensk mening ("Sparar batteriet — elen // värd 0.34 kr mer kl 18:45") ELLER solverns interna trace // ("LP: selfconsumption | hysteres: …", "BgLP: HEMS: …", // "EV-pause: laddbox 4667W", "…→… (sol 2218W, hus 1171W …)"). // Vi visar BARA den mänskliga meningen: allt som innehåller // pipe/pil eller solver-/skydds-tokens avvisas (matchar // StatusCards/mobile-mönstret men täcker fler prefix). Tom ⇒ // inget extra. Gäller alla kvartar — spec-exemplet är en // passerad pause-kvart (15:45). const why = window.gcReason((s.reason || '').trim(), ''); return (
{past ? 'Skickat' : 'Plan'} {ACTION_META[s.action].label} {why && {why}}
); })()}
); })()}
{ const r = svgRef.current.getBoundingClientRect(); const x = ((e.clientX - r.left) / r.width) * W; const i = Math.max(0, Math.min(N-1, Math.floor((x - padL) / slotW))); setHover(i); }} onMouseLeave={() => setHover(null)} onTouchStart={(e)=>{ e.preventDefault(); const t = e.touches[0]; if (!t) return; const r = svgRef.current.getBoundingClientRect(); const x = ((t.clientX - r.left) / r.width) * W; setHover(Math.max(0, Math.min(N-1, Math.floor((x - padL) / slotW)))); }} onTouchMove={(e)=>{ e.preventDefault(); const t = e.touches[0]; if (!t) return; const r = svgRef.current.getBoundingClientRect(); const x = ((t.clientX - r.left) / r.width) * W; setHover(Math.max(0, Math.min(N-1, Math.floor((x - padL) / slotW)))); }} onTouchEnd={()=>setTimeout(()=>setHover(null), 1800)} style={{touchAction:'none'}}> {/* Preliminär-hatch för morgondagens osäkra zon (SPEC_IMORGON) */} {/* Soft chart bg */} {/* y guides + left-side kW labels */} {(() => { // Pick ~3 nice ticks const step = kwMax > 12 ? 5 : kwMax > 7 ? 4 : kwMax > 4 ? 2 : 1; const ticks = []; for (let v = 0; v <= kwMax; v += step) ticks.push(v); return ticks.map(t => ( {t} )); })()} {/* day boundary */} {/* Låst zon (innevarande + nästa timme) — diskret bakgrundston så man ser var "låst" (fryst prognos) övergår i "levande". */} {visible.solar && nowIdx != null && ( LÅST )} {/* diff bands — sol: UTFALL vs FRYST prognos; förbrukning: oförändrat */} {visible.solBands && solarFrozenBands.posPath && } {visible.solBands && solarFrozenBands.negPath && } {visible.consBands && consBands.posPath && } {visible.consBands && consBands.negPath && } {/* sol — levande framtidsprognos (punktad, svag) från slutet av låsta zonen */} {visible.solar && livingSolarLine && } {/* sol — fryst prognos inför timmen (punktad, tunn/sekundär), dåtid + låst zon. Bryts vid null-timmar (gap, inte nolla). */} {visible.solar && frozenSolarLine && } {/* sol — utfall: heldragen fylld yta + linje (dåtid) */} {visible.solar && actualSolarArea && } {visible.solar && actualSolarLine && } {/* consumption forecast — dashed line, very faint */} {visible.cons && } {/* consumption actual — solid line, past only */} {visible.cons && actualConsLine && } {/* Elbilen stackad ovanpå förbrukningen — husets största last, Elbil-färg. Fyllningen kollapsar där EV=0. (önskemål 2026-07-24) */} {showEv && evBandPath && } {/* Price (öre/kWh) — buy = violet solid, sell = teal dashed. Buy line stops at end-of-today when Nordpool hasn't released tomorrow. */} {visible.buy && buyLine && } {visible.sell && sellLine && } {/* (Tomorrow placeholder moved to the footer next to Netto) */} {/* SOC — battery state of charge on its own % scale */} {visible.soc && socLine && } {visible.soc && actualSocLine && } {/* Right Y axis — SOC % ticks (only when SOC layer visible) */} {visible.soc && [0, 50, 100].map(p => ( {p}% ))} {/* Beslutsremsa — förflutna kvartar visar FAKTISK sub-kvart-mix (flera beslut inom samma 15-min-kvart, staplade i proportion), framtida visar planens action. Delad källa med LIVE-staplarna (window.hemsAdapter.quarterSegments) så graferna aldrig kan driva isär. (display-spec 2026-06-25, mix återinförd) */} {(() => { const sy = padT + plotH + 6; const sh = 32; const segOf = (window.hemsAdapter && window.hemsAdapter.quarterSegments) || getSegments; return slots.map((s, i) => { const past = nowIdx != null && i <= nowIdx; const tomorrow = i >= 96; const localQ = i % 96; // Sub-quarter mix only exists for past quarters today const segs = segOf(localQ, past && !tomorrow, quarterHistory, s.action); const x = padL + i*slotW + 0.15; const w = Math.max(0.7, slotW - 0.3); if (segs.length > 1){ // Stack vertically within the bar with a thin dark separator // between each segment so sub-quarter splits are unmistakable. let acc = 0; return ( {segs.map((seg, j) => { const m = metaForApiAction(seg.action); const segH = sh * seg.proportion; const segY = sy + acc; acc += segH; return ( {j < segs.length - 1 && ( )} ); })} ); } const m = metaForApiAction(segs[0].action); return ; }); })()} {/* x ticks */} {ticks.map(t=>( {String(t.h%24).padStart(2,'0')}:00 ))} {/* Left kW unit hint */} kW {/* IDAG / IMORGON labels */} IDAG IMORGON {/* Preliminär-zon (SPEC_IMORGON): hatch + diskret notis över hela morgondagens del när priserna inte släppts än. Täcker prislinje, band och beslutsremsa i ett — pointer-events av. */} {prelim && ( PRELIMINÄRT · elpris släpps ~13:00 )} {/* now marker — subtle vertical fade line only (no dot) */} {nowIdx != null && (() => { const xN = xAt(nowIdx); return ( ); })()} {/* Hover crosshair + value dots */} {hover != null && (() => { const s = slots[hover]; const xH = xAt(hover); return ( {visible.solar && } {visible.cons && } {visible.soc && } ); })()} {/* EV-/last-band (SPEC_LASTER_EV §3, FRYSTA fält): laddfönster (ev_plan) resp. körfönster (deferrable_plan) — egna lanes direkt under besluts- remsan, x-linjerade med plotten via padL/padR-procent. Passerad tid: inget separat utfalls-fält i v1 — planens band ritas även bakåt men dämpat (§3c), delning vid NU. */} {evLoad && (() => { // Mobil-fix 2026-06-12: band utanför slots-fönstret (i ≥ N) klipps — // textraden under grafen visar dem ändå ("kör 11:00–16:00 imorgon"). const clipB = (b) => b.i0 >= N ? null : (b.i1 >= N ? { ...b, i1: N - 1 } : b); const evBands = (evLoad.ev || []).map(clipB).filter(Boolean) .map(g => ({ ...g, parts: (g.parts || []).map(clipB).filter(Boolean) })); const loadLanes = (evLoad.loads || []) .map(l => ({ ...l, bands: (l.bands || []).map(clipB).filter(Boolean) })) .filter(l => l.bands.length); if (!evBands.length && !loadLanes.length) return null; const kwTxt = (kw) => kw == null ? '' : (Math.round(kw*10)/10).toString().replace('.', ','); const krTxt = (kr) => kr == null ? '' : ` · ≈ ${kr.toFixed(2).replace('.', ',')} kr`; const spans = (b) => { if (nowIdx == null || nowIdx <= b.i0) return [{ i0:b.i0, i1:b.i1, past:false }]; if (nowIdx > b.i1) return [{ i0:b.i0, i1:b.i1, past:true }]; return [{ i0:b.i0, i1:nowIdx-1, past:true }, { i0:nowIdx, i1:b.i1, past:false }]; }; // Mobil-fix 2026-06-12: x-positioner beräknas i SVG:ns koordinat- // domän (padL + i/N × plotW, allt i % av W) — INTE via margin-% på // .fc-lanes. Marginal-/positionsprocent löses mot scroll-containerns // SYNLIGA bredd (~360px på mobil) medan SVG:n är 920px bred, så // banden komprimerades till fel klockslag. Nu delar band och graf // exakt samma tidsaxel; .fc-lanes får samma breddregel som SVG:n i // mobile.css. Desktop opåverkad (där är containerbredd = SVG-bredd). const lx = (i) => ((padL + (i / N) * plotW) / W * 100).toFixed(2); const lw = (n) => ((n / N) * plotW / W * 100).toFixed(2); const bandStyle = (s, bg, isPrelim) => ({ left:`${lx(s.i0)}%`, width:`${lw(s.i1-s.i0+1)}%`, background:bg, opacity: (s.past ? .45 : .95) * (isPrelim ? .5 : 1), }); return (
{/* EV-lane (tydlighetsfix efter användartest 2026-06-11): varje mergat band får ett sammanhållande SKAL (svag blå platta + ram) med intensitetsfyllnad inuti — segmenten läses som ETT laddfönster med varierad effekt, inte lösa blobbar. Pauser mellan banden får en streckad connector. Etiketten (energi + klar-tid, ALDRIG snitteffekt) ligger UTANFÖR bandet på egen platta — efter sista bandet, eller före första om det är trångt till höger. */} {evBands.length > 0 && (
{evSessions.map((sess, si) => { const maxKwhQ = Math.max(...evBands.flatMap(g => g.parts.flatMap(p => [p.kwhPerQ, p.actKwh || 0])), 0.0001); const sBands = (sess.groups && sess.groups.length ? sess.groups : evLoad.ev || []) .map(clipB).filter(Boolean) .map(g => ({ ...g, parts: (g.parts || []).map(clipB).filter(Boolean) })); if (!sBands.length) return null; const first = sBands[0], last = sBands[sBands.length - 1]; const lastEnd = last.i1 + 1; const nk = v => (Math.round(v*10)/10).toString().replace('.',','); // Etiketten gäller BARA sin egen session: avslutad = facit, // kommande = plan + klar-tid. Placeringen mäts mot GRANN- // SESSIONERNA och etiketten har TRE steg efter uppmätt bredd // i lane (836 px): full ≈17 % ("Elbil · 8,3 kWh laddat"), // kort ≈12 % ("8,3 kWh laddat"), minimal ≈7 % ("✓ 8,3 kWh"). // ✓ bär facit-betydelsen när ordet "laddat" inte får plats, så // etiketten aldrig behöver klippas mitt i ett ord. const FULL_PCT = 17, SHORT_PCT = 12; const pctOf = i => Number(lx(Math.max(0, Math.min(N, i)))); const nextStart = si + 1 < evSessions.length ? evSessions[si+1].i0 : N; const prevEnd = si > 0 ? evSessions[si-1].i1 + 1 : 0; const roomAfter = pctOf(nextStart) - pctOf(lastEnd); const roomBefore = pctOf(first.i0) - pctOf(prevEnd); const labelAtEnd = roomAfter >= FULL_PCT ? true : roomBefore >= FULL_PCT ? false : roomAfter >= SHORT_PCT ? true : roomBefore >= SHORT_PCT ? false : roomAfter >= roomBefore; const room = Math.max(labelAtEnd ? roomAfter : roomBefore, 0); const kwhTxt = nk(sess.done ? (sess.actKwh || 0) : (sess.planKwh || 0)); const full = sess.done ? `${evLoad.evName || 'Elbil'} · ${kwhTxt} kWh laddat` : `${evLoad.evName || 'Elbil'} · ${kwhTxt} kWh · klar ${fmt.hhmm(lastEnd % 96)}`; const label = room >= FULL_PCT ? full : room >= SHORT_PCT ? (sess.done ? `${kwhTxt} kWh laddat` : `${kwhTxt} kWh · klar ${fmt.hhmm(lastEnd % 96)}`) : (sess.done ? `✓ ${kwhTxt} kWh` : `${kwhTxt} kWh`); const labelStyle = { ...(labelAtEnd ? {left:`calc(${lx(lastEnd)}% + 6px)`} : {right:`calc(${(100 - Number(lx(first.i0))).toFixed(2)}% + 6px)`}), maxWidth:`calc(${room.toFixed(2)}% - 8px)`, whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis', }; return ( {sBands.length > 1 && ( )} {sBands.map((g, k) => { const span = g.i1 - g.i0 + 1; // Facit i bandet (SPEC_EV_ACTUAL_HISTORIK): dåtiden dämpas // INTE längre — kontur = plan, fylld höjd = levererat. const hasAct = g.parts.some(p => p.actKwh != null); const hPct = (kwh) => Math.max(16, Math.min(100, Math.round(kwh / maxKwhQ * 100))); const evN = evLoad.evName || 'Elbil'; return ( = 96 ? ' prelim' : ''}`} style={{ left:`${lx(g.i0)}%`, width:`${lw(span)}%`, opacity: (!hasAct && nowIdx != null && g.i1 < nowIdx ? .5 : 1) * (prelim && g.i0 >= 96 ? .55 : 1)}}> {g.parts.map((p, j) => spans(p).map((s, m) => { const act = p.actKwh; // null = inget facit ⇒ rita plan const L = `${((s.i0 - g.i0)/span*100).toFixed(2)}%`; const Wd = `${((s.i1 - s.i0 + 1)/span*100).toFixed(2)}%`; const title = act == null ? `${evN} ${kwTxt(p.kwhPerQ*4)} kW` : p.cls === 'extra' ? `${evN} · utanför plan · ${kwTxt(act*4)} kW` : p.cls === 'miss' ? `${evN} · planerat ${kwTxt(p.kwhPerQ*4)} kW · ingen laddning` : `${evN} · planerat ${kwTxt(p.kwhPerQ*4)} kW · levererat ${kwTxt(act*4)} kW`; // Bristen (plan − facit) ritas som RÖD remsa från facit- // nivån upp till plannivån — färgskifte läses vid 15 px // lane-höjd där ren höjdskillnad inte gör det. Miss = // hela planhöjden röd. Färgen är samma varningsröda som // ev-outside-badgen. const aPct = act ? hPct(act) : 0; const pPct = hPct(p.kwhPerQ); const shortfall = act != null && p.kwhPerQ > 0 && pPct - aPct > 0; return ( {act == null && p.kwhPerQ > 0 && ( )} {/* Facit-kvart: släck skalets blå platta lokalt så att staplarna läses som staplar, inte som ett block. */} {act != null && ( )} {act != null && p.cls === 'ok' && ( )} {shortfall && ( )} {act != null && act > 0 && ( )} ); }))} ); })} {label} ); })}
)} {loadLanes.map((l) => (
{l.bands.map((b, k) => spans(b).map((s, j) => { const isPrelim = prelim && s.i0 >= 96; // §2 SPEC_LASTER_MODE_SPLIT: flera band per last (delkörningar) // — "del X av Y" i tooltipen när lasten delats upp. const partTxt = l.bands.length > 1 ? ` · del ${k+1} av ${l.bands.length}` : ''; return ( {s.past ? '' : l.name} ); }))}
))}
); })()}
{/* Last-rad under grafen — en per aktiv last (SPEC_LASTER_EV §3c) + EV-rad i samma stil: "Elbil · laddar 00:00–07:00 · 10,7 kWh" (tider från första intervallstart till sista intervallslut, kWh = total_kwh) och ⚠-varning när unmet_kwh > 0. ev_plan null/tom → ingen rad. "klar" när end_quarter ≤ nuvarande kvart (dag 0); last utan post i deferrable_plan → "ryms inte i fönstret". */} {evLoad && ((evLoad.loads || []).length > 0 || (evLoad.ev || []).length > 0 || evLoad.evPending) && (
{/* EV-väntläge: session angiven men ev_plan saknas än (avresetid ej passerad) — samma väntstil som lasterna så inmatningen syns tas emot. Ersätts av laddfönster-raden när ev_plan dyker upp. */} {evLoad.evPending && (evLoad.ev || []).length === 0 && (() => { // §4.1: finns en orsak är tillståndet "kan inte", inte "väntar". // Utan orsaksfält (backend ej deployat) = oförändrat väntläge. const r = window.evReasonText ? window.evReasonText(evLoad.evWhy, { minKw: evLoad.evMinKw, missKwh: evLoad.evClosestMissKwh }) : null; return (
{evLoad.evName || 'Elbil'} {r ? r.body : 'nästa planering inom ~15 min'} {r ? r.head : 'inte planerad än'}
); })()} {/* EN RAD PER SESSION (2026-08-01). Avslutad session = facit ("laddade 01:45–05:00 · 8,3 kWh"), aktiv/kommande = plan. Spannet bryts vid sessionsgräns — aldrig ett hopslået 01:45–16:00. */} {evSessions.map((s, k) => { const nk = v => (Math.round(v*10)/10).toString().replace('.',','); const single = evSessions.length === 1; const planKwh = single && evLoad.evTotalKwh != null ? evLoad.evTotalKwh : s.planKwh; const from = fmt.hhmm(s.i0 % 96), to = fmt.hhmm((s.i1 + 1) % 96); const tomorrow = s.i0 >= 96 ? ' imorgon' : ''; const txt = s.done ? `laddade ${from}–${to}${tomorrow} · ${nk(s.actKwh || 0)} kWh` : s.active ? `laddar ${from}–${to}${tomorrow} · plan ${nk(planKwh)} kWh${s.actKwh ? ` · hittills ${nk(s.actKwh)} kWh` : ''}` : `laddar ${from}–${to}${tomorrow} · ${nk(planKwh)} kWh`; const st = s.done ? { txt:'klar', c:'var(--c-sell)' } : s.active ? { txt:'pågår', c:'#7AB8FF' } : { txt:'planerad', c:'var(--ink-2)' }; return (
{evLoad.evName || 'Elbil'} {txt} {st.txt} {prelim && s.i1 >= 96 && ( preliminärt )} {/* Unmet gäller målet för den PLANERADE (sista) sessionen. */} {k === evSessions.length - 1 && !s.done && evLoad.evUnmetKwh > 0 && ( ⚠ Hinner inte nå målet — saknar {nk(evLoad.evUnmetKwh)} kWh till avresetid )} {/* Planerad laddning som ALDRIG skedde — enda stället frontend påstår en miss, och bara för kvartar som hållit i > 6 min. */} {(s.missRanges || []).length > 0 && (() => { const f = s.missRanges[0], more = s.missRanges.length - 1; return ( ▲ planerad laddning uteblev {fmt.hhmm(f.i0 % 96)}–{fmt.hhmm((f.i1 + 1) % 96)}{more > 0 ? ` +${more} fler` : ''} · {nk(s.missedKwh)} kWh ); })()}
); })} {evLoad.loads.map((l, k) => { const b0 = (l.bands || []).find(b => b.i0 < 96) || (l.bands || [])[0] || null; // Ingen post alls = planeraren har inte hunnit än (ny last) — INTE // samma sak som "ryms inte"; det sägs bara vid explicit unfit-signal. // SPEC_SOLAR_ONLY_LOADS §3: sol-only-last utan placering får // sol-reasonet i stället för "ryms inte i fönstret". // SPEC_VARFOR_INTE_PLANERAD §4.1: TRE tillstånd, aldrig fler. // Explicit ORSAK vinner över både "väntar" och det gamla generiska // "ryms inte" — dagens fel var att tillstånd 3 visades som 2 i // all evighet ("nästa planering inom ~15 min" hela dagen). const rt = (l.reason && window.loadReasonText) ? window.loadReasonText(l.reason, l.detail, l) : null; const status = rt ? { txt: rt.head, c:'#E8C66B' } : !b0 ? (l.unfit ? (l.solar_only ? { txt:'inget solöverskott som täcker lasten — körs ej', c:'#E8C66B' } : { txt:'ryms inte i fönstret', c:'#E8C66B' }) : { txt:'inte planerad än', c:'var(--ink-3)' }) : nowIdx != null && b0.i0 < 96 && nowIdx > b0.i1 ? { txt:'klar', c:'var(--c-sell)' } : nowIdx != null && nowIdx >= b0.i0 && nowIdx <= b0.i1 ? { txt:'kör nu', c:'#C9A0FF' } : { txt:'väntar', c:'var(--ink-2)' }; return (
{l.name}{l.solar_only && } {l.earliest && fönster {l.earliest}–{l.latest || '—'}} {(l.bands || []).map((b, j) => ( kör {fmt.hhmm(b.i0 % 96)}–{fmt.hhmm((b.i1 + 1) % 96)}{b.i0 >= 96 ? ' imorgon' : ''}{b.cost_kr != null ? ` · ≈ ${b.cost_kr.toFixed(2).replace('.',',')} kr` : ''}{l.bands.length > 1 ? ` (del ${j+1}/${l.bands.length})` : ''} ))} {prelim && (l.bands || []).some(b => b.i0 >= 96) && ( preliminärt )} {rt && {rt.body}} {!b0 && !l.unfit && !rt && nästa planering inom ~15 min} {status.txt}
); })}
)} {/* Legend strip — chips are toggle buttons */}
{evAny && ( )} Fryst prognos Utfall Låst zon
{/* Insights row */}
Soltopp {peakSolar.toFixed(1).replace('.',',')} kW kl {fmt.hhmm(peakSolarHour)}
Förbr.topp {peakCons.toFixed(1).replace('.',',')} kW kl {fmt.hhmm(peakConsHour)}
Solöverskott =0?'var(--c-sell)':'var(--c-peak)'}}> {(sumSolarToday-sumConsToday>=0?'+':'')}{(sumSolarToday-sumConsToday).toFixed(1).replace('.',',')} kWh idag
{tomorrowIsCopy && (
vi väntar på morgondagens elpriser · släpps ca 13:00
)} {fcSolarSoFar > 0.1 && (
Avvikelse mot prognos: {(() => { const solPct = (actualSolarSoFar-fcSolarSoFar)/fcSolarSoFar*100; const consPct = (actualConsSoFar-fcConsSoFar)/Math.max(.5,fcConsSoFar)*100; const arrow = (v) => v >= 0 ? '↑' : '↓'; return (<> =0?'var(--c-sell)':'var(--c-unchanged)'}}> Sol {arrow(solPct)}{Math.abs(solPct).toFixed(0)}% · Förbr. {arrow(consPct)}{Math.abs(consPct).toFixed(0)}% ); })()}
)}
); }; const FStat = ({label, v, u, pip, pipDim, hint, delta, deltaInvert}) => { let deltaColor = null, deltaSign = ''; if (delta != null && Math.abs(delta) > 0.05){ const positive = delta > 0; const good = deltaInvert ? !positive : positive; deltaColor = good ? 'var(--c-sell)' : 'var(--c-unchanged)'; deltaSign = positive ? '+' : '−'; } return (
{label}
{v}{u}
{hint} {deltaColor && ( {deltaSign}{Math.abs(delta).toFixed(1).replace('.',',')} )}
); }; const __forecastStyles = document.createElement('style'); __forecastStyles.textContent = ` .card.forecast{border-color:var(--line-2)} .card.forecast .schema-head{padding:18px 22px} .card.forecast .schema-head h3{font-size:15px;letter-spacing:-.01em} .card.forecast .schema-head h3 .ico{width:16px;height:16px} .forecast .forecast-stats{ display:grid;grid-template-columns:repeat(4,1fr); border-bottom:1px solid var(--line); } .forecast-stat{ display:flex;gap:10px;align-items:stretch; padding:14px 22px;position:relative; } .forecast-stat::after{ content:"";position:absolute;right:0;top:24%;bottom:24%; width:1px;background:var(--line); } .forecast-stat:last-child::after{display:none} .forecast-stat .fc-pip{width:5px;border-radius:3px;flex:none;align-self:center;height:44px} .forecast-stat .fc-meta{flex:1;min-width:0} .forecast-stat .l{font-size:10px;letter-spacing:.10em;font-weight:600;color:var(--ink-3);text-transform:uppercase} .forecast-stat .v{margin-top:6px;font-size:24px;line-height:1;display:flex;align-items:baseline;gap:4px} .forecast-stat .v b{font-weight:600;letter-spacing:-.02em;color:var(--ink)} .forecast-stat .v .u{font-size:11px;color:var(--ink-2);font-weight:500} .forecast-stat .h{margin-top:6px;font-size:10.5px;letter-spacing:.02em} .forecast-wrap{padding:18px 22px 4px;position:relative} .forecast-svg{display:block;width:100%;height:320px;cursor:crosshair} /* EV-/last-band under beslutsremsan (SPEC_LASTER_EV §3) */ .fc-lanes{display:flex;flex-direction:column;gap:3px;margin-top:5px} .fc-lane{position:relative;height:15px;border-radius:4px;background:rgba(255,255,255,.025);overflow:hidden} .fc-band{position:absolute;top:0;bottom:0;border-radius:4px;color:#06080d; font-size:8.5px;font-weight:700;letter-spacing:.05em;font-family:"Geist Mono",monospace; display:flex;align-items:center;padding:0 5px;white-space:nowrap;overflow:hidden} .fc-band-label{position:absolute;top:50%;transform:translateY(-50%);display:flex;align-items:center;padding:2px 7px; font-size:8.5px;font-weight:700;letter-spacing:.05em;font-family:"Geist Mono",monospace; color:#CFE3FF;background:rgba(8,12,20,.78);border:1px solid rgba(122,184,255,.25);border-radius:5px; white-space:nowrap;pointer-events:none;z-index:2} .fc-lane-ev{overflow:visible} .fc-ev-shell{position:absolute;top:0;bottom:0;border-radius:4px;overflow:hidden; background:rgba(122,184,255,.14);box-shadow:inset 0 0 0 1px rgba(122,184,255,.38)} .fc-ev-fill{position:absolute;top:0;bottom:0;background:#7AB8FF} /* Facit (SPEC_EV_ACTUAL_HISTORIK): ghost = planens nivå som kontur, act = uppmätt energi som bottenankrad höjd → gapet mellan dem är missen. */ .fc-ev-fill.ghost{top:auto;bottom:0;background:transparent;border-radius:2px 2px 0 0; box-shadow:inset 0 0 0 1px rgba(122,184,255,.6); background-image:repeating-linear-gradient(45deg,transparent,transparent 2px,rgba(122,184,255,.14) 2px,rgba(122,184,255,.14) 4px)} .fc-ev-fill.act{top:auto;bottom:0;background:#7AB8FF;border-radius:2px 2px 0 0} .fc-ev-fill.act.out{background:#FF9E9E} /* gap = släcker skalets blå platta bakom facit-kvartar; short = bristen (planerat minus levererat) i varningsrött med plannivån som streckad topp. */ .fc-ev-fill.gap{top:0;bottom:0;background:rgba(4,6,11,.66)} .fc-ev-fill.short{top:auto;box-sizing:border-box;background:rgba(255,107,107,.20); border-top:2px dashed #FF9E9E; background-image:repeating-linear-gradient(45deg,transparent,transparent 2px,rgba(255,107,107,.32) 2px,rgba(255,107,107,.32) 4px)} .fc-ev-connector{position:absolute;top:50%;height:0;border-top:1px dashed rgba(122,184,255,.4)} .fc-loadrows{display:flex;flex-direction:column;gap:6px;padding:10px 22px 2px} .fc-loadrow{display:flex;align-items:center;gap:10px;font-size:11.5px;color:var(--ink-1);flex-wrap:wrap} .fc-loadrow .d{width:8px;height:8px;border-radius:2px;flex:none} .fc-loadrow .n{font-weight:500;color:var(--ink)} .fc-loadrow .w,.fc-loadrow .p{font-size:11px} .fc-loadrow .st{margin-left:auto;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase} .fc-loadrow .ev-unmet{font-size:11px;color:#E8C66B} .fc-band.prelim{box-shadow:inset 0 0 0 1px rgba(201,160,255,.65); background-image:repeating-linear-gradient(45deg,transparent,transparent 3px,rgba(6,8,13,.30) 3px,rgba(6,8,13,.30) 5px)} .fc-ev-shell.prelim{box-shadow:inset 0 0 0 1px rgba(122,184,255,.55)} .fc-prelim-tag{font-size:9.5px;font-weight:600;color:#C9A0FF;border:1px dashed rgba(201,160,255,.5); border-radius:4px;padding:1px 6px;letter-spacing:.03em;white-space:nowrap} /* Sticky info-readout — lives OUTSIDE the scroll container so it's always visible regardless of horizontal scroll position in the forecast graph. */ .forecast-readout{ margin:14px 22px 0; padding:10px 12px; background:rgba(14,21,36,.55); border:1px solid var(--line); border-radius:10px; font-family:Geist; /* Flimmer-fix (2026-07-24): LÅST höjd. Panelen ligger ovanför grafen, så när den ändrade höjd per hovrad (Elbil/last/utfall dyker upp/försvinner) flödade grafen om → musen lämnade svg:n → panelen krympte → graf upp → mus in igen → oscillation. Fast höjd + intern scroll = noll omflöde, ingen loop. */ height:176px; overflow-y:auto; display:flex; flex-direction:column; justify-content:flex-start; transition:background .2s, border-color .2s; } .forecast-readout.active{ background:rgba(14,21,36,.92); border-color:var(--line-2); } .forecast-readout .hint{ font-size:11px; color:var(--ink-3); text-align:center; letter-spacing:.02em; } .forecast-readout .t{ font-size:11px; color:var(--ink-1); font-weight:500; margin-bottom:6px; } .forecast-readout .rows{ display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:4px 14px; } .forecast-readout .r{ display:flex; align-items:center; gap:6px; font-size:11px; } .forecast-readout .r .d{width:7px;height:7px;border-radius:2px;flex:none} .forecast-readout .r .k{color:var(--ink-2);flex:1;min-width:0} .forecast-readout .r .v{color:var(--ink);font-weight:500} .forecast-readout .r.action{ grid-column:1/-1; flex-wrap:wrap; padding-top:6px;margin-top:4px; border-top:1px solid var(--line); } .forecast-readout .r.frozen{ grid-column:1/-1; flex-wrap:wrap; gap:6px 8px; } .forecast-readout .r.frozen .fz-out{ flex-basis:100%; padding-left:13px; color:var(--ink-3);font-size:10.5px;letter-spacing:.01em; } .forecast-readout .r.frozen .fz-out b{font-weight:600} .forecast-readout .r.action .why{ flex-basis:100%; margin-top:3px;padding-left:13px; color:var(--ink-3);font-size:10.5px;line-height:1.35; font-style:italic;letter-spacing:.01em; display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden; } /* Legend strip — matches clock-howto vocabulary */ .forecast-legend{ display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:10px; padding:12px 22px;border-top:1px solid var(--line); font-size:11px;color:var(--ink-2); } .forecast-legend .cs-line{display:inline-flex;align-items:center;gap:7px;line-height:1.2;padding:4px 8px} .forecast-legend .cs-line .sw{width:12px;height:6px;border-radius:2px;flex:none} .forecast-legend .cs-line .sw.ln{width:18px;height:0;border-top:1.6px solid var(--ink-2)} .forecast-legend .cs-line .sw.ln.dashed{border-top-style:dashed;opacity:.7} .forecast-legend .cs-line .sw.ln.solid{border-top-style:solid} .forecast-legend .cs-line .sw.box{width:12px;height:9px;border-radius:2px} .forecast-legend .cs-line .sw.box.lock{background:rgba(180,200,240,.14);box-shadow:inset 0 0 0 1px rgba(180,200,240,.22)} .forecast-legend .cs-sep{width:1px;height:14px;background:var(--line)} .forecast-legend .cs-toggle{ display:inline-flex;align-items:center;gap:7px;line-height:1.2; padding:4px 9px;border-radius:7px; border:1px solid var(--line);background:rgba(255,255,255,.02); color:var(--ink-1); font:inherit; font-size:11px; cursor:pointer; transition:background .15s, border-color .15s, color .15s, opacity .15s; } .forecast-legend .cs-toggle:hover{background:rgba(255,255,255,.04);border-color:var(--line-strong)} .forecast-legend .cs-toggle .sw{width:12px;height:6px;border-radius:2px;flex:none;transition:opacity .15s} .forecast-legend .cs-toggle.off{color:var(--ink-3);opacity:.55} .forecast-legend .cs-toggle.off .sw{opacity:.35 !important;filter:grayscale(.55)} .forecast-legend .cs-toggle.on{background:rgba(91,168,255,.06);border-color:rgba(91,168,255,.18)} .forecast-foot{display:flex;flex-wrap:wrap;gap:20px;padding:12px 22px 16px; border-top:1px solid var(--line);font-size:11.5px;color:var(--ink-2); background:linear-gradient(180deg, rgba(255,255,255,.012), transparent); align-items:center; } .forecast-foot .ins{display:inline-flex;align-items:center;gap:6px} .forecast-foot .ins b{color:var(--ink);font-weight:500;margin:0 1px} .forecast-foot .ins .ico{display:inline-flex;color:var(--ink-3)} .forecast-foot .np-wait{ display:inline-flex;align-items:center;gap:6px; padding:4px 10px;border-radius:999px; background:rgba(157,123,255,.10); border:1px solid rgba(157,123,255,.28); color:rgba(180,160,255,.85); font-size:11px; letter-spacing:.02em; } .forecast-foot .np-wait svg{color:rgba(180,160,255,.7)} `; document.head.appendChild(__forecastStyles); Object.assign(window, { ForecastCard });