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;