'use client'; import { useEffect, useState, useCallback } from 'react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import type { PlotParams } from 'react-plotly.js'; import type { ForecastsResponse, ForecastData, Model } from '@/src/types/challenge'; import { getChallengeSeries, getSeriesData, getSeriesForecasts, getRoundModels } from '@/src/services/roundService'; import humanizeDuration from 'humanize-duration'; import { parse, toSeconds } from 'iso8601-duration'; import wrap from 'word-wrap'; // Dynamically import Plotly to avoid SSR issues const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); // Wrap a legend label: replace underscores with spaces and break at ~25 chars function wrapLegendLabel(label: string | undefined, width = 25): string { if (!label) return ''; const spaced = label.replace(/_/g, ' '); return wrap(spaced, { width, indent: '', trim: true, cut: false }).replace(/\n/g, '
'); } // Convert a hex color to an rgba() string function hexToRgba(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r},${g},${b},${alpha})`; } // CI bands to render: wider interval → lower alpha (more transparent) const CI_BANDS = [ { lower: 'q_0.2', upper: 'q_0.8', alpha: 0.12 }, { lower: 'q_0.3', upper: 'q_0.7', alpha: 0.22 }, ] as const; // Convert ISO 8601 duration to milliseconds function durationToMs(isoStr: string | undefined): number | null { if (!isoStr) return null; try { return toSeconds(parse(isoStr)) * 1000; } catch { return null; } } // Convert ISO 8601 duration to human-readable format function formatFrequency(freq: string | undefined): string { if (!freq) return 'N/A'; try { // Parse ISO 8601 duration string (e.g., "PT3M" for 3 minutes) const duration = parse(freq); // Convert to seconds, then to milliseconds const seconds = toSeconds(duration); const milliseconds = seconds * 1000; // Use humanize-duration to format return humanizeDuration(milliseconds, { largest: 2, round: true, conjunction: ' and ', serialComma: false }); } catch (error) { // If parsing fails, return the original string console.warn(`Failed to parse frequency "${freq}":`, error); return freq; } } interface SeriesDataItem { contextData: any[]; testData: any[]; series_id: number; series_name: string; unit?: string | null; forecasts?: ForecastsResponse; forecastsLoading?: boolean; forecastsError?: string; } interface TimeSeriesChartProps { challengeId: number; challengeName: string; challengeDescription?: string; registrationStart?: string; registrationEnd?: string; evaluationStart?: string; frequency?: string; horizon?: string; seriesId?: number; on_title_page?: boolean; definitionId?: number; status?: string; } export default function TimeSeriesChart({ challengeId, challengeName, challengeDescription, registrationStart, registrationEnd, evaluationStart, frequency, horizon, seriesId, on_title_page = false, definitionId, status }: TimeSeriesChartProps) { const [seriesData, setSeriesData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedSeries, setExpandedSeries] = useState>(new Set()); // Forecast filter state const [maxSizeFilter, setMaxSizeFilter] = useState(''); const [architectureFilter, setArchitectureFilter] = useState(''); const [modelSearchFilter, setModelSearchFilter] = useState(''); // Models data const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); // Function to load data and forecasts for a specific series const loadForecastsForSeries = useCallback(async (seriesId: number) => { // Check if data is already loaded or loading setSeriesData(prev => { const series = prev.find(s => s.series_id === seriesId); if (!series || (series.forecasts && series.contextData?.length > 0)) { return prev; } // Mark as loading return prev.map(s => s.series_id === seriesId ? { ...s, forecastsLoading: true, forecastsError: undefined } : s ); }); try { // Get the series metadata to fetch context and test data const series = await getChallengeSeries(challengeId); const currentSeries = series.find(s => s.series_id === seriesId); if (!currentSeries) { throw new Error(`Series ${seriesId} not found`); } const startTime = currentSeries.context_start_time; const endTimeContext = currentSeries.context_end_time; const endTimeTest = currentSeries.end_time; if (!startTime || !endTimeContext || !endTimeTest) { throw new Error(`Missing time range for series ${seriesId}`); } // Load context data, test data, and forecasts in parallel const [dataContext, dataTest, forecasts] = await Promise.all([ getSeriesData(challengeId, seriesId, startTime, endTimeContext), getSeriesData(challengeId, seriesId, endTimeContext, endTimeTest), getSeriesForecasts(challengeId, seriesId) ]); const modelCount = forecasts?.forecasts ? Object.keys(forecasts.forecasts).length : 0; console.log(`✓ Loaded ${dataContext.data?.length || 0} context data points for series ${seriesId}`); console.log(`✓ Loaded ${dataTest.data?.length || 0} test data points for series ${seriesId}`); console.log(`✓ Loaded ${modelCount} forecast models for series ${seriesId}`); // Update with loaded data and forecasts setSeriesData(prev => prev.map(s => s.series_id === seriesId ? { ...s, contextData: dataContext.data, testData: dataTest.data, unit: currentSeries.unit, forecasts, forecastsLoading: false } : s )); } catch (err) { console.error(`Failed to fetch data for series ${seriesId}:`, err); setSeriesData(prev => prev.map(s => s.series_id === seriesId ? { ...s, forecastsLoading: false, forecastsError: 'Failed to load data' } : s )); } }, [challengeId]); const toggleSeries = (seriesId: number) => { setExpandedSeries(prev => { const newSet = new Set(prev); if (newSet.has(seriesId)) { newSet.delete(seriesId); } else { newSet.add(seriesId); // Load forecasts when expanding loadForecastsForSeries(seriesId); } return newSet; }); }; // Fetch models for the challenge useEffect(() => { async function fetchModels() { try { setModelsLoading(true); const modelsData = await getRoundModels(challengeId); // API returns models as a direct array const modelsList = Array.isArray(modelsData) ? modelsData : []; setModels(modelsList); } catch (err) { console.error('Failed to fetch models:', err); setModels([]); } finally { setModelsLoading(false); } } fetchModels(); }, [challengeId]); useEffect(() => { async function fetchData() { try { setLoading(true); setError(null); // Fetch all series for this challenge const series = await getChallengeSeries(challengeId); console.log(`Fetched ${series.length} series for challenge ${challengeId}`); // Filter by seriesId if provided const filteredSeries = seriesId ? series.filter(s => s.series_id === seriesId) : series; if (seriesId && filteredSeries.length === 0) { console.warn(`Series ${seriesId} not found in challenge ${challengeId}`); } // Create series items with metadata only (data will be loaded on demand) const validData = filteredSeries.map((s) => ({ contextData: [], testData: [], series_id: s.series_id, series_name: s.name, forecasts: undefined, forecastsLoading: false, forecastsError: undefined } as SeriesDataItem)); setSeriesData(validData); // Expand the first series by default and load its data if (validData.length > 0) { const firstSeriesId = validData[0].series_id; setExpandedSeries(new Set([firstSeriesId])); // Load data and forecasts for the first series loadForecastsForSeries(firstSeriesId); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load time series data'); } finally { setLoading(false); } } fetchData(); }, [challengeId, loadForecastsForSeries, seriesId]); if (loading) { return (
); } if (error) { return (

Error loading time series data

{error}

); } if (seriesData.length === 0) { return (
No time series data available for this challenge.
); } // Filter forecasts based on user input by matching with models const filterForecasts = (forecasts: Record) => { const filtered = Object.entries(forecasts).filter(([modelName, forecastData]) => { // Find the matching model from the models list const model = models.find(m => { const match1 = m.name === modelName; const match2 = m.readable_id === forecastData.model_id; const match3 = m.readable_id === modelName; return match1 || match2 || match3; }); if (!model) { console.log(' ❌ NO MODEL FOUND'); return false; } // Filter by max_size - use model data if available if (maxSizeFilter) { const filterValue = parseFloat(maxSizeFilter); if (!isNaN(filterValue)) { if (model.model_size !== undefined && model.model_size > filterValue) { return false; } } } // Filter by architecture - use model data if available if (architectureFilter) { if (model.architecture) { if (!model.architecture.toLowerCase().includes(architectureFilter.toLowerCase())) { return false; } } else { return false; } } // Filter by model name/ID search if (modelSearchFilter) { const searchLower = modelSearchFilter.toLowerCase(); const modelIdMatch = model.readable_id?.toLowerCase() === searchLower; const modelNameMatch = model.name?.toLowerCase().includes(searchLower); if (!modelIdMatch && !modelNameMatch) { return false; } } return true; }).reduce((acc, [key, value]) => { acc[key] = value; return acc; }, {} as Record); return filtered; }; // Get unique architectures and max sizes for filter options from models data const getFilterOptions = () => { const architectures = new Set(); const maxSizes = new Set(); models.forEach(model => { if (model.architecture) architectures.add(model.architecture); if (model.model_size !== undefined) maxSizes.add(model.model_size); }); return { architectures: Array.from(architectures).sort(), maxSizes: Array.from(maxSizes).sort((a, b) => a - b) }; }; const filterOptions = getFilterOptions(); return (
{on_title_page && definitionId ? (
Now Live!

Currently competing in challenge:{' '} {challengeName}

) : (

{challengeName}

)} {!on_title_page && ( <> {challengeDescription && (

{challengeDescription}

)}

Status

{status || 'N/A'}

ID

{challengeId}

Series

{seriesData.length}

Models

{seriesData.length > 0 && seriesData[0].forecasts?.forecasts ? Object.keys(seriesData[0].forecasts.forecasts).length : 0}

Context

{seriesData.length > 0 && seriesData[0].contextData?.length ? seriesData[0].contextData.length : 'N/A'}

Frequency

{formatFrequency(frequency)}

Horizon

{horizon || 'N/A'}

Registration Start

{registrationStart ? new Date(registrationStart).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) : 'N/A'}

Registration End

{registrationEnd ? new Date(registrationEnd).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) : 'N/A'}

Start of Evaluation

{evaluationStart ? new Date(evaluationStart).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) : 'N/A'}

)} {/* Forecast Filters */} {!on_title_page && (

Filter Forecasts

{/* Model Search */}
setModelSearchFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
{/* Max Size Filter */}
setMaxSizeFilter(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> {filterOptions.maxSizes.length > 0 && (

Available: {filterOptions.maxSizes.join(', ')}

)}
{/* Architecture Filter */}
{/* Clear Filters Button */} {(maxSizeFilter || architectureFilter || modelSearchFilter) && (
)}
)}
{seriesData.map((series: any) => { // Prepare traces for context and test data const traces: any[] = []; // Ground truth trace (solid black line) — pushed first so it renders behind all other traces if (series.testData && series.testData.length > 0) { traces.push({ x: series.testData.map((d: any) => d.ts), y: series.testData.map((d: any) => d.value), type: 'scatter', mode: 'lines', name: wrapLegendLabel(`Live: ${series.series_name || series.series_id}`), line: { width: 2, color: '#000000', dash: 'solid' }, legendgroup: 'actual', legendgrouptitle: { text: 'Observations' }, hovertemplate: '%{x|%Y-%m-%d %H:%M} UTC
Value: %{y:.4g}%{fullData.name}', }); } // Context data trace (solid blue line) if (series.contextData && series.contextData.length > 0) { traces.push({ x: series.contextData.map((d: any) => d.ts), y: series.contextData.map((d: any) => d.value), type: 'scatter', mode: 'lines', name: 'Historical Data', line: { width: 2, color: '#2563eb' }, marker: { size: 4 }, legendgroup: 'actual', hovertemplate: '%{x|%Y-%m-%d %H:%M} UTC
Value: %{y:.4g}%{fullData.name}', }); } // Add forecast traces for each model if (series.forecasts?.forecasts) { const colors = ['#dc2626', '#16a34a', '#9333ea', '#ea580c', '#0891b2', '#ca8a04']; // Get the last context data point to connect forecasts const lastActualPoint = series.contextData && series.contextData.length > 0 ? series.contextData[series.contextData.length - 1] : null; // Apply filters to forecasts const filteredForecasts = filterForecasts(series.forecasts.forecasts); // Sort models by MASE (ascending - best first) const modelEntries = Object.entries(filteredForecasts) .map(([modelName, forecastData]: [string, ForecastData]) => ({ modelName, forecastData, label: forecastData?.label, mase: forecastData?.current_mase })) .sort((a, b) => { // Handle undefined/null MASE values - put them at the end if (a.mase === undefined || a.mase === null) return 1; if (b.mase === undefined || b.mase === null) return -1; return a.mase - b.mase; }); let visibleForecastCount = 0; modelEntries.forEach(({ modelName, forecastData, label, mase }, idx) => { const dataArray = forecastData?.data; if (Array.isArray(dataArray) && dataArray.length > 0 && lastActualPoint) { const displayName = wrapLegendLabel( mase !== undefined && mase !== null ? `${label} (MASE: ${typeof mase === 'number' ? mase.toFixed(3) : mase})` : label ); // Prepend the last actual point to connect the forecast line const connectedX = [lastActualPoint.ts, ...dataArray.map((d: any) => d.ts)]; const connectedY = [lastActualPoint.value, ...dataArray.map((d: any) => d.y)]; // Show only the best 2 forecasts by default const isVisible = visibleForecastCount < 2; visibleForecastCount++; const color = colors[idx % colors.length]; console.log(`CI bounds for model "${modelName}":`, dataArray[0]?.ci ?? 'none'); // CI bands: push upper + lower (fill tonexty) pairs BEFORE the forecast // line so the line renders on top. Each pair must be consecutive for // fill: 'tonexty' to fill between them correctly. CI_BANDS.forEach(({ lower, upper, alpha }) => { const hasCI = dataArray.some( (d: any) => d.ci?.[lower] !== undefined && d.ci?.[upper] !== undefined ); if (!hasCI) return; const ciUpperY = [ lastActualPoint.value, ...dataArray.map((d: any) => d.ci?.[upper] ?? d.y), ]; const ciLowerY = [ lastActualPoint.value, ...dataArray.map((d: any) => d.ci?.[lower] ?? d.y), ]; // Upper bound (invisible line, no legend entry) traces.push({ x: connectedX, y: ciUpperY, type: 'scatter', mode: 'lines', line: { width: 0, color: 'transparent' }, showlegend: false, legendgroup: `forecast-${idx}`, visible: isVisible ? true : 'legendonly', hoverinfo: 'skip', }); // Lower bound – fills to the upper bound trace above it traces.push({ x: connectedX, y: ciLowerY, type: 'scatter', mode: 'lines', fill: 'tonexty', fillcolor: hexToRgba(color, alpha), line: { width: 0, color: 'transparent' }, showlegend: false, legendgroup: `forecast-${idx}`, visible: isVisible ? true : 'legendonly', hoverinfo: 'skip', }); }); // Forecast line rendered on top of CI bands traces.push({ x: connectedX, y: connectedY, type: 'scatter', mode: 'lines', name: displayName, line: { width: 2, dash: 'dash', color }, visible: isVisible ? true : 'legendonly', legendgroup: `forecast-${idx}`, legendgrouptitle: idx === 0 ? { text: 'Forecasts' } : undefined, hovertemplate: '%{x|%Y-%m-%d %H:%M} UTC
Value: %{y:.4g}%{fullData.name}', }); } }); } const plotData: PlotParams['data'] = traces; // Compute default zoom: show last 3× horizon of context + full forecast window. const defaultXRange: [string, string] | undefined = (() => { const horizonMs = durationToMs(horizon); if (!horizonMs || !series.contextData?.length) { return undefined; } const contextEnd = new Date( series.contextData[series.contextData.length - 1].ts ); const rangeStart = new Date(contextEnd.getTime() - 3 * horizonMs); const rangeEnd = new Date(contextEnd.getTime() + 1.1 * horizonMs); return [rangeStart.toISOString(), rangeEnd.toISOString()]; })(); const layout: PlotParams['layout'] = { xaxis: { title: { text: '' }, showgrid: true, type: 'date', domain: [0, 0.82], range: defaultXRange, rangeslider: { visible: true }, }, yaxis: { title: { text: series.unit ?? '' }, autorange: true, showgrid: true, }, hovermode: 'closest', showlegend: true, legend: { orientation: 'v', yanchor: 'top', y: 1, xanchor: 'left', x: 0.85, font: { size: 10 }, bgcolor: 'rgba(255, 255, 255, 0.95)', bordercolor: '#E5E7EB', borderwidth: 1, }, autosize: true, margin: { l: 60, r: 20, t: 60, b: 50 }, uirevision: "static" }; const isExpanded = expandedSeries.has(series.series_id); return (
{isExpanded && (
{series.forecastsLoading && (

Loading forecasts...

)} {series.forecastsError && !series.forecastsLoading && (

Warning

{series.forecastsError}

)} {series.contextData.length > 0 && ( )}
)}
); })}
); }