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;