/* 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. */ /* Textbredd för bågetiketter: mät fonten i stället för att gissa em/tecken. */ const cvMeasureCache = {}; function cvTextW(str, fontSize){ const key = fontSize + '|' + str; if (cvMeasureCache[key] != null) return cvMeasureCache[key]; let w; try { const c = cvTextW._c || (cvTextW._c = document.createElement('canvas').getContext('2d')); c.font = '800 ' + fontSize + 'px "Geist Mono", ui-monospace, monospace'; w = c.measureText(str).width + str.length*0.3; // letterSpacing 0.3 } catch { w = str.length*fontSize*0.62; } return (cvMeasureCache[key] = w); } /* Bågen får fyllas till 92 % av texten. */ const cvArcNeed = (str, fontSize) => cvTextW(str, fontSize) / 0.92; /* Etikett som ryms på bågen: hela namnet, annars längsta prefix (minst 3 tecken), annars null (Lennie 14/9: "Smar" hade plats för "Smartlas"). */ function cvArcLabel(name, arcLen, fontSize){ if (arcLen > cvArcNeed(name, fontSize)) return name; for (let n = name.length - 1; n >= 3; n--) { const s = name.slice(0, n); if (arcLen > cvArcNeed(s, fontSize)) return s; } return null; } /* Krav 13-raden per enhet i listan (Lennie 14/9: låg i kvartspanelen och fick klockan att hoppa en rad när kvarten låg inom kommandot). Läser dev.cmds (placeringar med i1 / done) i stället för vald kvart. Rättat 21/9 (André): raden stod i presens om ALLA placeringar, även passerade. Poolpumpen med fönstren 00:00–05:00 och 10:15–17:15 läste kl 12:57 "HEMS håller Poolpump på till 05:00 — förväntas klar ca 05:00 · HEMS håller Poolpump på till 17:15 — förväntas klar ca 17:15": första meningen handlar om natten men låter som nu, och "förväntas klar" stod på en tid som passerat. Tempus följer nu placeringens läge mot NU, och startpunkten står med i de placeringar som inte pågår — annars går det inte att se vilken mening som gäller vilket fönster. "förväntas klar ca" visas bara när on_q ger en tid FÖRE fönstrets slut. Med on_q = hela fönstret blev done = i1 och meningen upprepade samma klockslag två gånger, vilket såg trasigt ut och inte sa något. */ function cvCmdNote(dev, tab, nowIdx){ const cmds = dev.cmds || []; if (!cmds.length) return null; const q0 = tab === 'today' ? 0 : CV_Q; const fmt = (q) => { const m = Math.round(q*15); return String(Math.floor(m/60)%24).padStart(2,'0')+':'+String(m%60).padStart(2,'0'); }; /* Morgondagens flik har inget NU — allt där ligger i framtiden. */ const now = tab === 'today' && nowIdx != null ? nowIdx : -1; return cmds.map(c => { const cmd = c.cmd || c; const i1 = cmd.i1 ?? c.end; const s = (cmd.i0 ?? c.start) - q0, e = i1 - q0; const till = e >= CV_Q ? '24:00' : cvLabel(e); const fran = cvLabel(s); /* done är start + on_q ur adaptern; bara en riktig upplysning när den ligger före fönstrets slut OCH inom flikens eget dygn. Adaptern delar inte cmd-objektet vid midnatt (gravio-adapter.jsx 339–340), så en placering 22:30 i dag → 01:00 i morgon bär samma done i BÅDA dygnen. På Imorgon-fliken (q0 = 96) blev doneQ då negativ och fmt skrev ut "-1:-30" (verifieraren 21/9). Klockslaget hör till gårdagen och ska inte stå här alls. */ const doneQ = cmd.done != null ? cmd.done - q0 : null; const done = doneQ != null && doneQ > 0 && doneQ < e - 0.01 ? fmt(doneQ) : null; const pre = cmd.remnant ? 'fortsättning från i går · ' : ''; if (e <= now) return pre + dev.name + ' hölls på ' + fran + '–' + till; if (s > now) return pre + 'HEMS håller ' + dev.name + ' på ' + fran + '–' + till + (done ? ' — förväntas klar ca ' + done : ''); return pre + 'HEMS håller ' + dev.name + ' på till ' + till + (done ? ' — förväntas klar ca ' + done : ''); }).join(' · '); } const CV_FOLD_PREF = typeof window !== 'undefined' ? window.useFoldPref : null; 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_CORE = 63; // kärnan (82 → 70 → 60 → 63, Lennie 14/9) const CV_R_CURVE0 = CV_R_CORE, CV_R_CURVE1 = 118; // sol/förbrukning: 0 = kärnans kant (82), 52 px /* Lägesringens plupp var 21 px hög och lästes som riskorn (Lennie 14/9). Nu 15 px (−30 %), skjuten utåt; prisstaplarna får de 6 frigjorda pixlarna. */ const CV_R_PRICE0 = 122, CV_R_PRICE_LEN = 53; // prisstaplar 122 → 175 const CV_R_MODE0 = CV_R_IN + 10.75, CV_R_MODE1 = CV_R_IN + 23.5; // lägesring 182,75 → 195,5 (12,75 px, −15 % igen) /* 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 = 16; // timsiffrornas MITT från yttersta spåret: 5 px luft + halva 22 px-siffran const CV_HOUR_PAD = 18; // halva 22 px-siffran + luft; NU-halon (r 13 vid hourR+4) ska rymmas (André 26/9: klipptes upptill) 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. /* fac: breddfaktor per spår (Last 0,7 = 30 % smalare, Lennie 14/9). Radierna staplas med varje spårs egen bredd; gapet (step − w) är detsamma. */ function cvLaneGeom(n, fac){ const w = n > 0 ? Math.max(6, Math.min(17, (CV_LANE_BUDGET - 2*(n-1))/n)) : 17; const wi = (i) => w*((fac && fac[i]) || 1); // 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, wOf: wi, r: (i) => { let e = CV_LANE_IN; for (let k = 0; k < i; k++) e += wi(k) + (step - w); return e + wi(i)/2; }, /* 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 ? (() => { let e = CV_LANE_IN; for (let k = 0; k < n; k++) e += wi(k) + (k < n-1 ? step - w : 0); return e; })() : 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, share) => (window.gcLoadCol ? window.gcLoadCol(src, share) : window.GC_LOAD_COL[src]) || CV_LANE_NEUTRAL; const cvNum = (n, d=1) => n == null ? '—' : n.toLocaleString('sv-SE', { minimumFractionDigits:d, maximumFractionDigits:d }); /* v673 (André 27/9): Hus-rutan = huset UTAN smartlaster och bilar, samma regel som Översiktens Plan/Uppmätt (v540). NU: home_consumption minus de uppmätta laster som går; går en last utan effektmätning visas inget tal (—). Passerat: consAct är netto utan laster. Med history.load_display_gross (v764, backend 29/9): consAct − consLd, kvartens eget lastavdrag ur quarters[].ld; utan ld drog ingen last och consAct visas. Är avdraget en uppskattning (last utan effektmätning) visas —. Timvärdet loadAct avgör inte längre (v673 tömde hela timmen när lasten gått någon gång under den). Framåt: husets plan utan laster (consPlan; consBase eller consPlan − consLoadsAdd om backend vikt in lasterna). Bilar ingår inte i home_consumption och dras inte bort. */ const cvHusVal = (s, gross) => { if (!s) return null; if (s.consLive != null){ const on = (s.loads || []).filter((x) => x && x.p != null && x.p > 0.05); if (on.some((x) => x.measured !== true)) return null; return Math.max(0, s.consLive - on.reduce((t, x) => t + x.p, 0)); } if (s.consAct != null){ if (!gross || s.consLd == null) return s.consAct; return s.consLdEst ? null : Math.max(0, s.consAct - s.consLd); } return !s.consIncludesLoads ? (s.consPlan ?? null) : s.consBase != null ? s.consBase : (s.consPlan != null && s.consLoadsAdd != null) ? Math.max(0, s.consPlan - s.consLoadsAdd) : null; }; 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. */ /* Krav 13: kommandots kontur som ihålig hylsa — två koncentriska bågar med halvcirkelavslut, så den läses som en tom form och inte ett grått band. */ const cvHollowArc = (A0, A1, r, w) => { const h = w/2, ro = r + h, ri = r - h; const lg = A1 - A0 > Math.PI ? 1 : 0; const [ox0, oy0] = cvPt(A0, ro), [ox1, oy1] = cvPt(A1, ro); const [ix0, iy0] = cvPt(A0, ri), [ix1, iy1] = cvPt(A1, ri); const f = (n) => n.toFixed(1); return `M${f(ox0)},${f(oy0)} A${ro},${ro} 0 ${lg} 1 ${f(ox1)},${f(oy1)} A${h},${h} 0 0 1 ${f(ix1)},${f(iy1)} A${ri},${ri} 0 ${lg} 0 ${f(ix0)},${f(iy0)} A${h},${h} 0 0 1 ${f(ox0)},${f(oy0)} Z`; }; /* Bandets form (André 5/10, mockup "Ramen följer bandet"): platt i båda ändar med små rundade hörn (cr), exakt på start- och sluttiden, inget som sticker ut. Samma form för planens band, det uppmätta och ramen (som kontur). Fylld form, inte stroke: linecap kan inte ge små hörn. null när bågen är för kort för hörnen. */ const cvPlanBand = (A0, A1, r, w, cr = 2.5) => { const h = w/2, ro = r + h, ri = r - h, f = (n) => n.toFixed(1); const span = (A1 - A0) * ri; if (span <= 0.6) return null; const c = Math.min(cr, h, span / 2 - 0.05); if (!(c > 0)) return null; const lg = A1 - A0 > Math.PI ? 1 : 0; const P = (a, rr) => { const [x, y] = cvPt(a, rr); return `${f(x)},${f(y)}`; }; return `M${P(A0 + c/ro, ro)} A${ro},${ro} 0 ${lg} 1 ${P(A1 - c/ro, ro)} Q${P(A1, ro)} ${P(A1, ro - c)} L${P(A1, ri + c)} Q${P(A1, ri)} ${P(A1 - c/ri, ri)} A${ri},${ri} 0 ${lg} 0 ${P(A0 + c/ri, ri)} Q${P(A0, ri)} ${P(A0, ri + c)} L${P(A0, ro - c)} Q${P(A0, ro)} ${P(A0 + c/ro, ro)} Z`; }; /* Ringar på start och avslag provades 5/10 och togs bort samma dag (André: "inga ringar skall vara med"): 4 px i hjulets storlek såg ut som smuts. */ 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; }; /* Raden under talet: "12,4 kWh i dag · +1,8 mot förväntat". Saknas mätning står det ärligt att den saknas — rutan får inte tiga om att talet ovanför är det enda som finns. Talen kommer ur window.gcDayTotals (gc-core.jsx), samma funktion som Prognoser-kortet. Etiketten följer vad diffen FAKTISKT är: för sol med ärlig prognos är den "(hittills + kvar) − i morse", alltså dygnets förväntade utfall mot morgonankaret, och då används kortets egna ord "mot grundprognosen". Att kalla den "mot förväntat" bredvid ett tal som är utfallet hittills hade påstått en annan jämförelse än den som räknats (verifieraren 21/9: rutan sa +0,2 medan kortet sa −4,1 på samma skärm). */ const cvDayLine = (x, honestLbl) => { if (!x || x.act == null) return ['ingen mätning i dag']; /* "mot i morse" i stället för kortets "mot grundprognosen": samma jämförelse (06-ankaret som inte rör sig intradag), men 16 tecken i stället för 23 — det som skiljer en rad från tre på en 360 px-telefon. Och det säger rakt ut vad ankaret är; "grundprognosen" kräver att man vet vilken av tre prognoser som menas. */ const lbl = honestLbl && x.honest ? 'mot i morse' : 'mot förväntat'; return [cvNum(x.act, 1) + ' kWh i dag', x.diff == null ? null : (x.diff >= 0 ? '+' : '−') + cvNum(Math.abs(x.diff), 1) + ' ' + lbl]; }; /* Bokad plan (bilar, laster) jämförs med planen för HELA dagen, inte med ett förflutet fönster: en last bokad till 14:00 är inte "efter plan" kl 08:00. Därför "av N planerat" i stället för en avvikelse — samma framing som enhetslistan under hjulet redan använder. */ const cvDayPlanLine = (act, plan) => { const p = plan > 0.05 ? cvNum(plan, 1) : null; /* EN rad (André 25/9: "bara 1 st rad under… planerad är bättre att ha där"). "ingen mätning i dag" och "X kWh i dag" borttagna ur rutan. */ return [p ? p + ' kWh planerat' : 'inget planerat']; }; /* Ikonrutor under hjulet (André 21/9, ur marknadsbilden). Ikonerna är projektets egna glyfer via window.Icon (samma som flödesvyn och GcNodeIcon använder) — inga nya ritningar. Talen kommer från SAMMA vald-kvart-objekt som KPI-raden högst upp; rutorna räknar inget eget och härleder inget. Marknadsbildens rad "AI-optimering aktiv" med strömbrytare är inte med: det finns inget sådant fält, och en attrapp hade lovat en funktion. */ /* Talen i sekundärraderna vita (André 21/9). Orden runt dem är dämpade, så siffran är det som bär raden. Samma mönster som KPI-radens undertext hade — men vitt, inte slate-100. */ const cvInk = (t) => String(t).split(/(\d[\d,.]*)/).map((part, k) => /^\d/.test(part) ? {part} : part); /* Ledande tal i en egen högerställd kolumn (André 24/9), så orden efter börjar på samma x i alla rader: " 1,4 kWh i dag" / "−7,8 mot i morse". Kolumnen är dimensionerad för två siffror med decimal ("−12,4" = 5ch); blir talet tresiffrigt tappas decimalen ("−124") så det ändå ryms (André 24/9). Rader utan ledande tal ("ingen mätning i dag") spänner hela bredden. */ const cvInkCol = (t) => { const m = /^([−+-]?)(\d[\d,.]*)\s+(.*)$/.exec(String(t)); if (!m) return {cvInk(t)}; let n = m[2]; const intPart = n.split(/[,.]/)[0]; if (intPart.replace(/\D/g, '').length >= 3) n = intPart; return {m[1]}{n}{cvInk(m[3])} ; }; function CvTile({ icon, iconCol, label, val, unit, sub, day, aside, iconFill }){ /* Alla sekundärrader i ETT block utanför ikonraden, så de får hela rutans bredd (~141 px vid 360 px i stället för ~105 px inne i textkolumnen). */ const dayLines = (Array.isArray(day) ? day : [day]).filter(Boolean); /* justify-START, inte center (André 24/9): centrerat innehåll lade ikonen olika högt beroende på hur många underrader rutan hade (Bilar och Nät hamnade lägre än Sol). Nu står ikonen på samma plats i alla sex, och rutan är 2 px tätare uppåt och åt vänster. */ return (
{typeof window.Icon === 'function' && }
{label}
{val} {unit}
{aside &&
{aside}
}
{(sub || dayLines.length > 0) && (
{sub &&
{cvInkCol(sub)}
} {/* Dagssumman och jämförelsen ligger SIDA VID SIDA när bredden räcker och radbryter när den inte gör det (André 21/9: "på dator finns det gott om plats"). flex-wrap gör det utan brytpunkt: 321 px ruta på skrivbordet rymmer båda på en rad (~232 px), 141 px på telefon gör det inte och de faller till två rader som förut. Ordfärgen är slate-400, inte slate-600 som i v517: orden bär hela skillnaden mellan "mot i morse", "mot förväntat" och "av N planerat" — den distinktion v517/v518 byggdes för att få ärlig — och på 12 px låg slate-600 på 2,46:1 mot rutans bakgrund (verifieraren 21/9). slate-400 ger 7,41:1 och håller ändå hierarkin mot de vita talen. */} {dayLines.length > 0 && (
{dayLines.map((t, i) => {cvInkCol(t)})}
)}
)}
); } function GcSchema({ day, tomorrow, units, live, nowIdx, tomorrowPrelim, evLoad }){ const [tab, setTab] = React.useState('today'); /* Teckenförklaringen är ett läsval som ska följa användaren (André 21/9). */ const _legend = CV_FOLD_PREF ? CV_FOLD_PREF('schema.teckenforklaring', false) : null; const _legendLocal = React.useState(false); const legendOpen = _legend ? _legend[0] : _legendLocal[0]; const setLegendOpen = _legend ? _legend[1] : _legendLocal[1]; /* /api/load-runs för "hittills" per last (Lennie 14/9). Hooken finns bara när lastfonster.js är laddad; annars null ⇒ hittills visas inte. */ const loadRuns = window.useLoadRuns ? window.useLoadRuns(true) : null; const [manual] = window.useLoadManual ? window.useLoadManual(true) : [null]; const [sel, setSel] = React.useState(nowIdx); /* Följer NU (Lennie 14/9): stod markeringen på förra NU-kvarten när kvarten slår över flyttas den med. En manuellt vald kvart lämnas orörd. */ const prevNow = React.useRef(nowIdx); React.useEffect(() => { if (nowIdx !== prevNow.current){ if (sel === prevNow.current) setSel(nowIdx); prevNow.current = nowIdx; } }, [nowIdx]); /* Första enheten utfälld från start (André 24/9). null = alla spår lika. */ const [actDev, setActDev] = React.useState(0); /* Förklaringarna till enhetsgrafen bakom en liten i-knapp nere till höger (André 26/9). */ const [devInfo, setDevInfo] = React.useState(false); 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', loadId: u.id, loadIdx: k, at: (x) => { const lp = x.loads[k]?.pPlan ?? x.loads[k]?.p; return lp != null && lp > 0.05 ? { p:lp, src:x.loads[k].src, share:x.loads[k].solarShare } : null; }, cmdAt: (x) => x.loads[k]?.cmd || null })), ]; return defs.map(def => { const slots = d.map(def.at); /* Krav 13 (14/9): kommandot, en kontur per placering. */ const cmds = []; if (def.cmdAt){ let prev = null; for (let i = 0; i < CV_Q; i++){ const c = def.cmdAt(d[i]); if (c && c !== prev) cmds.push({ start:i, end:i+1, cmd:c }); else if (c && prev === c) cmds[cmds.length-1].end = i+1; prev = c || null; } } /* Dominerande källa per FÖNSTER (Lennie 15/9): källbyten inom en körning visas inte; hela fönstret får den källa som har flest kvartar. Fönster = sammanhängande körda kvartar (cmd om det finns, annars p > 0). */ (() => { const on = (i) => !!slots[i]; let i = 0; while (i < slots.length){ if (!on(i)){ i++; continue; } let j = i; const agg = {}; while (j < slots.length && on(j)){ const s = slots[j].src || '_'; agg[s] = (agg[s] || 0) + 1; j++; } const dom = Object.keys(agg).sort((a, b) => agg[b] - agg[a])[0]; const shareAvg = (() => { let s = 0, n = 0; for (let k = i; k < j; k++){ if (slots[k].share != null){ s += slots[k].share; n++; } } return n ? s/n : undefined; })(); for (let k = i; k < j; k++){ slots[k] = { ...slots[k], src: dom === '_' ? null : dom, share: shareAvg }; } i = j; } })(); 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, share: slots[st]?.share }); st = i; cur = sc; } if (!sc && st !== null){ ranges.push({ start:st, end:i, src: cur === '_' ? null : cur, share: slots[st]?.share }); 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)); /* Uppmätt hittills (Lennie 14/9): load_kwh i history är husets HELA lastenergi, inte per enhet, så den tillskrivs bara när enheten är hushållets enda last. null = går inte att ange. */ /* Per last ur /api/load-runs (mätare på lasten). Fallback: husets samlade load_kwh, bara när hushållet har en enda last. */ /* "hittills" = UPPMÄTT eller ingenting (Lennie 15/9). Avslutade delar ur /api/load-runs + pågående körning ur live.load_runs_open (K4-ackumulatorn). Timdocsens loadAct (strypt writer) får ALDRIG visas som uppmätt — fallbacken är borttagen. */ let actKwh = null, manSep = null; if (def.kind === 'Last'){ const fromRuns = window.lwTodayKwh ? window.lwTodayKwh(loadRuns, def.loadId) : null; /* load_runs_open via lwOpenRuns (5/10): backend prefixar load_id (`inst:|`), done-rader räknas inte. */ const open = window.lwOpenRuns ? window.lwOpenRuns(day?.loadRunsOpen, def.loadId) : []; const openKwh = open.reduce((a, r) => a + (Number(r.kwh) || 0), 0); /* Manuell körning (André 4/10): dagens manuella energi står varken i history-raden eller i load_runs_open (kontrollerat 4/10: history 7,74 kWh i fönstret 10–15, manuellt day_kwh 5,02 kWh 16:07–17:53). Den läggs till, så Hittills är dagens hela energi. day_kwh = dagens manuella summa, kwh som reserv. */ const ma = tab === 'today' && window.lwManualAdd ? window.lwManualAdd(manual, loadRuns, day?.loadRunsOpen, def.loadId) : null; /* Backend 4/10: samma effekt kan stå i båda bokföringarna. Ihop bara när den manuella tiden ligger utanför dagens körningar; annars bär enheten den manuella energin separat (manSep) och talen visas var för sig. */ if (fromRuns != null || open.length || ma) actKwh = (fromRuns || 0) + openKwh + (ma && !ma.overlap ? ma.kwh : 0); if (ma && ma.overlap) manSep = ma.kwh; } return { ...def, slots, ranges, cmds, kwh, maxP, actKwh, manSep }; }); }, [d, units, loadRuns, day, manual, tab]); /* Klar ca för det kommando som bär en planrad (André 5/10, väg 1: "effekten tar abrupt slut"). Planens profil per kvart är inlärd andel på-tid, inte lägre effekt: 0,28 kWh på en kvart är 2,9 kW i sex minuter. Bandet ritas därför i full bredd från kommandots start till energi / uppmätt på-effekt (on_power_kw, annars inlärd), aldrig efter kommandots done, och slutar skarpt. Samma tal som kortets vita streck. Tab-relativ kvart (bråkdel); null när kommandot saknar done. */ const klarOf = React.useCallback((dev, rg0) => { if (!dev || dev.kind !== 'Last' || !rg0) return null; const q0 = tab === 'today' ? 0 : CV_Q; const hit = (dev.cmds || []).map(c => ({ c, cmd: c.cmd || c })).find(({ c, cmd }) => { const cs = cmd.i0 != null ? cmd.i0 - q0 : c.start, ce = cmd.i1 != null ? cmd.i1 - q0 : c.end; return cs <= rg0.start && ce > rg0.start; }); if (!hit || hit.cmd.done == null) return null; const { c, cmd } = hit; const cs = cmd.i0 != null ? cmd.i0 - q0 : c.start, dn = cmd.done - q0; const rr = cmd.winIdx != null && window.lwRunsFor && loadRuns ? window.lwRunsFor(loadRuns, { id: dev.loadId }, cmd.winIdx) : null; const pl = rr && window.lwPlanned ? window.lwPlanned(rr, null, null) : null; const kwRun = rr ? (Number(rr.on_power_kw) || Number(rr.learned_power_kw) || null) : null; const kwh = (pl && pl.kwh > 0 && pl.src !== 'specified') ? Math.min(pl.kwh, cmd.energyKwh > 0 ? cmd.energyKwh : pl.kwh) : (cmd.energyKwh > 0 ? cmd.energyKwh : null); return kwRun > 0 && kwh > 0 ? Math.min(cs + kwh / kwRun * 4, dn) : dn; }, [tab, loadRuns]); /* Hjulet (André 5/10): framåt visar planen, bakåt det som faktiskt hänt. För laster i dag ritas planen bara från NU, och före NU ritas uppmätta körningar: avslutade delar ur /api/load-runs (on_intervals när de finns), pågående ur load_runs_open och dagens manuella körning. Samma källor som enhetskortets dygnsaxel. Kvartar, bråkdelar tillåtna. */ const measOf = React.useCallback((dv) => { if (tab !== 'today' || !dv || dv.kind !== 'Last') return []; const q = (t) => { if (t == null || !isFinite(Number(t))) return null; const x = new Date(Number(t) * (Number(t) < 1e12 ? 1000 : 1)); return isNaN(x) ? null : x.getHours()*4 + x.getMinutes()/15 + x.getSeconds()/900; }; const nowQ = q(Date.now() / 1000); const parts = window.lwTodayParts ? window.lwTodayParts(loadRuns, dv.loadId) : []; const out = parts.flatMap(p => p.on_iv ? p.on_iv.map(iv => ({ s: iv.s, e: iv.e })) : (p.on_sec == null || p.on_sec >= (p.eq - p.sq) * 900 - 900) ? [{ s: p.sq, e: p.eq }] : []); (window.lwOpenRuns ? window.lwOpenRuns(day?.loadRunsOpen, dv.loadId) : []).forEach(r => { const s = q(r.start_epoch) ?? (Number.isFinite(r.start_q) ? r.start_q : null); if (s == null || !(Number(r.start_epoch) > 0 || Number.isFinite(r.start_q))) return; if (Array.isArray(r.on_intervals) && r.on_intervals.length) r.on_intervals.forEach(iv => { const a = q(iv && iv.from), b = q(iv && iv.to); if (a != null && b != null && b > a) out.push({ s: a, e: Math.min(b, nowQ) }); }); else out.push({ s, e: Math.max(s + 0.1, Math.min(q(r.last_epoch) ?? nowQ, nowQ)) }); }); return out.filter(x => x.e > x.s + 0.02).sort((a, b) => a.s - b.s); }, [tab, loadRuns, day]); /* Spår utan information (inaktiv bil, oplanerad last) ritas inte — en tom ring innanför VVB äger ingenting (Lennie 14/9, foto). Kvarvarande spår delar på ytan. */ /* Spårordning väljs av användaren (Lennie 14/9): dra i enhetslistan under hjulet. Sparas som lista av dev.key i localStorage; okända nycklar sist. Index 0 = innersta spåret, sista = ytterst. */ const [laneOrder, setLaneOrder] = React.useState(() => { try { return JSON.parse(localStorage.getItem('hems_lane_order') || '[]'); } catch { return []; } }); /* Manuell körning i hjulet (André 4/10: "syns inte i klockan"). Backend 5/10: ETT band per post i `runs_today` (since till until, eller NU för den pågående), så dagens tidigare körningar inte försvinner vid ett nytt tryck. Samma källa som enhetskortets dygnsaxel. En last med bara manuell körning får också sitt spår. Kvartar från midnatt, bråkdelar tillåtna, midnatt = 96. */ const manRunsOf = (dv) => { if (tab !== 'today' || !dv || dv.kind !== 'Last' || !window.lwManualRuns || !window.lwManualFor) return []; const m = window.lwManualFor(manual, dv.loadId); if (!m) return []; const t0 = new Date(); t0.setHours(0, 0, 0, 0); const q = (sec) => Math.min(CV_Q, Math.max(0, (sec - t0.getTime() / 1000) / 900)); return window.lwManualRuns(m).map(r => ({ s: q(r.s), e: q(r.e), solar: r.mode === 'solar' })).filter(r => r.e > r.s); }; const shown = React.useMemo(() => { const vis = devices.filter(dv => dv.ranges.length > 0 || (dv.cmds||[]).length > 0 || manRunsOf(dv).length > 0 || measOf(dv).length > 0); const rank = (k) => { const i = laneOrder.indexOf(k); return i < 0 ? 1e6 : i; }; return vis.slice().sort((a, b) => rank(a.key) - rank(b.key)); }, [devices, laneOrder, manual, tab, measOf]); const dragKey = React.useRef(null); const moveLane = (fromKey, toKey) => { if (!fromKey || !toKey || fromKey === toKey) return; const keys = shown.map(d => d.key); const from = keys.indexOf(fromKey), to = keys.indexOf(toKey); if (from < 0 || to < 0) return; keys.splice(to, 0, keys.splice(from, 1)[0]); setLaneOrder(keys); setActDev(null); try { localStorage.setItem('hems_lane_order', JSON.stringify(keys)); } catch {} }; const geom = React.useMemo(() => cvLaneGeom(shown.length, shown.map(() => 0.7)) /* alla spår samma bredd som lasten (Lennie 14/9) */, [shown]); 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; /* FAST viewBox (Lennie 14/9, foto): mitten får inte växa när ett spår försvinner. Rutan dimensioneras alltid för hela spårbudgeten, så kärna, kurvor, pris- och lägesring har samma skärmstorlek oavsett antal enheter; bara zonen från lägesringen ut till timsiffrorna varierar. */ /* Lennie 14/9 (två foton): med fast viewBox krympte hela hjulet i sin ruta; med helt följsam viewBox växte mitten så fort ett spår försvann. Kompromiss: viewBoxen följer det ritade men reserverar ALLTID minst spårbudgeten för två spår, så hjulet fyller rutan och mitten är lika stor för 0–2 enheter. Från tre spår och uppåt växer bara zonen utanför lägesringen. */ const vbH = hourR + CV_HOUR_PAD; const viewBox = `${CV_C-vbH} ${CV_C-vbH} ${2*vbH} ${2*vbH}`; /* Dygnssummor till prognosavsnittet. 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]); /* Dagssummorna gäller DAGEN, inte den valda kvarten — de visas därför bara på Idag-fliken. */ const daySum = React.useMemo(() => window.gcDayTotals(day, nowIdx), [day, nowIdx]); /* Laster: mätt dygnssumma per last kommer ur /api/load-runs + pågående körningar (dev.actKwh, satt i devices-memon ovan). Timdocsens load_kwh får ALDRIG visas som uppmätt, så laster utan egen mätare bidrar med ingenting — därför räknas bara de som faktiskt rapporterar, och saknas alla står "ingen mätning i dag". */ const loadDay = React.useMemo(() => { const ls = devices.filter(x => x.kind === 'Last'); const meas = ls.filter(x => x.actKwh != null); return { act: meas.length ? meas.reduce((a,x) => a + x.actKwh, 0) : null, plan: ls.reduce((a,x) => a + (x.kwh || 0), 0) }; }, [devices]); /* Lastrutans dagsrad (André 4/10): före körningen "planerat", medan den kör "hittills", när dagens körningar är klara "totalt". Manuell körning ingår i talet (actKwh, v773) och räknas som pågående när den är på. Pågår = manuellt på, öppen körning (load_runs_open med start) eller planerad effekt i NU-kvarten. Kvar = planerad effekt efter NU i dag. */ const loadState = (x) => { const m = window.lwManualFor ? window.lwManualFor(manual, x.loadId) : null; const v = x.slots && x.slots[nowIdx]; const running = !!(m && m.on) || (window.lwOpenRuns ? window.lwOpenRuns(day?.loadRunsOpen, x.loadId) : []).some(r => Number(r.start_epoch) > 0) || !!(v && v.p > 0.05); const ahead = (x.slots || []).some((w, i) => i > nowIdx && i < CV_Q && w && w.p > 0.05); return { running, ahead }; }; const loadDayLine = (() => { const ls = devices.filter(x => x.kind === 'Last'); const st = ls.map(loadState), running = st.some(z => z.running), ahead = st.some(z => z.ahead); const act = loadDay.act; const sep = ls.reduce((a, x) => a + (x.manSep || 0), 0); if (act == null || (!running && ahead && !(act > 0.05))) return cvDayPlanLine(act, loadDay.plan); return [cvNum(act, 1) + (running || ahead ? ' kWh hittills' : ' kWh totalt') + (sep > 0.005 ? ' · ' + cvNum(sep, 1) + ' manuellt' : '')]; })(); /* v673: Hus-rutans dagsrad utan smartlaster. Netto (standard): consAct är redan utan laster. Brutto (load_display_gross): uppmätt lastenergi i dag dras bort; har en last planerats utan mätning visas ingen rad. */ const husDay = (() => { if (!(tab === 'today' && daySum && daySum.cons)) return null; if (!(day && day.loadDisplayGross === true)) return daySum.cons; const ls = devices.filter((x) => x.kind === 'Last'); if (ls.some((x) => x.actKwh == null && (x.kwh || 0) > 0.05)) return null; const sub = ls.reduce((a, x) => a + (x.actKwh || 0), 0); return daySum.cons.act == null ? daySum.cons : { ...daySum.cons, act: Math.max(0, daySum.cons.act - sub) }; })(); /* Bilar: facit per session ur ev_actual (SPEC_EV_ACTUAL_HISTORIK). Saknas fältet är actKwh null i alla sessioner ⇒ ingen mätning att visa. Bara dagens sessioner (i0 < 96); morgondagens hör inte hit. */ const evDay = React.useMemo(() => { const ss = (evLoad && Array.isArray(evLoad.evSessions) ? evLoad.evSessions : []).filter(x => x.i0 < CV_Q); const meas = ss.filter(x => x.actKwh != null); return { act: meas.length ? meas.reduce((a,x) => a + x.actKwh, 0) : null, plan: ss.reduce((a,x) => a + (x.planKwh || 0), 0) }; }, [evLoad]); 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); /* Lastens kW är planens effekt utom i NU-kvarten när mätaren rapporterar (gravio-adapter: measured). En passerad kvart visade planen som om den vore förbrukad (André 22/9) — därför märks talet, och kvartar som ligger inuti en UPPMÄTT körningsdel får delen utskriven. Ingen kW per kvart räknas fram ur delen. */ const loadMeasured = s.loads.some(x => x.measured); const loadRunLines = React.useMemo(() => { if (tab !== 'today' || sel > nowIdx || !window.lwTodayParts) return []; const end = (e) => e >= CV_Q ? '24:00' : cvLabel(e); return devices.filter(x => x.kind === 'Last').flatMap(x => window.lwTodayParts(loadRuns, x.loadId).filter(p => p.s <= sel && sel < p.e) .map(p => 'körde ' + cvLabel(p.s) + '–' + end(p.e) + ' · ' + cvNum(p.kwh, 1) + ' kWh uppmätt')); }, [tab, sel, nowIdx, devices, loadRuns]); /* Detaljen för en enhet (mockup 3b, v659): renderas inne i enhetens kort under raden. */ const renderDetail = (dev, di) => { const act = dev.slots[sel]; const q0 = tab === 'today' ? 0 : CV_Q; const now = tab === 'today' ? nowIdx : -1; const wins = (dev.cmds && dev.cmds.length) ? dev.cmds : (() => { const out = []; dev.ranges.forEach(r => { const l = out[out.length-1]; if (l && l.end === r.start) l.end = r.end; else out.push({ start:r.start, end:r.end }); }); return out; })(); const winCol = (w) => { const agg = {}; dev.ranges.forEach(r => { const s0 = Math.max(r.start, w.start), e0 = Math.min(r.end, w.end); if (e0 > s0) agg[r.src || '_'] = (agg[r.src || '_'] || 0) + (e0 - s0); }); const dom = Object.keys(agg).sort((x, y) => agg[y] - agg[x])[0]; const rg = dom ? dev.ranges.find(r => (r.src || '_') === dom) : null; return rg ? cvSrcCol(rg.src, rg.share) : '#64748b'; }; /* Klar ca: första kommandot som inte är slut, dess done inom dygnet. */ const fmtQ = (q) => { const m = Math.round(q*15); return String(Math.floor(m/60)%24).padStart(2,'0')+':'+String(m%60).padStart(2,'0'); }; const cur = (dev.cmds||[]).map(c => ({ c, cmd: c.cmd || c })).find(({c, cmd}) => ((cmd.i1 ?? c.end) - q0) > now); const doneQ = cur && cur.cmd.done != null ? cur.cmd.done - q0 : null; const e1 = cur ? (cur.cmd.i1 ?? cur.c.end) - q0 : null; const done = doneQ != null && doneQ > 0 && e1 != null && doneQ < e1 - 0.01 ? fmtQ(doneQ) : null; const hasMeas = tab === 'today' && dev.actKwh != null; const srcTxt = act && act.src ? act.src : null; /* UPPMÄTT KÖRNING i grafen (André 24/9). Avslutade delar ur /api/load-runs (kvartar, minutexakt när backend ger started_at/ended_at) + pågående ur live.load_runs_open (start kvart → NU). Färgregel, en enda: • uppmätt inuti planen = mättat band i källfärgen • planen efter faktiskt slut (slutade tidigare) = urvattnad • uppmätt efter planens slut (går längre) = amber Ingen grön/röd: kortare är inte bra, längre är inte dåligt. */ const parts = tab === 'today' && window.lwTodayParts ? window.lwTodayParts(loadRuns, dev.loadId) : []; /* Manuell körning (§23): läget ur /api/load-manual. På nu ⇒ bandet från since till NU ritas randigt (vitt/grått) ovanpå; rutan Planerat byts mot Manuellt med tid och kWh. Avslutad manuell körning i dag ⇒ samma rand över since–until. */ const manAll = tab === 'today' && window.lwManualFor ? window.lwManualFor(manual, dev.loadId) : null; const openRuns = tab === 'today' && window.lwOpenRuns ? window.lwOpenRuns(day?.loadRunsOpen, dev.loadId) : []; /* load_runs_open bär start_epoch (sekunder), last_epoch och start_q (kontrollerat mot live 24/9). Utan känd start ritas INGET för den öppna körningen — en kvart vid NU vore en gissning. */ const epochQ = (t) => { if (t == null || !isFinite(t)) return null; const d = new Date(Number(t) * (Number(t) < 1e12 ? 1000 : 1)); return isNaN(d) ? null : d.getHours()*4 + d.getMinutes()/15 + d.getSeconds()/900; }; const runQ = (r) => epochQ(r.start_epoch) ?? (Number.isFinite(r.start_q) ? r.start_q : null); const runE = (r) => epochQ(r.last_epoch) ?? nowIdx; const nowQx = (() => { const d = new Date(); return d.getHours()*4 + d.getMinutes()/15 + d.getSeconds()/900; })(); /* on_intervals (backend 24/9): varje period med förbrukning blir ett eget fyllt segment. Utan fältet (äldre delar) gäller regeln från v552: fyllt bara om on_sec täcker spannet. */ const ivOf = (list) => (list || []).map(iv => ({ s: epochQ(iv && iv.from), e: epochQ(iv && iv.to) })).filter(iv => iv.s != null && iv.e != null && iv.e > iv.s); const measRuns = [ /* cont: delen var på hela spannet. on_sec klart kortare än spannet (mer än en kvart) = lasten gick av och på inom delen; staging 24/9: s=44 e=64 (5 h) men on_sec 10320 (2 h 52 min). Var i spannet den var på vet vi inte ⇒ ljus fyllning, inget gult/grönt. */ ...parts.flatMap(p => p.on_iv ? p.on_iv.map(iv => ({ s: iv.s, e: iv.e, open: false, exact: true, cont: true })) : [{ s: p.sq, e: p.eq, open: false, exact: p.exact, on_sec: p.on_sec, cont: p.on_sec == null || p.on_sec >= (p.eq - p.sq) * 900 - 900 }]), ...openRuns.flatMap(r => { const s = runQ(r); if (s == null) return []; if (Array.isArray(r.on_intervals)) return ivOf(r.on_intervals).map(iv => ({ s: iv.s, e: Math.min(iv.e, nowQx), open: true, exact: true, cont: true })); return [{ cont: true, s, e: Math.max(s + 0.1, Math.min(runE(r), nowQx)), open: true, exact: r.start_epoch != null }]; }) ].filter(r => r.e > r.s).sort((a, b) => a.s - b.s); const hasRuns = measRuns.length > 0; /* På utan förbrukning = delens spann (HEMS hade lasten på) minus on_intervals. Ritas som streck, aldrig fyllt. */ const minus = (a, b, ivs) => { const o = []; let c = a; ivs.forEach(iv => { if (iv.s > c) o.push({ s: c, e: Math.min(iv.s, b) }); c = Math.max(c, iv.e); }); if (c < b) o.push({ s: c, e: b }); return o.filter(x => x.e - x.s > 0.05); }; const idle = [ ...parts.filter(p => p.on_iv && !p.trunc).flatMap(p => minus(p.sq, p.eq, p.on_iv)), ...openRuns.filter(r => Array.isArray(r.on_intervals) && runQ(r) != null).flatMap(r => minus(runQ(r), nowQx, ivOf(r.on_intervals))) ]; const trunc = parts.some(p => p.trunc); /* Planens slut jämförs mot det kommando körningen FAKTISKT låg i (André 24/9: "helt fylld, inte gul när den körde längre"). Tidigare användes bara nästa kommando som inte är slut — efter fönstret fanns inget, planEnd blev null och varken gult eller grönt kunde visas. Senaste kommandot som överlappar sista uppmätta delen; annars nästa (cur) som förut. */ const lastRun = measRuns.length ? measRuns[measRuns.length - 1] : null; const runCmd = lastRun ? (dev.cmds||[]).map(c => ({ c, cmd: c.cmd || c })).filter(({c}) => (c.start - q0) < lastRun.e && (c.end - q0) > lastRun.s).pop() : null; const planCmd = runCmd || cur; /* Klartiden räknas på SNITTET (André 26/9: "Vita strecket skall vara där den planeras vara klar, alltså snittet"). Backend bokar ännu körtid × effekt (§22), så planens on_q ger fönstrets slut. Klar = start + energi / lastens effekt, där effekten är den uppmätta på-effekten ur /api/load-runs (on_power_kw, annars inlärd), inte planens energi per på-timme: den är energin delat på hela bokningen och gav alltid bokningens slut (André 5/10: 7 kWh på 4,5 h är "typ 14 kWh"). Energin är snittet när det finns, annars planens. Utan känd effekt gäller planens on_q som förut. */ const pDone = (() => { const c = planCmd && planCmd.cmd; if (!c || c.done == null || c.done - q0 <= 0) return null; const r = dev.kind === 'Last' && c.winIdx != null && window.lwRunsFor && loadRuns ? window.lwRunsFor(loadRuns, { id: dev.loadId }, c.winIdx) : null; const pl = r && window.lwPlanned ? window.lwPlanned(r, null, null) : null; const kwRun = r ? (Number(r.on_power_kw) || Number(r.learned_power_kw) || null) : null; const kwh = (pl && pl.kwh > 0 && pl.src !== 'specified') ? Math.min(pl.kwh, c.energyKwh > 0 ? c.energyKwh : pl.kwh) : (c.energyKwh > 0 ? c.energyKwh : null); if (kwRun > 0 && kwh > 0){ const q = c.i0 + (kwh / kwRun) * 4; return Math.min(q, c.done) - q0; } const onH = (c.done - c.i0) / 4, kwPlan = c.energyKwh > 0 && onH > 0 ? c.energyKwh / onH : null; if (pl && pl.kwh > 0 && pl.src !== 'specified' && kwPlan > 0 && pl.kwh < c.energyKwh - 0.05){ const q = c.i0 + (pl.kwh / kwPlan) * 4; return Math.min(q, c.done) - q0; } return c.done - q0; })(); /* Rutan Klar ca visar samma tid som det vita strecket. */ const doneTxt = pDone != null && e1 != null && pDone < e1 - 0.01 && pDone > 0 ? fmtQ(pDone) : done; const pE1 = planCmd ? (planCmd.cmd.i1 ?? planCmd.c.end) - q0 : null; const planEnd = pDone != null && (pE1 == null || pDone < pE1 - 0.01) ? pDone : (pE1 != null ? pE1 : null); /* Faktiskt slut: sista avslutade delens slut, bara när ingen körning pågår och lasten inte längre är på (NU ligger efter planens fönster eller done). */ const lastClosed = parts.length ? parts[parts.length - 1] : null; /* Gick av = sista förbrukningens slut när on_intervals finns, annars delens slut. Avklippt lista (> 48) ⇒ okänt. */ const lastEnd = !lastClosed ? null : lastClosed.on_iv ? (lastClosed.trunc || !lastClosed.on_iv.length ? null : lastClosed.on_iv[lastClosed.on_iv.length - 1].e) : lastClosed.eq; const finished = hasRuns && !openRuns.length && lastEnd != null && planEnd != null && lastClosed.eq <= nowIdx && (nowIdx >= planEnd - 0.01 || lastEnd < planEnd - 0.5); const wentOff = finished ? fmtQ(lastEnd) : null; const overrun = hasRuns && planEnd != null && measRuns.some(r => r.cont && r.e > planEnd + 0.05); const gappy = measRuns.some(r => !r.cont); /* Gick av mot planens klartid (André 24/9: "planerade klar 13:55, gick till 16:00"). */ const late = finished && pDone != null && lastEnd > pDone + 0.05; /* Tid på: summa uppmätt on_sec för dagens avslutade delar. Bara när alla delar har on_sec och ingen körning pågår. Avslutad manuell körning i dag räknas med (manual_on_sec, day_on_sec hos äldre backend), som energin i Hittills (4/10). */ const manDay = manAll && window.lwManualDay ? window.lwManualDay(manAll) : null; const manSec = !dev.manSep && manAll && !manAll.on && manDay && manDay.onSec != null ? manDay.onSec : 0; const onSec = parts.length && !openRuns.length && parts.every(p => p.on_sec != null) ? parts.reduce((a, p) => a + p.on_sec, 0) + manSec : null; const fmtDur = (s) => { const m = Math.round(s / 60); return m >= 60 ? Math.floor(m / 60) + ' h ' + String(m % 60).padStart(2, '0') + ' min' : m + ' min'; }; const early = finished && planEnd != null && lastEnd < planEnd - 0.5; /* Synligt fönster (André 24/9): 6-timmarsblock utan plan, mätning eller NU klipps bort. Block = 24 kvartar. Minst ett block visas. */ const marks = [...wins.flatMap(w => [w.start, w.end]), ...measRuns.flatMap(r => [r.s, r.e]), ...idle.flatMap(r => [r.s, r.e]), ...(tab === 'today' ? [nowIdx, nowIdx + 1] : [])]; const b0 = marks.length ? Math.max(0, Math.floor(Math.min(...marks) / 24) * 24) : 0; const b1 = marks.length ? Math.min(CV_Q, Math.ceil(Math.max(...marks) / 24) * 24) : CV_Q; const vx = b0, vw = Math.max(24, b1 - b0); const hourLabels = []; for (let h = vx / 4; h <= (vx + vw) / 4; h += 6) hourLabels.push(String(h % 24 === 0 && h > 0 ? 24 : h).padStart(2, '0')); /* NU minutexakt (kvart + minuter/15), så linjen och det gula slutar på samma ställe — pågående körning ritas till last_epoch, inte till kvartsgränsen. */ const nowQ = (() => { const d = new Date(); return d.getHours()*4 + d.getMinutes()/15 + d.getSeconds()/900; })(); /* Dagens manuella körningar (backend 5/10): ett randigt band per post i runs_today, since till until eller NU för den pågående. Klippt till dygnet i lwManualRuns (5/10: since från i går ritades som band i dagens axel). "sedan" = den pågåendes since. */ const manT0 = (() => { const t = new Date(); t.setHours(0, 0, 0, 0); return t.getTime() / 1000; })(); const manQ = (sec) => Math.min(CV_Q, Math.max(0, (sec - manT0) / 900)); const manRuns = manAll && window.lwManualRuns ? window.lwManualRuns(manAll).map(r => ({ s: manQ(r.s), e: r.open ? nowQ : manQ(r.e), solar: r.mode === 'solar' })).filter(r => r.e > r.s) : []; const manOpen = manAll && window.lwManualOpen ? window.lwManualOpen(manAll) : null; const manS = manOpen && manOpen.s != null ? manQ(manOpen.s) : null; const manRun = manRuns.length > 0; /* Totalt i stället för Hittills när dagens körningar är klara (André 4/10), samma regel som lastrutan ovanför hjulet. */ const dayDone = tab === 'today' && !(manAll && manAll.on) && !openRuns.some(r => Number(r.start_epoch) > 0) && !(dev.slots && dev.slots[nowIdx] && dev.slots[nowIdx].p > 0.05) && !(dev.slots || []).some((w, i) => i > nowIdx && i < CV_Q && w && w.p > 0.05); return (
{/* Rubriken bärs av kortets rad ovanför (namn, tagg, kW). Tunn linje i lastens färg skiljer rad från innehåll. */}
{/* Dygnsramen: 1 px kant runt hela 0–24 så man ser vad fönstret ligger i (André 24/9). */} {/* TVÅ SPÅR (André 24/9: "bara en plupp med massa färger"). Övre spåret = plan, undre = uppmätt. Samma tidsaxel, så avvikelsen läses som skillnaden mellan spårens slut. Bara tre markörer: NU (röd), planerat slut (vit tick i planspåret), vald kvart (svag). */} {/* ETT band, full höjd (André 24/9). Planen = fönstret med energi i källfärgen, som förut. Avvikelsen ritas bara där den finns: GRÖNT från faktiskt slut till planerat slut när lasten blev klar tidigare; GULT från planerat slut till NU när den går längre. Inget inre band, ingen dubbelgraf. Planerat slut = vit tick. */}
{/* Fönstret ihåligt (kontur), fyllt bara där körningen faktiskt skett: med mätning = de uppmätta delarna, utan mätning = passerade kvartar. */} {/* Planerad energi (André 25/9: "man ser inte planerad energi"): planens kvartar med energi (dev.ranges), svagt i källfärgen. I dag bara från NU och framåt (André 5/10): det passerade är uppmätt, inte plan. Full höjd till Klar ca (energi / på-effekt) och skarpt slut (André 5/10, väg 1): profilens svans är på-tid, inte lägre effekt, och ritas inte. */} {dev.ranges.map((r, i) => { const s0 = tab === 'today' ? Math.max(r.start, nowQx) : r.start; const kl = klarOf(dev, r); const e = kl != null ? Math.min(r.end, kl) : r.end; return e > s0 + 0.01 ? : null; })} {hasRuns ? measRuns.map((r, i) => { const col = act ? cvSrcCol(act.src, act.share) : winCol(wins[0] || { start: r.s, end: r.e }); const inEnd = planEnd != null ? Math.min(r.e, planEnd) : r.e; if (!r.cont) return null; return inEnd > r.s ? : null; }) : null} {/* Kortare än planen: grönt över den plan som inte behövdes */} {/* Längre än planen: gult från planerat slut till där körningen är/slutade */} {hasRuns && overrun && planEnd != null && measRuns.filter(r => r.cont && r.e > planEnd).map((r, i) => )} {/* På utan förbrukning: tunt streck mitt i bandet, inte fyllt. */} {/* På utan förbrukning = bara bakgrund, skarpt (André 26/9). Målar över planens svaga fyllning; inget streck. */} {idle.map((r, i) => )} {manRun && <>{manRuns.map((mr, k) => )}} {/* Ramen runt hela körtiden (fönstret, t.ex. 5 h) ritas ovanpå fyllning och glapp så den alltid syns (André 26/9). */} {wins.map((w, i) => )} {/* Vald kvart, bara när den inte är NU-kvarten (André 24/9: rutan runt NU-strecket förvirrade). */} {!(tab === 'today' && sel === nowIdx) && } {/* NU: skarp linje ~1 px. GRÖN när lasten är på just nu (pågående uppmätt körning, eller planerad kvart utan mätning), RÖD när den är av. */} {/* Planerat slut och NU som HTML-streck, 2 px på hel pixel (André 24/9: "suddig och ojämn"). I SVG:n med preserveAspectRatio=none skalades bredden i kvartar och hamnade på halva pixlar. */} {tab === 'today' && pDone != null && pDone > vx && pDone < vx + vw &&
} {tab === 'today' && nowQ >= vx && nowQ <= vx + vw && (() => { const onNow = openRuns.length > 0 || (!hasRuns && !!dev.slots[nowIdx]); return
; })()}
{hourLabels.map((t, i) => {t})}
{hasMeas &&
{dayDone ? 'Totalt' : 'Hittills'}
{cvNum(dev.actKwh, 1)}kWh
{onSec != null &&
på {fmtDur(onSec)}
}{dev.manSep != null &&
+{cvNum(dev.manSep, 1)} manuellt
}
} {manAll && manAll.on ?
{manAll.mode === 'solar' ? 'Bara sol' : 'Manuellt'}
{(manDay && manDay.kwh != null) ? cvNum(manDay.kwh, 1) : manAll.kwh != null ? cvNum(manAll.kwh, 1) : '—'}kWh
sedan {manS != null ? fmtQ(manS) : '—'}
:
Planerat
{dev.kwh > 0.05 ? cvNum(dev.kwh, 1) : '—'}kWh
{wentOff && pDone != null &&
klar {fmtQ(pDone)}
}
} {wentOff ?
Gick av{lastClosed.exact ? '' : ' · kvart'}
{wentOff}
{late &&
efter planen
}{early &&
före planen
}
: doneTxt &&
Klar ca
{doneTxt}{overrun && går längre}
}
{(() => { /* Körs-raden bär bara tiden. Förklaringar (streck, gult, randigt, fortsättning) ligger bakom i-knappen. */ const notes = []; if (gappy && onSec != null) notes.push('På ' + fmtDur(onSec) + ' någon gång i fönstret.'); if (idle.length) notes.push('Glapp i bandet = på utan förbrukning.'); if (hasRuns && overrun) notes.push('Gult = längre än planen.'); if (manRun){ const sol = manRuns.every(r => r.solar), mix = !sol && manRuns.some(r => r.solar); notes.push('Randigt = ' + (mix ? 'manuellt (blått) eller på sol (gult)' : sol ? 'manuellt på sol' : 'manuellt') + (manRuns.length > 1 ? ', en rand per körning i dag' : '') + (manDay && manDay.onSec > 600 ? ', räknas inte in i snittet.' : '.')); } if ((dev.cmds||[]).some(c => (c.cmd || c).remnant)) notes.push('Fortsättning från i går.'); if (trunc) notes.push('Fler än 48 på-perioder, bara de första visas.'); notes.push('Fyllt = uppmätt förbrukning. Kontur = fönstret. Vitt streck = tiden då planen räknade med att körningen skulle vara klar. NU-linjen grön när lasten är på, röd när den är av.'); return <> {/* En rad (André 26/9: "mycket tomrum"): Körs-tiden, knapparna och i-knappen. Bryts bara om det inte ryms. */}
{/* Manuellt på: planens tid gäller inte, lasten går till midnatt (André 26/9). Körs-texten döljs då. */} {/* i-knappen ligger sist på Körs-raden (order), före bekräftelsen i flödet, så den inte lämnar en tom rad under (André 26/9). */} {tab === 'today' && dev.kind === 'Last' && window.GcManualRun && } {/* Knapparna först till vänster, Körs-tiden efter, i-knappen längst till höger (André 26/9). Knapparna har fast bredd och byter inte text. */} {!(manAll && manAll.on) &&
{wins.length === 0 ? ingen körning planerad {tab === 'today' ? 'idag' : 'imorgon'} : <>Körs {wins.map((w, k) => {cvLabel(w.start)}–{w.end % CV_Q === 0 ? '24:00' : cvLabel(w.end % CV_Q)}{k < wins.length - 1 ? ', ' : ''})}}
}
{devInfo &&
{notes.map((n, k) =>
{n}
)}
} ; })()}
); }; return (
{/* 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. */} {/* Den klistrade toppen bar VALD-raden; den bor nu i hjulkortets nederkant (André 24/9: "under klockan men kopplat till samma yta"). Kvar här är bara noten när priset inte är släppt. */} {priceUnknown &&
{/* Sexkolumnsraden borttagen 21/9 (André: "nu kan vi ta bort denna"). Den bar SOL, FÖRBRUKNING, IMPORT, SOC, BILAR och LAST i 12 px- kolumner — samma sex storheter som ikonrutorna under hjulet visar sedan v515, med mer plats och dagssumma. Överlappet var flaggat i DESIGN_STATE v508 och väntade på besked. Kvar i den klistrade toppen: VALD-raden med kvartsnavigering och pris, noten om mätvärde mot prognos, och BATTERI + PLANERAT som bär kvartens beslut (inget av det finns i rutorna). */} {/* Fyra lika kvartar är inte ett fel, det är källans upplösning. */} {priceUnknown && (

{window.GC_PRICE_UNRELEASED}

)} {/* Noten om sol och förbrukning låg HÄR, direkt under de två kolumner den förklarade. Med sexkolumnsraden borta (v523) stod den kvar i toppen och beskrev tal som fanns 792 px längre ner, med hela klockhjulet emellan (verifieraren 21/9) — den är flyttad till ikonrutnätet. Prisnoten ovanför hör kvar: den gäller priset i VALD-raden, som fortfarande står här. */} {/* BATTERI + PLANERAT borttagna 21/9 (André: "denna är kvar. ta bort"). Batteriets SoC och kWh står i Batteri-rutan under hjulet; kvartens läge står kvar i VALD-raden ovanför med sin färgprick. Det som försvann med blocket: kommandots rekommenderade effekt (+6,8 kW), lägets beskrivning, raden om att ett kommando gäller alla batterier, och noten om vilka batterier extern AI styr. Samt SoC PER batteri — rutan visar bankens kapacitetsviktade tal. Säkringsbegränsningen (kontrakt 20/9) satt bara här och ersatte lägesnamn och beskrivning. Den är därför flyttad till VALD-raden: ett oförändrat lägesnamn medan säkringen stryper vore ett falskt påstående, inte bara mindre information. */} {/* 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. */}
}
{/* 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; /* Siffror under ett etikettpiller döljs. Pillerzonerna räknas ur samma villkor som pillret självt (aktivt spår eller för kort band). */ const pillHit = shown.some((dv, k) => { if (!(dv.ranges.length > 0 || (dv.cmds||[]).length > 0)) return false; const fits = geom.showLabels && (dv.ranges.length ? dv.ranges : (dv.cmds||[])).some((rg) => { const a0 = angleOf(rg.start) - CV_HS, a1 = angleOf(rg.end-1) + CV_HS; return !!cvArcLabel(dv.name, geom.r(k)*(a1-a0), geom.fontSize); }); if (fits) return false; const big = (dv.ranges.length ? dv.ranges : dv.cmds).reduce((x,y) => (y.end-y.start > x.end-x.start ? y : x)); const am = angleOf((big.start + big.end - 1)/2); const lbl = dv.name.length > 12 ? dv.name.slice(0,11)+'…' : dv.name; const wPill = lbl.length*7.6 + 16; const flipL = Math.cos(am) < 0; const [sx, sy] = cvPt(a, hourR); const [cx, cy] = cvPt(am, hourR); const px = flipL ? cx - 10 - wPill : cx + 10; // samma formel som pillret return sx > px - 14 && sx < px + wPill + 14 && Math.abs(sy - cy) < 24; }); if (pillHit) return null; 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; /* Siffran rakt under NU-markören döljs — NU står redan där, och hjulet roterar så vilken siffra som helst kan hamna där. */ const dUp = Math.abs(((a + Math.PI/2) % (Math.PI*2) + Math.PI*2) % (Math.PI*2)); const underNu = tab === 'today' && Math.min(dUp, Math.PI*2 - dUp) < 0.13; if (underNu) return null; 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 lr = window.gcLimitRing ? window.gcLimitRing(x.mode, x.limit, window.GC_MODES) : null; const col = lr?.color || (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_MODE0); const [x2,y2] = cvPt(a, CV_R_MODE1); const isSel = x.i === sel; /* Säkringsskydd: stoppad laddning ritas som Vänta, begränsad får en prick. */ const lr = window.gcLimitRing ? window.gcLimitRing(x.mode, x.limit, window.GC_MODES) : null; const col = lr?.color || (window.GC_MODES[x.mode] || window.GC_MODES.vanta).color; const [mx,my] = cvPt(a, (CV_R_MODE0+CV_R_MODE1)/2); return ( {isSel && ( )} {lr?.marked && } ); })} {/* Enhetsspår */} {shown.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. */ /* Etikettkollision mellan grannspår (verifier 14/9): text längs bågen sitter i bandets mittvinkel. Ligger ett INRE spår med egen bågtext inom ~halva textbredden i vinkel döljs vår text; pillret tar över om inget band får text. */ const labelMidOf = (dv, kk) => { const lst = dv.ranges.length ? dv.ranges : (dv.cmds||[]); const rr = geom.r(kk), out = []; lst.forEach(rg => { const a0 = angleOf(rg.start) - CV_HS, a1 = angleOf(rg.end-1) + CV_HS; const arc = rr*(a1-a0); const lab = geom.showLabels ? cvArcLabel(dv.name, arc, geom.fontSize) : null; if (lab) out.push({ mid:(a0+a1)/2, half:cvArcNeed(lab, geom.fontSize)/2/rr }); }); return out; }; /* Grannspår är koncentriska: texten ligger i sitt eget band och kan inte överlappa grannens i radie. Kollisionsregeln (v274) dolde felaktigt texten på ett av två grannspår (Lennie 14/9). Avstängd. */ const clashesInner = (kk, mid, halfW) => { return false; for (let j = 0; j < kk; j++) { for (const m of labelMidOf(shown[j], j)) { let d = Math.abs(((mid - m.mid) % (Math.PI*2) + Math.PI*3) % (Math.PI*2) - Math.PI); if (d < halfW + m.half) return true; } } return false; }; const anyLabelFits = geom.showLabels && (dev.ranges.length ? dev.ranges : (dev.cmds||[])).some((rg) => { const a0 = angleOf(rg.start) - CV_HS, a1 = angleOf(rg.end-1) + CV_HS; const lab = cvArcLabel(dev.name, r*(a1-a0), geom.fontSize); return !!lab && !clashesInner(di, (a0+a1)/2, cvArcNeed(lab, geom.fontSize)/2/r); }); /* Planens band (André 5/10, väg 1): laster i dag från NU och framåt, i båda flikarna klippta vid Klar ca (klarOf) med skarpt slut. Profilens svans ritas inte; dess energi ligger i Planerat. Bilar ritas som förut. Slutet är en bråkdel av en kvart; arcPath tar kanten vid e (angleOf(e − 1) + CV_HS). */ const planArcs = dev.ranges.map((rg0, k) => { const isLast = dev.kind === 'Last'; const s0 = tab === 'today' && isLast ? Math.max(rg0.start, nowIdx) : rg0.start; const kl = isLast ? klarOf(dev, rg0) : null; const e = kl != null ? Math.min(rg0.end, kl) : rg0.end; return e > s0 + 0.01 ? { ...rg0, start: s0, end: e, k, startClipped: s0 !== rg0.start } : null; }).filter(Boolean); const arcPath = (rg) => { const a0 = angleOf(rg.start) - CV_HS, a1 = angleOf(rg.end - 1) + CV_HS; const [x0, y0] = cvPt(a0, r), [x1, y1] = cvPt(a1, r); return `M${x0.toFixed(1)},${y0.toFixed(1)} A${r},${r} 0 ${a1 - a0 > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)},${y1.toFixed(1)}`; }; return ( { if (!moved.current) setActDev(actDev === di ? null : di); }}> {(dev.cmds||[]).map((rg, k) => { const pts = cvBandPts(rg, r, angleOf); const A0 = angleOf(rg.start) - CV_HS, A1 = angleOf(rg.end-1) + CV_HS; const arcLen = r*(A1-A0); const covered = dev.ranges.some(f => f.start <= rg.start && f.end >= rg.end); /* Konturens färg = fyllningens (Lennie 15/9): förr togs FÖRSTA överlappande bandet, och eftersom banden inte ligger i tidsordning kunde en kort solbit ge gul kontur runt en blå körning. Nu bandet som täcker flest kvartar i kommandot, samma dominansregel som fyllningen. */ const cmdFill = dev.ranges.reduce((best, f) => { const ov = Math.min(f.end, rg.end) - Math.max(f.start, rg.start); return ov > 0 && (!best || ov > best.ov) ? { f, ov } : best; }, null); const cmdSrc = cmdFill ? (cmdFill.f.src || null) : null; const cmdShare = cmdFill ? cmdFill.f.share : null; const label = cvArcLabel(dev.name, arcLen, geom.fontSize) || ''; const fits = dev.ranges.length === 0 && geom.showLabels && !!label; const mid = (A0+A1)/2, flip = Math.sin(mid) > 0; const [sx,sy] = cvPt(flip ? A1 : A0, r), [ex,ey] = cvPt(flip ? A0 : A1, r); const large = A1-A0 > Math.PI ? 1 : 0; const arcId = `gscmd-${tab}-${dev.key}-${k}`; return ( {/* Ramen följer bandet (André 5/10): samma form som bandet, kontur i källfärgen, exakt på bokningens start och slut. */} {/* Klar ca i hjulet (André 5/10): vitt streck där planen räknar med att lasten är klar, när det ligger före bokningens slut. Samma tal som kortet. */} {(() => { const kl = klarOf(dev, rg); if (kl == null || kl >= rg.end - 0.05 || kl <= rg.start) return null; const a = angleOf(kl - 0.5), w = geom.wOf(di); const [x0, y0] = cvPt(a, r - w/2 - 2), [x1, y1] = cvPt(a, r + w/2 + 2); return ; })()} {fits && ( {label} )} ); })} {/* Halon följer bandet: samma klippning vid Klar ca, raka ändar för laster (5/10). */} {actDev === di && planArcs.map((rg) => { const col = cvSrcCol(rg.src, rg.share), wFull = geom.wOf(di); if (dev.kind === 'Last'){ const dP = cvPlanBand(angleOf(rg.start) - CV_HS, angleOf(rg.end - 1) + CV_HS, r, wFull + 8, 4); return dP ? : ; } const pts = cvBandPts(rg, r, angleOf); return p.join(',')).join(' ')} fill="none" stroke={col} strokeWidth={wFull+8} strokeLinecap="round" opacity="0.28" filter="url(#cvSoft)"/>; })} {planArcs.map((rg) => { const k = rg.k; 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 label = cvArcLabel(dev.name, arcLen, geom.fontSize) || ''; const fits = geom.showLabels && !!label && !clashesInner(di, mid, cvArcNeed(label, geom.fontSize)/2/r); const wFull = geom.wOf(di), col = cvSrcCol(rg.src, rg.share); return ( {dev.kind === 'Last' ? (() => { const dP = cvPlanBand(A0, A1, r, wFull); return dP ? : ; })() : p.join(',')).join(' ')} fill="none" stroke={col} strokeWidth={wFull} 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. */} {/* André 25/9: detaljrutan är alltid utfälld, så pillret visas BARA när inget band får text längs bandet (namnet döljs annars). Inte längre för aktivt spår. */} {(() => { const ms = measOf(dev); if (!ms.length) return null; /* Uppmätta körningar före NU, i fönstrets färg (samma regel som kortets axel: dominerande plankälla), annars neutral. */ const colAt = (s, e) => { const agg = {}; dev.ranges.forEach(x => { const o = Math.min(x.end, e) - Math.max(x.start, s); if (o > 0) agg[x.src || '_'] = (agg[x.src || '_'] || 0) + o; }); const dom = Object.keys(agg).sort((x, y) => agg[y] - agg[x])[0]; const x = dom ? dev.ranges.find(z => (z.src || '_') === dom) : (dev.ranges[0] || null); return x ? cvSrcCol(x.src, x.share) : CV_LANE_NEUTRAL; }; /* Samma form som planens band (André 5/10): platt med mjuka hörn på start och avslag. Inga ringar (André 5/10). */ return ms.map((m, k) => { const a0 = angleOf(m.s - 0.5), a1 = angleOf(m.e - 0.5), w = geom.wOf(di), dP = cvPlanBand(a0, a1, r, w); if (dP) return ; const pts = []; for (let t = m.s; t < m.e; t += 0.25) pts.push(cvPt(angleOf(t - 0.5), r)); pts.push(cvPt(angleOf(m.e - 0.5), r)); return p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' ')} fill="none" stroke={colAt(m.s, m.e)} strokeWidth={geom.wOf(di)} strokeLinecap="butt" strokeLinejoin="round" opacity={actDev === di ? 1 : 0.94}/>; }); })()} {(() => { const mrs = manRunsOf(dev); if (!mrs.length) return null; /* Randig båge per manuell körning, samma färger som dygnsaxeln i enhetskortet: nätblå (manuellt) eller solgul (bara sol) mot grått. Tid t i kvartar från midnatt ligger på vinkeln angleOf(t − 0,5). */ return {mrs.map((mr, k) => { const pts = []; for (let t = mr.s; t < mr.e; t += 0.25) pts.push(cvPt(angleOf(t - 0.5), r)); pts.push(cvPt(angleOf(mr.e - 0.5), r)); const P = pts.map(p => p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' '); return ; })}; })()} {(dev.ranges.length > 0 || (dev.cmds||[]).length > 0) && !anyLabelFits && (() => { const muted = actDev !== di; const big = (dev.ranges.length ? dev.ranges : dev.cmds).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.wOf(di)/2); const rOut = hourR; // pillret på timsiffrornas radie; siffran under döljs 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; /* Pillret får aldrig klippas av rutan (Lennie 14/9): saknas plats utåt i x läggs det ovanför/under punkten, centrerat, och klampas inuti viewBoxen. */ let px = flipL ? lx2 - 10 - wPill : lx2 + 10; let py = ly2 - 11; const vbMin = CV_C - vbH + 4, vbMax = CV_C + vbH - 4; if (px < vbMin || px + wPill > vbMax){ px = Math.max(vbMin, Math.min(vbMax - wPill, lx2 - wPill/2)); py = Math.sin(am) < 0 ? ly2 - 11 - 14 : ly2 - 11 + 14; py = Math.max(vbMin, Math.min(vbMax - 22, py)); } 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, CV_R_CORE); const [x2,y2] = cvPt(a, CV_R_MODE1 + 5.5); // punkten (r 2,6) helt utanför pluppen 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, CV_R_CORE); const [x2,y2] = cvPt(a, CV_R_MODE1); return ( ); })()} {tab === 'today' && (() => { const a = angleOf(nowIdx); /* NU-markören UTANFÖR timsiffrorna (v243 lade siffrorna 5 px från spåren, och NU hamnade rakt över "18"). Punkten på siffrornas radie, texten utanför; siffran under NU döljs (se timsiffrorna). */ /* Punkten står på siffrornas radie och tar den dolda siffrans plats; "NU" ligger strax innanför punkten (mot spåren), inte utanför — så inget behöver reserveras utanför siffrorna. */ const rNu = hourR + 4; const [x1,y1] = cvPt(a, rNu); const [tx,ty] = cvPt(a, rNu-15); return ( NU ); })()} {cvLabel(sel)} {priceUnknown || s.buy == null ? '—' : Math.round(s.buy)} ÖRE / KWH {/* Säljpriset UNDER lägeschippet, närmast kärnans kant (kordan vid y+71 är ~85 px; "sälj 545" tar ~55). Kort form så den läses som ett andra tal, inte en rubrik. */} {!priceUnknown && s.sell != null && ( sälj {Math.round(s.sell)} )} {(() => { /* 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(CV_R_CORE*CV_R_CORE - 28*28) - 8; const w = Math.min(mode.label.length*6.4 + 26, wMax); return ( {mode.label} ); })()} {/* Teckenförklaringen (lägen, prognoser) vikt bakom en knapp nere till höger i rutan; öppnas åt vänster på två rader (Lennie 14/9). */} {/* Popovern får aldrig bli bredare än kortet (verifieraren 24/9: 474 px i ett 368 px-kort klipptes till vänster). Flex-wrap i stället för två rader grid — på mobil blir det tre–fyra rader, på desktop två. */}
{legendOpen && (
{Object.values(window.GC_MODES).map(m => ( {m.label} ))} Solprognos Förbrukning Snittpris {pMean != null ? Math.round(pMean) + ' öre' : '—'}
)}
{/* Kvartsval + dygnsval i hjulkortets nederkant, avgränsat med hårlinje — samma yta som hjulet, inget eget fält (André 24/9). i-knappen ligger i samma rad så den inte täcker något. */}
{/* En rad på mobil (André 24/9): VALD-märket borta, pilarna sitter i samma kapsel som tiden, och bara kvartens START skrivs — slutet står i hjulet och i rutorna. Röd tid = kvarten är NU, i stället för ett eget chip. */} {/* Samma text som rutornas rubrik hade ("10:45–11:00 · NU"), i samma grå digitala stil — den stora röda klockan är borta (André 24/9). */}
{cvRange(sel)}{sel === nowIdx && tab === 'today' ? · nu : ''}
{/* Idag/Imorgon + ⟲ Nu på VALD-raden (André 24/9): lägesnamn och köp/sälj-pris togs bort här — priset står redan i hjulets mitt och i prisfältet, läget i lägesringen. Raden bär bara tid och dygnsval. */}
{(sel !== nowIdx || tab !== 'today') && ( )}
{[{id:'today',t:'Idag'},{id:'tomorrow',t:'Imorgon'}].map(x => ( ))}
{/* Enhetslegenden borttagen (André 25/9): dubblerade (i)-förklaringen ovanför. */} {false && shown.length > 0 && (
Enheter: {[['sol','Sol'],['nät','Nät'],['batteri','Batteri']].map(([k,t]) => ( {t} ))}
)} {/* Rubriken säger vilken kvart rutorna gäller: i den här vyn följer ALLT den valda kvarten, inte klockan på väggen. Utan raden hade rutorna lästs som live-värden (marknadsbilden visar dem som "NU"). */}
{/* Kvartsrubriken flyttad till hjulkortets nederkant (André 24/9). */} {/* Ordningen är parvis (André 21/9): sol + batteri, hus + nät, bilar + laster. Två i bredd även på telefon, så paret läses som ett par — produktion mot lager, förbrukning mot nät, det flyttbara sist. Korta etiketter är Andrés egna ord och samma vokabulär som hjullegenden ("Sol · Nät · Batteri"); de långa namnen ur marknadsbilden rymdes inte två i bredd. */}
{/* Statusorden borta 21/9 (André: "sån text är överflödig — man ser ju att det står 0"). Behållna där de säger något talet INTE gör: nätets riktning, bilens källa, lastens namn. Solen har inget sådant — "producerar" och "vilar" är bara talet i ord. */} {/* socAggPct = hela bankens SoC, kapacitetsviktad (samma fält som KPI-raden). Effekt (André 25/9) bara när adaptern har ETT tal för banken: live.battery_power nu, utfallet bakåt, planen framåt (märkt). Vid NU: batBankKw (live.battery_power, även summan för flera batterier, backend 25/9). Bakåt/framåt: adapterns enda banktal. */} {(() => { const one = s.batUnmeasured ? null : s.batSource === 'live' ? (Number.isFinite(s.batBankKw) ? s.batBankKw : null) : Array.isArray(s.bat) && s.bat.length === 1 && Number.isFinite(s.bat[0].p) ? s.bat[0].p : null; /* André 25/9 (variant B + ikon från D): stora talet = batteriets effekt med tecken (− = laddar ur), ingen riktningstext. SoC efter BATTERI i rubriken. Ikonen fylls efter SoC (Icon fill, samma färgskala som flödet). Utan ETT banktal: SoC som stort tal. */ const socTxt = s.socAggPct != null ? String(Math.round(s.socAggPct)) : '—'; const socFill = s.socAggPct != null ? s.socAggPct / 100 : undefined; const kwhTxt = s.socTot != null && s.socCapKwh ? `${cvNum(s.socTot,1)} av ${cvNum(s.socCapKwh, Number.isInteger(s.socCapKwh) ? 0 : 1)} kWh` : null; return one == null ? ( ) : ( Batteri {socTxt} %} val={Math.abs(one) < 0.05 ? cvNum(0) : cvNum(one)} unit="kW" sub={kwhTxt}/> ); })()} {/* Samma val av förbrukningstal som KPI-raden: mätt när mätning finns, annars planens summa. */} {/* Ingen kvartsenergi (André 21/9: "kvartsdata behöver vi inte alls"). Raden var effekten × 0,25 h — samma mätvärde omräknat, omärkt, och i NU-kvarten inte ens förfluten energi utan vad kvarten SKULLE bli om effekten höll i femton minuter. Dagsraden säger det som behövs. */} {/* Nät, som KPI-radens Import/Export-fält: ETT tal, riktningen i undertexten och i färgen. Export grönt, import blått, stillastående grått — samma tre färger som raden högst upp. */} {(() => { const out = (s.gridOut || 0) > 0.05, inn = (s.gridIn || 0) > 0.05; return ( ); })()} {/* Ingen laddbox respektive ingen skjutbar last i hushållet ⇒ ingen ruta. En ruta som alltid står på "—" är ingen upplysning. */} {units.evs.length > 0 && ( )} {units.loads.length > 0 && ( /* Samma fält som KPI-radens Last: loadTot är kvartens planerade eller mätta effekt, loadOn den last som är igång. */ /* En last: rutan heter som lasten (VVB) och namnet upprepas inte i underraden. Flera laster: LASTER med sammanslagna tal, och den som är igång namnges i underraden (André 24/9). */ )}
{/* Backendens not om mätvärde mot prognos, flyttad hit från toppen (v524): den handlar om Sol- och Hus-rutorna ovanför. */} {window.gcFcNote(s) && (

{window.gcFcNote(s)}

)}
{/* Enhetslistan identifierar spåren. Hans kort: spårikon, namn, värde för vald kvart, linjärt dygnsspår och tidsintervallen under. */} {/* ENHETER I HJULET som chips + detalj (André 24/9, mockup A). Chipsraden fyller hela bredden med lika breda chips (grid), tryck väljer enheten i hjulet och öppnar detaljrutan; tryck igen fäller ihop. Detaljrutan: dygnsaxel med kommandots fönster (färg = dominerande källa), ljusare del = redan körd, NU-markör; sedan Hittills (bara med effektmätning), Planerat och Klar ca. Klar ca är cmd.done ur adaptern — när energin beräknas vara levererad i lastens takt, INTE fönstrets slut. */} {shown.length > 0 && (
{/* Mockup 3b (André 26/9: "3b kör vi på"): ett kort per enhet. Raden bär punkt, namn, manuelltagg, kW och pil; det valda kortet får kantfärg och detaljen under raden. Ingen flik-form, inga klippta namn. */} {shown.map((dev, di) => { const on = actDev === di; const act = dev.slots[sel]; /* kW i chipet: vid NU-kvarten den UPPMÄTTA effekten ur /live (day[nowIdx].loads[k].p, measured:true) — 0,0 om lasten rapporterar 0. Annars planens effekt i vald kvart; "—" bara när inget planerat. */ const liveLoad = (dev.kind === 'Last' && day && day[nowIdx]) ? (day[nowIdx].loads || [])[dev.loadIdx] : null; const liveP = liveLoad && liveLoad.measured === true && liveLoad.p != null ? liveLoad.p : null; const col = act ? cvSrcCol(act.src, act.share) : (dev.ranges[0] ? cvSrcCol(dev.ranges[0].src, dev.ranges[0].share) : '#475569'); /* Manuell körning (§23) syns i chipet även när detaljen är ihopfälld (André 26/9). */ const mst = dev.kind === 'Last' && tab === 'today' && window.lwManualFor ? window.lwManualFor(manual, dev.loadId) : null; const manOn = !!(mst && mst.on), manSolar = manOn && mst.mode === 'solar'; /* v700 (André 27/9): punkten visar bara om enheten är på, inte källan. Grön = HEMS har den på i vald kvart (kommando i planen eller manuell körning), oavsett uppmätt förbrukning; röd = av. Källan syns i spåret och detaljen. v705 (André 27/9: "Den verkar visa om den förbrukar eller inte"): act är planens eller den uppmätta effekten i kvarten, så VVB blev röd inne i sitt fönster när effekten var 0. För laster läses nu kommandot: vald kvart inom ett av lastens fönster (dev.cmds, samma som "Körs 10:00–15:00"). Laddboxar har inget kommando här och visar som förut laddning. */ const cmdOn = dev.kind === 'Last' ? (dev.cmds || []).some((c) => sel >= c.start && sel < c.end) : !!act; const isOn = cmdOn || manOn; return (
{on && renderDetail(dev, di)}
); })}
)} {/* 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, cvCmdNote });