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;