'use client'; import { useState } from 'react'; import Link from 'next/link'; import dynamic from 'next/dynamic'; import type { PlotParams } from 'react-plotly.js'; import type { ArenaScoreScopeHistory } from '@/src/services/modelService'; import { useIsMobile } from '@/src/hooks/useIsMobile'; import { MOBILE_PLOT_CONFIG, MOBILE_PLOT_HEIGHT, MOBILE_PLOT_MARGIN } from '@/src/components/plotMobile'; import RankFigure from '@/src/components/RankFigure'; import WithReason from '@/src/components/WithReason'; import { describeRank, formatInterval, formatScore } from '@/src/lib/arenaScore'; import { ChevronDown, ChevronRight } from 'lucide-react'; // Dynamically import Plotly to avoid SSR issues. `PlotlyPlot` is the app's // single Plotly entry point — see the note there on why it is not // `react-plotly.js` directly. const Plot = dynamic(() => import('@/src/components/PlotlyPlot'), { ssr: false }); interface ModelPerformanceChartsProps { scopes: ArenaScoreScopeHistory[]; } export default function ModelPerformanceCharts({ scopes }: ModelPerformanceChartsProps) { const [expandedScopes, setExpandedScopes] = useState>({}); const isMobile = useIsMobile(); const toggleScope = (uniqueKey: string) => { setExpandedScopes(prev => ({ ...prev, [uniqueKey]: !prev[uniqueKey] })); }; const formatFrequencyHorizon = (scopeId: string) => { // Remove everything after comma const cleanedScopeId = scopeId.split(',')[0].trim(); // Split by :: const [frequencyPart, horizonPart] = cleanedScopeId.split('::'); // Parse frequency (format: H:MM:SS) const [hours, minutes, seconds] = frequencyPart.split(':').map(v => parseInt(v, 10)); let frequencyDisplay = ''; if (hours > 0) { frequencyDisplay = `${hours}h`; } else if (minutes > 0) { frequencyDisplay = `${minutes}min`; } else if (seconds > 0) { frequencyDisplay = `${seconds}s`; } else { frequencyDisplay = 'instant'; } return { frequency: frequencyDisplay, horizon: horizonPart?.trim() || 'N/A' }; }; const getChartTitle = (scope: ArenaScoreScopeHistory) => { if (scope.scope_type === 'global') { return 'Overall Ranking'; } else if (scope.scope_type === 'definition') { return scope.definition_name ?? `Challenge ${scope.definition_id ?? scope.scope_id}`; } else if (scope.scope_type === 'frequency_horizon' && scope.scope_id) { const { frequency, horizon } = formatFrequencyHorizon(scope.scope_id); return (
Horizon: {horizon}, Frequency: {frequency} ({scope.scope_id})
); } return scope.scope_type; // fallback }; if (!scopes || scopes.length === 0) { return (
No ranking data available for this model.
); } // Sort scopes to show global first, then definitions (alphabetically), then frequency_horizon const sortedScopes = [...scopes].sort((a, b) => { // Global always comes first if (a.scope_type === 'global') return -1; if (b.scope_type === 'global') return 1; // Frequency_horizon always comes last if (a.scope_type === 'frequency_horizon') return 1; if (b.scope_type === 'frequency_horizon') return -1; // Both are definitions, sort alphabetically by name return (a.definition_name ?? '').localeCompare(b.definition_name ?? ''); }); // Group scopes by scope_type const groupedScopes = sortedScopes.reduce((acc, scope) => { if (!acc[scope.scope_type]) { acc[scope.scope_type] = []; } acc[scope.scope_type].push(scope); return acc; }, {} as Record); const renderSectionHeader = (scopeType: string) => { if (scopeType === 'global') return null; if (scopeType === 'definition') { return (

Challenge Definitions

Rankings evaluated for each individual challenge definition.

); } if (scopeType === 'frequency_horizon') { return (

Frequency & Horizon Combinations

Rankings evaluated across different forecast frequency and horizon configurations.

); } return null; }; return (
{['global', 'definition', 'frequency_horizon'].map((scopeType) => { const scopesOfType = groupedScopes[scopeType]; if (!scopesOfType || scopesOfType.length === 0) return null; return (
{renderSectionHeader(scopeType)} {scopesOfType.map((scope) => { const uniqueKey = `${scope.scope_type}-${scope.scope_id ?? ''}`; const isExpanded = expandedScopes[uniqueKey]; const chartTitle = getChartTitle(scope); // Sort the days by date const days = [...scope.daily_scores].sort((a, b) => new Date(a.calculation_date).getTime() - new Date(b.calculation_date).getTime() ); const latest = days.length > 0 ? days[days.length - 1] : null; const latestRank = latest ? describeRank(latest) : null; const hasScores = days.some(d => d.score !== null); // Prepare data for Plotly const dates = days.map(d => d.calculation_date); const scores = days.map(d => d.score); const upper = days.map(d => d.ci_upper); const lower = days.map(d => d.ci_lower); const rankTexts = days.map(d => describeRank(d).text); const plotData: PlotParams['data'] = [ { x: dates, y: lower, fill: 'none', line: { color: 'transparent' }, name: '95 % CI lower', type: 'scatter', mode: 'lines', showlegend: false, hoverinfo: 'skip', }, { x: dates, y: upper, fill: 'tonexty', fillcolor: 'rgba(59, 130, 246, 0.2)', line: { color: 'transparent' }, name: '95 % CI upper', type: 'scatter', mode: 'lines', showlegend: false, hoverinfo: 'skip', }, { x: dates, y: scores, name: 'Arena Score', type: 'scatter', mode: 'lines+markers', line: { color: 'rgb(59, 130, 246)', width: 2 }, marker: { color: 'rgb(59, 130, 246)', size: 6 }, customdata: rankTexts, hovertemplate: 'Date: %{x}
' + 'Arena Score: %{y:.1f}
' + 'Rank: %{customdata}
' + '', }, ]; const layout: PlotParams['layout'] = isMobile ? { // No plot title on a phone: the accordion header above already // names the scope, and the y-axis title names the metric, so the // title would only cost 50px of a 360px-tall chart. xaxis: { gridcolor: '#e5e7eb', showgrid: true, automargin: true, nticks: 4, tickangle: 0, tickfont: { size: 10 }, }, yaxis: { title: { text: 'Arena Score' }, gridcolor: '#e5e7eb', showgrid: true, automargin: true, tickfont: { size: 10 }, }, hovermode: 'closest', // The only legend entry duplicates the y-axis title, and it sits // on top of the plot at (0, 1) — drop it rather than replace it. showlegend: false, dragmode: false, autosize: true, margin: MOBILE_PLOT_MARGIN, paper_bgcolor: 'white', plot_bgcolor: 'white', } : { title: { text: 'Arena Score Over Time' }, xaxis: { title: { text: 'Date' }, gridcolor: '#e5e7eb', showgrid: true, }, yaxis: { title: { text: 'Arena Score' }, gridcolor: '#e5e7eb', showgrid: true, }, hovermode: 'closest', showlegend: true, legend: { x: 0, y: 1, bgcolor: 'rgba(255, 255, 255, 0.8)', }, margin: { l: 60, r: 40, t: 50, b: 60 }, paper_bgcolor: 'white', plot_bgcolor: 'white', }; return (
{isExpanded && latest && latestRank && hasScores && (
{/* Current stats */}
Current Rank
{latestRank.bounds ? ( ) : ( {latestRank.text} )}
Current Arena Score
{latest.score === null ? '–' : formatScore(latest.score)}
95 % CI
{formatInterval(latest.ci_lower, latest.ci_upper) ?? '–'}
)} {isExpanded && !(latest && hasScores) && (
No score history available for this ranking.
)}
); })}
); })}
); }