import React, { useEffect, useState, useContext, useRef, useMemo } from 'react'; import { Typography, Spin, Message, Card, Grid } from '@arco-design/web-react'; import '@arco-design/web-react/dist/css/arco.css'; import { initVChartArcoTheme } from '@visactor/vchart-arco-theme'; import { VChart } from '@visactor/react-vchart'; import API_BASE_URL from '../config'; import { MarketContext, MARKET_FULL_NAMES } from '../App'; // TODO: // // - [ ] Cancel Promise if market changes // - [ ] Fix Data Zoom being reset const { Row, Col } = Grid; function TimeAgo({ timestamp, prefix }) { const [now, setNow] = useState(new Date()); useEffect(() => { const timer = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(timer); }, []); const formatTimeAgo = () => { if (!timestamp) return ''; const seconds = Math.floor((now - timestamp) / 1000); if (seconds < 60) return `${seconds} sec ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes} min ago`; const hours = Math.floor(minutes / 60); return `${hours} hr ago`; }; return ( {prefix}: {formatTimeAgo()} ); } function MarketDataPage() { const { selectedMarket } = useContext(MarketContext); const fiveMinChartRef = useRef(null); const dayAheadChartRef = useRef(null); const [fiveMinZoom, setFiveMinZoom] = useState({ start: 0.9, end: 1 }); const [dayAheadZoom, setDayAheadZoom] = useState({ start: 0.05, end: 1 }); const [fiveMinData, setFiveMinData] = useState([]); const [dayAheadData, setDayAheadData] = useState([]); const [loading, setLoading] = useState(true); const [fiveMinLastUpdated, setFiveMinLastUpdated] = useState(null); const [dayAheadLastUpdated, setDayAheadLastUpdated] = useState(null); const [, setNow] = useState(new Date()); const handleZoomChange = (e, title) => { if (!e?.start || !e?.end) return; if (title === 'fiveMin') { setFiveMinZoom({ start: e.start, end: e.end }); } else { setDayAheadZoom({ start: e.start, end: e.end }); } }; useEffect(() => { const fetchFiveMinData = async () => { try { const res = await fetch(`${API_BASE_URL}/market/real-time?market=${selectedMarket}`); if (!res.ok) throw new Error('Failed to fetch real-time data'); const json = await res.json(); setFiveMinData(json); setFiveMinLastUpdated(new Date()); } catch (err) { console.error(err); Message.error('Failed to load real-time market data.'); } }; const fetchDayAheadData = async () => { try { const res = await fetch(`${API_BASE_URL}/market/day-ahead?market=${selectedMarket}`); if (!res.ok) throw new Error('Failed to fetch day-ahead data'); const json = await res.json(); setDayAheadData(json); setDayAheadLastUpdated(new Date()); } catch (err) { console.error(err); Message.error('Failed to load day-ahead market data.'); } }; const initialize = async () => { setLoading(true); await Promise.all([fetchFiveMinData(), fetchDayAheadData()]); setLoading(false); initVChartArcoTheme({ defaultMode: 'light', isWatchingMode: true }); }; initialize(); const fiveMinInterval = setInterval(fetchFiveMinData, 5 * 60 * 1000); const dayAheadInterval = setInterval(fetchDayAheadData, 60 * 60 * 1000); const timer = setInterval(() => setNow(new Date()), 1000); return () => { clearInterval(fiveMinInterval); clearInterval(dayAheadInterval); clearInterval(timer); }; }, [selectedMarket]); const fiveMinSpec = useMemo(() => ({ type: 'line', data: { values: fiveMinData.map(d => ({ timestamp: new Date(d.timestamp).toLocaleString(), LMP: d.lmp, Energy: d.energy, Congestion: d.congestion, Loss: d.loss })), transforms: [{ type: 'fold', options: { key: 'name', value: 'value', fields: ['LMP', 'Energy', 'Congestion', 'Loss'] } }] }, xField: 'timestamp', yField: 'value', seriesField: 'name', smooth: true, legend: { position: 'top' }, tooltip: { formatter: (datum) => ({ name: datum.name, value: datum.value.toFixed(2) }) }, dataZoom: [{ orient: 'bottom', height: 20, start: fiveMinZoom.start, end: fiveMinZoom.end }] }), [fiveMinData, fiveMinZoom]); // <== DEPENDENCIES const dayAheadSpec = useMemo(() => ({ type: 'line', data: { values: dayAheadData.map(d => ({ timestamp: new Date(d.timestamp).toLocaleString(), LMP: d.lmp, Energy: d.energy, Congestion: d.congestion, Loss: d.loss })), transforms: [{ type: 'fold', options: { key: 'name', value: 'value', fields: ['LMP', 'Energy', 'Congestion', 'Loss'] } }] }, xField: 'timestamp', yField: 'value', seriesField: 'name', smooth: true, legend: { position: 'top' }, tooltip: { formatter: (datum) => ({ name: datum.name, value: datum.value.toFixed(2) }) }, dataZoom: [{ orient: 'bottom', height: 20, start: dayAheadZoom.start, end: dayAheadZoom.end }] }), [dayAheadData, dayAheadZoom]); const computeKPIs = (data) => { if (!data.length) return { avgLmp: 0, totalEnergy: 0, maxCongestion: 0, avgLoss: 0 }; const avgLmp = data.reduce((sum, d) => sum + d.lmp, 0) / data.length; const totalEnergy = data.reduce((sum, d) => sum + d.energy, 0); const maxCongestion = Math.max(...data.map(d => d.congestion)); const avgLoss = data.reduce((sum, d) => sum + d.loss, 0) / data.length; return { avgLmp, totalEnergy, maxCongestion, avgLoss }; }; const fiveMinKPIs = computeKPIs(fiveMinData); const dayAheadKPIs = computeKPIs(dayAheadData); return (
Market Data for {MARKET_FULL_NAMES[selectedMarket]} {loading ? ( ) : ( <> {[ { label: 'Avg Real-Time LMP ($/MWh)', value: fiveMinKPIs.avgLmp }, { label: 'Total Real-Time Energy (MWh)', value: fiveMinKPIs.totalEnergy }, { label: 'Max Real-Time Congestion ($)', value: fiveMinKPIs.maxCongestion }, { label: 'Avg Real-Time Loss ($)', value: fiveMinKPIs.avgLoss } ].map((item, idx) => ( ${item.value.toFixed(2)} ))} {[ { label: 'Avg Day-Ahead LMP ($/MWh)', value: dayAheadKPIs.avgLmp }, { label: 'Total Day-Ahead Energy (MWh)', value: dayAheadKPIs.totalEnergy }, { label: 'Max Day-Ahead Congestion ($)', value: dayAheadKPIs.maxCongestion }, { label: 'Avg Day-Ahead Loss ($)', value: dayAheadKPIs.avgLoss } ].map((item, idx) => ( ${item.value.toFixed(2)} ))} } style={{ borderRadius: 12, marginBottom: 24 }}>
handleZoomChange(e.detail, 'fiveMin')} />
} style={{ borderRadius: 12, marginBottom: 24 }}>
handleZoomChange(e.detail, 'dayAhead')} />
)}
); } export default MarketDataPage;