/* schema-clock.jsx — 24h round dial. NU always at top; the dial rotates over time. */
const ElprisSchemaClock = ({ slots, nowIdx, now, scenario, refreshCount, refreshAt, quarterHistory, liveAction, soc, battKwh = 13.5 }) => {
// Live decision för NU-kvarten kan skilja sig från LP-planen (reaktiv guard).
// Visa det faktiskt skickade kommandot i NU-sektorn och i centrum.
const nowAction = liveAction || (slots[nowIdx] && slots[nowIdx].action);
const W = 520, H = 520;
const cx = W/2, cy = H/2;
// Rings (radii)
const Router = 240;
const Rlabel = 232; // fixed relative-time label radius
const Rtomo2 = 218, Rtomo1 = 212; // imorgon teaser ring (thin)
const Raction2= 206, Raction1= 188; // today action sector ring
const Rprice2 = 184, Rprice1 = 138; // radial price bars
const Rcurve2 = 136, Rcurve1 = 74; // solar + consumption radial space (närmare staplarna, större utslag)
const Rcenter = 70; // central disc
const today = slots.slice(0, 96);
const tomorrow = slots.slice(96, 192);
// Vald dag att visa i ringen (UX 2026-06-04: Idag/Imorgon-knappar).
const [dayView, setDayView] = React.useState(0);
const hasTomorrow = tomorrow.length === 96 && tomorrow.some(s => s && s.buy != null);
const viewDay = (dayView === 1 && hasTomorrow) ? tomorrow : today;
// Färg-uppslag för action-ringen (Lennie-fix 2026-05-31). Identisk teknik
// som forecast.jsx:metaForApiAction — slå alltid upp via HEMS-koden (som kan
// vara rå API-kod från quarter-history ELLER redan svensk plan-nyckel) och
// mappa internt. Platt meta.color används överallt (inga gradient-id:n) så
// past multi, past single och framtida kvartar ser konsekventa ut.
const segMeta = (apiAction, planAction) => {
const map = window.hemsAdapter && window.hemsAdapter.mapAction;
const k = map ? map(apiAction) : apiAction;
if (ACTION_META[k] && ACTION_META[k].color) return ACTION_META[k];
// SPEC graf-sanning §1: okänd/osänd action i dåtid färgas ALDRIG från
// planen — rendera neutralt (ACTION_META-fallback = 'Okänd', grå).
return ACTION_META['okand'];
};
// Segment per kvart. Framtid = ett plan-segment. Past med blandade actions
// = array av {action, proportion} från quarter-history. Past single/saknad
// = ett segment med faktiskt skickat kommando (annars planen).
const getRingSegments = (s, i, past) => {
// Delad källa med prognosremsan + live-staplarna (hemsAdapter.quarterSegments):
// förflutet = dispatch-sanning m. <10%-golv, utan data = neutralt, framtid = planen.
// Så klockan och graferna aldrig kan driva isär (SPEC graf-sanning 2026-07-10).
const segOf = window.hemsAdapter && window.hemsAdapter.quarterSegments;
if (segOf) return segOf(i, past, quarterHistory, s.action);
if (!past) return [{ action: s.action, proportion: 1.0 }];
const q = (quarterHistory?.quarters || {})[String(i)];
if (!q || !Array.isArray(q.actions) || q.actions.length === 0) return [{ action: 'okand', proportion: 1 }];
return q.actions.map(a => ({ action: a.action, proportion: a.proportion ?? 1/q.actions.length }));
};
// 00:00 at the original top (before rotation), clockwise.
const angleQ = (q) => (q / 96) * 360 - 90;
const polar = (r, deg) => {
const a = deg * Math.PI / 180;
return [cx + r*Math.cos(a), cy + r*Math.sin(a)];
};
const wedge = (rIn, rOut, a0, a1) => {
const [x0o,y0o] = polar(rOut, a0);
const [x1o,y1o] = polar(rOut, a1);
const [x1i,y1i] = polar(rIn, a1);
const [x0i,y0i] = polar(rIn, a0);
const large = (a1-a0) > 180 ? 1 : 0;
return `M ${x0o} ${y0o}
A ${rOut} ${rOut} 0 ${large} 1 ${x1o} ${y1o}
L ${x1i} ${y1i}
A ${rIn} ${rIn} 0 ${large} 0 ${x0i} ${y0i} Z`;
};
// --- Wheel interaction (UX 2026-06-04): dialen är ett vridbart hjul.
// Svep/skrolla horisontellt → välj tid; norrpilen (toppen) visar VALD kvart.
// selQ === null betyder "följ NU". Annars en vald kvart (0..95). ---
const [selQ, setSelQ] = React.useState(null);
// Auto-återgång till NU efter 5s utan interaktion (UX 2026-06-04).
const selTimer = React.useRef(null);
React.useEffect(() => {
if (selQ == null) return;
if (selTimer.current) clearTimeout(selTimer.current);
selTimer.current = setTimeout(() => setSelQ(null), 5000);
return () => { if (selTimer.current) clearTimeout(selTimer.current); };
}, [selQ]);
const selQuarter = selQ != null ? selQ : nowIdx;
// Rotera så vald kvart hamnar i toppen (-90°).
const selDeg = (selQuarter / 96) * 360 - 90;
const nowDeg = (((now ? (now.getHours()*60 + now.getMinutes()) : (nowIdx*15)) / (24*60)) * 360) - 90;
// Live-läge (ingen skrubbning) → följ minut-exakt tid mjukt. Skrubbning → snäpp till kvart.
const dialRot = -90 - (selQ != null ? selDeg : nowDeg); // degrees
// --- Smooth a series with a small moving average ---
const smoothed = (arr, key) => {
return arr.map((_, i) => {
const a = arr[Math.max(0, i-1)][key];
const b = arr[i][key];
const c = arr[Math.min(arr.length-1, i+1)][key];
return (a*0.25 + b*0.5 + c*0.25);
});
};
const solarSmoothed = smoothed(viewDay, 'solar');
const consSmoothed = smoothed(viewDay, 'consumption');
// --- Radial closed path from N samples ---
const radialPath = (values, getR) => {
const N = values.length;
if (N < 2) return '';
const pts = values.map((v,i)=> polar(getR(v), angleQ(i + 0.5)));
let d = `M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)}`;
for (let i=1; i<=N; i++){
const p0 = pts[i-1];
const p1 = pts[i % N];
const mx = (p0[0]+p1[0])/2;
const my = (p0[1]+p1[1])/2;
d += ` Q ${p0[0].toFixed(2)} ${p0[1].toFixed(2)} ${mx.toFixed(2)} ${my.toFixed(2)}`;
}
return d + ' Z';
};
const solarMax = Math.max(0.6, ...solarSmoothed, ...consSmoothed);
const consMax = solarMax; // share scale so they're visually comparable
const solarR = (v) => Rcurve1 + (v / solarMax) * (Rcurve2 - Rcurve1);
const consR = (v) => Rcurve1 + (v / consMax) * (Rcurve2 - Rcurve1);
const solarPath = radialPath(solarSmoothed, solarR);
const consPath = radialPath(consSmoothed, consR);
const maxPrice = Math.max(...slots.map(s=>s.buy));
const meanPrice = today.reduce((a,s)=>a+s.buy,0)/today.length;
const meanR = Rprice1 + (meanPrice/maxPrice) * (Rprice2 - Rprice1);
const cur = slots[((dayView === 1 && hasTomorrow) ? 96 : 0) + selQuarter];
// selQ scrubbing via swipe / wheel — make the dial behave like a wheel.
const dragRef = React.useRef(null);
const wheelDir = (clientX) => {
if (dragRef.current == null){ dragRef.current = clientX; return; }
const dx = clientX - dragRef.current;
if (Math.abs(dx) >= 14){ // ~14px per kvart-steg
const steps = Math.trunc(dx / 14);
setSelQ(prev => {
const base = prev != null ? prev : nowIdx;
// dra åt höger (dx>0) = bakåt i tid, åt vänster = framåt (hjul-känsla)
let n = base - steps;
return Math.max(0, Math.min(95, n));
});
dragRef.current = clientX;
}
};
const onWheel = (e) => {
e.preventDefault();
setSelQ(prev => {
const base = prev != null ? prev : nowIdx;
const dir = (e.deltaY || e.deltaX) > 0 ? 1 : -1;
return Math.max(0, Math.min(95, base + dir));
});
};
const onMouseDown = (e) => { dragRef.current = e.clientX; };
const onMouseMoveDrag = (e) => { if (e.buttons === 1) wheelDir(e.clientX); };
const onMouseUp = () => { dragRef.current = null; };
const onTouchStartW = (e) => { const t=e.touches[0]; if(t) dragRef.current = t.clientX; };
const onTouchMoveW = (e) => { const t=e.touches[0]; if(!t) return; e.preventDefault(); wheelDir(t.clientX); };
const onTouchEndW = () => { dragRef.current = null; };
// Day boundary marker — in the rotating dial, this is the slot at q=0 (midnight start).
const dayBoundaryDeg = angleQ(0);
// Hover: convert pointer to local (un-rotated) angle and pick quarter.
const svgRef = React.useRef(null);
const [hover, setHover] = React.useState(null);
const pickAt = (clientX, clientY) => {
const r = svgRef.current.getBoundingClientRect();
const mx = ((clientX - r.left)/r.width) * W - cx;
const my = ((clientY - r.top )/r.height) * H - cy;
const dist = Math.hypot(mx,my);
if (dist < Rcenter-4 || dist > Raction2+8) { setHover(null); return; }
let visualDeg = Math.atan2(my, mx) * 180/Math.PI;
let localDeg = (visualDeg - dialRot + 720) % 360;
let fromTop = (localDeg + 90 + 360) % 360;
const q = Math.floor((fromTop / 360) * 96);
setHover(q);
};
const handleMove = (e) => pickAt(e.clientX, e.clientY);
const handleTouchMove = (e) => {
const t = e.touches[0]; if (!t) return;
e.preventDefault();
pickAt(t.clientX, t.clientY);
};
const handleTouchEnd = () => setTimeout(() => setHover(null), 1800);
// Stats
const sumSolarToday = today.reduce((a,s)=>a+s.solar*0.25, 0);
const sumConsToday = today.reduce((a,s)=>a+s.consumption*0.25, 0);
// Lennie-fix 2026-05-31: KVARVARANDE solöverskott (sol kvar − förbrukning kvar
// resten av dygnet). Det actionable talet — heldagssiffran (sumSolar−sumCons)
// krockar med verkligheten kl 12 (kvar ≈ 0). Räknas från NU till midnatt.
const remStart = Math.min(96, (nowIdx ?? 0) + 1);
const remSolarToday = today.slice(remStart).reduce((a,s)=>a+s.solar*0.25, 0);
const remConsToday = today.slice(remStart).reduce((a,s)=>a+s.consumption*0.25, 0);
const remSurplus = remSolarToday - remConsToday;
const fullDaySurplus = sumSolarToday - sumConsToday;
const cd = cur;
const isHover = selQ != null;
// Gravio-korten (topprad) speglar VALD kvart (cur = slots[selQuarter]).
const sv1 = (n) => (n ?? 0).toFixed(1).replace('.',',');
const sv2 = (n) => (n ?? 0).toFixed(2).replace('.',',');
const selAct = (selQ == null && nowAction) ? nowAction : cur.action;
const selMeta = ACTION_META[selAct] || ACTION_META.okand;
const curSurplus = cur.solar - cur.consumption;
const curSoc = (typeof cur.soc === 'number') ? cur.soc : (soc ?? 0);
const socKwh = (curSoc/100) * battKwh;
const priceDelta = Math.round(cur.buy - meanPrice);
return (
Schema 24h
{(() => {
if (refreshAt == null) return 'Bakåt: skickat · framåt: plan · NU uppåt';
const s = Math.max(0, Math.floor((Date.now()-refreshAt)/1000));
return s<20?'Plan uppdaterad just nu'
: s<60?`Plan uppdaterad för ${s}s sedan`
: s<3600?`Plan uppdaterad för ${Math.floor(s/60)} min sedan`
: `Plan uppdaterad för ${Math.floor(s/3600)} h sedan`;
})()}
{refreshCount != null && }