/* gc-schema.jsx — SCHEMA-fliken i läge Ny: hans dygnshjul i HANS skal. ──────────────────────────────────────────────────────────────────────── clock-v2.jsx byggdes när nya vyerna skulle bo i VÅRT skal: samma hjul, men våra CSS-klasser och våra färgvariabler. Här är samma hjul i hans markup — Tailwind, hans hex-palett, hans kortform — plus de block som föll bort då: sexrutan för vald kvart, batterikorten, enheternas linjära dygnsspår, teckenförklaringen och prognosavsnittet. Matematiken och interaktionen är oförändrade, inklusive spårbudgeten och träffytan. Den gamla filen ligger kvar och driver Schema i gamla designen. (Ur clock-v2.jsx, som i sin tur är porterad ur GravioApp.jsx SchemaView.) ──────────────────────────────────────────────────────────────────────── 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_UNUSED = { sol: '#FFD54A', 'nät': '#4F8CFF', batteri: '#FF8A3D', blandat: '#00D4D8', }; const CV_LANE_NEUTRAL = '#6B7A94'; /* Hans lastfärger, inte våra variabler — spåren ska matcha resten av läge Ny. */ const cvSrcCol = (src) => window.GC_LOAD_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 GcSchema({ 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.GC_MODES[s.mode] || window.GC_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), 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')}`; /* Dygnssummor till prognosavsnittet. Samma regel som i NU-blocket: bara kvartar med mätvärde jämförs, och pågående kvart skalas med sin fyllnadsgrad. Saknas mätvärden helt blir avvikelsen null, inte noll. */ /* 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:'#64748b' }; if (bp.p > 0.05) return { txt:`+${cvNum(bp.p)} kW${bp.src ? ' · '+bp.src : ''}`, col: bp.src === 'sol' ? '#facc15' : '#60a5fa' }; if (bp.p < -0.05) return { txt:`${cvNum(bp.p)} kW${bp.src ? ' · '+bp.src : ''}`, col: bp.src === 'export' ? '#2dd4bf' : '#fb923c' }; return { txt:'vilar', col:'#64748b' }; }; const Diff = ({ v, unit, good }) => v == null ? inget mätvärde : = 0) ? '#4ade80' : '#fb923c'}}> 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 */} {/* Klistrad: allt som följer vald kvart. top-0 på egen sida, under appens topbar i kortskalet — gc.css sköter den skillnaden. */}
{/* gc-sel-row: en rad över 380 px, två under. Se gc.css — brytpunkten är container-driven, inte viewport-driven, eftersom raden bor i en kolumn som inte är lika bred som skärmen. */}
VALD {/* nowrap + tabular-nums: aldrig radbrytning vid tankstrecket, och samma bredd oavsett siffror — annars hoppar raden vid dragning. */} {cvRange(sel)} {sel === nowIdx && tab === 'today' && ( = NU )}
{/* Lägesnamnet är det som får kortas när bredden inte räcker. */} {mode.label} {priceUnknown || s.buy == null ? '—' : Math.round(s.buy)} öre {priceUnknown && inte släppt}
{[ { l:'Sol', v:cvNum(s.sol), u:'kW', c:'#facc15', 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.consLive != null || s.consAct != null ? s.cons : (s.consPlanTot ?? s.cons)), u:'kW', c:'#e2e8f0', sub: s.cons != null ? `${cvNum((s.consLive != null || s.consAct != null ? s.cons : (s.consPlanTot ?? 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 ? '#4ade80' : (s.gridIn||0) > 0.05 ? '#60a5fa' : '#64748b', 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:'#34d399', /* 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) : '#64748b', sub: evOn ? (evOn.src ? evOn.src : 'laddar') : '—' }, { l:'Last', v: units.loads.length ? cvNum(loadTot) : '—', u:'kW', c: loadOn ? cvSrcCol(loadOn.src) : '#64748b', 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. */} {priceUnknown && (

{window.GC_PRICE_UNRELEASED}

)} {window.gcFcNote(s) && (

{window.gcFcNote(s)}

)} {/* Ett kort per batteri — SAMMA kort i ALLA kvartar (kollegan 1/9: vyn bytte layout så fort NU lämnades och "all info försvann"). PLANERADE kvartar har ingen per-enhet-data: quarterly_decisions bär ETT soc_after_pct och EN action per kvart (verifierat 25/8) — så SoC-fältet visar — utanför NU (aldrig bankens tal per kort, Anders bugg 18/8), men kortet står kvar och bär planens kommando: samma action går till varje HEMS-batteri, AI-batteriet styrs inte. */}
{(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 line = per || units.bats.length <= 1 ? batLine(bp) : ai ? { txt:'extern AI styr', col:'#C4ADFF' } : s.mode ? { txt:`plan · ${mode.label}`, col:mode.color } : { txt:'—', col:'#64748b' }; /* Bara ett banktal? Då finns ingen mätning per enhet, och samma siffra på varje kort hade påstått att det fanns. */ 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 för kvarten. Bara när SoC inte är per enhet, dvs. allt utom NU: då bär korten ovan redan varje batteris eget värde. GRINDAS PÅ ATT KOMMANDOT FINNS, inte på dess effekt. Första försöket krävde |batCmd.p| > 0,05 och renderades därför aldrig: för planerade kvartar är charge_kwh och discharge_kwh BÅDA 0 i quarterly_decisions medan action ("chargesolar") och reason ("HEMS: Ladda från sol") bär beslutet — batCmd.p blev 0 och raden en no-op i precis det scenario den skrevs för. Beslutet är action, inte effekten. Skriv ALDRIG bankens tal på varje batterikort — Anders bugg 18/8: en aggregatsiffra får inte se ut som en mätning per enhet. */} {!s.socPerUnit && (s.mode || s.reason) && (
Planerat {mode.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') && ( )}
{/* Kvadratisk, som gamla klockan (.schema-clock .clock-svg). Höjdbudgeten ligger i BREDDEN — en max-height vinner över aspect-ratio medan width står kvar på 100 %, och då brevlådar preserveAspectRatio innehållet med död zon på sidorna i stället för att krympa rutan. */} {[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.GC_MODES[x.mode] || window.GC_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.GC_MODES[x.mode] || window.GC_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? Samma mått som fits-testet nedan, men räknat här: det beror på lanens radie och på geom, som i sin tur beror på antalet spår — alltså inte känt i useMemo. */ 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; /* labelledInline nedan: ett band på en enda kvart är ~1/96 av ringen och rymmer ingen text, så spåret ritades helt anonymt — bilen låg på hjulet men syntes inte som "Forden" (André 25/8: laddplanen var 09:30–09:45, 2,2 kWh på en kvart, medan varmvattnets timlånga band fick sina "Varm"-chips). */ 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. Gällde bara det AKTIVA spåret; nu också spår vars band är för korta för text längs bandet — annars är en kort laddning osynlig som enhet. 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. Hans kort: spårikon, namn, värde för vald kvart, linjärt dygnsspår och tidsintervallen under. */} {devices.length > 0 && (
Enheter · spår i hjulet {actDev !== null && ( )}
{devices.map((dev, di) => { const on = actDev === di; const act = dev.slots[sel]; return ( ); })}
)} {/* Teckenförklaring — hans rad, med lägena vi faktiskt får från backend. */}
{Object.values(window.GC_MODES).map(m => ( {m.label} ))} Solprognos Förbrukning Snittpris {pMean != null ? Math.round(pMean) + ' öre' : '—'} {devices.length > 0 && ( Enheter: {[['sol','Sol'],['nät','Nät'],['batteri','Batteri']].map(([k,t]) => ( {t} ))} )}
{/* Prognoser: samma komponent som Översikt, inte en egen kopia. Schemat hade en fyrkortsvariant vars diff-rendering aldrig fick något värde, så utfall mot prognos syntes bara på Översikt. */} {window.GcPrognoser && (
)}
); } Object.assign(window, { GcSchema });