/* clock-v2.jsx — dygnshjulet med enhetsspår, kollegans design mot vår data. ──────────────────────────────────────────────────────────────────────── Porterat ur GravioApp.jsx SchemaView. Behållet: huvudringen med läge per kvart, prisspikarna, sol- och förbrukningskurvan, ett eget spår per bil och last med namnet längs bågen, NU-markören, den valda kvarten i mitten och den klistrade raden med kvartens värden. Tre skillnader mot prototypen, alla tvingande: 1. SPÅREN RÄKNAS UR ENHETSLISTAN. Prototypen har två bilar och två laster på fasta radier. Vi har N av varje ur settings, och spåren delar på ett fast utrymme: fler enheter krymper spårets tjocklek i stället för att växa utåt. Yttergränsen är konstant, så NU-markören och namn-pillen hamnar aldrig ovanpå timringen eller utanför rutan. 2. UTFALL ÄR MÄTVÄRDEN. Prototypens "Utfall +1,3 %" är `(rnd(nowIdx + 901) − 0.5) × 4` — ett slumptal. Här jämförs uppmätt (/api/history quarters[]) mot prognos (samma kvart i planen). Saknas mätvärdet visas ingen avvikelse alls. 3. PRISET ÄR BACKENDENS. Prototypen räknar `spot + NET_ORE` med NET_ORE=95 hårdkodat. Vår buy_price innehåller redan avgifterna. Prototypens "Igår"-kort är utelämnat: det byggde på buildDay(8000, 0.9), alltså ett påhittat dygn. Riktig historik hör till statistikvyn. */ const CV_MONO = { fontFamily: '"Geist Mono", ui-monospace, monospace' }; const CV_Q = 96; const CV_C = 320; const CV_R_IN = 172; // huvudringens innerkant /* Radierna utifrån och in. Gamla klockan hade prisstaplarna som ett BREDT band närmast lägesringen (138–184 av 240) och kurvorna innanför (74–136) — den ordningen gäller här också, skalad till våra ringar. NOLLAN LIGGER PÅ KÄRNANS KANT (82), inte fyra pixlar utanför. Kurvbandet började på 86, så en natt utan sol ritade en tunn oliv ring runt kärnan och lästes som "lite sol på natten" (Lennie 20/8). Kärnan målas efter kurvorna, så ett nollvärde göms nu helt av den — ingen sol, ingen yta. */ const CV_R_CURVE0 = 82, CV_R_CURVE1 = 134; // sol/förbrukning: 0 = kärnans kant (82), 52 px const CV_R_PRICE0 = 138, CV_R_PRICE_LEN = 29; // prisstaplar 138 → 170, 32 px /* Spårgeometrin räknas ur ANTALET enheter mot en fast yttergräns, inte ur fasta konstanter. Prototypen var tunad för exakt fyra hårdkodade enheter; vi kan ha fler. Utan budget hamnade fyra spår ovanpå timringen (r 308) och fem utanför viewBox (320), där .cv-wheel klipper bort dem. LANE_IN = innerkant närmast huvudringen. LANE_BUDGET = utrymmet spåren får dela på. Yttergränsen är därmed konstant, så NU-markören och namn-pillen ligger alltid på samma radie oavsett hur många enheter hushållet har. */ const CV_LANE_IN = 207; // 8 px glapp efter lägesringens avgränsning (199) const CV_LANE_BUDGET = 55; // TAK, inte reservation: yttergränsen följer antalet const CV_HOUR_GAP = 34; // timsiffrorna, mätt från yttersta spåret const CV_HOUR_PAD = 14; // luft mellan siffrorna och viewBoxens kant const CV_HS = Math.PI / CV_Q; // Tre enheter ger exakt prototypens mått (w 17, steg 19, radier 228/247/266). // Fler krymper spåret i stället för att växa utåt. function cvLaneGeom(n){ const w = n > 0 ? Math.max(6, Math.min(17, (CV_LANE_BUDGET - 2*(n-1))/n)) : 17; // Steget kapas till spårets tjocklek plus mellanrum. Utan kapningen sprids // två spår isär och gapet emellan läses som ett tredje, tomt spår — ett tomt // spår betyder redan "enheten finns men inget planerat". const step = n > 1 ? Math.min(w + 2, (CV_LANE_BUDGET - w)/(n-1)) : 0; return { w, step, r: (i) => CV_LANE_IN + w/2 + i*step, /* Yttergränsen följer ANTALET spår. En fast gräns gav tom reserverad ring för hushåll med få enheter — och eftersom NU-markören, namn-pillen och timsiffrorna räknas härifrån sköts hela hjulet inåt. */ outer: n > 0 ? CV_LANE_IN + w/2 + (n-1)*step + w/2 : CV_R_IN + 27, // Namnet längs bågen kräver plats i höjd — under ~12 px blir det oläsligt. showLabels: w >= 12, fontSize: Math.max(10, Math.min(15, w - 3)), }; } // Färg per energikälla. Okänd källa får en neutral spårfärg — aldrig en // påhittad sol- eller nätfärg. const CV_SRC_COL = { sol: '#FFD54A', 'nät': '#4F8CFF', batteri: '#FF8A3D', blandat: '#00D4D8', }; const CV_LANE_NEUTRAL = '#6B7A94'; const cvSrcCol = (src) => CV_SRC_COL[src] || CV_LANE_NEUTRAL; const cvNum = (n, d=1) => n == null ? '—' : n.toLocaleString('sv-SE', { minimumFractionDigits:d, maximumFractionDigits:d }); const cvLabel = (i) => String(Math.floor((i%CV_Q)/4)).padStart(2,'0')+':'+String(((i%CV_Q)%4)*15).padStart(2,'0'); const cvRange = (i) => cvLabel(i)+'–'+cvLabel((i+1)%CV_Q); const cvPt = (a, r) => [CV_C + r*Math.cos(a), CV_C + r*Math.sin(a)]; /* Bandets punkter. Varje kvart bidrar med sina BÅDA kanter, inte sin mitt: ett band på en enda kvart gav annars en ettpunkts-polyline, och en nollängds-subpath stroke:as inte — 0 px målade, round linecap till trots (mätt 25/8: ev-bandet 09:30–09:45 hade nPoints 1 och getBBox 0×0 medan lasternas fyrkvartsband ritades). Det gällde ALLA 15-minutersband på alla spår, inte bara bilens. Kanterna ger dessutom en polyline som följer ringen närmare än mittpunkterna gjorde. */ const cvBandPts = (rg, r, angleOf) => { const pts = []; for (let i = rg.start; i < rg.end; i++){ pts.push(cvPt(angleOf(i) - CV_HS, r)); pts.push(cvPt(angleOf(i) + CV_HS, r)); } return pts; }; function ClockV2({ day, tomorrow, units, live, nowIdx, tomorrowPrelim }){ const [tab, setTab] = React.useState('today'); const [sel, setSel] = React.useState(nowIdx); const [actDev, setActDev] = React.useState(null); const dragging = React.useRef(false); const svgRef = React.useRef(null); const d = tab === 'today' ? day : (tomorrow || day); /* Morgondagens elpris släpps ~13:00. Innan dess planerar backend på dagens priser som proxy, och då får varken mitten eller spikarna se ut som ett avgjort pris (samma regel som forecast.jsx:322). */ const prelim = tomorrowPrelim === true && tab === 'tomorrow'; /* Priset finns inte för morgondagen förrän Nord Pool släppt det — visa aldrig proxytalet, se gc-core.gcPriceUnknown. */ const priceUnknown = window.gcPriceUnknown(prelim); const s = d[Math.min(sel, d.length-1)] || d[0]; const mode = window.GRAVIO_MODES[s.mode] || window.GRAVIO_MODES.vanta; // Hjulet roteras så att NU ligger rakt upp. const rotOffset = -Math.PI/2 - ((nowIdx + 0.5)/CV_Q)*Math.PI*2; const angleOf = React.useCallback((i) => ((i + 0.5)/CV_Q)*Math.PI*2 + rotOffset, [rotOffset]); const pMax = React.useMemo(() => Math.max(...d.map(x => x.buy ?? 0)), [d]); /* Snittpris som referensring. En stapellängd utan skala är inget pris — gamla klockan hade meanR av precis det skälet (schema-clock.jsx:130). */ const pMean = React.useMemo(() => { const v = d.map(x => x.buy).filter(x => x != null); return v.length ? v.reduce((a,c)=>a+c,0)/v.length : null; }, [d]); /* Kurvorna ritas ur PLANEN, inte ur x.sol/x.cons: de bär livevärdet i NU-kvarten och gav ett hack i en annars timvis kurva. Livevärdet lever i avläsningen ovanför i stället. Skalan måste läsa samma serie som kurvan. */ const solOf = React.useCallback((x) => x.solPlan ?? null, []); const consOf = React.useCallback((x) => x.consPlanTot ?? x.consPlan ?? null, []); const solRuns = React.useMemo(() => window.gcRuns(d, solOf).map(window.gcSmoothRun), [d, solOf]); const consRuns = React.useMemo(() => window.gcRuns(d, consOf).map(window.gcSmoothRun), [d, consOf]); /* EN skala för båda kurvorna, som i gamla klockan (schema-clock.jsx:121). Med en skala per serie blev 1 kW förbrukning lika hög som 11 kW sol, och kurvorna gick inte att jämföra med ögat. */ const curveMax = React.useMemo(() => Math.max(0.6, ...solRuns.map(r => r.reduce((m,p) => Math.max(m, p.v), 0)), ...consRuns.map(r => r.reduce((m,p) => Math.max(m, p.v), 0))), [solRuns, consRuns]); /* Kurvbandet börjar UTANFÖR kärnan (r 82) och slutar före prisstaplarna. Gamla klockan: kärna 70, kurvor 74–136 — samma förhållande här. */ const curveR = React.useCallback((v) => CV_R_CURVE0 + (v/curveMax)*(CV_R_CURVE1 - CV_R_CURVE0), [curveMax]); /* Sluten ring när körningen täcker hela dygnet — då rider nattens nollor på baslinjen r=50, precis som gamla klockan. Är körningen kortare finns ett hål, och då stängs ytan mot baslinjen i stället. */ const ringPath = React.useCallback((runs, fillToBase) => runs.map(run => { const full = run.length === d.length; const outer = run.map(p => cvPt(angleOf(p.x.i), curveR(p.v))); const open = window.gcRadialPath(outer, full); if (full || !fillToBase || !open) return open; const inner = run.slice().reverse().map(p => cvPt(angleOf(p.x.i), 50)); return open + 'L' + inner.map(p => p.map(v => v.toFixed(1)).join(',')).join('L') + 'Z'; }).join(' '), [d, angleOf, curveR]); /* Ett delfält per körning med känd prognos: ytterkant längs värdena, tillbaka längs r=50. Tidigare drogs `x.sol ?? 0` genom hela dygnet, så en timme utan prognos ritades som noll sol — kurvan dök in mot mitten och läste som att solen tagit slut, medan avläsningen samtidigt visade 4 kW (Lennie 18/8). Ett hål säger "vi vet inte", vilket är sant. */ const solPath = React.useMemo(() => ringPath(solRuns, true), [ringPath, solRuns]); // Streck, inte yta: varje körning blir sitt eget segment. Utan uppdelningen // drogs linjen rakt igenom en lucka och påstod ett värde som inte fanns. const consPath = React.useMemo(() => ringPath(consRuns, false), [ringPath, consRuns]); /* Enhetsspår: ett per laddbox och last. Sammanhängande körningar bryts när källan byts, precis som i prototypen — men källan får vara null (okänd), och då är spåret neutralt färgat i stället för gissat. */ const devices = React.useMemo(() => { const defs = [ /* Etiketten är BILEN när vi vet vilken (u.carName), inte laddboxen: spåret svarar på "vilken bil laddas här", och boxens namn ("Zappe") säger inget om det. Boxen följer med som via och skrivs i radens gråa text, så kopplingen till hårdvaran inte försvinner. */ ...units.evs.map((u, k) => ({ key:'ev'+k, name:u.carName || u.name, kind:'Bil', via: u.carName ? u.name : null, at: (x) => (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) => ({ key:'ld'+k, name:u.name, kind:'Last', at: (x) => (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(def => { const slots = d.map(def.at); const ranges = []; let st = null, cur; for (let i = 0; i <= CV_Q; i++){ const v = i < CV_Q ? slots[i] : null; const sc = v ? (v.src ?? '_') : null; if (sc && (st === null || sc !== cur)){ if (st !== null) ranges.push({ start:st, end:i, src: cur === '_' ? null : cur }); st = i; cur = sc; } if (!sc && st !== null){ ranges.push({ start:st, end:i, src: cur === '_' ? null : cur }); st = null; cur = undefined; } } const kwh = slots.reduce((a, v) => a + (v ? v.p*0.25 : 0), 0); const maxP = Math.max(0, ...slots.map(v => v ? v.p : 0)); return { ...def, slots, ranges, kwh, maxP }; }); }, [d, units]); const geom = React.useMemo(() => cvLaneGeom(devices.length), [devices.length]); const laneOuter = geom.outer; /* viewBoxen skärs till det som ritas: timsiffror + luft. Utan det låg yttergränsen kvar på 320 och skillnaden blev tom ring. */ const hourR = laneOuter + CV_HOUR_GAP; const vbH = hourR + CV_HOUR_PAD; const viewBox = `${CV_C-vbH} ${CV_C-vbH} ${2*vbH} ${2*vbH}`; /* Innestående timme: prognos mot uppmätt. Bara passerade kvartar med mätvärde räknas, och pågående kvart skalas med sin fyllnadsgrad. */ const nowHour = Math.floor(nowIdx/4); const hourInfo = React.useMemo(() => { const hq = day.slice(nowHour*4, nowHour*4 + 4); const done = day.slice(nowHour*4, nowIdx + 1).filter(x => x.solAct != null || x.consAct != null); const sum = (arr, f) => arr.reduce((a,x) => a + (f(x) || 0), 0); const scale = (x) => x.actPartial != null ? x.actPartial : 1; const solPlanSum = sum(done, x => (x.solPlan||0)*0.25*scale(x)); const consPlanSum = sum(done, x => ((x.consPlanTot ?? x.consPlan) || 0)*0.25*scale(x)); const solActSum = sum(done, x => x.solAct != null ? x.solAct*0.25*scale(x) : 0); const consActSum = sum(done, x => x.consAct != null ? x.consAct*0.25*scale(x) : 0); const cur = day[nowIdx]; return { solProg: sum(hq, x => (x.solPlan||0)*0.25), /* consPlanTot först, som consOf på rad 139 och gc-schema.jsx. Utan fallbacken visade SAMMA fil två olika tal för samma timme så fort backend fyller serien: 3,000 kWh fel per hel lasttimme, mätt 15,000 kWh/dygn för Lennie (−26,0 %). */ consProg: sum(hq, x => ((x.consPlanTot ?? x.consPlan) || 0)*0.25), solDiff: done.length ? solActSum - solPlanSum : null, consDiff: done.length ? consActSum - consPlanSum : null, socPlan: cur?.socPlanPct ?? null, socDiff: (cur?.socActPct != null && cur?.socPlanPct != null) ? cur.socActPct - cur.socPlanPct : null, hourly: done.some(x => x.actHourly), }; }, [day, nowIdx, nowHour]); const hourLbl = `kl ${String(nowHour).padStart(2,'0')}–${String((nowHour+1)%24).padStart(2,'0')}`; /* Interaktion: bara själva ringen är dragbar — mitten och hörnen släpps till sidans scroll. */ // Ringens träffyta i SKÄRMPIXLAR, räknad ur samma skala som SVG:n renderas // med (preserveAspectRatio ⇒ min av bredd och höjd). Gränserna är hjulets // egna radier i viewBox-koordinater: mittnodens kant och ytterkanten. const hitBand = React.useCallback((el) => { const r = el.getBoundingClientRect(); // Skalan mot viewBoxens FAKTISKA storlek, inte mot 640: boxen skärs efter // hur många spår hushållet har. const scale = Math.min(r.width, r.height) / (2*vbH); return { cx: r.left + r.width/2, cy: r.top + r.height/2, inner: 82*scale, outer: vbH*scale }; }, [vbH]); const idxFromEvent = React.useCallback((e) => { const el = svgRef.current; if (!el) return null; const b = hitBand(el); const dx = e.clientX - b.cx, dy = e.clientY - b.cy; const dist = Math.hypot(dx, dy); if (dist < b.inner || dist > b.outer) return null; let a = Math.atan2(dy, dx) - rotOffset; a = ((a % (Math.PI*2)) + Math.PI*2) % (Math.PI*2); return Math.floor((a/(Math.PI*2))*CV_Q) % CV_Q; }, [rotOffset, hitBand]); const onDown = (e) => { const i = idxFromEvent(e); if (i === null) return; dragging.current = true; moved.current = false; e.currentTarget.setPointerCapture?.(e.pointerId); setSel(i); }; // Ett spår får inte äta upp ringens drag: vi lyssnar på click och ignorerar // det när pekaren rört sig, i stället för att stoppa pointerdown. const moved = React.useRef(false); const onMove = (e) => { if (dragging.current){ moved.current = true; const i = idxFromEvent(e); if (i !== null) setSel(i); } }; const onUp = () => { dragging.current = false; }; React.useEffect(() => { const h = (e) => { if (e.key === 'ArrowRight') setSel(v => (v+1)%CV_Q); if (e.key === 'ArrowLeft') setSel(v => (v+CV_Q-1)%CV_Q); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, []); // iOS: blockera sid-scroll bara när touchen startar i ringen. React.useEffect(() => { const el = svgRef.current; if (!el) return; const inWheel = (x, y) => { const b = hitBand(el); const dd = Math.hypot(x - b.cx, y - b.cy); return dd >= b.inner && dd <= b.outer; }; const bs = (e) => { const t = e.touches[0]; if (t && inWheel(t.clientX, t.clientY)) e.preventDefault(); }; const bm = (e) => { if (dragging.current) e.preventDefault(); }; el.addEventListener('touchstart', bs, { passive:false }); el.addEventListener('touchmove', bm, { passive:false }); return () => { el.removeEventListener('touchstart', bs); el.removeEventListener('touchmove', bm); }; }, [hitBand]); const surplus = (s.sol != null && s.cons != null) ? s.sol - s.cons : null; const evTot = s.ev.reduce((a,e) => a + (e.p||0), 0); const loadTot = s.loads.reduce((a,x) => a + (x.p||0), 0); const evOn = s.ev.find(e => e.p != null && e.p > 0.05); const loadOn = s.loads.find(x => x.p != null && x.p > 0.05); const batLine = (bp) => { if (!bp || bp.p == null) return { txt:'okänt', col:'var(--ink-3)' }; if (bp.p > 0.05) return { txt:`+${cvNum(bp.p)} kW${bp.src ? ' · '+bp.src : ''}`, col: bp.src === 'sol' ? 'var(--c-charge-sol)' : 'var(--c-charge-net)' }; if (bp.p < -0.05) return { txt:`${cvNum(bp.p)} kW${bp.src ? ' · '+bp.src : ''}`, col: bp.src === 'export' ? 'var(--c-export)' : 'var(--c-cover)' }; return { txt:'vilar', col:'var(--ink-3)' }; }; const Diff = ({ v, unit, good }) => v == null ? inget mätvärde : = 0) ? 'var(--c-sell)' : 'var(--c-cover)'}}> Utfall {v >= 0 ? '+' : '−'}{cvNum(Math.abs(v), unit === '%' ? 1 : 2)} {unit} ; return (