UserDashboard/components/dashboards/EnergyLineChart.tsx
2025-08-20 09:07:32 +08:00

605 lines
18 KiB
TypeScript

import React, { useRef, useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';
import ChartJS from 'chart.js/auto';
import zoomPlugin from 'chartjs-plugin-zoom';
import {
getISOWeek,
startOfDay,
endOfDay,
startOfWeek,
endOfWeek,
startOfMonth,
endOfMonth,
startOfYear,
endOfYear,
} from 'date-fns';
import { fetchPowerTimeseries, fetchForecast } from '@/app/utils/api';
import { color } from 'html2canvas/dist/types/css/types/color';
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 {
time: string;
value: number;
}
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',
agg: 'mean' | 'max' | 'sum' = 'mean'
): TimeSeriesEntry[] {
const groupMap = new Map<string, number[]>();
for (const entry of data) {
const date = new Date(entry.time);
let key = '';
switch (mode) {
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':
key = `${date.getFullYear()}-W${String(getISOWeek(date)).padStart(2, '0')}`;
break;
case 'monthly':
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
break;
case 'yearly':
key = date.getFullYear().toString();
break;
}
if (!groupMap.has(key)) groupMap.set(key, []);
groupMap.get(key)!.push(entry.value);
}
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');
const [consumption, setConsumption] = useState<TimeSeriesEntry[]>([]);
const [generation, setGeneration] = useState<TimeSeriesEntry[]>([]);
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'
? document.body.classList.contains('dark')
: false
);
useEffect(() => {
const check = () => setIsDark(document.body.classList.contains('dark'));
const observer = new MutationObserver(check);
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return isDark;
}
useEffect(() => {
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();
const fetchData = async () => {
try {
const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
setConsumption(res.consumption);
setGeneration(res.generation);
// ⬇️ ADD THIS here — fetch forecast
const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67);
const selectedDateStr = selectedDate.toISOString().split('T')[0];
setForecast(
forecastData
.filter(({ time }) => time.startsWith(selectedDateStr)) // ✅ filter only selected date
.map(({ time, forecast }) => ({
time,
value: forecast
}))
);
} catch (error) {
console.error('Failed to fetch energy timeseries:', error);
}
};
fetchData();
}, [siteId, viewMode, selectedDate]);
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]));
const allTimes = Array.from(new Set([
...groupedConsumption.map(d => d.time),
...groupedGeneration.map(d => d.time),
...groupedForecast.map(d => d.time),
])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value]));
const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value]));
const [startIndex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(allTimes.length - 1);
useEffect(() => {
if (typeof window !== 'undefined') {
import('hammerjs');
}
}, []);
useEffect(() => {
setStartIndex(0);
setEndIndex(allTimes.length - 1);
}, [viewMode, allTimes.length]);
const formatLabel = (key: string) => {
switch (viewMode) {
case 'day':
return new Date(key).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Asia/Kuala_Lumpur',
});
case 'monthly':
return new Date(`${key}-01`).toLocaleString('en-GB', { month: 'short', year: 'numeric' });
case 'weekly':
return key.replace('-', ' ');
default:
return key;
}
};
const filteredLabels = allTimes.slice(startIndex, endIndex + 1);
const filteredConsumption = filteredLabels.map(t => consumptionMap[t] ?? 0);
const filteredGeneration = filteredLabels.map(t => generationMap[t] ?? 0);
const filteredForecast = filteredLabels.map(t => forecastMap[t] ?? null);
const allValues = [...filteredConsumption, ...filteredGeneration].filter(v => v !== null) as number[];
const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0;
const yAxisSuggestedMax = maxValue * 1.15;
const isDark = useIsDarkMode();
const axisColor = isDark ? '#fff' : '#222';
function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
const { ctx: g, chartArea } = ctx.chart;
if (!chartArea) return hex; // initial render fallback
const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
// top more opaque → bottom fades out
gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0'));
gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
return gradient;
}
// Define colors for both light and dark modes
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),
datasets: [
{
label: 'Consumption',
data: filteredConsumption,
borderColor: consumptionColor,
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
fill: true, // <-- fill under line
tension: 0.4,
spanGaps: true,
},
{
label: 'Generation',
data: filteredGeneration,
borderColor: generationColor,
backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
fill: true, // <-- fill under line
tension: 0.4,
spanGaps: true,
},
{
label: 'Forecasted Solar',
data: filteredForecast,
borderColor: '#fcd913', // orange
backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
tension: 0.4,
borderDash: [5, 5], // dashed line to distinguish forecast
fill: true,
spanGaps: true,
}
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: {
color: axisColor, // legend text color
},
},
zoom: {
zoom: {
wheel: { enabled: true },
pinch: { enabled: true },
mode: 'x' as const,
},
pan: { enabled: true, mode: 'x' as const },
},
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
backgroundColor: isDark ? '#232b3e' : '#fff',
titleColor: axisColor,
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: {
x: {
title: {
display: true,
color: axisColor,
text:
viewMode === 'day'
? 'Time (HH:MM)'
: viewMode === 'daily'
? 'Day'
: viewMode === 'weekly'
? 'Week'
: viewMode === 'monthly'
? 'Month'
: 'Year',
font: { weight: 'normal' as const },
},
ticks: {
color: axisColor,
},
},
y: {
beginAtZero: true,
suggestedMax: yAxisSuggestedMax,
title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
ticks: {
color: axisColor,
},
},
},
} as const;
const handleResetZoom = () => {
chartRef.current?.resetZoom();
};
return (
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
<div className="h-98 w-full">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2>
<button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm">
Reset
</button>
</div>
<div className="mb-4 flex gap-4 items-center dark:text-white">
{viewMode === 'day' && (
<label className="font-medium">
Date:{' '}
<DatePicker
selected={selectedDate}
onChange={(date) => setSelectedDate(date!)}
dateFormat="dd/MM/yyyy" // ✅ sets correct format
className="dark:bg-rtgray-700 dark:text-white bg-white border border-rounded dark:border-rtgray-700 text-black p-1 rounded"
/>
</label>
)}
<label className="font-medium ">
From:{' '}
<select
value={startIndex}
onChange={(e) => {
const val = Number(e.target.value);
setStartIndex(val <= endIndex ? val : endIndex);
}}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
{allTimes.map((label, idx) => (
<option key={idx} value={idx}>{formatLabel(label)}</option>
))}
</select>
</label>
<label className="font-medium ">
To:{' '}
<select
value={endIndex}
onChange={(e) => {
const val = Number(e.target.value);
setEndIndex(val >= startIndex ? val : startIndex);
}}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
{allTimes.map((label, idx) => (
<option key={idx} value={idx}>{formatLabel(label)}</option>
))}
</select>
</label>
<label className="font-medium">
View:{' '}
<select
value={viewMode}
onChange={(e) => setViewMode(e.target.value as typeof viewMode)}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
<option value="day">Day</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</label>
</div>
<div className="h-96 w-full">
<Line ref={chartRef} data={data} options={options} />
</div>
</div>
</div>
);
};
export default EnergyLineChart;