Horizon redesign: space theme, visual dashboard, chart reskin
Replaces the citron/green glass theme with the Horizon direction (mockup at /design-horizon): space-void background, starfield, dim Mars horizon, rim-cyan accent, rose for negative. Palette validated for CVD separation and contrast on the dark surface. - tailwind.config: gray -> cool space neutrals, blue/emerald -> cyan scale, red -> rose; display font Space Grotesk - globals.css: Horizon tokens, atmosphere, denser glass, cyan buttons/inputs, price-rail / R-bar / radar chart CSS - Dashboard rebuilt: verdict hero, top-pick card with spatial price rail, KPI tiles, open positions as diverging R-bars with expandable detail + trade chart (OHLCV since entry, entry/stop/target levels), radar list with per-setup disqualify reason, watchlist chips - OpenTradesPanel: table replaced by R-bar rows; all fields kept in the drill-down (shares, P&L, alpha, trailing stop, sell) - qualification: disqualifyReason() mirrors qualifiesSetup rule order - CandlestickChart: canvas colors moved to Horizon palette; S/R now cyan/neutral so rose stays reserved for the stop level - ScoreCard: radar score fingerprint with hover values - Design mockup pages included (routed in App.tsx) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -160,7 +160,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const nTicks = 6;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillStyle = '#6e7484';
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= nTicks; i++) {
|
||||
@@ -178,7 +178,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const labelInterval = Math.max(Math.floor(visibleData.length / 8), 1);
|
||||
for (let i = 0; i < visibleData.length; i += labelInterval) {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillStyle = '#6e7484';
|
||||
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.fillStyle = '#6e7484';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Volume', ml, volumeTop - 13);
|
||||
ctx.textAlign = 'right';
|
||||
@@ -206,20 +206,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const bullish = bar.close >= bar.open;
|
||||
const yVolume = volumeScale(bar.volume);
|
||||
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
|
||||
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
|
||||
ctx.fillStyle = bullish ? 'rgba(47, 157, 178, 0.32)' : 'rgba(227, 106, 88, 0.28)';
|
||||
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
|
||||
});
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
const color = isSupport ? '#10b981' : '#ef4444';
|
||||
// Support = rim-cyan, resistance = neutral ink — rose stays reserved for
|
||||
// the stop level so S/R never impersonates trade levels.
|
||||
const color = isSupport ? '#2f9db2' : '#9aa0b0';
|
||||
const yMid = yScale(m.price);
|
||||
|
||||
if (m.high > m.low) {
|
||||
const yTop = yScale(m.high);
|
||||
const rectHeight = Math.max(yScale(m.low) - yTop, 2);
|
||||
ctx.fillStyle = isSupport ? 'rgba(16, 185, 129, 0.12)' : 'rgba(239, 68, 68, 0.12)';
|
||||
ctx.fillStyle = isSupport ? 'rgba(47, 157, 178, 0.12)' : 'rgba(154, 160, 176, 0.10)';
|
||||
ctx.fillRect(ml, yTop, cw, rectHeight);
|
||||
}
|
||||
|
||||
@@ -253,10 +255,10 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Stop-loss zone: red semi-transparent rectangle between entry and stop-loss
|
||||
const slTop = Math.min(entryY, stopY);
|
||||
const slHeight = Math.max(Math.abs(stopY - entryY), 1);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.13)';
|
||||
ctx.fillStyle = 'rgba(227, 106, 88, 0.13)';
|
||||
ctx.fillRect(ml, slTop, cw, slHeight);
|
||||
// Stop-loss border
|
||||
ctx.strokeStyle = 'rgba(239, 68, 68, 0.4)';
|
||||
ctx.strokeStyle = 'rgba(227, 106, 88, 0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
@@ -268,10 +270,10 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Take-profit zone: green semi-transparent rectangle between entry and target
|
||||
const tpTop = Math.min(entryY, targetY);
|
||||
const tpHeight = Math.max(Math.abs(targetY - entryY), 1);
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.13)';
|
||||
ctx.fillStyle = 'rgba(47, 157, 178, 0.13)';
|
||||
ctx.fillRect(ml, tpTop, cw, tpHeight);
|
||||
// Target border
|
||||
ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
|
||||
ctx.strokeStyle = 'rgba(47, 157, 178, 0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
@@ -280,8 +282,8 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Entry price: dashed horizontal line (blue/white)
|
||||
ctx.strokeStyle = 'rgba(96, 165, 250, 0.9)';
|
||||
// Entry price: dashed horizontal line (neutral ink)
|
||||
ctx.strokeStyle = 'rgba(154, 160, 176, 0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath();
|
||||
@@ -293,11 +295,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Labels on right side
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
|
||||
ctx.fillStyle = 'rgba(154, 160, 176, 0.95)';
|
||||
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)';
|
||||
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
|
||||
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.8)';
|
||||
ctx.fillStyle = 'rgba(110, 201, 219, 0.9)';
|
||||
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
||||
}
|
||||
|
||||
@@ -316,7 +318,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
|
||||
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
|
||||
ctx.fillStyle = '#0e120f';
|
||||
ctx.fillStyle = '#0a0b11';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, ml + 6, py);
|
||||
@@ -327,7 +329,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
visibleData.forEach((bar, i) => {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
const bullish = bar.close >= bar.open;
|
||||
const color = bullish ? '#10b981' : '#ef4444';
|
||||
const color = bullish ? '#2f9db2' : '#e36a58';
|
||||
|
||||
const yHigh = yScale(bar.high);
|
||||
const yLow = yScale(bar.low);
|
||||
@@ -441,11 +443,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const labelX = ml + cw + 2;
|
||||
const labelY = cy - labelH / 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.fillStyle = 'rgba(35, 39, 51, 0.95)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.fillStyle = '#edeef3';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(priceText, labelX + labelPadX, cy);
|
||||
@@ -462,11 +464,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const dateLabelX = cx - dateLabelW / 2;
|
||||
const dateLabelY = H - mb + 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.fillStyle = 'rgba(35, 39, 51, 0.95)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(dateLabelX, dateLabelY, dateLabelW, dateLabelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.fillStyle = '#edeef3';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(dateText, cx, dateLabelY + dateLabelH / 2);
|
||||
@@ -667,8 +669,8 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
|
||||
<span>Direction</span><span class="text-right text-gray-200">${tradeSetup.direction}</span>
|
||||
<span>Entry</span><span class="text-right text-blue-300">${formatPrice(tradeSetup.entry_price)}</span>
|
||||
<span>Stop</span><span class="text-right text-red-400">${formatPrice(tradeSetup.stop_loss)}</span>
|
||||
<span>Target</span><span class="text-right text-green-400">${formatPrice(tradeSetup.target)}</span>
|
||||
<span>Stop</span><span class="text-right text-red-300">${formatPrice(tradeSetup.stop_loss)}</span>
|
||||
<span>Target</span><span class="text-right text-emerald-300">${formatPrice(tradeSetup.target)}</span>
|
||||
<span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Horizon chart primitives shared by the dashboard and ticker page.
|
||||
* All colors come from the :root tokens in globals.css; the data marks
|
||||
* (--up / --down) are validated against the dark surface.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { OHLCVBar } from '../../lib/types';
|
||||
|
||||
function fmt(n: number, digits = 2): string {
|
||||
return n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function signedR(r: number): string {
|
||||
return `${r > 0 ? '+' : r < 0 ? '−' : ''}${fmt(Math.abs(r))}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* RBar — diverging R-multiple bar growing from a centered zero line */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function RBar({ r, max = 1.6 }: { r: number | null; max?: number }) {
|
||||
const pct = r == null ? 0 : Math.min(Math.abs(r) / Math.max(max, 0.01), 1) * 50;
|
||||
return (
|
||||
<div className="hz-rbar" role="img" aria-label={r == null ? 'no R value' : `${signedR(r)} R`}>
|
||||
<span className="hz-rbar-zero" />
|
||||
{r != null && (
|
||||
<span
|
||||
className="hz-rbar-fill"
|
||||
style={{
|
||||
left: r >= 0 ? '50%' : `${50 - pct}%`,
|
||||
width: `${pct}%`,
|
||||
background: r >= 0 ? 'var(--up)' : 'var(--down)',
|
||||
borderRadius: r >= 0 ? '0 4px 4px 0' : '4px 0 0 4px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PriceRail — stop → entry → now → target laid out spatially */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function PriceRail({
|
||||
direction, entry, stop, target, current,
|
||||
}: {
|
||||
direction: string;
|
||||
entry: number;
|
||||
stop: number;
|
||||
target: number;
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [entry, stop, target, ...(current != null ? [current] : [])];
|
||||
const span = Math.max(...points) - Math.min(...points) || 1;
|
||||
const lo = Math.min(...points) - span * 0.06;
|
||||
const hi = Math.max(...points) + span * 0.06;
|
||||
const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));
|
||||
const risk = Math.abs(entry - stop);
|
||||
const rNow = current != null && risk > 0
|
||||
? (direction === 'long' ? current - entry : entry - current) / risk
|
||||
: null;
|
||||
const rTarget = risk > 0 ? Math.abs(target - entry) / risk : null;
|
||||
const inProfit = rNow != null && rNow >= 0;
|
||||
const progressLeft = current != null ? Math.min(pct(entry), pct(current)) : 0;
|
||||
const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0;
|
||||
return (
|
||||
<div className="hz-rail" role="img" aria-label={
|
||||
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, target ${fmt(target)}`
|
||||
}>
|
||||
<div className="hz-rail-track" />
|
||||
<div
|
||||
className="hz-rail-risk"
|
||||
style={{ left: `${Math.min(pct(stop), pct(entry))}%`, width: `${Math.abs(pct(entry) - pct(stop))}%` }}
|
||||
/>
|
||||
{current != null && (
|
||||
<div
|
||||
className="hz-rail-progress"
|
||||
style={{
|
||||
left: `${progressLeft}%`,
|
||||
width: `${progressWidth}%`,
|
||||
background: inProfit ? 'var(--up)' : 'var(--down)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(stop)}%` }}>
|
||||
<span className="hz-rail-tick" style={{ background: 'var(--down)' }} />
|
||||
<span className="hz-rail-label">
|
||||
<em>stop</em>
|
||||
<b>{fmt(stop)}</b>
|
||||
<i>−1R</i>
|
||||
</span>
|
||||
</div>
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(entry)}%` }}>
|
||||
<span className="hz-rail-tick" style={{ background: 'var(--ink-2)' }} />
|
||||
<span className="hz-rail-label">
|
||||
<em>entry</em>
|
||||
<b>{fmt(entry)}</b>
|
||||
</span>
|
||||
</div>
|
||||
{current != null && (
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(current)}%` }}>
|
||||
<span className="hz-rail-dot" style={{ background: inProfit ? 'var(--up)' : 'var(--down)' }} />
|
||||
<span className="hz-rail-label hz-rail-label-top">
|
||||
<em>now</em>
|
||||
<b style={{ color: inProfit ? 'var(--up-text)' : 'var(--down-text)' }}>{fmt(current)}</b>
|
||||
{rNow != null && <i>{signedR(rNow)}R</i>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hz-rail-mark" style={{ left: `${pct(target)}%` }}>
|
||||
<span className="hz-rail-ring" />
|
||||
<span className="hz-rail-label">
|
||||
<em>target</em>
|
||||
<b>{fmt(target)}</b>
|
||||
{rTarget != null && <i>+{fmt(rTarget, 1)}R</i>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* RadarChart — score fingerprint over n axes (0–100), hover for values */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface RadarAxis {
|
||||
label: string;
|
||||
value: number;
|
||||
/** Longer name for the tooltip / screen reader (falls back to label). */
|
||||
full?: string;
|
||||
}
|
||||
|
||||
export function RadarChart({
|
||||
axes, size = 120, labels = true,
|
||||
}: { axes: RadarAxis[]; size?: number; labels?: boolean }) {
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const n = axes.length;
|
||||
if (n < 3) return null;
|
||||
const pad = labels ? 74 : 10;
|
||||
const vb = size + pad * 2;
|
||||
const c = vb / 2;
|
||||
const r = size / 2;
|
||||
const angle = (i: number) => ((-90 + (i * 360) / n) * Math.PI) / 180;
|
||||
const pt = (v: number, i: number): [number, number] => {
|
||||
const a = angle(i);
|
||||
return [c + (Math.cos(a) * r * Math.max(0, Math.min(100, v))) / 100, c + (Math.sin(a) * r * Math.max(0, Math.min(100, v))) / 100];
|
||||
};
|
||||
const poly = (vals: number[]) =>
|
||||
vals.map((v, i) => pt(v, i).map((x) => x.toFixed(1)).join(',')).join(' ');
|
||||
const tip = hover !== null ? pt(axes[hover].value, hover) : null;
|
||||
return (
|
||||
<div className="hz-radar-wrap" style={{ width: vb, height: vb }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${vb} ${vb}`}
|
||||
width={vb}
|
||||
height={vb}
|
||||
role="img"
|
||||
aria-label={`Score fingerprint: ${axes.map((a) => `${a.full ?? a.label} ${Math.round(a.value)}`).join(', ')} (0–100)`}
|
||||
>
|
||||
{[50, 100].map((ring) => (
|
||||
<polygon key={ring} points={poly(Array(n).fill(ring))} fill="none" stroke="var(--grid)" strokeWidth="1" />
|
||||
))}
|
||||
{axes.map((a, i) => {
|
||||
const [x, y] = pt(100, i);
|
||||
return <line key={a.label} x1={c} y1={c} x2={x} y2={y} stroke="var(--grid)" strokeWidth="1" />;
|
||||
})}
|
||||
<polygon
|
||||
points={poly(axes.map((a) => a.value))}
|
||||
fill="var(--up)" fillOpacity="0.13"
|
||||
stroke="var(--up)" strokeWidth="2" strokeLinejoin="round"
|
||||
/>
|
||||
{axes.map((a, i) => {
|
||||
const [x, y] = pt(a.value, i);
|
||||
return (
|
||||
<circle
|
||||
key={a.label} cx={x} cy={y} r={hover === i ? 4.5 : 3}
|
||||
fill="var(--up)" stroke="var(--surface)" strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{labels && axes.map((a, i) => {
|
||||
const ang = angle(i);
|
||||
const x = c + Math.cos(ang) * (r + 13);
|
||||
const y = c + Math.sin(ang) * (r + 13);
|
||||
const anchor = Math.cos(ang) > 0.3 ? 'start' : Math.cos(ang) < -0.3 ? 'end' : 'middle';
|
||||
const dy = Math.sin(ang) < -0.3 ? -2 : Math.sin(ang) > 0.3 ? 9 : 4;
|
||||
return (
|
||||
<text key={a.label} x={x} y={y + dy} textAnchor={anchor} className="hz-radar-axis">
|
||||
{a.label} <tspan className="hz-radar-axisval">{Math.round(a.value)}</tspan>
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
{/* hit targets on top — comfortably larger than the dots, keyboard-reachable */}
|
||||
{axes.map((a, i) => {
|
||||
const [x, y] = pt(a.value, i);
|
||||
return (
|
||||
<circle
|
||||
key={`hit-${a.label}`} cx={x} cy={y} r="13"
|
||||
fill="transparent" style={{ cursor: 'default', outline: 'none' }}
|
||||
tabIndex={0}
|
||||
aria-label={`${a.full ?? a.label}: ${Math.round(a.value)} of 100`}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={() => setHover(i)}
|
||||
onBlur={() => setHover(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{hover !== null && tip && (
|
||||
<div
|
||||
className="hz-radar-tip"
|
||||
style={{ left: `${(tip[0] / vb) * 100}%`, top: `${(tip[1] / vb) * 100}%` }}
|
||||
>
|
||||
<b>{axes[hover].full ?? axes[hover].label}</b>
|
||||
<span className="num">{Math.round(axes[hover].value)} / 100</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* TradeChart — close path since entry + entry / stop / target levels */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function TradeChart({
|
||||
direction, entry, stop, target, bars, openedAt,
|
||||
}: {
|
||||
direction: string;
|
||||
entry: number;
|
||||
/** Current stop level (trailing stop if active, else the initial stop). */
|
||||
stop: number;
|
||||
target: number;
|
||||
bars: OHLCVBar[];
|
||||
openedAt: string;
|
||||
}) {
|
||||
const openedDate = openedAt.slice(0, 10);
|
||||
const closes = bars
|
||||
.filter((b) => b.date.slice(0, 10) >= openedDate)
|
||||
.map((b) => b.close);
|
||||
const series = [entry, ...closes];
|
||||
if (series.length < 3) return null;
|
||||
|
||||
const w = 560; const h = 150;
|
||||
const padL = 8; const padR = 96; const padT = 10; const padB = 12;
|
||||
const isShort = direction === 'short';
|
||||
const vals = [...series, stop, entry];
|
||||
let lo = Math.min(...vals);
|
||||
let hi = Math.max(...vals);
|
||||
const range = hi - lo || 1;
|
||||
// Only stretch the scale to the target when it doesn't flatten the price action.
|
||||
const targetFits = isShort ? lo - target <= range * 0.9 : target - hi <= range * 0.9;
|
||||
if (targetFits) { lo = Math.min(lo, target); hi = Math.max(hi, target); }
|
||||
const pad = (hi - lo) * 0.07;
|
||||
lo -= pad; hi += pad;
|
||||
const plotW = w - padL - padR;
|
||||
const plotH = h - padT - padB;
|
||||
const px = (i: number) => padL + (i / (series.length - 1)) * plotW;
|
||||
const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH;
|
||||
const path = series.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ');
|
||||
const last = series[series.length - 1];
|
||||
const perShare = isShort ? entry - last : last - entry;
|
||||
const col = perShare >= 0 ? 'var(--up)' : 'var(--down)';
|
||||
const labelX = w - padR + 10;
|
||||
const entryY = py(entry);
|
||||
let stopY = py(stop);
|
||||
if (Math.abs(stopY - entryY) < 11) stopY = entryY + (stopY >= entryY ? 11 : -11);
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className="hz-tradechart"
|
||||
role="img"
|
||||
aria-label={`Price since entry ${fmt(entry)}, now ${fmt(last)}, stop ${fmt(stop)}, target ${fmt(target)}`}
|
||||
>
|
||||
<line x1={padL} x2={w - padR} y1={entryY} y2={entryY} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
|
||||
<text x={labelX} y={entryY + 3.5} className="hz-lvl">entry {fmt(entry)}</text>
|
||||
<line x1={padL} x2={w - padR} y1={py(stop)} y2={py(stop)} stroke="var(--down)" strokeWidth="1.5" opacity="0.85" />
|
||||
<text x={labelX} y={stopY + 3.5} className="hz-lvl hz-lvl-down">stop {fmt(stop)}</text>
|
||||
{targetFits ? (
|
||||
<>
|
||||
<line x1={padL} x2={w - padR} y1={py(target)} y2={py(target)} stroke="var(--up)" strokeWidth="1" opacity="0.6" />
|
||||
<text x={labelX} y={py(target) + 3.5} className="hz-lvl hz-lvl-up">target {fmt(target)}</text>
|
||||
</>
|
||||
) : (
|
||||
<text x={labelX} y={isShort ? h - padB : padT + 4} className="hz-lvl hz-lvl-up">
|
||||
target {fmt(target)} {isShort ? '↓' : '↑'}
|
||||
</text>
|
||||
)}
|
||||
<path d={path} fill="none" stroke={col} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<circle cx={px(series.length - 1)} cy={py(last)} r="4" fill={col} stroke="var(--surface)" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +1,249 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
|
||||
import { useTickerNames } from '../../hooks/useTickers';
|
||||
import { getOHLCV } from '../../api/ohlcv';
|
||||
import { tradePnl } from '../../lib/paperTrade';
|
||||
import { formatPrice } from '../../lib/format';
|
||||
import { RBar, TradeChart } from '../charts/horizon';
|
||||
import { Section } from '../ui/Section';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import type { PaperTrade } from '../../lib/types';
|
||||
|
||||
function money(v: number): string {
|
||||
const sign = v >= 0 ? '+' : '−';
|
||||
return `${sign}$${Math.abs(v).toFixed(2)}`;
|
||||
}
|
||||
function pnlColor(v: number): string {
|
||||
if (v > 0) return 'text-emerald-400';
|
||||
if (v < 0) return 'text-red-400';
|
||||
if (v > 0) return 'text-emerald-300';
|
||||
if (v < 0) return 'text-red-300';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
function DirTag({ direction }: { direction: string }) {
|
||||
const isLong = direction === 'long';
|
||||
return (
|
||||
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
||||
isLong ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/15 text-red-300'
|
||||
}`}>
|
||||
{direction}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value, valueClass = 'text-gray-100' }: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500">{label}</dt>
|
||||
<dd className={`num mt-0.5 text-sm ${valueClass}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expanded row: full trade detail + price chart with entry/stop/target levels. */
|
||||
function TradeDetail({ trade, exitLabel, onClose, closing }: {
|
||||
trade: PaperTrade;
|
||||
exitLabel: string | null;
|
||||
onClose: () => void;
|
||||
closing: boolean;
|
||||
}) {
|
||||
const p = tradePnl(trade);
|
||||
const stopLevel = trade.trailing_stop ?? trade.stop_loss;
|
||||
const ohlcv = useQuery({
|
||||
queryKey: ['ohlcv', trade.symbol],
|
||||
queryFn: () => getOHLCV(trade.symbol),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return (
|
||||
<div className="flex flex-col gap-4 px-2 pb-4 pt-1">
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4">
|
||||
<Detail label="opened" value={`${opened} · ${trade.shares} shares`} />
|
||||
<Detail label="entry → now" value={
|
||||
`${formatPrice(trade.entry_price)} → ${trade.current_price != null ? formatPrice(trade.current_price) : '—'}`
|
||||
} />
|
||||
<Detail
|
||||
label="P&L"
|
||||
value={p ? `${money(p.pnl)} · ${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
|
||||
valueClass={p ? pnlColor(p.pnl) : 'text-gray-500'}
|
||||
/>
|
||||
<Detail
|
||||
label="alpha vs SPY"
|
||||
value={
|
||||
trade.alpha_pct != null
|
||||
? `${trade.alpha_pct >= 0 ? '+' : ''}${trade.alpha_pct.toFixed(1)}%${trade.alpha_usd != null ? ` · ${money(trade.alpha_usd)}` : ''}`
|
||||
: '—'
|
||||
}
|
||||
valueClass={trade.alpha_pct != null ? pnlColor(trade.alpha_pct) : 'text-gray-500'}
|
||||
/>
|
||||
<Detail
|
||||
label="stop"
|
||||
value={
|
||||
<>
|
||||
{formatPrice(stopLevel)}
|
||||
{trade.trailing_stop != null && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-500">
|
||||
trailing{trade.trailing_distance_pct != null ? ` · ${Math.abs(trade.trailing_distance_pct).toFixed(1)}% away` : ''}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Detail label="target" value={formatPrice(trade.target)} />
|
||||
<Detail label="exit rule" value={exitLabel ?? 'target/stop'} />
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={closing}
|
||||
className="rounded-md border border-white/[0.1] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Sell at market
|
||||
</button>
|
||||
</div>
|
||||
</dl>
|
||||
{ohlcv.data && (
|
||||
<div>
|
||||
<p className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500">
|
||||
since entry · entry / stop / target
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<TradeChart
|
||||
direction={trade.direction}
|
||||
entry={trade.entry_price}
|
||||
stop={stopLevel}
|
||||
target={trade.target}
|
||||
bars={ohlcv.data}
|
||||
openedAt={trade.opened_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenTradesPanel() {
|
||||
const { data: trades, isLoading } = usePaperTrades('open');
|
||||
const { data: policy } = useExitPolicy();
|
||||
const tickerNames = useTickerNames();
|
||||
const close = useClosePaperTrade();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const exitLabel = policy
|
||||
? policy.mode === 'atr_trailing'
|
||||
? `auto-exit: ${(policy.atr_multiplier ?? 3).toFixed(1)}x ATR trail / ${policy.hold_days}d max`
|
||||
? `${(policy.atr_multiplier ?? 3).toFixed(1)}x ATR trailing stop / ${policy.hold_days}d max`
|
||||
: policy.mode === 'trailing'
|
||||
? `auto-exit: trailing ${Math.round(policy.trailing_pct)}%`
|
||||
? `trailing ${Math.round(policy.trailing_pct)}%`
|
||||
: policy.mode === 'time'
|
||||
? `auto-exit: ${policy.hold_days}d hold`
|
||||
: 'auto-exit: target/stop'
|
||||
? `${policy.hold_days}d hold`
|
||||
: 'target/stop'
|
||||
: null;
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
|
||||
for (const t of trades ?? []) {
|
||||
const rows = trades ?? [];
|
||||
|
||||
const { totals, rMax } = useMemo(() => {
|
||||
let pnl = 0, winners = 0, losers = 0, alphaUsd = 0, alphaPriced = 0, rMax = 1;
|
||||
for (const t of rows) {
|
||||
const p = tradePnl(t);
|
||||
if (p) {
|
||||
priced += 1;
|
||||
pnl += p.pnl;
|
||||
if (p.pnl > 0) winners += 1;
|
||||
else if (p.pnl < 0) losers += 1;
|
||||
if (p.r != null) rMax = Math.max(rMax, Math.abs(p.r));
|
||||
}
|
||||
if (t.alpha_usd != null) {
|
||||
alphaUsd += t.alpha_usd;
|
||||
alphaPriced += 1;
|
||||
}
|
||||
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
|
||||
}
|
||||
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
|
||||
}, [trades]);
|
||||
return { totals: { pnl, winners, losers, alphaUsd, alphaPriced }, rMax };
|
||||
}, [rows]);
|
||||
|
||||
if (isLoading) return null;
|
||||
const rows = trades ?? [];
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Open Trades"
|
||||
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}▲ ${totals.losers}▼${exitLabel ? ` · ${exitLabel}` : ''}` : 'paper trading'}
|
||||
title="Open Positions"
|
||||
hint={rows.length > 0
|
||||
? `${rows.length} open · ${totals.winners}▲ ${totals.losers}▼ · R-multiple to entry · select a row for detail`
|
||||
: 'paper trading'}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Callout variant="empty">
|
||||
No open paper trades. Open a ticker and tap “Mark as taken” on a setup to start.
|
||||
</Callout>
|
||||
) : (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Ticker</th>
|
||||
<th className="px-4 py-3">Dir</th>
|
||||
<th className="px-4 py-3 text-right">Shares</th>
|
||||
<th className="px-4 py-3 text-right">Entry</th>
|
||||
<th className="px-4 py-3 text-right">Now</th>
|
||||
<th className="px-4 py-3 text-right">P&L</th>
|
||||
<th className="px-4 py-3 text-right">%</th>
|
||||
<th className="px-4 py-3 text-right">R</th>
|
||||
<th className="px-4 py-3 text-right">Alpha</th>
|
||||
<th className="px-4 py-3 text-right">Trail Stop</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => {
|
||||
const p = tradePnl(t);
|
||||
return (
|
||||
<tr key={t.id} className="border-b border-white/[0.04] hover:bg-white/[0.03]">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
|
||||
<div className="glass px-4 py-1">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{rows.map((t) => {
|
||||
const p = tradePnl(t);
|
||||
const open = expandedId === t.id;
|
||||
return (
|
||||
<li key={t.id}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setExpandedId(open ? null : t.id)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpandedId(open ? null : t.id);
|
||||
}
|
||||
}}
|
||||
aria-expanded={open}
|
||||
className="grid w-full cursor-pointer grid-cols-[110px_1fr_60px_16px] items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors hover:bg-white/[0.03] sm:grid-cols-[130px_150px_1fr_70px_16px]"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/ticker/${t.symbol}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-blue-300 transition-colors hover:text-blue-200"
|
||||
>
|
||||
{t.symbol}
|
||||
</Link>
|
||||
{tickerNames.get(t.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[150px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(t.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{t.direction}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300">{t.shares}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">
|
||||
{t.current_price != null ? formatPrice(t.current_price) : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right font-semibold ${p ? pnlColor(p.pnl) : 'text-gray-500'}`}>
|
||||
{p ? money(p.pnl) : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${p ? pnlColor(p.pct) : 'text-gray-500'}`}>
|
||||
{p ? `${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
||||
<DirTag direction={t.direction} />
|
||||
</span>
|
||||
<span className="num hidden text-xs text-gray-400 sm:block">
|
||||
{formatPrice(t.entry_price)} → {t.current_price != null ? formatPrice(t.current_price) : '—'}
|
||||
</span>
|
||||
<RBar r={p?.r ?? null} max={rMax} />
|
||||
<span className={`num text-right text-[13px] font-semibold ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
|
||||
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
|
||||
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-300" title="Current trailing-stop level · how far below the price">
|
||||
{t.trailing_stop != null ? (
|
||||
<>
|
||||
{formatPrice(t.trailing_stop)}
|
||||
{t.trailing_distance_pct != null && (
|
||||
<span className="ml-1 text-[10px] text-gray-500">
|
||||
{Math.abs(t.trailing_distance_pct).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
|
||||
close.mutate({ id: t.id });
|
||||
}
|
||||
}}
|
||||
disabled={close.isPending}
|
||||
className="rounded-md border border-white/[0.1] px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-white/[0.08]">
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
|
||||
Total unrealized P&L · alpha vs S&P 500
|
||||
</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
|
||||
{money(totals.pnl)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
|
||||
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</span>
|
||||
<span className={`text-[10px] text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
<TradeDetail
|
||||
trade={t}
|
||||
exitLabel={exitLabel}
|
||||
closing={close.isPending}
|
||||
onClose={() => {
|
||||
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
|
||||
close.mutate({ id: t.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* company name for screen readers / row context */}
|
||||
<span className="sr-only">{tickerNames.get(t.symbol.toUpperCase()) ?? ''}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-t border-white/[0.06] px-2 py-2.5 text-xs text-gray-500">
|
||||
<span>total unrealized · alpha vs S&P 500{exitLabel ? ` · auto-exit: ${exitLabel}` : ''}</span>
|
||||
<span className="num flex gap-4">
|
||||
<b className={`font-semibold ${pnlColor(totals.pnl)}`}>{money(totals.pnl)}</b>
|
||||
<b className={`font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
|
||||
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -5,6 +5,10 @@ import MobileNav from './MobileNav';
|
||||
export default function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen text-gray-100">
|
||||
<div className="app-horizon" aria-hidden="true">
|
||||
<div className="app-horizon-rim" />
|
||||
<div className="app-horizon-planet" />
|
||||
</div>
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<MobileNav />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { DimensionBreakdownPanel } from '../ticker/DimensionBreakdownPanel';
|
||||
import { RadarChart } from '../charts/horizon';
|
||||
import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
|
||||
|
||||
interface ScoreCardProps {
|
||||
@@ -18,9 +19,9 @@ function scoreColor(score: number): string {
|
||||
}
|
||||
|
||||
function ringGradient(score: number): string {
|
||||
if (score > 70) return '#10b981';
|
||||
if (score > 70) return '#2f9db2';
|
||||
if (score >= 40) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
return '#e36a58';
|
||||
}
|
||||
|
||||
function barGradient(score: number): string {
|
||||
@@ -64,7 +65,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
{showComposite && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{compositeScore !== null ? (
|
||||
<ScoreRing score={compositeScore} />
|
||||
) : (
|
||||
@@ -87,6 +88,18 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{dimensions.length >= 3 && (
|
||||
<div className="ml-auto" title="Score fingerprint — hover a corner for the exact value">
|
||||
<RadarChart
|
||||
axes={dimensions.map((d) => ({
|
||||
label: d.dimension.length > 9 ? `${d.dimension.slice(0, 8)}.` : d.dimension,
|
||||
full: d.dimension,
|
||||
value: d.score,
|
||||
}))}
|
||||
size={104}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user