TS-Arena / src /components /ModelSeriesList.tsx
Henrik Albers
fix caching of plots on model page
2100f4a
'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<Set<number>>(() => {
// 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<Set<string>>(new Set());
const [seriesData, setSeriesData] = useState<Map<string, ExpandedSeriesData>>(new Map());
const [dateFilters, setDateFilters] = useState<Map<string, { startDate?: string; endDate?: string }>>(new Map());
const [pendingDateFilters, setPendingDateFilters] = useState<Map<string, { startDate?: string; endDate?: string }>>(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 (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
);
}
if (data.error) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg">
<p className="text-sm">{data.error}</p>
</div>
);
}
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<br>Value: %{y:.4g}<extra>%{fullData.name}</extra>',
});
}
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<br>Value: %{y:.4g}<extra>%{fullData.name}</extra>',
});
});
if (traces.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No forecast data available for this series.
</div>
);
}
// 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<PlotParams['layout']> = {
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 (
<div className="w-full" style={{ minHeight: '400px' }}>
<Plot
data={traces}
layout={layout}
config={config}
style={{ width: '100%', height: '100%' }}
useResizeHandler={true}
/>
</div>
);
};
if (!definitions || definitions.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No series data available for this model.
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<button
onClick={() => {
if (expandedDefinitions.size === definitions.length) {
setExpandedDefinitions(new Set());
} else {
setExpandedDefinitions(new Set(definitions.map(d => d.definition_id)));
}
}}
className="w-full px-6 py-4 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors"
>
<h3 className="text-lg font-semibold text-gray-900">
Series by Challenge Definition ({definitions.length} definitions)
</h3>
<span className="text-gray-600 text-xl">
{expandedDefinitions.size === definitions.length ? '−' : '+'}
</span>
</button>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Challenge Definition
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Series Count
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Rounds
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Details
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{definitions.map((definition) => {
const isExpanded = expandedDefinitions.has(definition.definition_id);
const totalRounds = definition.series.reduce((sum, s) => sum + s.rounds_participated, 0);
return (
<React.Fragment key={definition.definition_id}>
<tr className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-gray-900 max-w-md">
<div className="break-words">{definition.definition_name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
{definition.series.length}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
{totalRounds}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<button
onClick={() => toggleDefinition(definition.definition_id)}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
{isExpanded ? 'Hide' : 'Show'}
</button>
</td>
</tr>
{isExpanded && definition.series.map((series) => {
const cacheKey = `${series.series_id}-${definition.definition_id}`;
const isSeriesExpanded = expandedSeries.has(cacheKey);
return (
<React.Fragment key={series.series_id}>
<tr className="bg-gray-50">
<td className="px-6 py-3 text-sm text-gray-900 pl-12">
<div className="flex items-center gap-2">
<span className="text-gray-400">└─</span>
<button
onClick={() => toggleSeries(series.series_id, definition.definition_id)}
className="flex items-center gap-2 hover:text-blue-600 transition-colors"
>
{isSeriesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
<div className="text-left">
<div className="font-medium">{series.series_name}</div>
</div>
</button>
</div>
</td>
<td className="px-6 py-3 whitespace-nowrap text-sm text-gray-600 text-center">
</td>
<td className="px-6 py-3 whitespace-nowrap text-sm text-gray-900 text-center">
{series.rounds_participated}
</td>
<td className="px-6 py-3 whitespace-nowrap text-center">
<button
onClick={() => toggleSeries(series.series_id, definition.definition_id)}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
{isSeriesExpanded ? 'Hide Plot' : 'Show Plot'}
</button>
</td>
</tr>
{isSeriesExpanded && (
<tr className="bg-white">
<td colSpan={5} className="px-6 py-6">
<div className="flex flex-col w-full">
<div className="flex justify-end mb-3 gap-3">
<label className="flex items-center text-xs text-gray-500 gap-1.5">
<span className="font-medium">From:</span>
<input
type="date"
value={(pendingDateFilters.get(cacheKey) || dateFilters.get(cacheKey))?.startDate || ''}
onChange={(e) => handleDateChange(series.series_id, definition.definition_id, 'start', e.target.value)}
className="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
/>
</label>
<label className="flex items-center text-xs text-gray-500 gap-1.5">
<span className="font-medium">To:</span>
<input
type="date"
value={(pendingDateFilters.get(cacheKey) || dateFilters.get(cacheKey))?.endDate || ''}
onChange={(e) => handleDateChange(series.series_id, definition.definition_id, 'end', e.target.value)}
className="px-2 py-1 text-xs bg-white border border-gray-200 rounded text-gray-600 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
/>
</label>
<button
onClick={() => applyDateFilters(series.series_id, definition.definition_id)}
className="px-3 py-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
disabled={!pendingDateFilters.has(cacheKey)}
>
Filter
</button>
</div>
{renderPlot(series.series_id, definition.definition_id)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
);
}