/* ============================================================================ 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 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 : ((x.consLive != null || x.consAct != 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 }){ const seq = React.useMemo(() => tomorrow ? day.concat(tomorrow) : day.slice(), [day, tomorrow]); const N = seq.length; /* Seriefiltret SPARAS. 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). Läses en gång vid mount; okända nycklar i lagringen ignoreras och nya serier får sitt default, så en framtida serie inte startar dold. Skriver bara vår egen nyckel — aldrig rensa annat i localStorage. */ const GA_SHOW_KEY = 'gc-adv-series'; const GA_SHOW_DEF = { sol:true, cons:true, soc:true, kop:true, salj:false, spot:false }; const [show, setShow] = React.useState(() => { try { const raw = localStorage.getItem(GA_SHOW_KEY); if (!raw) return GA_SHOW_DEF; const saved = JSON.parse(raw); return Object.fromEntries(Object.keys(GA_SHOW_DEF).map(k => [k, typeof saved?.[k] === 'boolean' ? saved[k] : GA_SHOW_DEF[k]])); } catch { return GA_SHOW_DEF; } }); React.useEffect(() => { try { localStorage.setItem(GA_SHOW_KEY, JSON.stringify(show)); } catch {} }, [show]); 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. */ /* Köp/spot är stapelväxeln. SÄLJ är en LINJE som skär genom staplarna (André 26/8) — den fungerar mot KÖPSTAPLAR (~90 öre under topparna) men är meningslös mot spotstaplar (2,5 öre isär, under en pixel), så där döljs den och knappen inaktiveras med förklaring. */ const toggle = (k) => setShow(s => { if (k === 'kop') return { ...s, kop: !s.kop, spot: false }; if (k === 'spot') return { ...s, spot: !s.spot, kop: false }; /* Sälj rör ALDRIG stapelvalet. Två försök föll: inaktiverad knapp (115, "går inte att trycka"), auto-växling till köppris (116, "det vill jag inte"). Knappen tänder linjen, punkt — även mot spotstaplar, där den ligger 2,5 öre över topparna. Det är användarens uttryckliga val; kortpanelen bär de exakta talen. */ 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. */ const W = 1840, H_PL = 84, H_PR = 16, PT = 24; /* 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)); const GY = PT + 10, 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) => (x.loads[k] && x.loads[k].p != null && x.loads[k].p > 0.05 ? { p:x.loads[k].p, src:x.loads[k].src } : null) })), ]; return defs.map(d => ({ ...d, slots: seq.map(d.at) })); }, [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 spot) — flyttad hit ovan yOre; barSrc-läsningen ligger senare, så läs källan direkt ur show. */ const _barKey = show.kop ? 'buy' : 'spot'; 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.75; 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); 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 solSmArea = smoothAreaOf(solRuns, yKw(0)); const consSm = smoothOf(runsOf(seq.map(gaCons), yKw)); // Bankens SoC, inte batteri 1:s — kurvan hoppade till batteri 1 vid NU. const socSm = smoothOf(runsOf(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; /* Stapelkällan: köp eller spot. Nivåfärgerna (billig/normal/dyr som tredjedelar av kända priser i fönstret) och öre-skalan räknas ur den VISADE serien — spot ligger ~90 öre under köp (skatt+avgifter), och köpets kvantiler på spotstaplar hade gjort allt "billigt". */ /* Rent spotpris finns bara när backend gett grid_benefit_kwh (adaptern härleder spot = sell − nyttan); annars döljs spotknappen helt. */ const hasSpot = seq.some(x => x.spot != null); const barSrc = show.kop ? 'kop' : (show.spot && hasSpot ? 'spot' : null); const barVals = seq.map((x,i) => priceMask(barSrc === 'spot' ? x.spot : x.buy, i)); const saljVisible = show.salj; 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(',') + ')'; }; const priceCol = (v) => { if (v == null) return '#64748b'; if (v <= pLo) return '#10b981'; if (v >= pHi) return '#f43f5e'; const mid = (pLo + pHi) / 2; return v <= mid ? _lerpCol('#10b981', '#64748b', (v - pLo) / ((mid - pLo) || 1)) : _lerpCol('#64748b', '#f43f5e', (v - mid) / ((pHi - mid) || 1)); }; 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 = () => { const el = scrollRef.current; if (!el) return; el.scrollTo({ left: Math.max(0, (nowIdx/N)*el.scrollWidth - el.clientWidth/2), behavior:'smooth' }); }; React.useEffect(() => { scrollToNow(); }, []); const ref = React.useRef(null); 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), 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 [zoom, setZoom] = React.useState(2); 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), 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:'#2dd4bf', 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:'#2dd4bf', dash:true }, /* 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 }, ...(hasSpot ? [{ k:'spot', txt:'Spotpris', col:'#64748b', levels:true }] : []), { k:'salj', txt:'Säljpris', col:'#f1f5f9' }, ]; const devBottom = DEV_Y + devices.length*(DEV_H + DEV_GAP); return (
{/* Avläsningspanel */}
{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 = [ { l:'Sol', c:'#facc15', v:gaNum(s.sol), u:'kW', sub: kwh(s.sol) }, { l:'Förbrukning', c:'#818cf8', v:gaNum(gaCons(s)), u:'kW', sub: kwh(gaCons(s)) }, { l: exp ? 'Export' : 'Import', c: exp ? '#4ade80' : imp ? '#60a5fa' : '#64748b', v: gridV != null ? gaNum(gridV) : '—', u:'kW', sub: gridV != null ? kwh(gridV) + (exp ? ' · ↑ sälj' : ' · ↓ köp') : 'inget utbyte' }, { l:'Batteri', c:'#2dd4bf', 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) }, { l: barSrc === 'spot' ? 'Spotpris' : 'Säljpris', c: barSrc === 'spot' ? priceCol(pu ? null : s.spot) : '#f1f5f9', v: pu ? '—' : barSrc === 'spot' ? (s.spot != null ? String(Math.round(s.spot)) : '—') : (s.sell != null ? String(Math.round(s.sell)) : '—'), u:'öre', sub: pu ? 'inte släppt' : barSrc === 'spot' ? [lvl, s.sell != null ? `sälj ${Math.round(s.sell)} öre` : null].filter(Boolean).join(' · ') : (hasSpot && s.spot != null ? `spot ${Math.round(s.spot)} öre` : null) }, ]; return cards.map(cd => (
{cd.l}
{cd.v} {cd.u}
{cd.sub || '\u2014'}
)); })()}
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}

)}
Skickat läge {mode.label}
{/* 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 följer bredden via viewBox-proportionen, så 200 % av 360 px gav en ~175 px hög graf; golvet ⇒ ~290 px. 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); return ; })} {/* 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) => )} {/* Ingen spotlinje genom staplarna — spot visas SOM staplarna via växeln (André 26/8). Sälj förblir linje: den är en annan storhet (intäkt per såld kWh), inte samma pris i annan form. */} {/* 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. */} {Math.round(priceMax)} 0 öre {/* 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 && ( SOC 100 % )} {show.sol && solSmArea.map((p,i) => )} {/* HALO under varje kurva: samma path i bakgrundsfärg, 2,5 px bredare. Det skär ut linjen ur stapelväggen — låga kW-värden lever ofrånkomligen i stapelzonen, och utan halo vävdes förbrukningen in i staplarna. */} {show.soc && socSm.map((p,i) => )} {show.cons && consSm.map((p,i) => )} {show.sol && solSm.map((p,i) => )} {/* 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. */} {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.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 {/* 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. */} {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 span = gaLabel(q) + '–' + gaLabel((q + 1) % GA_Q); const lx = Math.min(Math.max(xAt(selI), H_PL + 150), W - H_PR - 150); return ( {String(Math.round(v))} öre {span} ); })()} {/* 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). */}
{/* Instruktionen följer inmatningen: pekskärm har tap + svep, mus har zondelningen. */} Dra på staplarna för att läsa värden {window.matchMedia && window.matchMedia('(pointer: coarse)').matches ? 'Svep under dem för att flytta grafen' : 'Dra under dem för att flytta grafen'} Zooma med +/− eller nyp{zoom !== 2 ? ` · ${zoom.toFixed(1)}× (dubbeltryck återställer)` : ''}
{/* Seriefilter */}
{SERIES.map(sr => ( ))}
{/* Enheter vid markerad tidpunkt */} {devices.length > 0 && (
Enheter · {selLbl}
{devices.map((d, di) => { const sl = d.slots[selI]; 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 : ''}` : 'av'}
); })}
)}
); } Object.assign(window, { GcAvancerat });