/* ============================================================================ GC · AVANCERAT — hans 48-timmarsgraf ---------------------------------------------------------------------------- Porterad ur GravioApp.jsx AvanceratView (2536). Behållet: avläsningspanelen, dygnsknapparna, den breda scrollande grafen med serier + lägesband + enhetsspår, seriefiltret och enhetsdetaljerna för markerad tidpunkt. Skillnader mot hans fil, alla av samma skäl som i de andra vyerna: 1. PRISET ÄR BACKENDENS. Han räknar spot + NET_ORE (95 öre hårdkodat) och spot + NYTTA_ORE. Vår buy/sell bär redan avgifterna. 2. UTFALL ÄR MÄTVÄRDEN, inte rnd(). Saknas mätvärdet för en kvart står det "inget mätvärde" i stället för en påhittad avvikelse. 3. ENHETERNA RÄKNAS UR SETTINGS — han har två fasta bilar och två laster. 4. FÖRBRUKNING = hus + planerade skjutbara laster (consPlanTot), samma definition som ForecastCard och Schema. Två tal under samma etikett är det fel vi städade bort 16/8. ========================================================================== */ const GA_MONO = { fontFamily:'"Geist Mono", ui-monospace, monospace' }; const GA_Q = 96; const GA_NOW_AT = 0.25; const gaNum = (n, d=1) => (n == null || !isFinite(n)) ? '—' : Number(n).toLocaleString('sv-SE', { minimumFractionDigits:d, maximumFractionDigits:d }); const gaLabel = (i) => String(Math.floor((i%GA_Q)/4)).padStart(2,'0')+':'+String(((i%GA_Q)%4)*15).padStart(2,'0'); /* Förbrukning: mätt när kvarten passerat, annars hus + planerade laster. */ /* Samma regel som gc-schema.jsx: vid NU saknar history den pågående kvarten, så consAct är null medan consLive är satt. Utan consLive-ledet föll raden tillbaka på PLANEN — och eftersom s.sol nu bär mätvärdet stod sol och förbrukning i olika baser under samma NU-etikett. hourInfo läser solPlan/ consPlanTot mot solAct/consAct uttryckligen och rörs inte av detta. */ const gaCons = (x) => x == null ? null /* Krav 11 (12/9): consAct var husets NETTO efter lastavdraget ⇒ + loadAct. K2 (15/9): med history.load_display_gross är consAct BRUTTO och lasten får inte adderas igen. Flaggan ligger på dagsobjektet (day.loadDisplayGross) och läses via window.__gaGross som GcAvancerat sätter per render. */ : (x.consAct != null ? x.consAct + (window.__gaGross ? 0 : (x.loadAct || 0)) : x.consLive != null ? x.cons : (x.consPlanTot ?? x.cons)); /* En regel för hela projektet, se gc-core: gcPriceUnknown + GC_PRICE_UNRELEASED. */ function GcAvancerat({ day, tomorrow, units, nowIdx, tomorrowPrelim }){ /* K2 (15/9): brutto-flaggan från history via dagsobjektet. */ const gross = day?.loadDisplayGross === true; window.__gaGross = gross; const seq = React.useMemo(() => tomorrow ? day.concat(tomorrow) : day.slice(), [day, tomorrow]); const N = seq.length; /* Seriefiltret SPARAS som vypreferens (uiPrefs: localStorage nu, settings.ui_prefs när backend öppnat fältet). Säljpriset bockades ur vid varje omladdning, så en användare som ville se det fick klicka fram det igen varje gång (André 26/8). Okända nycklar ignoreras och nya serier får sitt default, så en framtida serie inte startar dold. */ const GA_SHOW_DEF = { sol:true, cons:true, soc:true, kop:true, salj:false, spot:false }; const [showRaw, setShow] = window.useUiPref('advSeries', GA_SHOW_DEF, (v) => !!v && typeof v === 'object' && !Array.isArray(v)); const show = React.useMemo(() => Object.fromEntries(Object.keys(GA_SHOW_DEF).map(k => [k, typeof showRaw?.[k] === 'boolean' ? showRaw[k] : GA_SHOW_DEF[k]])), [showRaw]); const [selI, setSelI] = React.useState(nowIdx); /* Köppris och Spotpris är SAMMA lager — staplarna — med två källor, inte två serier ovanpå varandra (André 26/8: "växla mellan köp och spotpris, visat genom staplarna"). Att slå på den ena släcker den andra; att slå av den aktiva släcker lagret. Övriga serier togglar fritt. Gamla sparningar med båda true migreras i barSrc-läsningen (köp vinner). */ /* TRE priskällor, ETT lager. Sälj var kvar som linje ovanpå staplarna, men i spotläget ligger den 2,5 öre över topparna — under en pixel — och lästes som att sälj = staplarnas pris (André 26/8). En prislinje ovanpå prisstaplar kan aldrig särskiljas när serierna är nära; alltså är sälj en tredje stapelkälla i växeln. Jämförelsen mellan serierna görs i kortpanelen. */ /* Historik: 26/8–16/9 fanns en Spotpris-knapp i växeln med köp. Borttagen 16/9 (härlett tal); säljpriset tog dess plats som andra stapelserie. */ const toggle = (k) => setShow(raw => { /* Normalisera först: en sparning från en äldre version kan sakna nycklar, och !undefined är true — då gjorde första klicket ingenting. */ const s = { ...GA_SHOW_DEF, ...(raw || {}) }; /* TVÅ PRISKRYSS, EN REGEL (André 16/9). Spotpris-knappen är borta — spot är ett härlett tal tills backend exponerar fältet. Köppris och Säljpris är fria kryss; vad som ritas följer kombinationen: köp → köpstaplar (som förut) köp + sälj → köpstaplar + vit säljlinje (som förut) sälj → SÄLJSTAPLAR — sälj tar köpets plats inget → inga staplar, bara kurvorna */ return { ...s, [k]: !s[k] }; }); /* ETT FÄLT (v3). Staplarna är grafens bas i bottenzonen (PZ i yOre), kurvorna lager ovanpå — prislanen ovanför grafen underkändes av André. PR_Y/PR_H är ALIAS för fältet så sälj/spot-trapporna och äldre referenser gäller. */ /* ZOOM = BARA TIDSAXELN (Lennie 15/9). Förr var viewBox fast 1840 bred och elementets bredd zoom·100 %, så höjden följde med proportionen och hela grafen förstorades. Nu växer ritytans bredd i viewBox-enheter med zoomen: rendered höjd = zoom·cw · H/(1840·zoom/2) = konstant. Text, streck och staplarnas höjd är oförändrade; bara kvartarnas bredd ändras. Zoom 1 ⇒ hela 48 h i vyn, 8 ⇒ ~6 h. useState ligger här för att W behöver zoomen. */ /* Zoomen sparas som vypreferens. Endast värden ur ZSTEPS accepteras; skräp i lagringen faller till 2×. uiPrefs buntar ihop skrivningarna, så nypets ström av setZoom (en per frame) kostar inte en skrivning per frame. */ const [zoom, setZoom] = window.useUiPref('advZoom', 2, (v) => [1, 1.5, 2, 3, 4, 6, 8].includes(Number(v))); /* Palett 15/9 (Tibber-referens, Lennie): billig TEAL, dyr ORANGE. Kall/varm i stället för grön/röd. SoC bytte till ljusblå för att inte krocka. */ /* PRISFÄRGERNA ÄR ANVÄNDARENS (André 16/9). Båda ändarna av skalan väljs i samma popup och sparas som hex i localStorage (hems_price_cols), inte som index: en omsorterad palett ska inte byta någons färger i efterhand. Kurerade svit — fri färgväljare ger lätt en billig-färg som krockar med solen (gul), SOC (ljusblå) eller lägesbandets färger. Billig-ändan hålls i kall grön/teal, dyr-ändan i varm orange/röd, plus en magenta för den som vill ha maximal skillnad mot solkurvan. */ const GA_CHEAPS = [ { n:'Teal (standard)', hex:'#22c4a0' }, { n:'Mint', hex:'#34d399' }, { n:'Grön', hex:'#1db954' }, { n:'Djupgrön', hex:'#0f9b6c' }, { n:'Cyan', hex:'#06b6d4' }, { n:'Lime', hex:'#84cc16' }, ]; const GA_DEARS = [ { n:'Standard', hex:'#d74b1e' }, { n:'Tomat', hex:'#e0401f' }, { n:'Cinnober', hex:'#e33a24' }, { n:'Klarröd', hex:'#e23030' }, { n:'Röd', hex:'#dc2626' }, { n:'Magenta', hex:'#c026d3' }, ]; const GA_COL_DEF = { cheap:'#22c4a0', dear:'#d74b1e' }; const GA_HEX = /^#[0-9a-f]{6}$/i; const okCols = (v) => !!v && GA_HEX.test(v.cheap || '') && GA_HEX.test(v.dear || ''); const [priceCols, setPriceCols] = window.useUiPref('priceCols', GA_COL_DEF, okCols); /* Engångsmigrering från hems_price_red (index i den gamla femlistan). Körs bara när inget färgpar sparats ännu, så ett medvetet val aldrig skrivs över av ett gammalt index. */ React.useEffect(() => { if (window.uiPrefs.get('priceCols', undefined) !== undefined) return; let old = NaN; try { old = parseInt(localStorage.getItem('hems_price_red'), 10); } catch {} if (old >= 0 && old < GA_DEARS.length) setPriceCols({ cheap:GA_COL_DEF.cheap, dear:GA_DEARS[old].hex }); }, []); const [colOpen, setColOpen] = React.useState(false); const [hintOpen, setHintOpen] = React.useState(false); /* Systemets färgväljare skickar change under hela draget i vissa webbläsare; uiPrefs buntar skrivningarna så vyn följer med direkt medan lagring och server får ett anrop när draget lugnat sig. */ const setPriceCol = (end, hex) => setPriceCols(p => ({ ...p, [end]: hex })); const GA_RED = priceCols.dear; /* Stapelfärgen: pris (grön→röd mot dygnets spann) eller beslutet under grafen, så en stapel får exakt lägets färg ur GC_MODES. Sparas som vypreferens. Default 'beslut' (André 20/9); 'pris' var default t.o.m. 494. En redan sparad preferens vinner över defaulten. */ const [barCol, setBarCol] = window.useUiPref('barColor', 'beslut', (v) => v === 'pris' || v === 'beslut'); const toggleBarCol = () => setBarCol(v => v === 'pris' ? 'beslut' : 'pris'); const GA_GREEN = priceCols.cheap; /* Tätare ram (Lennie 15/9, foto: stora tomytor vänster och upptill). H_PL 84 → 58: 11 px mono ≈ 6,8/tecken ⇒ 7 tecken ("SigenDC" ryms, längre namn klipps med …). PT 24 → 4: NU-pillret (17 hög, GY−21) är enda som behöver luft ovanför fältet, så GY = PT + 22. Staplarnas zon 75 → 90 %. */ const W = 1840*zoom/2, H_PL = 58, H_PR = 8, PT = 2; /* 11 px mono ≈ 6,8 enheter/tecken (etiketterna växte från 10 px 26/8 — André: "tydligare texter"). H_PL 84 ⇒ 10 tecken: "Varmvatten" ryms helt. */ const GA_LBL_CH = Math.max(4, Math.floor((H_PL - 10)/6.8)); /* Ovanför fältet bor PRISPILLRET (v346, Lennie 15/9: "priset i utrymmet ovanför den streckade linjen"); NU-pillret sitter i stället inne i fältets topp vid strecket. 40 hög pill + 4 luft ⇒ GY = PT + 44. */ const GY = PT + 44, GH = 296; const PR_Y = GY, PR_H = GH; const MODE_Y = GY + GH + 14, MODE_H = 20; const DEV_Y = MODE_Y + MODE_H + 9, DEV_H = 14, DEV_GAP = 4; const devices = React.useMemo(() => { const defs = [ ...units.evs.map((u, k) => ({ name:u.name, kind:'Bil', at: (x) => (x.ev[k] && x.ev[k].p != null && x.ev[k].p > 0.05 ? { p:x.ev[k].p, src:x.ev[k].src } : null) })), ...units.loads.map((u, k) => ({ name:u.name, kind:'Last', at: (x) => { const lp = x.loads[k] ? (x.loads[k].pPlan ?? x.loads[k].p) : null; return lp != null && lp > 0.05 ? { p:lp, src:x.loads[k].src, remnant: x.loads[k].remnant === true } : null; }, cmdAt: (x) => (x.loads[k] && x.loads[k].cmd) || null })), ]; /* Krav 13: konturen är kommandot (cmd start–end per placering), fyllningen förväntningen (slots). Samma regel som ringarna. */ return defs.map(d => { const slots = seq.map(d.at); const cmds = []; if (d.cmdAt){ let prev = null; for (let i = 0; i < seq.length; i++){ const c = d.cmdAt(seq[i]); if (c && c !== prev) cmds.push({ start:i, end:i+1, cmd:c }); else if (c && prev === c) cmds[cmds.length-1].end = i+1; prev = c || null; } } return { ...d, slots, cmds }; }).filter(d => d.slots.some(Boolean) || d.cmds.length > 0); /* André 25/9: enhet utan plan i det visade fönstret får ingen rad. */ }, [seq, units]); /* Runda upp till nästa 5 kW. Med bara 8 % marginal hamnade den översta gridlinjen på samma rad som "kW"-etiketten (max 9,4 → 10,17, linjen på y≈17 mot etikettens 14) och texten trasslade ihop sig. */ const kwMax = Math.max(5, Math.ceil(Math.max(...seq.map(x => Math.max(x.sol || 0, gaCons(x) || 0)), 1) * 1.08 / 5) * 5); /* Skalan får bara läsa KÄNDA priser. Med morgondagens proxy inne sattes öre-skalan av tal som inte finns, och dagens linje trycktes ihop mot ett maxvärde ur okänd data. Golvet 50 öre håller skalan stabil när mängden är tom, precis som forecast.jsx:326. */ /* Skalan följer den VISADE stapelserien (köp eller sälj) — flyttad hit ovan yOre; barSrc-läsningen ligger senare, så läs källan direkt ur show. */ const _barKey = show.kop ? 'buy' : 'sell'; const priceMax = Math.max(50, ...seq.map((x,i) => window.gcPriceUnknown(tomorrowPrelim, i) ? 0 : (x[_barKey] ?? 0))) * 1.02; const xAt = (i) => H_PL + (i/(N-1))*(W - H_PL - H_PR); const bw = (W - H_PL - H_PR)/N; const yKw = (v) => GY + GH - ((v || 0)/kwMax)*GH; const yPct = (v) => GY + GH - ((v || 0)/100)*GH; /* Staplarna får BOTTENZONEN (75 % av fältet — 55 lämnade en tom övervåning, foto 26/8), kurvorna hela höjden — samma grepp som volymstaplar under kurslinjer i handelsappar. Med full höjd begravde stapelväggen kurvorna: elpriser bottnar sällan nära noll, så varje stapel var 40–95 % hög och fältet blev en vägg (mätt 26/8). */ const PZ = GH*0.9; const yOre = (v) => GY + GH - ((v || 0)/priceMax)*PZ; /* Null i en serie bryter linjen i stället för att falla till noll — en saknad kvart är inte ett nollvärde. Runs = sammanhängande stycken; ur dem byggs både linje och ytfyllning, så de aldrig kan glida isär. */ const runsOf = (vals, y) => { const out = []; let run = []; vals.forEach((v, i) => { if (v == null){ if (run.length > 1) out.push(run); run = []; return; } run.push([xAt(i), y(v)]); }); if (run.length > 1) out.push(run); return out; }; const ptsOf = (rs) => rs.map(r => r.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ')); const line = (vals, y) => ptsOf(runsOf(vals, y)); /* Monoton kubisk interpolation (Fritsch–Carlson). Räta segment mellan kvartspunkter läste "föråldrat"; catmull-rom överslängar vid platåer och ritar dippar som inte finns i datan. Monoton spline går genom exakt samma punkter utan att uppfinna extremvärden — markörprickarna sitter kvar på kurvan. */ const monoPath = (r) => { if (r.length < 2) return ''; if (r.length === 2) return `M${r[0][0].toFixed(1)},${r[0][1].toFixed(1)} L${r[1][0].toFixed(1)},${r[1][1].toFixed(1)}`; const n = r.length, xs = r.map(p=>p[0]), ys = r.map(p=>p[1]); const dx = [], m = []; for (let i = 0; i < n-1; i++){ dx.push(xs[i+1]-xs[i]); m.push((ys[i+1]-ys[i])/((xs[i+1]-xs[i]) || 1e-9)); } const t = [m[0]]; for (let i = 1; i < n-1; i++){ if (m[i-1]*m[i] <= 0) t.push(0); else { const w1 = 2*dx[i]+dx[i-1], w2 = dx[i]+2*dx[i-1]; t.push((w1+w2)/(w1/m[i-1]+w2/m[i])); } } t.push(m[n-2]); let d = `M${xs[0].toFixed(1)},${ys[0].toFixed(1)}`; for (let i = 0; i < n-1; i++){ const h = dx[i]; d += ` C${(xs[i]+h/3).toFixed(1)},${(ys[i]+t[i]*h/3).toFixed(1)} ${(xs[i+1]-h/3).toFixed(1)},${(ys[i+1]-t[i+1]*h/3).toFixed(1)} ${xs[i+1].toFixed(1)},${ys[i+1].toFixed(1)}`; } return d; }; const smoothOf = (rs) => rs.map(monoPath); /* UTFALL HELDRAGET, PROGNOS STRECKAD (Lennie 15/9). Serien delas vid NU: index ≤ nowIdx är utfall, ≥ nowIdx prognos — NU-punkten ingår i båda så linjen är kontinuerlig utan glapp. Streckningen nästan heldragen (5 1,5 px): skillnaden ska synas men inte skrika (Lennie 15/9). */ const splitAtNow = (vals) => ({ past: vals.map((v, i) => i <= nowIdx ? v : null), future: vals.map((v, i) => i >= nowIdx ? v : null), }); const smoothSplit = (vals, y) => { const s = splitAtNow(vals); return { past: smoothOf(runsOf(s.past, y)), future: smoothOf(runsOf(s.future, y)) }; }; const smoothAreaOf = (rs, base) => rs.map(r => monoPath(r) + ` L${r[r.length-1][0].toFixed(1)},${base.toFixed(1)} L${r[0][0].toFixed(1)},${base.toFixed(1)} Z`); const solRuns = runsOf(seq.map(x => x.sol), yKw); const solSm = smoothOf(solRuns); const solSp = smoothSplit(seq.map(x => x.sol), yKw); /* Ingen ytfyllning under solen alls (Lennie 15/9, v353). */ const consSp = smoothSplit(seq.map(gaCons), yKw); /* SMARTLASTEN I PROGNOS-GRAFEN (kravlista 12/9 krav 11). Finns cons_components ritas förbrukningen som TRE serier i stället för en: baslast (heldragen) = final − loads_add, 48 h plan (streckad) = final, bara där loads_add > 0, fäst i basen utfall (linje+fyllning) = hus + last (brutto direkt när load_display_gross), passerade kvartar Fyllningen mellan bas och utfall visar vad lasten faktiskt drog. Timme där planen hade lasten PÅ men inget förbrukades får INGEN fyllning — den streckade planen står kvar ovanför. Energi utanför plan (load_kwh − load_kwh_plan) fylls i avvikande ton. Lastens energi finns bara per timme, så fyllningen bedöms per timme. Saknas komponenterna ritas consSm som förut. */ /* André 20/9: BAKÅT ritas bara EN heldragen lila linje = verklig uppmätt förbrukning (hus + last). FRAMÅT baslast streckad lila och bas + samtliga smartlaster ljusare streckad. Baslasten ritas inte bakåt och den ljusa utfallslinjen är borttagen — fyllningarna (utfall mot bas) med den. */ const hasComp = seq.some(x => x && x.consBase != null); /* Brytet vid NU (André 20/9): utfallet (hus + last) och baslastprognosen är olika storheter och möts inte. Streckade serierna utgår därför från utfallets NU-punkt och går över till sin egen nivå i kvarten efter, så linjen är sammanhängande utan hopp. */ const actTot = (x) => (x.consAct != null && x.consBase != null) ? x.consAct + (gross ? 0 : (x.loadAct || 0)) : null; /* NU-kvarten har ofta inget mätvärde ännu (DOM 20/9: heldragen slutade en kvart före NU-strecket, streckade började på baslastens nivå). Senaste kända utfall bärs fram till NU så heldragen alltid når strecket och de streckade utgår därifrån. */ /* Baslastens streckade utgår från UTFALLET MINUS uppmätt last (André 20/9): vid NU går smartlasten ofta, och utfallet är då inte baslast. Netto = consAct när history är netto, consAct − loadAct när brutto. Smartlastens streckade utgår från hela utfallet. */ /* loadAct är null i dagens kvartar (DOM 20/9: backend skickar inte load_kwh per kvart). Då används planens consLoadsAdd för kvarten om en körning i loadRunsOpen täcker den (lasten är känd att vara på; effekten är planens). Ingen aktiv körning ⇒ inget avdrag. */ const runsOpen = Array.isArray(day && day.loadRunsOpen) ? day.loadRunsOpen.filter(r => r && r.kwh > 0 && r.end_q > r.start_q) : []; const todayKey = (typeof window.hemsToday === 'function') ? window.hemsToday() : null; const runOnAt = (i) => runsOpen.some(r => (!r.day_key || !todayKey || r.day_key === todayKey) && i >= r.start_q && i < r.end_q); const loadAtQ = (x, i) => x.loadAct != null ? x.loadAct : (runOnAt(i) ? (x.consLoadsAdd || 0) : 0); const actBase = (x, i) => (x.consAct != null && x.consBase != null) ? Math.max(0, x.consAct - (gross ? loadAtQ(x, i) : 0)) : null; const lastKnown = (fn) => { if (nowIdx == null) return null; for (let i = nowIdx; i >= 0 && i >= nowIdx - 4; i--){ const v = seq[i] ? fn(seq[i], i) : null; if (v != null) return v; } return null; }; const nowAct = lastKnown(actTot), nowBase = lastKnown(actBase); const fromNow = (vals, at) => vals.map((v, i) => i === nowIdx && at != null ? at : v); const baseSp = hasComp ? { past:[], future: smoothSplit(fromNow(seq.map(x => x.consBase ?? null), nowBase), yKw).future } : { past:[], future:[] }; const planSm = (() => { if (!hasComp) return []; const vals = seq.map((x, i) => (i > nowIdx && x.consBase != null && (x.consLoadsAdd || 0) > 0.01) ? x.consBase + x.consLoadsAdd : null); // Fäst varje streckat stycke i basen kvarten före och efter. const out = []; let run = []; const flush = (end) => { if (!run.length) return; const a = run[0][2], b = run[run.length-1][2]; const pts = run.map(p => [p[0], p[1]]); if (a > 0 && seq[a-1].consBase != null) pts.unshift([xAt(a-1), yKw(a-1 === nowIdx && nowAct != null ? nowAct : seq[a-1].consBase)]); if (b < N-1 && seq[b+1].consBase != null) pts.push([xAt(b+1), yKw(seq[b+1].consBase)]); if (pts.length > 1) out.push(pts); run = []; }; vals.forEach((v, i) => { if (v == null){ flush(); return; } run.push([xAt(i), yKw(v), i]); }); flush(); return out.map(monoPath); })(); const actSm = hasComp ? smoothOf(runsOf(splitAtNow(fromNow(seq.map(actTot), nowAct)).past, yKw)) : []; const actFills = (() => { return []; // André 20/9: ingen fyllning — baslasten ritas inte bakåt if (!hasComp) return []; const polys = []; for (let h0 = 0; h0 < N; h0 += 4){ const hq = seq.slice(h0, h0 + 4).filter(x => actTot(x) != null); if (hq.length < 2) continue; const x0 = hq[0]; const planOn = (x0.consLoadsAdd || 0) > 0.01; const used = x0.loadAct != null ? x0.loadAct : null; if (planOn && (used == null || used < 0.05)) continue; // aktiv men ej förbrukat const idx = hq.map(x => seq.indexOf(x)); const top = idx.map(i => `${xAt(i).toFixed(1)},${yKw(actTot(seq[i])).toFixed(1)}`); const bot = idx.slice().reverse().map(i => `${xAt(i).toFixed(1)},${yKw(seq[i].consBase).toFixed(1)}`); polys.push({ d: `M${top.join(' L')} L${bot.join(' L')} Z`, kind:'plan' }); const extra = (x0.loadAct != null && x0.loadActPlan != null) ? x0.loadAct - x0.loadActPlan : 0; if (extra > 0.05){ const bot2 = idx.slice().reverse().map(i => `${xAt(i).toFixed(1)},${yKw(actTot(seq[i]) - extra).toFixed(1)}`); polys.push({ d: `M${top.join(' L')} L${bot2.join(' L')} Z`, kind:'extra' }); } } return polys; })(); // Bankens SoC, inte batteri 1:s — kurvan hoppade till batteri 1 vid NU. const socSp = smoothSplit(seq.map(x => x.socAggPct ?? null), yPct); /* Priset ritas ALDRIG in i morgondagen innan Nord Pool släppt det. Backend planerar på dagens priser som proxy, och en linje som fortsätter över dygnsgränsen läses som ett känt pris. Samma regel som forecast.jsx:323 (priceVisibleN = 96) — null bryter linjen. */ const priceMask = (v, i) => window.gcPriceUnknown(tomorrowPrelim, i) ? null : v; /* Nivåfärgerna (billig/normal/dyr som tredjedelar av kända priser i fönstret) och öre-skalan räknas ur den VISADE serien — sälj ligger ~90 öre under köp, och köpets kvantiler på säljstaplar hade gjort allt "billigt". */ /* Stapelkällan: köp om det är ikryssat, annars sälj om det är ikryssat. Säljlinjen ritas bara när sälj ligger OVANPÅ köpstaplar — är sälj själv stapel finns ingen linje att rita. Spot ritas inte alls: härlett tal, väntar på backendens spot_price (BACKEND_BEHOV öppet 4). */ const barSrc = show.kop ? 'kop' : (show.salj ? 'salj' : null); const barVals = seq.map((x,i) => priceMask(barSrc === 'salj' ? x.sell : x.buy, i)); const saljVisible = show.salj && barSrc === 'kop'; const saljSteps = (() => { const out = []; let run = []; seq.forEach((x, i) => { const v = priceMask(x.sell, i); if (v == null){ if (run.length > 1) out.push(run.join(' ')); run = []; return; } const yy = yOre(v); run.push(`${(xAt(i)-bw/2).toFixed(1)},${yy.toFixed(1)}`, `${(xAt(i)+bw/2).toFixed(1)},${yy.toFixed(1)}`); }); if (run.length > 1) out.push(run.join(' ')); return out; })(); const knownBars = barVals.filter(v => v != null).sort((a,b)=>a-b); const qAt = (fr) => knownBars.length ? knownBars[Math.min(knownBars.length-1, Math.floor(fr*knownBars.length))] : 0; const pLo = qAt(1/3), pHi = qAt(2/3); /* Djupare kulörer + högre viloopacitet (André 26/8, foto: "mer punch"). 0,55 på färgade staplar mot mörk botten gav pastell — nivåfärgen ska kunna läsas i ögonvrån. */ /* MJUKA övergångar (André 27/8, Tibber-referens): färgen GLIDER med priset i stället för att hoppa mellan tre fasta hinkar — smaragd vid billig- gränsen, skiffer i mitten, ros vid dyr-gränsen, linjärt däremellan. Orden ("billig"/"dyr"/nivåtext) behåller hinkgränserna — språk är diskret, färg behöver inte vara det. */ const _lerpCol = (c1, c2, t) => { const px = (c) => [parseInt(c.slice(1,3),16), parseInt(c.slice(3,5),16), parseInt(c.slice(5,7),16)]; const a = px(c1), b = px(c2), k = Math.max(0, Math.min(1, t)); return 'rgb(' + a.map((x,j) => Math.round(x + (b[j]-x)*k)).join(',') + ')'; }; /* KONTINUERLIG toning (Lennie 15/9): dygnets billigaste kvart är helt grön, dyraste helt röd, allt emellan glider procentuellt via skiffer i mitten. Förr var undre/övre tredjedelen platt grön/röd — hela fönstret såg ut som två block. Spannet räknas per DYGN ur kända priser (pMin/pMax per dag) så dagens skala inte trycks ihop av morgondagens, och läggs om när Nord Pool släpper morgondagen ~13. Ord (billig/dyr) behåller tredjedelarna. */ const dayRange = (i) => { const d0 = i < GA_Q ? 0 : GA_Q, vals = barVals.slice(d0, d0 + GA_Q).filter(v => v != null); if (!vals.length) return null; return { lo: Math.min(...vals), hi: Math.max(...vals) }; }; const priceColAt = (v, i) => { if (v == null) return '#64748b'; const r = dayRange(i); if (!r || r.hi - r.lo < 0.5) return '#64748b'; const t0 = Math.max(0, Math.min(1, (v - r.lo) / (r.hi - r.lo))); /* Platåer (Lennie 15/9): billigaste 25 % helt gröna, dyraste 25 % helt röda, övergången sker i mittens 50 %. Mellansteget bärnsten. */ /* INGEN mittfärg (Lennie 15/9): grön under mitten, röd över, med ett kort övergångsspann (±6 % kring dygnets mittpris) så gränsen inte hackar. Bärnsten krockade med solen, sand blev grumligt. */ const t = Math.max(0, Math.min(1, (t0 - 0.44) / 0.12)); return _lerpCol(GA_GREEN, GA_RED, t); }; /* Utan index (kortpanelen, markören) ⇒ vald kvarts dygn. */ const priceCol = (v) => priceColAt(v, selI); /* Stapelns fyllning: prisläget som ovan, beslutsläget lägets egen färg. */ const barColAt = (v, i, row) => barCol === 'beslut' ? (window.GC_MODES[row && row.mode] || window.GC_MODES.vanta).color : priceColAt(v, i); const scrollRef = React.useRef(null); const scrollToDay = (d) => { const el = scrollRef.current; if (el) el.scrollTo({ left: d*(el.scrollWidth/2), behavior:'smooth' }); }; const scrollToNow = () => { setSelI(nowIdx); /* Nu snappar även draglinjen (Lennie 14/9) */ const el = scrollRef.current; if (!el) return; /* NU vid 25 % från vänster (André 16/9): en fjärdedel utfall, tre fjärdedelar kommande — det är planen framåt man läser. */ el.scrollTo({ left: Math.max(0, (nowIdx/N)*el.scrollWidth - el.clientWidth*GA_NOW_AT), behavior:'smooth' }); }; React.useEffect(() => { scrollToNow(); }, []); /* Följer NU (Lennie 15/9): när kvarten slår över och markeringen stod på förra NU flyttas markering och scroll med, så NU står kvar i vyn. En manuellt vald kvart lämnas orörd. */ const prevNow = React.useRef(nowIdx); React.useEffect(() => { if (nowIdx === prevNow.current) return; const wasOnNow = selI === prevNow.current; prevNow.current = nowIdx; if (!wasOnNow) return; setSelI(nowIdx); const el = scrollRef.current; if (!el) return; el.scrollTo({ left: Math.max(0, (nowIdx/N)*el.scrollWidth - el.clientWidth*GA_NOW_AT), behavior:'smooth' }); }, [nowIdx]); const ref = React.useRef(null); /* Skalan mellan viewBox-enheter och skärm-px: svg:n är 100 % bred, så en konstant i användarenheter krymper med fönstret. Mäts om vid varje storleksändring och när zoomen (och därmed W) ändras. */ const [pxPerUnit, setPxPerUnit] = React.useState(0.7); React.useEffect(() => { const el = ref.current; if (!el) return; const measure = () => { const w = el.getBoundingClientRect().width; if (w > 0) setPxPerUnit(w / W); }; measure(); if (typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [W]); const drag = React.useRef(false); const idxAt = (e) => { const el = ref.current; if (!el) return null; const r = el.getBoundingClientRect(); const fx = ((e.clientX - r.left)/r.width)*W; return Math.min(N-1, Math.max(0, Math.round(((fx - H_PL)/(W - H_PL - H_PR))*(N-1)))); }; /* Kantglidningen under drag är KLAMPAD till ett konstant steg per event. Tidigare teleporterade den scrollLeft till målet: vid kanten flyttar det innehållet under fingret, nästa darr-event läser ett större index, målet hamnar längre bort — självmatande acceleration som stack iväg över dygnsgränsen och landade i slutet av imorgon (André 27/8). Konstant steg ⇒ samma fart hela vägen; snabbförflyttning är svepet/knapparna. */ /* rAF-DRIVEN kantglidning (30/8): glider med konstant fart SÅ LÄNGE fingret hålls i kantzonen — även helt stilla. Förra varianten steg 14 px per pointermove-EVENT: när fingret nådde kanten och stannade kom inga fler event, glidningen dog och grafen "fastnade" efter några staplar (André 30/8, mobil). Loopen läser senaste fingerläget, rullar scrollLeft och flyttar markören till fingrets index varje frame; stoppas på släpp/cancel och vid innehållets ände. Konstant fart per FRAME (9 px ≈ 540 px/s) — ingen målteleport, så 27/8-rusningen kan inte återkomma. */ const glide = React.useRef(0); const lastPtr = React.useRef(null); const stopGlide = () => { if (glide.current){ cancelAnimationFrame(glide.current); glide.current = 0; } }; const glideStep = () => { glide.current = 0; const sc = scrollRef.current, e = lastPtr.current; if (!sc || !e || !drag.current) return; const r = sc.getBoundingClientRect(); const margin = Math.min(56, sc.clientWidth*0.14), SPEED = 9; const dx = e.clientX < r.left + margin ? -SPEED : e.clientX > r.right - margin ? SPEED : 0; if (!dx) return; const before = sc.scrollLeft; sc.scrollLeft = Math.max(0, Math.min(sc.scrollWidth - sc.clientWidth, before + dx)); const i = idxAt(e); if (i !== null) setSelI(i); if (sc.scrollLeft !== before) glide.current = requestAnimationFrame(glideStep); }; const ensureVisible = (i) => { /* Behålls för TANGENTBORDET (piltangenter har inget finger att glida mot): hoppa direkt så markören alltid syns. */ const sc = scrollRef.current; if (!sc) return; const x = (xAt(i)/W)*sc.scrollWidth; const margin = sc.clientWidth*0.12; if (x < sc.scrollLeft + margin) sc.scrollLeft = Math.max(0, x - margin); else if (x > sc.scrollLeft + sc.clientWidth - margin) sc.scrollLeft = x - sc.clientWidth + margin; }; /* TVÅ GESTZONER (André 27/8: "fingret på den och dra den" — den gamla modellen var omöjlig att komma överens med). Roten var att SVG:n preventDefault:ade touchstart OCH touchmove över hela ytan: browserns panorering var död, så enda vägen i sidled var kantglidningen — som drog markören med sig och rusade vid dygnsbytet. En yta kan inte betyda både "läs värden" och "flytta grafen". ÖVRE zonen (staplarna/priserna) = LÄSA. Drag flyttar markören. NEDRE zonen (lägesbandet, lasterna, klockan) = FLYTTA. Drag panorerar grafen direkt under fingret, med tröghet när man släpper. NYP i båda zonerna = ZOOMA tidsaxeln (1–8× bredd, höjden konstant), ankrad i nypets mittpunkt så innehållet under fingrarna står still. DUBBELTRYCK i nedre zonen återställer zoomen till 2×. Zonerna är HTML-lager över SVG:n, inte touch-action på SVG-element: touch-action på SVG-barn är inte pålitligt i Safari. Varje lager äger sin gest helt, så ingen browserpanorering konkurrerar. */ const ptrs = React.useRef(new Map()); const panSt = React.useRef(null); const pinch = React.useRef(null); const zAnchor = React.useRef(null); const inertia = React.useRef(0); const pinchRaf = React.useRef(0); const pinchZ = React.useRef(2); const lastTap = React.useRef(0); const stopInertia = () => { if (inertia.current){ cancelAnimationFrame(inertia.current); inertia.current = 0; } }; React.useLayoutEffect(() => { const a = zAnchor.current, sc = scrollRef.current; if (!a || !sc) return; /* Håll innehållet under nypets mittpunkt stilla: samma contentX efter bredduppdateringen. Utan detta glider grafen iväg medan man nyper. */ sc.scrollLeft = a.contentX*(zoom/a.z0) - a.mid; zAnchor.current = null; }, [zoom]); const ZSTEPS = [1, 1.5, 2, 3, 4, 6, 8]; const zoomStep = (dir) => { const sc = scrollRef.current; if (!sc) return; const i = ZSTEPS.reduce((best, v, k) => Math.abs(v - zoom) < Math.abs(ZSTEPS[best] - zoom) ? k : best, 0); const z = ZSTEPS[Math.min(ZSTEPS.length - 1, Math.max(0, i + dir))]; if (z === zoom) return; const mid = sc.clientWidth/2; zAnchor.current = { contentX: sc.scrollLeft + mid, z0: zoom, mid }; setZoom(z); }; const gDown = (kind) => (e) => { e.currentTarget.setPointerCapture?.(e.pointerId); ptrs.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); stopInertia(); if (ptrs.current.size === 2){ const [p1, p2] = [...ptrs.current.values()]; const sc = scrollRef.current; if (!sc) return; const r = sc.getBoundingClientRect(), mid = (p1.x + p2.x)/2; /* Avståndet i BÅDA axlarna. Förra varvet mätte bara |x1−x2|: ett nyp med fingrarna lodrätt gav d0 = golvet 24, och första lilla isärdragningen i sidled dubblade zoomen på ett event — "sticker iväg"-känslan på mobilen. Hypot ger samma tal oavsett nypets vinkel. */ pinch.current = { d0: Math.max(24, Math.hypot(p1.x - p2.x, p1.y - p2.y)), z0: zoom, active: false, contentX: sc.scrollLeft + (mid - r.left) }; panSt.current = null; drag.current = false; return; } if (kind === 'scrub'){ /* Drag-scrub för ALLA pekartyper (André 1/9: "lugnt och försiktigt kunna dra markören över elpriset", inte träffa exakt stapel). Förra varvets pointercancel-död kom inte av scrubben utan av pan-y: browsern tog gesten vid vertikalt darr. Zonen är nu touchAction 'none' — vi äger gesten hela vägen. Priset: sidan kan inte scrollas vertikalt med fingret PÅ staplarna — nedre zonen och resten av sidan scrollar. */ drag.current = true; lastPtr.current = { clientX: e.clientX, clientY: e.clientY }; const i = idxAt(e); if (i !== null) setSelI(i); stopGlide(); glide.current = requestAnimationFrame(glideStep); } else { const sc = scrollRef.current; if (!sc) return; panSt.current = { x0: e.clientX, left: sc.scrollLeft, lastX: e.clientX, lastT: performance.now(), v: 0, moved: 0 }; } }; const gMove = (kind) => (e) => { if (ptrs.current.has(e.pointerId)) ptrs.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pinch.current && ptrs.current.size >= 2){ const [p1, p2] = [...ptrs.current.values()]; const sc = scrollRef.current; if (!sc) return; const d = Math.max(24, Math.hypot(p1.x - p2.x, p1.y - p2.y)); /* Dödzon 12 px innan zoomen griper: två fingrar som landar för att panorera darrar alltid några px, och utan dödzon zoomade varje tvåfingerkontakt lite — grafen "andades" under scroll. */ if (!pinch.current.active){ if (Math.abs(d - pinch.current.d0) < 12) return; pinch.current.active = true; } const z = Math.min(8, Math.max(1, pinch.current.z0*(d/pinch.current.d0))); const r = sc.getBoundingClientRect(); zAnchor.current = { contentX: pinch.current.contentX, z0: pinch.current.z0, mid: (p1.x + p2.x)/2 - r.left }; /* rAF-gate: setZoom renderar hela SVG:n om; varje pointermove (60–120 Hz × två fingrar) gav render-kö och ryckig zoom på mobil. Senaste värdet vinner, max en render per frame. */ pinchZ.current = z; if (!pinchRaf.current) pinchRaf.current = requestAnimationFrame(() => { pinchRaf.current = 0; setZoom(pinchZ.current); }); return; } if (kind === 'scrub'){ if (!drag.current) return; lastPtr.current = { clientX: e.clientX, clientY: e.clientY }; const i = idxAt(e); if (i !== null) setSelI(i); if (!glide.current) glide.current = requestAnimationFrame(glideStep); return; } const st = panSt.current, sc = scrollRef.current; if (!st || !sc) return; /* TOUCH panorerar NATIVT (touch-action pan-x pan-y på lagret) — vi rör aldrig scrollLeft själva och lägger ingen JS-tröghet ovanpå browserns momentum. Förra modellen (pan-y + JS-pan) dog i pointercancel så fort browsern klassade gesten som vertikal: "det slutar lyssna när man drar i sidled" (André 29/8). moved uppdateras ändå, för tap-detekteringen. */ st.moved = Math.max(st.moved, Math.abs(e.clientX - st.x0)); if (e.pointerType !== 'mouse') return; sc.scrollLeft = st.left - (e.clientX - st.x0); const t = performance.now(), dt = t - st.lastT; if (dt > 8){ st.v = (e.clientX - st.lastX)/dt; st.lastX = e.clientX; st.lastT = t; } }; const gUp = (kind) => (e) => { ptrs.current.delete(e.pointerId); if (ptrs.current.size < 2) pinch.current = null; if (kind === 'scrub'){ drag.current = false; stopGlide(); return; } const st = panSt.current; panSt.current = null; if (!st) return; /* Tryck utan rörelse i panoreringszonen väljer ändå kvarten — annars vore halva grafen död för tap. Dubbeltryck (<300 ms) återställer zoomen. */ if (st.moved < 6){ const now = performance.now(); if (now - lastTap.current < 300){ zAnchor.current = null; setZoom(2); lastTap.current = 0; return; } lastTap.current = now; const i = idxAt(e); if (i !== null) setSelI(i); return; } let v = st.v; const sc = scrollRef.current; if (!sc || Math.abs(v) < 0.08) return; const step = () => { v *= 0.94; sc.scrollLeft -= v*16; inertia.current = Math.abs(v) > 0.02 ? requestAnimationFrame(step) : 0; }; inertia.current = requestAnimationFrame(step); }; React.useEffect(() => stopInertia, []); React.useEffect(() => { const h = (e) => { if (e.key === 'ArrowRight') setSelI(v => { const n = Math.min(N-1, v+1); ensureVisible(n); return n; }); if (e.key === 'ArrowLeft') setSelI(v => { const n = Math.max(0, v-1); ensureVisible(n); return n; }); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [N]); const s = seq[Math.min(selI, N-1)]; if (!s) return null; const mode = window.GC_MODES[s.mode] || window.GC_MODES.vanta; const isTomorrow = selI >= GA_Q; const selLbl = `${gaLabel(selI)} ${isTomorrow ? 'imorgon' : 'idag'}`; const socNow = s.socAggPct ?? null; /* Timmens prognos mot mätvärde. Bara kvartar med mätning räknas, och pågående kvart skalas med sin fyllnadsgrad — samma regel som Schema. */ const hourStart = Math.floor((selI % GA_Q)/4)*4 + (isTomorrow ? GA_Q : 0); const hourInfo = React.useMemo(() => { const hq = seq.slice(hourStart, hourStart+4); const done = seq.slice(hourStart, Math.min(selI+1, hourStart+4)).filter(x => x.solAct != null || x.consAct != null); const sc = (x) => x.actPartial != null ? x.actPartial : 1; const sum = (arr, f) => arr.reduce((a,x) => a + (f(x) || 0), 0); const mk = (planF, actF) => { const hour = sum(hq, x => planF(x)*0.25); if (!done.length) return { prog: hour, hour, act:null, base:null, partial:false }; const base = sum(done, x => planF(x)*0.25*sc(x)); return { prog: base, hour, partial: done.length < hq.length, act: sum(done, x => (actF(x) ?? 0)*0.25*sc(x)), base }; }; return { sol: { ...mk(x => x.solPlan || 0, x => x.solAct), unit:'kWh', col:'#facc15', txt:'Sol', good:(d)=>d>=0 }, cons: { ...mk(x => (x.consPlanTot ?? x.consPlan ?? 0), x => x.consAct != null ? x.consAct + (gross ? 0 : (x.loadAct || 0)) : null), unit:'kWh', col:'#818cf8', txt:'Förbrukning', good:(d)=>d<=0 }, /* prog, utfall och avvikelse måste läsa SAMMA kvart. Hans fil visar timmens slut-SoC mot nuvarande SoC och drar differensen mellan dem — då stämde inte raden med sig själv (61 → 65 gav −2). Här är alla tre den valda kvarten. */ soc: { prog: s.socPlanPct ?? null, hour: s.socPlanPct ?? null, act: s.socActPct ?? null, base: s.socPlanPct ?? null, unit:'%', col:'#bae6fd', txt:'Batteri (SOC)', good:(d)=>d>=0, dec:0 }, }; }, [seq, hourStart, selI, s]); /* Hur många av timmens kvartar som faktiskt är mätta. Ligger utanför hourInfo eftersom renderingen itererar dess nycklar som serierader. */ const hourMeta = React.useMemo(() => { const hq = seq.slice(hourStart, hourStart+4); const done = seq.slice(hourStart, Math.min(selI+1, hourStart+4)).filter(x => x.solAct != null || x.consAct != null); return { done: done.length, total: hq.length }; }, [seq, hourStart, selI]); const SERIES = [ { k:'sol', txt:'Sol', col:'#facc15' }, { k:'cons', txt:'Förbrukning', col:'#818cf8' }, { k:'soc', txt:'Batteri (SOC)', col:'#bae6fd' }, /* Köppris och Spotpris SIDA VID SIDA — de är samma växel (stapellagrets källa), och en växel vars lägen står i var sin ände av raden läses som två oberoende serier. Säljpriset efter dem. */ { k:'kop', txt:'Köppris', col:'#64748b', levels:true }, /* Säljknappens plupp: vit linje när den ligger på köpstaplar, nivåfärgad stapel när den är ensam — samma behandling som serien får i grafen. */ { k:'salj', txt:'Säljpris', col:'#f1f5f9', levels: !show.kop }, ]; const devBottom = DEV_Y + devices.length*(DEV_H + DEV_GAP); return (
{/* Avläsningspanel. GRAFEN ÄR ALLTID ÖVERST (André 13/9): panelen ligger först i DOM (tabb-ordning, hookar) men flyttas visuellt under grafen och seriefiltret via order — dygnsnav + graf + legend är order 0. */}
{selI === nowIdx && NU} {/* Tiden får inte brytas: NU-chippet dyker upp exakt när vald kvart är den pågående, och "08:45 imorgon" på två rader hoppade då. */} {selLbl} · {s.consAct != null || s.solAct != null ? 'utfall' : 'prognos'}
{/* KORT i stället för tabellrader (kollegan 26/8: "tydligare/större fält", "på export vill jag veta effekten men även mängden energi"). Samma mönster som Schemas KPI-rad. Energin är kvartens: kW × 0,25 — märkt "/kvart" så den inte läses som timenergi. */}
{(() => { const pu = window.gcPriceUnknown(tomorrowPrelim, selI); const kwh = (v) => v != null ? window.gcNum(v*0.25, 2) + ' kWh/kvart' : null; const exp = (s.gridOut||0) > 0.05, imp = (s.gridIn||0) > 0.05; const gridV = exp ? s.gridOut : imp ? s.gridIn : null; const barV = barSrc ? barVals[selI] : null; const lvl = barV == null ? null : (barV <= pLo ? 'billig' : barV >= pHi ? 'dyr' : null); const cards = [ /* André 20/9: inga kWh/kvart-rader i korten. */ { l:'Sol', c:'#facc15', v:gaNum(s.sol), u:'kW', sub: null }, /* André 20/9: uppdelningen bas + last som egen rad under talet (underraden trunkerades: "varav last 2,8 k…"). Lasten = uppmätt bakom NU (loadAct), planens consLoadsAdd framför; basen = total − last. Utan last ingen rad. Färgprickarna är kurvornas. */ (() => { const tot = gaCons(s); /* Utfallskvart (consAct finns) utan uppmätt last ⇒ ingen uppdelning: planens last får inte dras från ett uppmätt tal (11:30 gav "bas 0,0", André 20/9). Prognoskvart ⇒ planens uppdelning. */ const isAct = s.consAct != null; const last = s.loadAct != null ? s.loadAct : (!isAct && (s.consLoadsAdd || 0) > 0.01 ? s.consLoadsAdd : null); const isPlan = s.loadAct == null; const split = (tot != null && last != null && last > 0.01) ? { base: Math.max(0, tot - last), last, isPlan, out: !isPlan && (s.loadAct - (s.loadActPlan || 0)) > 0.05 } : null; /* Utfallskvart där planen hade last men mätningen saknas: säg det, i stället för tomt (André 20/9: "måste läsas ut faktiskt"). */ const missing = isAct && s.loadAct == null && (s.consLoadsAdd || 0) > 0.01; return { l:'Förbrukning', c:'#818cf8', v:gaNum(tot), u:'kW', split, sub: missing ? 'lastens andel per kvart saknas' : null }; })(), { l: exp ? 'Export' : 'Import', c: exp ? '#4ade80' : imp ? '#60a5fa' : '#64748b', v: gridV != null ? gaNum(gridV) : '—', u:'kW', sub: gridV != null ? (exp ? '↑ sälj' : '↓ köp') : 'inget utbyte' }, { l:'Batteri', c:'#bae6fd', v: socNow != null ? String(Math.round(socNow)) : '—', u:'%', sub: s.socTot != null && s.socCapKwh ? `${window.gcNum(s.socTot,1)} av ${window.gcNum(s.socCapKwh, Number.isInteger(s.socCapKwh) ? 0 : 1)} kWh` : null }, { l:'Köppris', c: barSrc === 'kop' ? priceCol(pu ? null : s.buy) : '#64748b', v: pu ? '—' : s.buy != null ? String(Math.round(s.buy)) : '—', u:'öre', sub: pu ? 'inte släppt' : (barSrc === 'kop' ? lvl : null) }, /* Nivåordet följer den serie som bär staplarna. */ { l:'Säljpris', c: barSrc === 'salj' ? priceCol(pu ? null : s.sell) : '#f1f5f9', v: pu ? '—' : s.sell != null ? String(Math.round(s.sell)) : '—', u:'öre', sub: pu ? 'inte släppt' : (barSrc === 'salj' ? lvl : null) }, ]; return cards.map(cd => ( /* Samma modell som Schemas kvartskort (v312–316, Lennie 15/9): rubrik 12 px, tal text-2xl, enhet/underrad 12 px, underraden ljus när den bär ett värde. */
{cd.l}
{cd.v} {cd.u}
{cd.split && ( /* Tre rader: total står redan ovan; bas och smartlast under med kurvornas färger och kW så de läses som delar av totalen. */
baslast{gaNum(cd.split.base)} kW smartlast{cd.split.isPlan ? ' · plan' : cd.split.out ? ' · utanför plan · timmedel' : ' · timmedel'}{gaNum(cd.split.last)} kW
)} {/* Siffrorna ljusa, orden/enheterna tonade (Lennie 15/9). */} {(cd.sub || !cd.split) &&
{String(cd.sub || '\u2014').split(/(\d[\d,.]*)/).map((part, k) => /^\d/.test(part) ? {part} : part)}
}
)); })()}
Timmen {String(Math.floor((selI % GA_Q)/4)).padStart(2,'0')}–{String((Math.floor((selI % GA_Q)/4)+1)%24).padStart(2,'0')}
{/* Egen rad, alltid. Statustexten växlar mellan 20 och 38 tecken med kvarten; på samma rad som rubriken bröt den till två rader ibland och flyttade grafen under fingret. */}
{/* Ett SPANN, inte "t.o.m." med en starttid: raden skrev gaLabel(selI), alltså den VALDA kvartens början. Med 1 av 4 räknade stod det "t.o.m. 19:00" fast den räknade kvarten är 19:00–19:15 — och står valet på 19:45 medan bara första kvarten är mätt påstod raden "t.o.m. 19:45". Spannet börjar alltid vid timmens start och slutar där de räknade kvartarna tar slut. */} {hourMeta.done === 0 ? 'inga kvartar räknade än' : hourMeta.done >= hourMeta.total ? `alla ${hourMeta.total} kvartar räknade` : `räknat ${gaLabel(hourStart)}–${gaLabel(hourStart + hourMeta.done)} · ${hourMeta.done} av ${hourMeta.total} kvartar`}
{Object.entries(hourInfo).map(([k, h]) => { const dec = h.dec ?? 1; const round = (x) => Math.round(x * Math.pow(10, dec)) / Math.pow(10, dec); const diff = (h.act != null && h.base != null) ? round(h.act) - round(h.prog) : null; return (
{h.txt} prog {gaNum(h.prog, dec)} {/* Timmens HELA prognos — en annan tidsbas än prog/utfall, så den måste säga vilken. "av 5,4" gjorde det inte. */} {h.partial && h.hour != null && ( timmen {gaNum(h.hour, dec)} )} utfall {h.act != null ? gaNum(h.act, dec) : '—'} {diff == null ? 'inget mätvärde' : `${diff >= 0 ? '+' : '−'}${gaNum(Math.abs(diff), dec)} ${h.unit}`}
); })}
{window.gcPriceUnknown(tomorrowPrelim, selI) && (

{window.GC_PRICE_UNRELEASED}

)} {/* André 20/9: beslutet med effekt, inte bara namnet. Batterikommandot ur s.batCmd (samma källa som Schemas "Planerat"-kort: live-kommando vid NU, planens kWh×4 annars; + = laddar, − = urladdar), nätflödet ur gridIn/gridOut (live vid NU, utfall bakåt, plan framåt). Rader visas bara när talet är > 0,05 kW — ingen nolla påstås. */} {(() => { const p = s.batCmd?.[0]?.p; const ai = s.batCmd?.[0]?.ai; const gIn = s.gridIn || 0, gOut = s.gridOut || 0; const rows = []; if (p != null && !ai && Math.abs(p) > 0.05) rows.push({ k: p > 0 ? 'Batteri laddar' : 'Batteri urladdar', v: (p > 0 ? '+' : '−') + gaNum(Math.abs(p)), c: p > 0 ? '#60a5fa' : '#fb923c' }); if (gIn > 0.05) rows.push({ k: 'Köper från nät', v: gaNum(gIn), c: '#60a5fa' }); if (gOut > 0.05) rows.push({ k: 'Säljer till nät', v: gaNum(gOut), c: '#4ade80' }); const lim = window.gcLimit ? window.gcLimit(s.mode, s.limit) : null; const src = s.modeSource === 'live' || s.batCmdSource === 'kommando' || s.batCmdSource === 'kommando-summa' ? 'Skickat läge' : (s.consAct != null ? 'Läge' : 'Planerat läge'); return (
{src} {lim ? lim.label : mode.label}
{lim &&
{lim.sub}
} {rows.length > 0 && (
{rows.map(r => {r.k}{r.v} kW)}
)}
); })()}
{/* Dygnsnavigering */}
{tomorrow ? '48 h' : '24 h'}
{/* Zoomknappar: nyp finns kvar men får aldrig vara enda vägen — det kräver två fria fingrar och krockar lätt med browserns gester. Stegen ankras i viewportens mitt via samma zAnchor som nypet. */} {tomorrow && ( )}
{/* 48h-grafen */}
{/* Bredden är zoomen (2× som default). minWidth-golvet är MOBILFIXEN (foto 27/8): höjden = bredd/zoom · H/920, så golvet zoom·575 ger ~290 px hög graf oavsett zoom (W följer zoomen sedan 15/9). OBS: aldrig JSX-kommentar i en tags attributlista — {} där är spread. */}
{/* Staplarnas vertikala fade: full färg vid stapelns topp, tonar mot baslinjen. En mask på GRUPPEN, inte per stapel — alla staplar delar samma ljusfall. */} {/* Ytfyllningar tonar ut nedåt — kurvan behåller sin kant medan ytan bara antyder volym. Utan uttoning blir en fylld yta en vägg som döljer allt bakom. */} {/* Viskning, inte vägg: ytan ligger ovanpå färgade staplar och varje procent opacitet blandar sig med dem — 0,5 gav oliv. */} {tomorrow && ( Imorgon )} Idag {/* Stapellagret: dämpat i vila (0,42) så kurvorna ovanpå äger förgrunden; vald stapel i full mättnad med vit kant. */} {barSrc && seq.map((x,i) => { const v = barVals[i]; if (v == null) return null; const y = yOre(v), h = Math.max(1.5, GY+GH-y), bx = xAt(i)-bw/2+1.1, bwr = Math.max(1.2, bw-2.2); /* Beslutsfärgning: toppen behåller prisfärgen i en 5 px kant, så dyrt/billigt går att läsa även när kroppen bär lägets färg. Kanten räknas i användarenheter ur aktuell skala — svg:n skalas med containerbredden, så 5 fasta enheter blev 1,8 px på mobil (verifieringen 15/9). */ const cap = barCol === 'beslut' ? Math.min(5/Math.max(0.05, pxPerUnit), h) : 0; return ( {cap > 0 && } ); })} {/* Säljlinjen: vågrät trappa genom staplarna, i ALLA stapellägen. Mot spotstaplar ligger den 2,5 öre över topparna (under en pixel) — det är känt och användarens uttryckliga val. */} {saljVisible && saljSteps.map((p,i) => )} {/* Säljlinjen finns bara ovanpå köpstaplar; ensam blir sälj staplarna själv (André 16/9). */} {/* Mikrolegend för nivåfärgerna — utan den är grönt/rött bara färg. VÄNSTER om mitten: högerkanten ligger utanför vyn vid 200 % bredd (tredje gången den fällan smäller i den här filen). */} {/* Inga nivåord vid klustren: färgen säger billig/dyr, ordet var bara brus i grafen (André 27/8). Klassningen lever kvar i stapelfärgen och i kortpanelens nivåtext. */} {/* Ingen öre-skala (Lennie 15/9): priset läses i pillret ovanför, färgen bär nivån. Gutteret är bara för läge/enhetsetiketterna. */} {/* Ingen kW-axel: vänstergutteret tillhör öret, och två skalors siffror i samma gutter var precis förra varvets kollision. Exakta kW för vald kvart står i panelen ovanför. */} {/* SoC får INGEN egen axel i vänstergutteret. yPct(50) ligger exakt på yKw(5) och yPct(100) på fältets överkant när kwMax = 10 — alltså i normalfallet — och två skalors siffror på samma baslinjer motverkar hela laneuppdelningen. I stället ETT ankare inne i plotytan, med sin egen enhet och färg: taket för den streckade kurvan. Exakt procent läses i panelen ovanför. */} {show.soc && ( )} {/* Kurvor och halo i SKÄRMPIXLAR (vector-effect non-scaling-stroke, 15/9): 1,6 px linje / 3,5 px halo oavsett zoom och vidd. Förr skalades 1,8 enheter till ~0,9 px och antialiasades suddigt. HALO under varje kurva: samma path i bakgrundsfärg, ~2 px bredare. Det skär ut linjen ur stapelväggen. Två försök med ljus kant 15/9 föll (vit 30 % "suddig", helvit tunn "inte alls bra") — mörk halo är beslutet. */} {show.soc && [...socSp.past.map((p,i) => ({p, k:'sop'+i, dash:false})), ...socSp.future.map((p,i) => ({p, k:'sof'+i, dash:true}))].map(({p,k,dash}) => )} {show.cons && !hasComp && [...consSp.past.map((p,i) => ({p, k:'cop'+i, dash:false})), ...consSp.future.map((p,i) => ({p, k:'cof'+i, dash:true}))].map(({p,k,dash}) => )} {/* André 20/9: bakåt = uppmätt heldragen lila; framåt = baslast streckad lila + bas+smartlaster ljusare streckad. */} {show.cons && hasComp && actFills.map((f,i) => ( ))} {show.cons && hasComp && [...baseSp.past.map((p,i) => ({p, k:'cbp'+i, dash:false})), ...baseSp.future.map((p,i) => ({p, k:'cbf'+i, dash:true}))].map(({p,k,dash}) => )} {show.cons && hasComp && planSm.map((p,i) => ( {/* André 20/9: smartlasten syntes knappt — färg mellan baslastens #818cf8 och #a5b4fc, 1,5 px, samma punktning 5/4. */} ))} {show.cons && hasComp && actSm.map((p,i) => )} {show.sol && [...solSp.past.map((p,i) => ({p, k:'slp'+i, dash:false})), ...solSp.future.map((p,i) => ({p, k:'slf'+i, dash:true}))].map(({p,k,dash}) => )} {/* Lägesbandet i SAMMA formspråk som prisstaplarna: fristående rundade pinnar med samma luft (0,7 in per sida) och samma markering av vald kvart — inte en heldragen vägg (André 26/8). Ingen bottenplatta: bandet är alltid fullt, till skillnad från enhetslanerna som behåller sin platta för tomrummet. */} {/* I beslutsläget bär staplarna lägets färg — då byter bandet plats med dem och visar priset i stället, så båda avläsningarna finns kvar i vyn (André 16/9). Saknas pris för kvarten faller bandet tillbaka på lägets färg. */} {seq.map((x,i) => ( ))} läge {devices.map((d, di) => { const y = DEV_Y + di*(DEV_H + DEV_GAP); return ( {/* Samma pinnform som pris- och lägesstaplarna: luft 0,7 per sida, rx 2, vald kvart i full färg med vit kant. Plattan bakom är kvar — spåren är mest tomrum och behöver golvet. */} {(d.cmds || []).map((c, k) => ( ))} {d.slots.map((sl, i) => sl ? : null)} {d.name.length > GA_LBL_CH ? d.name.slice(0, GA_LBL_CH-1)+'…' : d.name} ); })} {Array.from({length: 16}, (_,k) => k*3).filter(h => h*4 < N).map(h => ( {String(h%24).padStart(2,'0')} ))} {/* NU-strecket stannar ovanför enhetsspåren — över dem dolde det NU-kvartens platta så lasten såg ut att stå still (Lennie 14/9). */} NU {/* Markörprickar får ring i bakgrundsfärgen så de läses mot en kurva de ligger ovanpå — utan den smälter pricken in i sin egen linje. */} {show.sol && s.sol != null && } {show.cons && gaCons(s) != null && } {show.soc && socNow != null && } {/* Markörpricken följde INTE maskningen: linjen slutade vid midnatt men pricken satt kvar på morgondagens proxypris, läsbar mot skalan — det var det Anders såg 18/8. */} {barSrc && barVals[selI] != null && } {/* Priset I grafen, vid markören (foto 26/8: värdet högst upp på sidan tvingade ögonflytt mellan kort och graf). Följer valet i x, klampat så pillen aldrig lämnar fältet. Kortpanelen ovanför behåller detaljerna; det här är blickfångets tal. */} {/* Gestlagren. ÖVRE zonen (staplarna) = LÄSA: drag flyttar markören, alla pekartyper, touchAction 'none' så browsern aldrig tar gesten (pan-y-varianten dog i pointercancel vid vertikalt darr, och tap-per-stapel-varianten underkändes: "lugnt och försiktigt kunna dra markören", André 1/9). NEDRE zonen = FLYTTA: nativt svep. Vertikal sidscroll med fingret på staplarna offras medvetet — det är läszonens pris, resten av sidan scrollar. */}
{/* pan-x pan-y: browsern äger BÅDA scrollriktningarna på touch — horisontellt sveper grafen (närmaste scrollbara förälder är scrollRef), vertikalt scrollar sidan. Mus-drag panorerar via JS som förut (touch-action gäller inte mus). */}
{barSrc && barVals[selI] != null && (() => { const v = barVals[selI]; /* Tidsspannet i stället för nivåordet (André 27/8): "istället för billigt/dyrt hade jag gärna sett tiden priset gäller". Färgen bär nivån och klusterorden pekar redan ut billig/dyr — tiden är det pillen inte sa. */ const q = selI % GA_Q; const pu = window.gcPriceUnknown(tomorrowPrelim, selI); const span = gaLabel(q) + '–' + gaLabel((q + 1) % GA_Q); /* Texten ligger UTANFÖR den rullande svg:n (André 16/9: värdet fick inte kunna döljas när grafen rörs). Fast i ramens mitt, ovanpå rullytan, så det står kvar oavsett scroll och zoom. */ return (
{String(Math.round(v))} {barSrc === 'salj' ? 'Sälj' : 'Köp'} {barSrc === 'kop' && s.sell != null && !pu && <>{String(Math.round(s.sell))}Sälj} {barSrc === 'salj' && s.buy != null && !pu && <>{String(Math.round(s.buy))}Köp} {span}
); })()}
{/* Raden ska rymmas på EN rad även på mobil (Lennie 15/9): bara kontrollerna står kvar, bruksanvisningen ligger bakom "?". */} {/* Stapelfärgen: pris eller beslut. Pricken visar vad man får efter klicket, så färgvalet för dyrt pris bara syns i prisläget. */} {/* Färgvalet gäller båda lägena: prisfärgen bär toppkanten även i beslutsläget. Båda ändarna i samma popup — en skala väljs som ett par, inte i två omgångar. Panelen är absolut placerad över grafen med en osynlig backdrop som fångar klick utanför. */} {colOpen && <>
setColOpen(false)}>
{[{ end:'cheap', lbl:'Billigt', list:GA_CHEAPS }, { end:'dear', lbl:'Dyrt', list:GA_DEARS }].map(g => (
{g.lbl} {g.list.map(c => ( ))} {/* Sista pluppen = HELA späktrumet. Kurerade sviten täcker det som fungerar mot vår bakgrund; den som vill något annat får systemets egen väljare i stället för en hemmasnickrad hjulwidget. Den valda frivärdet visas i pluppens mitt så regnbågen inte gömmer vad som gäller. */} {(() => { const free = !g.list.some(c => c.hex.toLowerCase() === String(priceCols[g.end]).toLowerCase()); return ( ); })()}
))}
}
{zoom !== 2 && {zoom.toFixed(1)}×}
{hintOpen && (
Heldragen = utfall, streckad = prognos. Dra på staplarna för att läsa värden, {window.matchMedia && window.matchMedia('(pointer: coarse)').matches ? ' svep under dem' : ' dra under dem'} för att flytta grafen. Zooma med +/− eller nyp{zoom !== 2 ? ' (dubbeltryck återställer)' : ''}.
)}
{/* Seriefilter */}
{SERIES.map(sr => ( ))}
{/* Enheter vid markerad tidpunkt */} {devices.length > 0 && (
Enheter · {selLbl}
{/* Lastens uppmätta energi hittills i dag (load_kwh bakom NU, pågående kvart pro rata). load_kwh är summan av alla laster — med en last är det lastens tal, annars "laster". */} {(() => { const past = seq.slice(0, Math.min(nowIdx + 1, GA_Q)).filter(x => x.loadAct != null); if (!past.length || !units.loads.length) return null; const kwh = past.reduce((a, x) => a + x.loadAct*0.25*(x.actPartial != null ? x.actPartial : 1), 0); const who = units.loads.length === 1 ? units.loads[0].name : 'Laster'; const plan = units.loads.length === 1 ? seq.slice(0, GA_Q).reduce((a, x) => a + ((x.loads[0]?.pPlan ?? x.loads[0]?.p) || 0)*0.25, 0) : null; return
{who} i dag: {gaNum(kwh)} kWh hittills{plan > 0.05 ? <> · plan {gaNum(plan)} kWh : ''}
; })()}
{devices.map((d, di) => { const sl = d.slots[selI]; const cm = d.cmds && seq[selI] && d.cmdAt ? d.cmdAt(seq[selI]) : null; const col = sl ? (window.GC_LOAD_COL[sl.src] || '#6B7A94') : '#334155'; return (
{d.name} {d.kind} {sl ? `${gaNum(sl.p)} kW${sl.src ? ' · ' + sl.src : ''}${sl.remnant ? ' · fortsättning från i går' : ''}` : cm ? `på till ${cm.i1 % GA_Q === 0 ? '24:00' : gaLabel(cm.i1)}` : 'av'}
); })}
)}
); } Object.assign(window, { GcAvancerat });