correct data queries
This commit is contained in:
parent
0885771131
commit
a82e62b9b4
@ -19,6 +19,7 @@ import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import './datepicker-dark.css'; // custom dark mode styles
|
||||
|
||||
|
||||
ChartJS.register(zoomPlugin);
|
||||
|
||||
interface TimeSeriesEntry {
|
||||
@ -30,9 +31,48 @@ interface EnergyLineChartProps {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
function powerSeriesToEnergySeries(
|
||||
data: TimeSeriesEntry[],
|
||||
guessMinutes = 30
|
||||
): TimeSeriesEntry[] {
|
||||
if (!data?.length) return [];
|
||||
|
||||
// Ensure ascending by time
|
||||
const sorted = [...data].sort(
|
||||
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
|
||||
);
|
||||
|
||||
const out: TimeSeriesEntry[] = [];
|
||||
let lastDeltaMs: number | null = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t0 = new Date(sorted[i].time).getTime();
|
||||
const p0 = sorted[i].value; // kW
|
||||
|
||||
let deltaMs: number;
|
||||
if (i < sorted.length - 1) {
|
||||
const t1 = new Date(sorted[i + 1].time).getTime();
|
||||
deltaMs = Math.max(0, t1 - t0);
|
||||
if (deltaMs > 0) lastDeltaMs = deltaMs;
|
||||
} else {
|
||||
// For the last point, assume previous cadence or a guess
|
||||
deltaMs = lastDeltaMs ?? guessMinutes * 60 * 1000;
|
||||
}
|
||||
|
||||
const hours = deltaMs / (1000 * 60 * 60);
|
||||
const kwh = p0 * hours; // kW * h = kWh
|
||||
|
||||
out.push({ time: sorted[i].time, value: kwh });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
function groupTimeSeries(
|
||||
data: TimeSeriesEntry[],
|
||||
mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly',
|
||||
agg: 'mean' | 'max' | 'sum' = 'mean'
|
||||
): TimeSeriesEntry[] {
|
||||
const groupMap = new Map<string, number[]>();
|
||||
|
||||
@ -41,19 +81,22 @@ function groupTimeSeries(
|
||||
let key = '';
|
||||
|
||||
switch (mode) {
|
||||
case 'day':
|
||||
const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }));
|
||||
const hour = local.getHours();
|
||||
const minute = local.getMinutes() < 30 ? '00' : '30';
|
||||
const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds
|
||||
key = adjusted.toISOString(); // ✅ full timestamp key
|
||||
case 'day': {
|
||||
const local = new Date(
|
||||
date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
|
||||
);
|
||||
const minute = local.getMinutes() < 30 ? 0 : 30;
|
||||
local.setMinutes(minute, 0, 0);
|
||||
key = local.toISOString();
|
||||
break;
|
||||
}
|
||||
case 'daily':
|
||||
key = date.toLocaleDateString('en-MY', {
|
||||
timeZone: 'Asia/Kuala_Lumpur',
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
break;
|
||||
case 'weekly':
|
||||
@ -71,12 +114,19 @@ function groupTimeSeries(
|
||||
groupMap.get(key)!.push(entry.value);
|
||||
}
|
||||
|
||||
return Array.from(groupMap.entries()).map(([time, values]) => ({
|
||||
time,
|
||||
value: values.reduce((sum, v) => sum + v, 0),
|
||||
}));
|
||||
return Array.from(groupMap.entries()).map(([time, values]) => {
|
||||
if (agg === 'sum') {
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
return { time, value: sum };
|
||||
}
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const max = values.reduce((a, b) => (b > a ? b : a), -Infinity);
|
||||
return { time, value: agg === 'max' ? max : mean };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
||||
const chartRef = useRef<any>(null);
|
||||
const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day');
|
||||
@ -85,6 +135,94 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
|
||||
|
||||
const LIVE_REFRESH_MS = 300000; // 5min when viewing a single day
|
||||
const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly
|
||||
|
||||
|
||||
const fetchAndSet = React.useCallback(async () => {
|
||||
const now = new Date();
|
||||
let start: Date;
|
||||
let end: Date;
|
||||
|
||||
switch (viewMode) {
|
||||
case 'day':
|
||||
start = startOfDay(selectedDate);
|
||||
end = endOfDay(selectedDate);
|
||||
break;
|
||||
case 'daily':
|
||||
start = startOfWeek(now, { weekStartsOn: 1 });
|
||||
end = endOfWeek(now, { weekStartsOn: 1 });
|
||||
break;
|
||||
case 'weekly':
|
||||
start = startOfMonth(now);
|
||||
end = endOfMonth(now);
|
||||
break;
|
||||
case 'monthly':
|
||||
start = startOfYear(now);
|
||||
end = endOfYear(now);
|
||||
break;
|
||||
case 'yearly':
|
||||
start = new Date('2020-01-01');
|
||||
end = now;
|
||||
break;
|
||||
}
|
||||
|
||||
const isoStart = start.toISOString();
|
||||
const isoEnd = end.toISOString();
|
||||
|
||||
try {
|
||||
const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
|
||||
setConsumption(res.consumption);
|
||||
setGeneration(res.generation);
|
||||
|
||||
// Forecast only needs updating for the selected day
|
||||
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 25.67);
|
||||
const selectedDateStr = selectedDate.toISOString().split('T')[0];
|
||||
setForecast(
|
||||
forecastData
|
||||
.filter(({ time }: any) => time.startsWith(selectedDateStr))
|
||||
.map(({ time, forecast }: any) => ({ time, value: forecast }))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch energy timeseries:', error);
|
||||
}
|
||||
}, [siteId, viewMode, selectedDate]);
|
||||
|
||||
// 3) Auto-refresh effect: initial load + interval (pauses when tab hidden)
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
// Avoid wasted calls when the tab is in the background
|
||||
if (!document.hidden) {
|
||||
await fetchAndSet();
|
||||
}
|
||||
const ms = viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS;
|
||||
timer = window.setTimeout(tick, ms);
|
||||
};
|
||||
|
||||
// initial load
|
||||
fetchAndSet();
|
||||
|
||||
// schedule next cycles
|
||||
timer = window.setTimeout(tick, viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS);
|
||||
|
||||
const onVis = () => {
|
||||
if (!document.hidden) {
|
||||
// kick immediately when user returns
|
||||
clearTimeout(timer);
|
||||
tick();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
};
|
||||
}, [fetchAndSet, viewMode]);
|
||||
|
||||
|
||||
function useIsDarkMode() {
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
typeof document !== 'undefined'
|
||||
@ -140,7 +278,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
||||
setGeneration(res.generation);
|
||||
|
||||
// ⬇️ ADD THIS here — fetch forecast
|
||||
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 20.67);
|
||||
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67);
|
||||
const selectedDateStr = selectedDate.toISOString().split('T')[0];
|
||||
|
||||
setForecast(
|
||||
@ -160,9 +298,40 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
||||
fetchData();
|
||||
}, [siteId, viewMode, selectedDate]);
|
||||
|
||||
const groupedConsumption = groupTimeSeries(consumption, viewMode);
|
||||
const groupedGeneration = groupTimeSeries(generation, viewMode);
|
||||
const groupedForecast = groupTimeSeries(forecast, viewMode);
|
||||
const isEnergyView = viewMode !== 'day';
|
||||
|
||||
// Convert to energy series for aggregated views
|
||||
const consumptionForGrouping = isEnergyView
|
||||
? powerSeriesToEnergySeries(consumption, 30)
|
||||
: consumption;
|
||||
|
||||
const generationForGrouping = isEnergyView
|
||||
? powerSeriesToEnergySeries(generation, 30)
|
||||
: generation;
|
||||
|
||||
const forecastForGrouping = isEnergyView
|
||||
? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60
|
||||
: forecast;
|
||||
|
||||
// Group: sum for energy views, mean for day view
|
||||
const groupedConsumption = groupTimeSeries(
|
||||
consumptionForGrouping,
|
||||
viewMode,
|
||||
isEnergyView ? 'sum' : 'mean'
|
||||
);
|
||||
|
||||
const groupedGeneration = groupTimeSeries(
|
||||
generationForGrouping,
|
||||
viewMode,
|
||||
isEnergyView ? 'sum' : 'mean'
|
||||
);
|
||||
|
||||
const groupedForecast = groupTimeSeries(
|
||||
forecastForGrouping,
|
||||
viewMode,
|
||||
isEnergyView ? 'sum' : 'mean'
|
||||
);
|
||||
|
||||
const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
|
||||
|
||||
|
||||
@ -238,6 +407,8 @@ function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02
|
||||
const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
|
||||
const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
|
||||
const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
|
||||
const yUnit = isEnergyView ? 'kWh' : 'kW';
|
||||
const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
||||
|
||||
const data = {
|
||||
labels: filteredLabels.map(formatLabel),
|
||||
@ -300,6 +471,13 @@ const forecastColor = '#fcd913'; // A golden yellow that works well in both mode
|
||||
bodyColor: axisColor,
|
||||
borderColor: isDark ? '#444' : '#ccc',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx: any) => {
|
||||
const dsLabel = ctx.dataset.label || '';
|
||||
const val = ctx.parsed.y;
|
||||
return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
@ -326,12 +504,7 @@ const forecastColor = '#fcd913'; // A golden yellow that works well in both mode
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
suggestedMax: yAxisSuggestedMax,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Power (kW)',
|
||||
color: axisColor,
|
||||
font: { weight: 'normal' as const },
|
||||
},
|
||||
title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
|
||||
ticks: {
|
||||
color: axisColor,
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@ -9,46 +9,25 @@ import {
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
import { fetchPowerTimeseries } from '@/app/utils/api';
|
||||
|
||||
import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api';
|
||||
|
||||
interface MonthlyBarChartProps {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
interface TimeSeriesEntry {
|
||||
time: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const groupTimeSeries = (
|
||||
data: TimeSeriesEntry[],
|
||||
mode: 'monthly'
|
||||
): TimeSeriesEntry[] => {
|
||||
const groupMap = new Map<string, number[]>();
|
||||
|
||||
for (const entry of data) {
|
||||
const date = new Date(entry.time);
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
if (!groupMap.has(key)) groupMap.set(key, []);
|
||||
groupMap.get(key)!.push(entry.value);
|
||||
const getLastNMonthKeys = (n: number): string[] => {
|
||||
const out: string[] = [];
|
||||
const now = new Date();
|
||||
// include current month, go back n-1 months
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - (n - 1 - i), 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
|
||||
out.push(key);
|
||||
}
|
||||
|
||||
return Array.from(groupMap.entries()).map(([time, values]) => ({
|
||||
time,
|
||||
value: values.reduce((sum, v) => sum + v, 0),
|
||||
}));
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
|
||||
const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
|
||||
const [chartData, setChartData] = useState<
|
||||
{ month: string; consumption: number; generation: number }[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
function useIsDarkMode() {
|
||||
function useIsDarkMode() {
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
typeof document !== 'undefined'
|
||||
? document.body.classList.contains('dark')
|
||||
@ -58,71 +37,77 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
|
||||
useEffect(() => {
|
||||
const check = () => setIsDark(document.body.classList.contains('dark'));
|
||||
check();
|
||||
|
||||
// Listen for class changes on <body>
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
const isDark = useIsDarkMode();
|
||||
const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
|
||||
const [chartData, setChartData] = useState<
|
||||
{ month: string; consumption: number; generation: number }[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const consumptionColor = isDark ? '#ba8e23' : '#003049';
|
||||
const generationColor = isDark ? '#fcd913' : '#669bbc';
|
||||
const isDark = useIsDarkMode();
|
||||
const consumptionColor = isDark ? '#ba8e23' : '#003049';
|
||||
const generationColor = isDark ? '#fcd913' : '#669bbc';
|
||||
|
||||
const monthKeys = useMemo(() => getLastNMonthKeys(6), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteId) return;
|
||||
|
||||
const fetchMonthlyData = async () => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const start = '2025-01-01T00:00:00+08:00';
|
||||
const end = '2025-12-31T23:59:59+08:00';
|
||||
|
||||
try {
|
||||
const res = await fetchPowerTimeseries(siteId, start, end);
|
||||
// Fetch all 6 months in parallel
|
||||
const results: MonthlyKPI[] = await Promise.all(
|
||||
monthKeys.map((month) =>
|
||||
fetchMonthlyKpi({
|
||||
site: siteId,
|
||||
month,
|
||||
// consumption_topic: '...', // optional if your API needs it
|
||||
// generation_topic: '...', // optional if your API needs it
|
||||
}).catch((e) => {
|
||||
// normalize failures to an error-shaped record so the chart can still render other months
|
||||
return {
|
||||
site: siteId,
|
||||
month,
|
||||
yield_kwh: null,
|
||||
consumption_kwh: null,
|
||||
grid_draw_kwh: null,
|
||||
efficiency: null,
|
||||
peak_demand_kw: null,
|
||||
avg_power_factor: null,
|
||||
load_factor: null,
|
||||
error: String(e),
|
||||
} as MonthlyKPI;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const groupedConsumption = groupTimeSeries(res.consumption, 'monthly');
|
||||
const groupedGeneration = groupTimeSeries(res.generation, 'monthly');
|
||||
// Map to chart rows; default nulls to 0 for stacking/tooltip friendliness
|
||||
const rows = results.map((kpi) => {
|
||||
const monthLabel = format(new Date(`${kpi.month}-01`), 'MMM');
|
||||
return {
|
||||
month: monthLabel,
|
||||
consumption: kpi.consumption_kwh ?? 0,
|
||||
generation: kpi.yield_kwh ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const monthMap = new Map<string, { consumption: number; generation: number }>();
|
||||
|
||||
for (const entry of groupedConsumption) {
|
||||
if (!monthMap.has(entry.time)) {
|
||||
monthMap.set(entry.time, { consumption: 0, generation: 0 });
|
||||
}
|
||||
monthMap.get(entry.time)!.consumption = entry.value;
|
||||
}
|
||||
|
||||
for (const entry of groupedGeneration) {
|
||||
if (!monthMap.has(entry.time)) {
|
||||
monthMap.set(entry.time, { consumption: 0, generation: 0 });
|
||||
}
|
||||
monthMap.get(entry.time)!.generation = entry.value;
|
||||
}
|
||||
|
||||
const formatted = Array.from(monthMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, val]) => ({
|
||||
month: format(new Date(`${key}-01`), 'MMM'),
|
||||
consumption: val.consumption,
|
||||
generation: val.generation,
|
||||
}));
|
||||
|
||||
setChartData(formatted.slice(-6)); // last 6 months
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch monthly power data:', error);
|
||||
setChartData([]);
|
||||
setChartData(rows);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMonthlyData();
|
||||
}, [siteId]);
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [siteId]); // monthKeys are stable via useMemo
|
||||
|
||||
if (loading || !siteId || chartData.length === 0) {
|
||||
return (
|
||||
@ -140,7 +125,7 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
||||
<div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light">
|
||||
<div className="h-[200px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} >
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
|
||||
@ -152,9 +137,9 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
||||
axisLine={{ stroke: isDark ? '#fff' : '#222' }}
|
||||
tickLine={{ stroke: isDark ? '#fff' : '#222' }}
|
||||
label={{
|
||||
value: 'Power (kW)', // <-- Y-axis label
|
||||
angle: -90, // Vertical text
|
||||
position: 'insideLeft', // Position inside the chart area
|
||||
value: 'Energy (kWh)', // fixed: units are kWh
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: {
|
||||
textAnchor: 'middle',
|
||||
fill: isDark ? '#fff' : '#222',
|
||||
@ -174,15 +159,11 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
||||
color: isDark ? '#fff' : '#222',
|
||||
}}
|
||||
cursor={{
|
||||
fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg
|
||||
fillOpacity: isDark ? 0.6 : 0.3, // adjust opacity as you like
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
color: isDark ? '#fff' : '#222',
|
||||
fill: isDark ? '#808080' : '#e0e7ef',
|
||||
fillOpacity: isDark ? 0.6 : 0.3,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: isDark ? '#fff' : '#222' }} />
|
||||
<Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" />
|
||||
<Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" />
|
||||
</BarChart>
|
||||
@ -194,3 +175,4 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
|
||||
|
||||
export default MonthlyBarChart;
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user