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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user