'use client'; import React, { useState } from 'react'; import dynamic from 'next/dynamic'; import type { PlotParams } from 'react-plotly.js'; import { DefinitionWithSeries, getModelSeriesForecasts } from '@/src/services/modelService'; import { ModelSeriesForecastsResponse } from '../types/challenge'; import { ChevronDown, ChevronUp } from 'lucide-react'; // Dynamically import Plotly to avoid SSR issues const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); interface ModelSeriesListProps { definitions: DefinitionWithSeries[]; modelId: string; } interface ExpandedSeriesData { loading: boolean; error?: string; data?: ModelSeriesForecastsResponse; } export default function ModelSeriesList({ definitions, modelId }: ModelSeriesListProps) { const [expandedDefinitions, setExpandedDefinitions] = useState>(() => { // Initialize with the first definition expanded if (definitions && definitions.length > 0) { return new Set([definitions[0].definition_id]); } return new Set(); }); const [expandedSeries, setExpandedSeries] = useState>(new Set()); const [seriesData, setSeriesData] = useState>(new Map()); const [dateFilters, setDateFilters] = useState>(new Map()); const [pendingDateFilters, setPendingDateFilters] = useState>(new Map()); const toggleDefinition = (definitionId: number) => { setExpandedDefinitions(prev => { const newSet = new Set(prev); if (newSet.has(definitionId)) { newSet.delete(definitionId); } else { newSet.add(definitionId); } return newSet; }); }; const fetchSeriesData = async (seriesId: number, definitionId: number, startDate?: string, endDate?: string) => { const cacheKey = `${seriesId}-${definitionId}`; setSeriesData(prev => new Map(prev).set(cacheKey, { loading: true })); try { const data = await getModelSeriesForecasts(modelId, definitionId, seriesId, startDate, endDate); const roundsWithForecasts = data.rounds.filter(r => r.forecast_exists && r.forecasts && r.forecasts.length > 0); console.log(`Series ${seriesId} (${data.series_name}):`, { totalRounds: data.rounds.length, roundsWithForecasts: roundsWithForecasts.length, rounds: roundsWithForecasts.map(r => ({ id: r.round_id, name: r.round_name, points: r.forecasts?.length || 0, forecasts: r.forecasts })) }); setSeriesData(prev => new Map(prev).set(cacheKey, { loading: false, data })); } catch (error) { console.error('Error fetching forecasts:', error); setSeriesData(prev => new Map(prev).set(cacheKey, { loading: false, error: 'Failed to load forecast data' })); } }; const toggleSeries = async (seriesId: number, definitionId: number) => { const cacheKey = `${seriesId}-${definitionId}`; const isExpanding = !expandedSeries.has(cacheKey); setExpandedSeries(prev => { const newSet = new Set(prev); if (newSet.has(cacheKey)) { newSet.delete(cacheKey); } else { newSet.add(cacheKey); } return newSet; }); // If expanding and we don't have data yet, fetch it if (isExpanding && !seriesData.has(cacheKey)) { let filters = dateFilters.get(cacheKey); // Set default start date to 30 days ago if not already set if (!filters?.startDate) { const defaultStartDate = new Date(); defaultStartDate.setDate(defaultStartDate.getDate() - 30); const startDateStr = defaultStartDate.toISOString().split('T')[0]; filters = { ...filters, startDate: startDateStr }; setDateFilters(prev => new Map(prev).set(cacheKey, filters!)); } await fetchSeriesData(seriesId, definitionId, filters?.startDate, filters?.endDate); } }; const handleDateChange = (seriesId: number, definitionId: number, type: 'start' | 'end', value: string) => { const cacheKey = `${seriesId}-${definitionId}`; const currentFilters = pendingDateFilters.get(cacheKey) || dateFilters.get(cacheKey) || {}; const newFilters = { ...currentFilters, [type === 'start' ? 'startDate' : 'endDate']: value }; setPendingDateFilters(prev => new Map(prev).set(cacheKey, newFilters)); }; const applyDateFilters = async (seriesId: number, definitionId: number) => { const cacheKey = `${seriesId}-${definitionId}`; const pending = pendingDateFilters.get(cacheKey); if (pending) { setDateFilters(prev => new Map(prev).set(cacheKey, pending)); setPendingDateFilters(prev => { const newMap = new Map(prev); newMap.delete(cacheKey); return newMap; }); await fetchSeriesData(seriesId, definitionId, pending.startDate, pending.endDate); } }; const renderPlot = (seriesId: number, definitionId: number) => { const cacheKey = `${seriesId}-${definitionId}`; const data = seriesData.get(cacheKey); const filters = dateFilters.get(cacheKey); if (!data) return null; if (data.loading) { return (
); } if (data.error) { return (

{data.error}

); } if (!data.data) return null; // Prepare plot data from rounds - keep colors per round but display as continuous timeline const traces: any[] = []; const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']; // Filter and sort rounds by their start time to display chronologically const roundsWithForecasts = data.data.rounds .filter(round => round.forecast_exists && round.forecasts && round.forecasts.length > 0) .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); // Add ground truth trace if available if (data.data.ground_truth && data.data.ground_truth.length > 0) { traces.push({ x: data.data.ground_truth.map(p => p.ts), y: data.data.ground_truth.map(p => p.value), type: 'scatter', mode: 'lines', name: 'Ground Truth', line: { width: 2, color: '#000000', dash: 'solid' }, marker: { size: 4 }, hovertemplate: '%{x|%Y-%m-%d %H:%M} UTC
Value: %{y:.4g}%{fullData.name}', }); } roundsWithForecasts.forEach((round, idx) => { const forecastPoints = round.forecasts!; const color = colors[idx % colors.length]; // Main forecast line for this round traces.push({ x: forecastPoints.map(p => p.ts), y: forecastPoints.map(p => p.y), type: 'scatter', mode: 'lines', name: round.round_name, line: { width: 2, color: color }, marker: { size: 3 }, hovertemplate: '%{x|%Y-%m-%d %H:%M} UTC
Value: %{y:.4g}%{fullData.name}', }); }); if (traces.length === 0) { return (
No forecast data available for this series.
); } // Calculate the maximum date from all forecast data let maxDate: Date | undefined = undefined; for (const round of roundsWithForecasts) { if (round.forecasts) { for (const point of round.forecasts) { const pointDate = new Date(point.ts); if (!maxDate || pointDate > maxDate) { maxDate = pointDate; } } } } // Use calculated maxDate if endDate filter is not set const effectiveEndDate = filters?.endDate || (maxDate ? maxDate.toISOString().split('T')[0] : undefined); const xAxisRange = (filters?.startDate || effectiveEndDate) ? [ filters?.startDate, effectiveEndDate ] : undefined; console.log(`X-axis range for series ${seriesId}:`, { from: filters?.startDate, to: filters?.endDate, effectiveEndDate, maxDateFromData: maxDate ? maxDate.toISOString() : null, range: xAxisRange }); const layout: Partial = { xaxis: { title: { text: '' }, type: 'date', range: xAxisRange, rangeslider: { visible: true }, }, yaxis: { title: { text: data.data.series_unit ?? '' }, }, hovermode: 'closest', showlegend: true, legend: { x: 1.02, y: 1, xanchor: 'left', yanchor: 'top', }, margin: { l: 60, r: 200, t: 60, b: 60 }, autosize: true, }; const config: PlotParams['config'] = { responsive: true, displayModeBar: true, displaylogo: false, modeBarButtonsToRemove: ['lasso2d', 'select2d'], }; return (
); }; if (!definitions || definitions.length === 0) { return (
No series data available for this model.
); } return (
{definitions.map((definition) => { const isExpanded = expandedDefinitions.has(definition.definition_id); const totalRounds = definition.series.reduce((sum, s) => sum + s.rounds_participated, 0); return ( {isExpanded && definition.series.map((series) => { const cacheKey = `${series.series_id}-${definition.definition_id}`; const isSeriesExpanded = expandedSeries.has(cacheKey); return ( {isSeriesExpanded && ( )} ); })} ); })}
Challenge Definition Series Count Total Rounds Details
{definition.definition_name}
{definition.series.length} {totalRounds}
└─
{series.rounds_participated}
{renderPlot(series.series_id, definition.definition_id)}
); }