/* global React */
/* Brave Alpha Capital — Diligence chart primitives.
* Hand-built SVG so they sit inside white cards with hairline grids, mono axis
* labels and the brand palette. Multi-series uses the categorical palette from
* data.js. Legends toggle series. Single-series charts get a hover readout. */
// CSS custom properties, not hex — inline SVG resolves them, so every chart follows
// the light/dark theme for free. Series palettes stay hex (legible on both).
const C = {
line: "var(--line)", ink: "var(--ink)", muted: "var(--muted)", gold: "var(--gold)",
green: "var(--green)", red: "var(--red)", paper: "var(--paper)",
};
const MONO = "var(--font-mono), 'IBM Plex Mono', monospace";
function fmtPct(v, dp = 0) { return (v >= 0 ? "+" : "\u2212") + Math.abs(v).toFixed(dp) + "%"; }
function niceTicks(min, max, n) {
if (min === max) { min -= 1; max += 1; }
const span = max - min, step0 = span / n, mag = Math.pow(10, Math.floor(Math.log10(step0)));
const norm = step0 / mag, step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag;
const lo = Math.floor(min / step) * step, hi = Math.ceil(max / step) * step, out = [];
for (let v = lo; v <= hi + 1e-9; v += step) out.push(Math.round(v * 1e6) / 1e6);
return out;
}
// Shared compact hover tile used by line and bar charts. Keeping one component
// prevents chart tooltips from drifting into different sizes and visual styles.
function ChartHoverTile({ left, top, flip = false, title, value, tone = C.ink, metaLeft, metaRight, sub }) {
const hasMeta = metaLeft || metaRight;
return
{String(title || "POINT").toUpperCase()}
{value}
{hasMeta && <>
{metaLeft || ""}{metaRight || ""}
>}
{sub &&
{sub}
}
;
}
// Keyboard-first searchable selector for long strategy registries. Typing ranks
// exact/prefix matches before word and partial matches; arrows + Enter select.
function SearchSelect({ value, onChange, options, placeholder = "Search…", ariaLabel, style }) {
const list = options || [];
const selected = list.find((o) => String(o.value) === String(value));
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const q = query.trim().toLowerCase();
const ranked = list.map((o, index) => {
const hay = `${o.label || ""} ${o.keywords || ""} ${o.value || ""}`.toLowerCase();
const words = hay.split(/[^a-z0-9]+/).filter(Boolean);
const score = !q ? index : hay === q ? -30 : hay.startsWith(q) ? -20 : words.some((w) => w.startsWith(q)) ? -10 : hay.includes(q) ? 0 : 10000;
return { o, index, score };
}).filter((x) => x.score < 10000).sort((a, b) => a.score - b.score || a.index - b.index).map((x) => x.o);
React.useEffect(() => { setActive(0); }, [query, open]);
function choose(o) { if (!o) return; onChange(o.value); setQuery(""); setOpen(false); }
function keyDown(e) {
if (e.key === "ArrowDown") { e.preventDefault(); if (!open) setOpen(true); else setActive((i) => Math.min(ranked.length - 1, i + 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); if (!open) setOpen(true); else setActive((i) => Math.max(0, i - 1)); }
else if (e.key === "Enter") { if (open && ranked.length) { e.preventDefault(); choose(ranked[active] || ranked[0]); } }
else if (e.key === "Escape") { setOpen(false); setQuery(""); }
}
return (
{ setOpen(true); setQuery(""); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }} onKeyDown={keyDown}
onBlur={() => setTimeout(() => { setOpen(false); setQuery(""); }, 120)}
style={{ width: "100%", boxSizing: "border-box", fontFamily: "var(--font-mono)", fontSize: 12, padding: "8px 28px 8px 10px", borderRadius: 7, border: "1px solid var(--line)", background: "var(--paper)", color: "var(--ink)", outline: "none" }} />
⌄
{open && (
{ranked.slice(0, 80).map((o, i) => (
))}
{!ranked.length &&
No matching strategies
}
)}
);
}
// ---- Legend (HTML) ---------------------------------------------------------
function Legend({ items, hidden, onToggle, small }) {
return (
{items.map((it) => {
const off = hidden && hidden.has(it.id);
return (
);
})}
);
}
// ---- Multi-series line chart ----------------------------------------------
// markers: [{ i, label, color? }] — vertical annotation lines (e.g. "paper starts",
// "live fills", a strategy switch) so a regime change reads off the curve itself.
function LineChart({ series, hidden, yDomain, yFmt, xLabels, xCount, height = 280, area, accent, baseline, hover, markers, hoverCard, initialHoverIndex = null }) {
const W = 900, H = height, padL = 52, padT = 14, padB = 28;
const vis = series.filter((s) => !(hidden && hidden.has(s.id)));
const showEnd = vis.length >= 2 && vis.length <= 10; // name each line at its right endpoint
const padR = showEnd ? 96 : 16;
const maxLen = xCount || Math.max(1, ...series.map((s) => s.data.length));
let lo, hi;
if (yDomain) { [lo, hi] = yDomain; }
else {
let mn = Infinity, mx = -Infinity;
for (const s of vis) for (const v of s.data) { if (v < mn) mn = v; if (v > mx) mx = v; }
if (!isFinite(mn)) { mn = 0; mx = 1; }
const pad = (mx - mn) * 0.08 || 1; lo = mn - pad; hi = mx + pad;
}
const yTicks = niceTicks(lo, hi, 5);
lo = Math.min(lo, yTicks[0]); hi = Math.max(hi, yTicks[yTicks.length - 1]);
const X = (i) => padL + (maxLen <= 1 ? 0 : i / (maxLen - 1)) * (W - padL - padR);
const Y = (v) => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB);
const safeInitialHover = initialHoverIndex == null ? null : Math.max(0, Math.min(maxLen - 1, Number(initialHoverIndex)));
const [hi_, setHi] = React.useState(safeInitialHover);
const svgRef = React.useRef(null);
const chartId = React.useId().replace(/:/g, "");
React.useEffect(() => { setHi(safeInitialHover); }, [safeInitialHover, maxLen]);
function path(data) { return data.map((v, i) => (i ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1)).join(" "); }
function smoothPath(data) {
if (!data || data.length < 2) return path(data || []);
let d = `M${X(0).toFixed(1)},${Y(data[0]).toFixed(1)}`;
for (let i = 0; i < data.length - 1; i++) {
const p0x = X(Math.max(0, i - 1)), p0y = Y(data[Math.max(0, i - 1)]);
const p1x = X(i), p1y = Y(data[i]);
const p2x = X(i + 1), p2y = Y(data[i + 1]);
const p3x = X(Math.min(data.length - 1, i + 2)), p3y = Y(data[Math.min(data.length - 1, i + 2)]);
const c1x = p1x + (p2x - p0x) / 6, c1y = p1y + (p2y - p0y) / 6;
const c2x = p2x - (p3x - p1x) / 6, c2y = p2y - (p3y - p1y) / 6;
d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2x.toFixed(1)},${p2y.toFixed(1)}`;
}
return d;
}
function onMove(e) {
if (!hover) return;
const r = svgRef.current.getBoundingClientRect();
const px = ((e.clientX - r.left) / r.width) * W;
const idx = Math.round(((px - padL) / (W - padL - padR)) * (maxLen - 1));
setHi(Math.max(0, Math.min(maxLen - 1, idx)));
}
const xt = [];
const nLabels = Math.min(8, maxLen);
for (let k = 0; k < nLabels; k++) { const i = Math.round((k / (nLabels - 1)) * (maxLen - 1)); xt.push(i); }
return (
{hover && hi_ != null && hoverCard && hoverCard[hi_] && (() => {
const d = hoverCard[hi_];
const px = X(hi_) / W * 100;
const py = Math.max(2, Math.min(64, Y(vis[0].data[hi_]) / H * 100 - 8));
const tone = d.pnl == null ? C.ink : Number(d.pnl) >= 0 ? C.green : C.red;
return
62} title={d.title || "Point"}
value={d.value || (yFmt ? yFmt(vis[0].data[hi_]) : vis[0].data[hi_].toFixed(2))}
tone={tone} metaLeft={d.detail} metaRight={d.pnlLabel} sub={d.sub} />;
})()}
);
}
// ---- Monthly P&L bars (single strategy, green/red) -------------------------
function MonthBars({ months, values, height = 240, barRatio = 0.56 }) {
const W = 900, H = height, padL = 48, padR = 12, padT = 14, padB = 28;
let mn = Math.min(0, ...values), mx = Math.max(0, ...values);
const ticks = niceTicks(mn, mx, 4); mn = Math.min(mn, ticks[0]); mx = Math.max(mx, ticks[ticks.length - 1]);
const Y = (v) => padT + (1 - (v - mn) / (mx - mn)) * (H - padT - padB);
const bw = (W - padL - padR) / months.length;
const [hover, setHover] = React.useState(null);
return (
{hover != null && values[hover] != null && (() => {
const value = Number(values[hover]);
const px = (padL + hover * bw + bw / 2) / W * 100;
const py = Math.max(2, Math.min(62, Math.min(Y(0), Y(value)) / H * 100 - 6));
return 62}
title={months[hover] || "Monthly P&L"}
value={fmtPct(value, 2)}
tone={value >= 0 ? C.green : C.red}
metaLeft="Calendar month"
metaRight={value >= 0 ? "GAIN" : "LOSS"} />;
})()}
);
}
// ---- Grouped monthly bars (multi-series) -----------------------------------
function GroupedBars({ months, series, hidden, height = 260, yFmt }) {
const W = 900, H = height, padL = 48, padR = 12, padT = 14, padB = 28;
const vis = series.filter((s) => !(hidden && hidden.has(s.id)));
let mn = 0, mx = 0;
for (const s of vis) for (const v of s.data) { if (v < mn) mn = v; if (v > mx) mx = v; }
const ticks = niceTicks(mn, mx, 4); mn = Math.min(mn, ticks[0]); mx = Math.max(mx, ticks[ticks.length - 1]);
const Y = (v) => padT + (1 - (v - mn) / (mx - mn)) * (H - padT - padB);
const gw = (W - padL - padR) / months.length, bw = (gw * 0.7) / Math.max(1, vis.length);
return (
);
}
// ---- Histogram (grouped multi-series by P&L bucket) ------------------------
function Histogram({ bins, labels, series, hidden, height = 240 }) {
const W = 900, H = height, padL = 40, padR = 12, padT = 14, padB = 30;
const vis = series.filter((s) => !(hidden && hidden.has(s.id)));
let mx = 1;
for (const s of vis) for (const v of s.data) if (v > mx) mx = v;
const ticks = niceTicks(0, mx, 4); mx = Math.max(mx, ticks[ticks.length - 1]);
const Y = (v) => padT + (1 - v / mx) * (H - padT - padB);
const gw = (W - padL - padR) / labels.length, bw = (gw * 0.74) / Math.max(1, vis.length);
return (
);
}
// ---- Sparkline -------------------------------------------------------------
function Sparkline({ data, color = C.ink, width = 120, height = 32, area }) {
if (!data || data.length < 2) return ;
const mn = Math.min(...data), mx = Math.max(...data), sp = mx - mn || 1, pad = 3;
const X = (i) => pad + (i / (data.length - 1)) * (width - pad * 2);
const Y = (v) => pad + (1 - (v - mn) / sp) * (height - pad * 2);
const d = data.map((v, i) => (i ? "L" : "M") + X(i).toFixed(1) + "," + Y(v).toFixed(1)).join(" ");
return (
);
}
// ---- Horizontal bar (composite breakdown / win-rate meter) -----------------
function MeterBar({ value, max = 100, color = C.gold, height = 7, track = "var(--line)" }) {
return (
);
}
// Long / short profile bars. The visual height is always measured upward from
// the bottom baseline; the signed value remains explicit in labels and hover.
function ModeBars({ data, height = 190, metric = "return" }) {
const W = 900, H = height, padL = 48, padR = 12, padT = 30, padB = 38;
const vals = (data || []).map((d) => Number(d[metric] || 0));
const magnitudes = vals.map((v) => metric === "return" ? Math.abs(v) : Math.max(0, v));
const rawMax = Math.max(1, ...magnitudes);
const hi = Math.max(1, Math.ceil(rawMax * 1.12));
const Y = (v) => padT + (1 - v / hi) * (H - padT - padB);
const yTicks = niceTicks(0, hi, 4).filter((t) => t >= 0 && t <= hi);
const bw = (W - padL - padR) / Math.max(1, data.length);
const [hover, setHover] = React.useState(null);
const fmt = (v) => metric === "trades" ? String(Math.round(v)) : (metric === "return" ? (v >= 0 ? "+" : "−") + Math.abs(v).toFixed(1) : v.toFixed(1)) + "%";
const axisFmt = (v) => metric === "trades" ? String(Math.round(v)) : v.toFixed(0) + "%";
return (
{hover != null && data[hover] && (() => {
const d = data[hover];
const v = Number(d[metric] || 0);
const px = (padL + hover * bw + bw / 2) / W * 100;
const tone = metric === "return" ? (v >= 0 ? C.green : C.red) : (d.mode === "Both" ? C.gold : d.mode === "Short" ? C.red : C.green);
return 62}
title={d.mode + " · " + (metric === "return" ? "Compounded return" : metric === "winRate" ? "Win rate" : "Closed trades")}
value={fmt(v)} tone={tone}
metaLeft={metric === "return" ? "Signed strategy result" : metric === "winRate" ? "Winning trades / total" : "Completed positions"} />;
})()}
);
}
Object.assign(window, { Legend, LineChart, MonthBars, GroupedBars, Histogram, Sparkline, MeterBar, ModeBars, SearchSelect, CHART_C: C, fmtPct });