← back to Ken
kalshi-dash/src/components/RiskKnob.jsx
171 lines
import React, { useState, useEffect, useRef } from 'react';
import { post } from '../api';
// ── polar gauge geometry: 270° arc, 135° → 405° (=45°) clockwise ──
const MIN = 50, MAX = 99, A0 = 135, A1 = 405, CX = 170, CY = 170, R = 132;
const rad = (d) => (d - 90) * Math.PI / 180;
const ptOn = (d, r = R) => [CX + r * Math.cos(rad(d)), CY + r * Math.sin(rad(d))];
const arcPath = (from, to, r = R) => {
const [x0, y0] = ptOn(from, r), [x1, y1] = ptOn(to, r);
const large = (to - from) > 180 ? 1 : 0;
return `M${x0} ${y0} A${r} ${r} 0 ${large} 1 ${x1} ${y1}`;
};
const val2ang = (v) => A0 + (v - MIN) / (MAX - MIN) * (A1 - A0);
const zone = (v) => v < 65 ? { t: 'HIGH RISK', c: '#ef4444' }
: v < 75 ? { t: 'ELEVATED', c: '#f59e0b' }
: v < 85 ? { t: 'BALANCED', c: '#eab308' }
: { t: 'CONSERVATIVE', c: '#22c55e' };
const TICKS = [];
for (let v = MIN; v <= MAX; v += 5) if (v % 5 === 0) TICKS.push(v);
export default function RiskKnob() {
const [val, setVal] = useState(75); // dialed (preview) value
const [committed, setCommitted] = useState(null); // last value confirmed live
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null); // {ok, text}
const stageRef = useRef(null);
const dragging = useRef(false);
const raf = useRef(0);
// fetch current min_confidence, then run the entrance sweep to it
useEffect(() => {
post('/trading/mode', { action: 'get' }).then((d) => {
const mc = d?.trade_config?.min_confidence ?? 75;
setCommitted(mc);
const t0 = performance.now();
const step = (now) => {
const t = Math.min(1, (now - t0) / 1300), e = 1 - Math.pow(1 - t, 3);
setVal(Math.round(MIN + (mc - MIN) * e));
if (t < 1) raf.current = requestAnimationFrame(step); else setVal(mc);
};
raf.current = requestAnimationFrame(step);
}).catch(() => setCommitted(75));
return () => cancelAnimationFrame(raf.current);
}, []);
// drag-to-turn
useEffect(() => {
const el = stageRef.current; if (!el) return;
const angOf = (cx, cy, x, y) => { let d = Math.atan2(y - cy, x - cx) * 180 / Math.PI + 90; if (d < 0) d += 360; return d; };
const fromEvent = (ev) => {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const p = ev.touches ? ev.touches[0] : ev;
let a = angOf(cx, cy, p.clientX, p.clientY);
if (a < A0 - 10 && a < 90) a += 360;
a = Math.max(A0, Math.min(A1, a));
setVal(Math.round(MIN + (a - A0) / (A1 - A0) * (MAX - MIN)));
};
const down = (e) => { dragging.current = true; cancelAnimationFrame(raf.current); fromEvent(e); e.preventDefault(); };
const move = (e) => { if (dragging.current) fromEvent(e); };
const up = () => { dragging.current = false; };
el.addEventListener('mousedown', down);
el.addEventListener('touchstart', down, { passive: false });
window.addEventListener('mousemove', move);
window.addEventListener('touchmove', move, { passive: false });
window.addEventListener('mouseup', up);
window.addEventListener('touchend', up);
return () => {
el.removeEventListener('mousedown', down); el.removeEventListener('touchstart', down);
window.removeEventListener('mousemove', move); window.removeEventListener('touchmove', move);
window.removeEventListener('mouseup', up); window.removeEventListener('touchend', up);
};
}, []);
async function apply() {
setBusy(true); setMsg(null);
try {
const d = await post('/trading/mode', { action: 'update_trade_config', min_confidence: val });
const mc = d?.trade_config?.min_confidence;
if (mc == null) throw new Error(d?.error || 'no confirmation from server');
setCommitted(mc); setVal(mc);
setMsg({ ok: true, text: `Applied — Ken now trades only on ≥${mc}% confidence signals.` });
} catch (e) {
setMsg({ ok: false, text: 'Failed: ' + e.message });
}
setBusy(false);
}
const a = val2ang(val), z = zone(val), ang = a - 270;
const dirty = committed != null && val !== committed;
return (
<div className="rk-wrap">
<style>{`
.rk-wrap{display:flex;flex-direction:column;align-items:center;gap:16px}
.rk-stage{position:relative;width:300px;height:300px;display:grid;place-items:center;touch-action:none;user-select:none;cursor:grab}
.rk-stage:active{cursor:grabbing}
.rk-stage svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.rk-halo{position:absolute;width:210px;height:210px;border-radius:50%;filter:blur(34px);opacity:.5;
transition:background .6s ease;animation:rkBreathe 3.4s ease-in-out infinite;pointer-events:none}
@keyframes rkBreathe{0%,100%{transform:scale(.96);opacity:.4}50%{transform:scale(1.05);opacity:.58}}
.rk-knob{position:absolute;width:170px;height:170px;border-radius:50%;
background:radial-gradient(110px 110px at 38% 30%,#1c2836,#0c131c 70%);
border:1px solid rgba(255,255,255,.08);
box-shadow:0 16px 34px rgba(0,0,0,.55),inset 0 2px 6px rgba(255,255,255,.06),inset 0 -16px 26px rgba(0,0,0,.5);
transition:transform .04s linear}
.rk-ptr{position:absolute;top:11px;left:50%;width:6px;height:30px;border-radius:4px;margin-left:-3px;
background:linear-gradient(#fff,#9fe8ff);box-shadow:0 0 13px #22d3ee,0 0 4px #fff}
.rk-read{position:absolute;text-align:center;pointer-events:none}
.rk-val{font-size:56px;font-weight:800;letter-spacing:-1px;line-height:1;font-variant-numeric:tabular-nums;color:var(--text,#e6edf3);text-shadow:0 0 20px rgba(34,211,238,.22)}
.rk-unit{font-size:11px;color:var(--text-muted,#7d8da3);letter-spacing:2px;margin-top:3px}
.rk-risk{margin-top:7px;font-size:12px;font-weight:800;letter-spacing:2px;transition:color .4s ease}
.rk-legend{display:flex;gap:14px;font-size:10.5px;color:var(--text-muted,#7d8da3);letter-spacing:.4px;flex-wrap:wrap;justify-content:center}
.rk-legend b{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:middle}
.rk-apply{display:flex;align-items:center;gap:12px}
.rk-btn{border-radius:10px;padding:9px 18px;font-size:13px;font-weight:700;letter-spacing:.4px;cursor:pointer;transition:.15s;border:1px solid}
.rk-btn.on{background:rgba(34,211,238,.14);border-color:#22d3ee;color:#67e8f9}
.rk-btn.on:hover{background:rgba(34,211,238,.22)}
.rk-btn.off{background:transparent;border-color:rgba(255,255,255,.12);color:var(--text-muted,#7d8da3);cursor:default}
.rk-note{font-size:12px}
`}</style>
<div className="rk-stage" ref={stageRef} title="Drag to set how selective Ken is">
<div className="rk-halo" style={{ background: `radial-gradient(circle, ${z.c}66, transparent 70%)` }} />
<svg viewBox="0 0 300 300" aria-hidden="true">
<defs>
<linearGradient id="rkArc" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stopColor="#ef4444" /><stop offset=".38" stopColor="#f59e0b" />
<stop offset=".62" stopColor="#eab308" /><stop offset="1" stopColor="#22c55e" />
</linearGradient>
</defs>
<path d={arcPath(A0, A1)} fill="none" stroke="rgba(255,255,255,.07)" strokeWidth="10" strokeLinecap="round" />
<path d={arcPath(A0, a)} fill="none" stroke="url(#rkArc)" strokeWidth="10" strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 6px rgba(34,211,238,.35))' }} />
<g stroke="rgba(255,255,255,.18)" strokeWidth="2">
{TICKS.map((v) => {
const ta = val2ang(v), [x0, y0] = ptOn(ta, R + 8), [x1, y1] = ptOn(ta, v % 25 === 0 ? R + 18 : R + 13);
return <line key={v} x1={x0} y1={y0} x2={x1} y2={y1} />;
})}
</g>
</svg>
<div className="rk-knob" style={{ transform: `rotate(${ang}deg)` }}><div className="rk-ptr" /></div>
<div className="rk-read">
<div className="rk-val">{val}</div>
<div className="rk-unit">MIN CONFIDENCE</div>
<div className="rk-risk" style={{ color: z.c }}>{z.t}</div>
</div>
</div>
<div className="rk-legend">
<span><b style={{ background: '#ef4444' }} />50–64 High risk</span>
<span><b style={{ background: '#f59e0b' }} />65–74 Elevated</span>
<span><b style={{ background: '#eab308' }} />75–84 Balanced</span>
<span><b style={{ background: '#22c55e' }} />85–99 Conservative</span>
</div>
<div className="rk-apply">
<button className={'rk-btn ' + (dirty && !busy ? 'on' : 'off')} onClick={dirty && !busy ? apply : undefined} disabled={!dirty || busy}>
{busy ? 'Applying…' : dirty ? `Apply ${val} to Ken` : committed == null ? 'Loading…' : `Applied · ${committed}`}
</button>
{msg && <span className="rk-note" style={{ color: msg.ok ? '#34d399' : '#f87171' }}>{msg.text}</span>}
</div>
<p className="rk-note" style={{ color: 'var(--text-muted,#7d8da3)', maxWidth: 460, textAlign: 'center', margin: 0 }}>
Higher = fewer, higher-conviction real trades (lower risk). Lower = more trades on thinner edges (higher risk).
Turning the dial previews; <b>Apply</b> commits it to the live trader.
</p>
</div>
);
}