From 37787895d56888ab44362252f21fb05c05e97250 Mon Sep 17 00:00:00 2001 From: Navan Chauhan Date: Sun, 27 Apr 2025 22:44:20 -0600 Subject: reorganize --- client/src/components/MarketDataPage.jsx | 320 +++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 client/src/components/MarketDataPage.jsx (limited to 'client/src/components/MarketDataPage.jsx') diff --git a/client/src/components/MarketDataPage.jsx b/client/src/components/MarketDataPage.jsx new file mode 100644 index 0000000..5e028f8 --- /dev/null +++ b/client/src/components/MarketDataPage.jsx @@ -0,0 +1,320 @@ +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; -- cgit v1.2.3