| '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'; |
|
|
|
|
| |
| const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); |
|
|
| |
| 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, '<br>'); |
| } |
|
|
| |
| 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})`; |
| } |
|
|
| |
| 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; |
|
|
| |
| function durationToMs(isoStr: string | undefined): number | null { |
| if (!isoStr) return null; |
| try { |
| return toSeconds(parse(isoStr)) * 1000; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| function formatFrequency(freq: string | undefined): string { |
| if (!freq) return 'N/A'; |
| |
| try { |
| |
| const duration = parse(freq); |
| |
| |
| const seconds = toSeconds(duration); |
| const milliseconds = seconds * 1000; |
| |
| |
| return humanizeDuration(milliseconds, { |
| largest: 2, |
| round: true, |
| conjunction: ' and ', |
| serialComma: false |
| }); |
| } catch (error) { |
| |
| 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<SeriesDataItem[]>([]); |
| const [loading, setLoading] = useState(true); |
| const [error, setError] = useState<string | null>(null); |
| const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set()); |
|
|
| |
| const [maxSizeFilter, setMaxSizeFilter] = useState<string>(''); |
| const [architectureFilter, setArchitectureFilter] = useState<string>(''); |
| const [modelSearchFilter, setModelSearchFilter] = useState<string>(''); |
| |
| |
| const [models, setModels] = useState<Model[]>([]); |
| const [modelsLoading, setModelsLoading] = useState(true); |
|
|
| |
| const loadForecastsForSeries = useCallback(async (seriesId: number) => { |
| |
| setSeriesData(prev => { |
| const series = prev.find(s => s.series_id === seriesId); |
| if (!series || (series.forecasts && series.contextData?.length > 0)) { |
| return prev; |
| } |
| |
| |
| return prev.map(s => |
| s.series_id === seriesId |
| ? { ...s, forecastsLoading: true, forecastsError: undefined } |
| : s |
| ); |
| }); |
|
|
| try { |
| |
| 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}`); |
| } |
|
|
| |
| 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}`); |
| |
| |
| 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); |
| |
| loadForecastsForSeries(seriesId); |
| } |
| return newSet; |
| }); |
| }; |
|
|
| |
| useEffect(() => { |
| async function fetchModels() { |
| try { |
| setModelsLoading(true); |
| const modelsData = await getRoundModels(challengeId); |
| |
| |
| 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); |
|
|
| |
| const series = await getChallengeSeries(challengeId); |
| console.log(`Fetched ${series.length} series for challenge ${challengeId}`); |
| |
| |
| 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}`); |
| } |
| |
| |
| 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); |
| |
| |
| if (validData.length > 0) { |
| const firstSeriesId = validData[0].series_id; |
| setExpandedSeries(new Set([firstSeriesId])); |
| |
| 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 ( |
| <div className="flex items-center justify-center p-12"> |
| <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> |
| </div> |
| ); |
| } |
|
|
| if (error) { |
| return ( |
| <div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg"> |
| <p className="font-semibold">Error loading time series data</p> |
| <p className="text-sm">{error}</p> |
| </div> |
| ); |
| } |
|
|
| if (seriesData.length === 0) { |
| return ( |
| <div className="text-center py-8 text-gray-500"> |
| No time series data available for this challenge. |
| </div> |
| ); |
| } |
|
|
| |
| const filterForecasts = (forecasts: Record<string, ForecastData>) => { |
| |
| const filtered = Object.entries(forecasts).filter(([modelName, forecastData]) => { |
| |
| 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; |
| } |
| |
| |
| if (maxSizeFilter) { |
| const filterValue = parseFloat(maxSizeFilter); |
| if (!isNaN(filterValue)) { |
| if (model.model_size !== undefined && model.model_size > filterValue) { |
| return false; |
| } |
| } |
| } |
| |
| |
| if (architectureFilter) { |
| if (model.architecture) { |
| if (!model.architecture.toLowerCase().includes(architectureFilter.toLowerCase())) { |
| return false; |
| } |
| } else { |
| return false; |
| } |
| } |
| |
| |
| 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<string, ForecastData>); |
| return filtered; |
| }; |
|
|
| |
| const getFilterOptions = () => { |
| const architectures = new Set<string>(); |
| const maxSizes = new Set<number>(); |
| |
| 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 ( |
| <div className="bg-white rounded-lg shadow p-6"> |
| <div className="mb-6"> |
| {on_title_page && definitionId ? ( |
| <div className="mb-4"> |
| <div className="flex items-center gap-3 mb-2"> |
| <span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 animate-pulse"> |
| <span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span> |
| Now Live! |
| </span> |
| </div> |
| <h2 className="text-xl font-semibold text-gray-900"> |
| Currently competing in challenge:{' '} |
| <Link href={`/challenges/${definitionId}/${challengeId}`}> |
| <span className="text-blue-600 hover:text-blue-700 underline decoration-2 underline-offset-2 transition-colors"> |
| {challengeName} |
| </span> |
| </Link> |
| </h2> |
| </div> |
| ) : ( |
| <h2 className="text-xl font-semibold mb-4">{challengeName}</h2> |
| )} |
| {!on_title_page && ( |
| <> |
| {challengeDescription && ( |
| <p className="text-sm text-gray-600 mb-4 italic">{challengeDescription}</p> |
| )} |
| <div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> |
| <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6 gap-4 auto-rows-auto"> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Status</p> |
| <p className={`text-sm font-semibold uppercase ${ |
| status === 'active' |
| ? 'text-green-600' |
| : status === 'completed' |
| ? 'text-blue-600' |
| : status === 'registration' |
| ? 'text-yellow-600' |
| : 'text-gray-600' |
| }`}> |
| {status || 'N/A'} |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">ID</p> |
| <p className="text-sm font-semibold text-gray-900">{challengeId}</p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Series</p> |
| <p className="text-sm font-semibold text-gray-900">{seriesData.length}</p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Models</p> |
| <p className="text-sm font-semibold text-gray-900"> |
| {seriesData.length > 0 && seriesData[0].forecasts?.forecasts |
| ? Object.keys(seriesData[0].forecasts.forecasts).length |
| : 0} |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Context</p> |
| <p className="text-sm font-semibold text-gray-900"> |
| {seriesData.length > 0 && seriesData[0].contextData?.length |
| ? seriesData[0].contextData.length |
| : 'N/A'} |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Frequency</p> |
| <p className="text-sm font-semibold text-gray-900"> |
| {formatFrequency(frequency)} |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Horizon</p> |
| <p className="text-sm font-semibold text-gray-900"> |
| {horizon || 'N/A'} |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Registration Start</p> |
| <p className="text-sm font-semibold text-gray-900"> |
| {registrationStart ? new Date(registrationStart).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) : 'N/A'} |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Registration End</p> |
| <p className="text-sm font-semibold text-gray-900"> |
| {registrationEnd ? new Date(registrationEnd).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) : 'N/A'} |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-gray-500 uppercase tracking-wide">Start of Evaluation</p> |
| <p className="text-sm font-semibold text-gray-900"> |
| {evaluationStart ? new Date(evaluationStart).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) : 'N/A'} |
| </p> |
| </div> |
| </div> |
| </div> |
| </> |
| )} |
| |
| {/* Forecast Filters */} |
| {!on_title_page && ( |
| <div className="mt-6"> |
| <h3 className="text-sm font-semibold text-gray-700 mb-3">Filter Forecasts</h3> |
| <div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| {/* Model Search */} |
| <div> |
| <label htmlFor="model-search" className="block text-xs font-medium text-gray-700 mb-1"> |
| Model Name/ID Search |
| </label> |
| <input |
| id="model-search" |
| type="text" |
| placeholder="Search by name or ID..." |
| value={modelSearchFilter} |
| onChange={(e) => 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" |
| /> |
| </div> |
| |
| {/* Max Size Filter */} |
| <div> |
| <label htmlFor="max-size" className="block text-xs font-medium text-gray-700 mb-1"> |
| Max Size (parameters) |
| </label> |
| <input |
| id="max-size" |
| type="number" |
| placeholder="Max parameters..." |
| value={maxSizeFilter} |
| onChange={(e) => 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 && ( |
| <p className="text-xs text-gray-500 mt-1"> |
| Available: {filterOptions.maxSizes.join(', ')} |
| </p> |
| )} |
| </div> |
| |
| {/* Architecture Filter */} |
| <div> |
| <label htmlFor="architecture" className="block text-xs font-medium text-gray-700 mb-1"> |
| Architecture |
| </label> |
| <select |
| id="architecture" |
| value={architectureFilter} |
| onChange={(e) => setArchitectureFilter(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 bg-white" |
| > |
| <option value="">All architectures</option> |
| {filterOptions.architectures.map((arch) => ( |
| <option key={arch} value={arch}>{arch}</option> |
| ))} |
| </select> |
| </div> |
| </div> |
| |
| {/* Clear Filters Button */} |
| {(maxSizeFilter || architectureFilter || modelSearchFilter) && ( |
| <div className="mt-3 flex justify-end"> |
| <button |
| onClick={() => { |
| setMaxSizeFilter(''); |
| setArchitectureFilter(''); |
| setModelSearchFilter(''); |
| }} |
| className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" |
| > |
| Clear Filters |
| </button> |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| </div> |
| <div className="space-y-6">{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<br>Value: %{y:.4g}<extra>%{fullData.name}</extra>', |
| }); |
| } |
|
|
| // 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<br>Value: %{y:.4g}<extra>%{fullData.name}</extra>', |
| }); |
| } |
|
|
| // 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<br>Value: %{y:.4g}<extra>%{fullData.name}</extra>', |
| }); |
| } |
| }); |
| } |
|
|
| 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 ( |
| <div key={series.series_id} className="w-full border border-gray-200 rounded-lg overflow-hidden"> |
| <button |
| onClick={() => toggleSeries(series.series_id)} |
| className="w-full px-4 py-3 flex items-center justify-between bg-gray-50 hover:bg-gray-100 transition-colors" |
| > |
| <div className="flex items-center gap-3"> |
| <svg |
| className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-90' : ''}`} |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> |
| </svg> |
| <div className="text-left"> |
| <h3 className="font-semibold text-gray-900"> |
| {series.series_name || `Series ${series.series_id}`} |
| </h3> |
| </div> |
| </div> |
| <span className="text-sm text-gray-500"> |
| {isExpanded ? 'Click to collapse' : 'Click to expand'} |
| </span> |
| </button> |
| {isExpanded && ( |
| <div className="p-4 relative"> |
| {series.forecastsLoading && ( |
| <div className="absolute top-0 left-0 right-0 bg-blue-50 border border-blue-200 rounded-lg p-3 mx-4 mt-4 z-10 flex items-center gap-3"> |
| <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div> |
| <p className="text-sm text-blue-700">Loading forecasts...</p> |
| </div> |
| )} |
| {series.forecastsError && !series.forecastsLoading && ( |
| <div className="bg-yellow-50 border border-yellow-200 text-yellow-700 p-4 rounded-lg mb-4"> |
| <p className="font-semibold">Warning</p> |
| <p className="text-sm">{series.forecastsError}</p> |
| <button |
| onClick={() => loadForecastsForSeries(series.series_id)} |
| className="mt-2 text-sm underline hover:no-underline" |
| > |
| Retry loading forecasts |
| </button> |
| </div> |
| )} |
| {series.contextData.length > 0 && ( |
| <Plot |
| data={plotData} |
| layout={layout} |
| style={{ width: '100%', height: '400px' }} |
| useResizeHandler |
| /> |
| )} |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| ); |
| } |
|
|