178 lines
7.7 KiB
TypeScript
178 lines
7.7 KiB
TypeScript
import { useState } from 'react';
|
||
import { DimensionBreakdownPanel } from '../ticker/DimensionBreakdownPanel';
|
||
import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
|
||
|
||
interface ScoreCardProps {
|
||
compositeScore: number | null;
|
||
dimensions: DimensionScoreDetail[];
|
||
compositeBreakdown?: CompositeBreakdown | null;
|
||
/** Hide the composite ring/header when the composite is shown elsewhere
|
||
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
|
||
showComposite?: boolean;
|
||
}
|
||
|
||
function scoreColor(score: number): string {
|
||
if (score > 70) return 'text-emerald-400';
|
||
if (score >= 40) return 'text-amber-400';
|
||
return 'text-red-400';
|
||
}
|
||
|
||
function ringGradient(score: number): string {
|
||
if (score > 70) return '#10b981';
|
||
if (score >= 40) return '#f59e0b';
|
||
return '#ef4444';
|
||
}
|
||
|
||
function barGradient(score: number): string {
|
||
if (score > 70) return 'from-emerald-500 to-emerald-400';
|
||
if (score >= 40) return 'from-amber-500 to-amber-400';
|
||
return 'from-red-500 to-red-400';
|
||
}
|
||
|
||
function ScoreRing({ score }: { score: number }) {
|
||
const radius = 36;
|
||
const circumference = 2 * Math.PI * radius;
|
||
const clamped = Math.max(0, Math.min(100, score));
|
||
const offset = circumference - (clamped / 100) * circumference;
|
||
const color = ringGradient(score);
|
||
|
||
return (
|
||
<div className="relative inline-flex items-center justify-center">
|
||
<svg width="88" height="88" className="-rotate-90">
|
||
<circle cx="44" cy="44" r={radius} fill="none" strokeWidth="6" className="stroke-white/[0.06]" />
|
||
<circle
|
||
cx="44" cy="44" r={radius} fill="none" strokeWidth="6" strokeLinecap="round"
|
||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||
stroke={color}
|
||
style={{ filter: `drop-shadow(0 0 8px ${color}40)`, transition: 'all 0.6s ease' }}
|
||
/>
|
||
</svg>
|
||
<span className={`absolute text-lg font-bold ${scoreColor(score)}`}>
|
||
{Math.round(score)}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, showComposite = true }: ScoreCardProps) {
|
||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||
|
||
const toggleExpand = (dimension: string) => {
|
||
setExpanded((prev) => ({ ...prev, [dimension]: !prev[dimension] }));
|
||
};
|
||
|
||
return (
|
||
<div className="glass p-5">
|
||
{showComposite && (
|
||
<div className="flex items-center gap-4">
|
||
{compositeScore !== null ? (
|
||
<ScoreRing score={compositeScore} />
|
||
) : (
|
||
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
|
||
)}
|
||
<div>
|
||
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
|
||
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
|
||
{compositeScore !== null ? Math.round(compositeScore) : '—'}
|
||
</p>
|
||
{compositeBreakdown && (
|
||
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[220px]" data-testid="renorm-explanation">
|
||
{compositeBreakdown.sentiment_adjustment != null &&
|
||
compositeBreakdown.base_score != null &&
|
||
Math.abs(compositeBreakdown.sentiment_adjustment) >= 0.05
|
||
? `Base ${Math.round(compositeBreakdown.base_score)} · sentiment ${
|
||
compositeBreakdown.sentiment_adjustment >= 0 ? '+' : '−'
|
||
}${Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}`
|
||
: 'Weighted base of the other dimensions; sentiment adjusts it up or down.'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{dimensions.length > 0 && (
|
||
<div className={`${showComposite ? 'mt-5' : ''} space-y-1`}>
|
||
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
|
||
{dimensions.map((d) => {
|
||
const isExpanded = expanded[d.dimension] ?? false;
|
||
const weight = compositeBreakdown?.renormalized_weights?.[d.dimension]
|
||
?? compositeBreakdown?.weights?.[d.dimension];
|
||
|
||
return (
|
||
<div key={d.dimension}>
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between text-sm py-1 hover:bg-white/[0.03] rounded transition-colors"
|
||
onClick={() => d.breakdown && toggleExpand(d.dimension)}
|
||
data-testid={`dimension-row-${d.dimension}`}
|
||
>
|
||
<span className="text-gray-300 capitalize flex items-center gap-1.5">
|
||
{d.breakdown && (
|
||
<span className="text-gray-500 text-[10px]">{isExpanded ? '▾' : '▸'}</span>
|
||
)}
|
||
{d.dimension}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
{d.dimension === 'sentiment' && compositeBreakdown?.sentiment_adjustment != null ? (
|
||
<span
|
||
className={`text-[10px] tabular-nums ${
|
||
compositeBreakdown.sentiment_adjustment > 0.05
|
||
? 'text-emerald-400/80'
|
||
: compositeBreakdown.sentiment_adjustment < -0.05
|
||
? 'text-red-400/80'
|
||
: 'text-gray-500'
|
||
}`}
|
||
data-testid="weight-sentiment"
|
||
title="Points sentiment adds to or subtracts from the base composite"
|
||
>
|
||
{compositeBreakdown.sentiment_adjustment >= 0 ? '+' : '−'}
|
||
{Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}
|
||
</span>
|
||
) : weight != null ? (
|
||
<span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
|
||
{Math.round(weight * 100)}%
|
||
</span>
|
||
) : null}
|
||
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
|
||
<div
|
||
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
|
||
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
|
||
/>
|
||
</div>
|
||
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
|
||
{Math.round(d.score)}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
{isExpanded && d.breakdown && (
|
||
<div className="ml-4 mt-1 mb-2 pl-3 border-l border-white/[0.06]">
|
||
<DimensionBreakdownPanel breakdown={d.breakdown} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Missing dimensions */}
|
||
{compositeBreakdown && compositeBreakdown.missing_dimensions.length > 0 && (
|
||
<div className="mt-2 space-y-1">
|
||
{compositeBreakdown.missing_dimensions
|
||
.filter((dim) => !dimensions.some((d) => d.dimension === dim))
|
||
.map((dim) => (
|
||
<div
|
||
key={dim}
|
||
className="flex items-center justify-between text-sm py-1 opacity-40"
|
||
data-testid={`missing-dimension-${dim}`}
|
||
>
|
||
<span className="text-gray-500 capitalize">{dim}</span>
|
||
<span className="text-[10px] text-gray-600 italic">redistributed</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|