/* 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 (
{/* NU: innestående timme. Ändras inte av hjulet. */}
NU {hourLbl} · innestående timme {hourInfo.hourly && · utfall per timme}
Sol
{cvNum(hourInfo.solProg)}kWh prognos
Förbrukning
{cvNum(hourInfo.consProg)}kWh prognos
Batteri SoC
{hourInfo.socPlan != null ? Math.round(hourInfo.socPlan) : '—'}% plan
{/* Följer vald kvart */}
VALD {cvRange(sel)} {sel === nowIdx && tab === 'today' && = NU}
{mode.label} {s.buy != null ? Math.round(s.buy) : '—'} öre
{[ { l:'Sol', v:cvNum(s.sol), u:'kW', c:'var(--c-charge-sol)', sub: s.sol == null ? '—' : surplus == null ? '—' : surplus >= 0 ? `+${cvNum(surplus)} över` : `${Math.round((s.sol/(s.cons||1))*100)} % behov` }, { l:'Förbr.', v:cvNum(s.cons), u:'kW', c:'var(--ink)', sub: s.cons != null ? `${cvNum(s.cons*0.25, 2)} kWh` : '—' }, { l: (s.gridOut||0) > 0.05 ? 'Export' : 'Import', v: cvNum((s.gridOut||0) > 0.05 ? s.gridOut : s.gridIn), u:'kW', c: (s.gridOut||0) > 0.05 ? 'var(--c-sell)' : (s.gridIn||0) > 0.05 ? 'var(--c-charge-net)' : 'var(--ink-3)', sub: (s.gridOut||0) > 0.05 ? '↑ sälj' : (s.gridIn||0) > 0.05 ? '↓ köp' : '—' }, /* socAggPct = HELA bankens SoC, kapacitetsviktad. Tidigare stod här socPct[0], vilket vid NU är BATTERI 1 — 82 % under etiketten SoC medan banken låg på 86 % (Anders 18/8). kWh-raden har en decimal så att den stämmer med procenten ovanför: 51,6/60 är 86 %, medan "52/60" hade läst som 87 %. */ { l:'SoC', v: s.socAggPct != null ? String(Math.round(s.socAggPct)) : '—', u:'%', c:'#3FCC8B', /* Täljare och nämnare måste ha SAMMA precision. Med kapaciteten avrundad till heltal blev "11,7 av 26" = 45 % under en rubrik som sa 46 — samma division Anders själv gjorde. Hel kapacitet skrivs utan decimal (26), bruten med (25,6). */ sub: s.socTot != null && s.socCapKwh ? `${cvNum(s.socTot,1)} av ${cvNum(s.socCapKwh, Number.isInteger(s.socCapKwh) ? 0 : 1)} kWh` : '—' }, { l: units.evs.length === 1 ? units.evs[0].name : 'Bilar', v: units.evs.length ? cvNum(evTot) : '—', u:'kW', c: evOn ? cvSrcCol(evOn.src) : 'var(--ink-3)', sub: evOn ? (evOn.src ? evOn.src : 'laddar') : '—' }, { l:'Last', v: units.loads.length ? cvNum(loadTot) : '—', u:'kW', c: loadOn ? cvSrcCol(loadOn.src) : 'var(--ink-3)', sub: loadOn ? loadOn.name : '—' }, ].map(f => (
{f.l}
{f.v}{f.u}
{f.sub}
))}
{/* Fyra lika kvartar är inte ett fel, det är källans upplösning. Sol- och förbrukningsprognosen kommer per timme, priset per kvart. */} {priceUnknown &&
{window.GC_PRICE_UNRELEASED}
} {/* Prisbandet behöver en skala för att läsas som pris — samma roll som meanR i gamla klockan. Ringen ritas utan etikett, texten står här. */} {!priceUnknown && pMean != null && (
Streckad ring i prisbandet = dygnets snittpris, {Math.round(pMean)} öre. Staplar utanför den är dyrare.
)} {window.gcFcNote(s) &&
{window.gcFcNote(s)}
} {/* Ett kort per batteri — SAMMA kort i ALLA kvartar (kollegan 1/9: layouten bytte till ett summakort utanför NU och "all info försvann"). Planerade kvartar saknar per-enhet-data (quarterly_decisions: ETT soc_after_pct, EN action — verifierat 25/8), så SoC visar — utanför NU (aldrig bankens tal per kort, Anders bugg 18/8) men korten står kvar med planens kommando. */}
{(units.bats.length ? units.bats : [{ id:'bank', name:'Batteri' }]) .map((u, k) => { const per = s.socPerUnit && s.bat.length === units.bats.length; const bp = per ? s.bat[k] : (units.bats.length <= 1 ? s.bat[0] : null); const ai = u.aiControlled || bp?.aiControlled; const pm = window.gcMode(s.mode); const line = per || units.bats.length <= 1 ? batLine(bp) : ai ? { txt:'extern AI styr', col:'#C4ADFF' } : s.mode ? { txt:`plan · ${pm.label}`, col:pm.color } : { txt:'—', col:'var(--ink-3)' }; const pct = per ? (s.socPct[k] ?? null) : units.bats.length <= 1 ? s.socPct[0] : null; return (
{u.name}{bp?.aiControlled && !/AI/i.test(u.name || '') ? ' · AI' : ''} {pct != null ? Math.round(pct) : '—'} %
{line.txt}
); })}
{/* Planens kommando. Bara när SoC inte är per enhet (allt utom NU) — annars bär korten ovan varje batteris eget värde. GRINDAS PÅ ATT KOMMANDOT FINNS, inte på effekten: planerade kvartar har charge_kwh = discharge_kwh = 0, så batCmd.p är 0 medan action bär beslutet. Bankens tal får ALDRIG skrivas på varje batterikort (Anders bugg 18/8). */} {!s.socPerUnit && (s.mode || s.reason) && (() => { const pm = window.gcMode(s.mode); return (
Planerat {pm.label} {Math.abs(s.batCmd?.[0]?.p || 0) > 0.05 && ( 0 ? '#60a5fa' : '#fb923c'}}> {s.batCmd[0].p > 0 ? '+' : ''}{cvNum(s.batCmd[0].p)} kW )} {units.bats.length > 1 && ett kommando för alla {units.bats.length} batterier} {units.bats.some(b => b.aiControlled) && ( · {units.bats.filter(b => b.aiControlled).map(b => b.name).join(', ')} styrs av extern AI )} {units.bats.length > 1 && · SoC per batteri mäts bara i nuläget}
); })()}
{[{id:'today',t:'Idag'},{id:'tomorrow',t:'Imorgon'}].map(x => ( ))}
{(sel !== nowIdx || tab !== 'today') && ( )}
{[0,3,6,9,12,15,18,21].map(h => { const a = (h/24)*Math.PI*2 + rotOffset; const [x,y] = cvPt(a, hourR); /* 15/12 px var för smått att läsa (kollega 18/8) — hjulet roterar, så siffrorna är den enda hållpunkten för vilken tid man tittar på. */ /* 00 är dygnsgränsen och bär linjens betydelse — störst och ljusast, så den syns utan en egen etikett (som krockade med siffran). */ const isMid = h === 0; return {String(h).padStart(2,'0')}; })} {/* Prisspikar — buy_price, avgifter inkluderade. */} {d.map(x => { const a = angleOf(x.i); /* Proportionell mot priset, som i gamla klockan (schema-clock.jsx:398): dubbla priset ger dubbel spik. Min–max-sträckningen förut blåste upp små skillnader, så en jämn dag såg ut som en berg-och-dalbana. */ /* Längden kodar priset. Är priset okänt ritas en jämn ring i stället för morgondagens proxyform. */ const len = 3 + (priceUnknown ? 0 : ((x.buy ?? 0)/Math.max(0.001, pMax))*CV_R_PRICE_LEN); const [x1,y1] = cvPt(a, CV_R_PRICE0); const [x2,y2] = cvPt(a, CV_R_PRICE0+len); const col = (window.GRAVIO_MODES[x.mode] || window.GRAVIO_MODES.vanta).color; return ; })} {/* Huvudring: läge per kvart */} {/* Snittprisring: staplar utanför den är dyrare än dagens snitt. Ritas UTAN etikett — ett värde i bandet krockar med staplarna vid 00:00; förklaringen står i teckenförklaringen under hjulet. */} {!priceUnknown && pMean != null && pMax > 0 && ( )} {d.map(x => { const a = angleOf(x.i); const [x1,y1] = cvPt(a, CV_R_IN+2.5); const [x2,y2] = cvPt(a, CV_R_IN+23.5); const isSel = x.i === sel; const col = (window.GRAVIO_MODES[x.mode] || window.GRAVIO_MODES.vanta).color; return ( {isSel && ( )} ); })} {/* Enhetsspår */} {devices.map((dev, di) => { const r = geom.r(di); const dim = actDev !== null && actDev !== di; /* Rymmer NÅGOT av spårets band sin text? Ett band på en enda kvart är ~1/96 av ringen och rymmer ingen — då ritades spåret helt anonymt (bilen låg på hjulet men syntes inte som "Forden"). Måttet är fits-testets, men räknat här: det beror på lanens radie och på geom, som i sin tur beror på antalet spår. */ const anyLabelFits = geom.showLabels && dev.ranges.some((rg) => { const a0 = angleOf(rg.start) - CV_HS, a1 = angleOf(rg.end-1) + CV_HS; const lbl = dev.name.length <= 6 ? dev.name : dev.name.slice(0,4); return r*(a1-a0) > lbl.length*10.5 + 12; }); return ( { if (!moved.current) setActDev(actDev === di ? null : di); }}> {actDev === di && dev.ranges.map((rg, k) => { const pts = cvBandPts(rg, r, angleOf); return p.join(',')).join(' ')} fill="none" stroke={cvSrcCol(rg.src)} strokeWidth={geom.w+8} strokeLinecap="round" opacity="0.28" filter="url(#cvSoft)"/>; })} {dev.ranges.map((rg, k) => { const pts = cvBandPts(rg, r, angleOf); const A0 = angleOf(rg.start) - CV_HS, A1 = angleOf(rg.end-1) + CV_HS; const mid = (A0+A1)/2; const flip = Math.sin(mid) > 0; const [sx,sy] = cvPt(flip ? A1 : A0, r); const [ex,ey] = cvPt(flip ? A0 : A1, r); const large = A1-A0 > Math.PI ? 1 : 0; const arcId = `cvarc-${tab}-${dev.key}-${k}`; const arcLen = r*(A1-A0); const short = dev.name.length <= 6 ? dev.name : dev.name.slice(0,4); const label = arcLen > dev.name.length*10.5 + 12 ? dev.name : short; const fits = geom.showLabels && arcLen > label.length*10.5 + 12; return ( p.join(',')).join(' ')} fill="none" stroke={cvSrcCol(rg.src)} strokeWidth={geom.w} strokeLinecap="round" strokeLinejoin="round" opacity={actDev === di ? 1 : 0.94}/> {fits && ( {label} )} ); })} {/* Etiketten utanför ringen: aktivt spår ELLER spår vars band är för korta för text längs bandet. Dämpad när den inte är aktiv, så den inte konkurrerar med markeringen. */} {dev.ranges.length > 0 && (actDev === di || !anyLabelFits) && (() => { const muted = actDev !== di; const big = dev.ranges.reduce((a,b) => (b.end-b.start > a.end-a.start ? b : a)); const am = angleOf((big.start + big.end - 1)/2); const [lx1,ly1] = cvPt(am, r + geom.w/2); const rOut = laneOuter + 22; const [lx2,ly2] = cvPt(am, rOut); const flipL = Math.cos(am) < 0; const label = dev.name.length > 12 ? dev.name.slice(0,11)+'…' : dev.name; const wPill = label.length*7.6 + 16; const px = flipL ? lx2 - 10 - wPill : lx2 + 10; return ( {label} ); })()} ); })} {/* Dygnsgränsen. Hjulet roterar så att NU står högst upp, alltså kan 00:00 ligga var som helst — utan en egen markering får man leta efter timsiffran. Linjen går genom alla band, från kärnan till spårens ytterkant, med klockslaget vid kanten. */} {(() => { const a = angleOf(0) - CV_HS; // kvartens FRAMKANT, inte mitten const [x1,y1] = cvPt(a, 82); const [x2,y2] = cvPt(a, laneOuter + 4); return ( ); })()} {/* Vald kvart som radiell linje — följer fingret vid dragning. Den markerade kapseln i lägesringen räckte inte: man ser inte VILKEN tid man dragit till förrän man släpper och läser mitten. */} {(() => { const a = angleOf(sel); const [x1,y1] = cvPt(a, 82); const [x2,y2] = cvPt(a, CV_R_IN + 23.5); return ( ); })()} {tab === 'today' && (() => { const a = angleOf(nowIdx); const rNu = laneOuter + 8; const [x1,y1] = cvPt(a, rNu); const [tx,ty] = cvPt(a, rNu+14); return ( NU ); })()} {cvLabel(sel)} {priceUnknown || s.buy == null ? '—' : Math.round(s.buy)} ÖRE / KWH {(() => { /* Chippet måste rymmas INNE i kärnan (r 82). Vid underkanten y = CV_C+58 är kärnans bredd 2·√(82²−58²) ≈ 116 px — gamla formeln gav 133 för "Sälj batteri", så hörnen stack ut i prisringen. Taket räknas ur geometrin i stället för att gissas. */ const wMax = 2*Math.sqrt(82*82 - 58*58) - 8; const w = Math.min(mode.label.length*6.9 + 26, wMax); return ( {mode.label} ); })()}
{/* Enhetslistan identifierar spåren */} {devices.length > 0 && (
Enheter i hjulet {actDev === null ? 'tryck på ett spår' : 'tryck igen för att visa alla'}
{devices.map((dev, di) => ( ))}
)}
); } Object.assign(window, { ClockV2 });