UserDashboard/components/dashboards/EnergyLineChart.tsx
Syasya eac2bb51e2
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m45s
point
2025-08-27 17:08:28 +08:00

706 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
import 'chartjs-adapter-date-fns';
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': {
// Snap to 5-minute buckets in local (KL) time
const local = new Date(
date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
);
local.setSeconds(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 };
});
}
// ---- NEW: build a 5-minute time grid for the day view
function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] {
const grid: string[] = [];
const t = new Date(start);
t.setSeconds(0, 0);
while (t.getTime() <= end.getTime()) {
grid.push(new Date(t).toISOString());
t.setTime(t.getTime() + stepMinutes * 60 * 1000);
}
return grid;
}
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);
} 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 dataTimesDay = [
...groupedConsumption.map(d => Date.parse(d.time)),
...groupedGeneration.map(d => Date.parse(d.time)),
...groupedForecast.map(d => Date.parse(d.time)),
].filter(Number.isFinite).sort((a, b) => a - b);
const dayGrid = viewMode === 'day'
? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1)
: [];
const unionTimes = 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 allTimes = viewMode === 'day' ? dayGrid : unionTimes;
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);
// after allTimes, consumptionMap, generationMap, forecastMap
const hasDataAt = (t: string) =>
t in consumptionMap || t in generationMap || t in forecastMap;
const firstAvailableIndex = allTimes.findIndex(hasDataAt);
const lastAvailableIndex = (() => {
for (let i = allTimes.length - 1; i >= 0; i--) {
if (hasDataAt(allTimes[i])) return i;
}
return -1;
})();
const selectableIndices =
firstAvailableIndex === -1 || lastAvailableIndex === -1
? []
: Array.from(
{ length: lastAvailableIndex - firstAvailableIndex + 1 },
(_, k) => firstAvailableIndex + k
);
useEffect(() => {
if (selectableIndices.length === 0) {
setStartIndex(0);
setEndIndex(Math.max(0, allTimes.length - 1));
return;
}
const minIdx = selectableIndices[0];
const maxIdx = selectableIndices[selectableIndices.length - 1];
setStartIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
setEndIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
}, [viewMode, allTimes.length, firstAvailableIndex, lastAvailableIndex]);
useEffect(() => {
if (typeof window !== 'undefined') {
import('hammerjs');
}
}, []);
useEffect(() => {
if (selectableIndices.length) {
const minIdx = selectableIndices[0];
const maxIdx = selectableIndices[selectableIndices.length - 1];
setStartIndex(minIdx);
setEndIndex(maxIdx);
} else {
setStartIndex(0);
setEndIndex(Math.max(0, allTimes.length - 1));
}
// run whenever mode changes or the timeline changes
}, [viewMode, allTimes, firstAvailableIndex, lastAvailableIndex]);
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 minutesOfDayForLabels =
viewMode === 'day'
? filteredLabels.map((iso) => {
const d = new Date(iso);
const kl = new Date(d.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }));
return kl.getHours() * 60 + kl.getMinutes();
})
: [];
// ---- CHANGED: use nulls for missing buckets (not zeros)
const filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null));
const filteredGeneration = filteredLabels.map(t => (t in generationMap ? generationMap[t] : null));
const filteredForecast = filteredLabels.map(t => (t in forecastMap ? forecastMap[t] : null));
const allValues = [...filteredConsumption, ...filteredGeneration].filter(
(v): v is number => v !== null
);
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,
tension: 0.2,
spanGaps: true,
pointRadius: 0.7, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2,
},
{
label: 'Generation',
data: filteredGeneration,
borderColor: generationColor,
backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
fill: true,
tension: 0.2,
spanGaps: true,
pointRadius: 0.7, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2,
},
{
label: 'Forecasted Solar',
data: filteredForecast,
borderColor: '#fcd913',
backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
tension: 0.4,
borderDash: [5, 5],
fill: true,
spanGaps: true,
pointRadius: 1, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2,
}
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
normalized: true, // faster lookup
plugins: {
decimation: {
enabled: true,
algorithm: 'lttb', // best visual fidelity
samples: 400, // cap points actually drawn (~400 is a good default)
},
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: {
type: 'category' as const,
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,
autoSkip: false, // let our callback decide
maxRotation: 0,
callback(
this: any,
tickValue: string | number,
index: number,
ticks: any[]
) {
if (viewMode !== 'day') return this.getLabelForValue(tickValue as number);
const scale = this.chart.scales.x;
const min = Math.max(0, Math.floor(scale.min ?? 0));
const max = Math.min(ticks.length - 1, Math.ceil(scale.max ?? ticks.length - 1));
const visibleCount = Math.max(1, max - min + 1);
let step = 30; // ≥ 6h
if (visibleCount < 80) step = 10;// 26h
// On a category scale, tickValue is usually the index (number).
const idx = typeof tickValue === 'number' ? tickValue : index;
const m = minutesOfDayForLabels[idx];
if (m != null && m % step === 0) {
return this.getLabelForValue(idx);
}
return ''; // hide crowded labels
},
},
},
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" onDoubleClick={handleResetZoom}>
<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);
}}
disabled={selectableIndices.length === 0}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
{selectableIndices.map((absIdx) => (
<option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option>
))}
</select>
</label>
<label className="font-medium ">
To:{' '}
<select
value={endIndex}
onChange={(e) => {
const val = Number(e.target.value);
setEndIndex(val >= startIndex ? val : startIndex);
}}
disabled={selectableIndices.length === 0}
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
>
{selectableIndices.map((absIdx) => (
<option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</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;