323 lines
9.0 KiB
TypeScript
323 lines
9.0 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 } from '@/app/utils/api';
|
|
|
|
ChartJS.register(zoomPlugin);
|
|
|
|
interface TimeSeriesEntry {
|
|
time: string;
|
|
value: number;
|
|
}
|
|
|
|
interface EnergyLineChartProps {
|
|
siteId: string;
|
|
}
|
|
|
|
function groupTimeSeries(
|
|
data: TimeSeriesEntry[],
|
|
mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'
|
|
): 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 hour = local.getHours();
|
|
const minute = local.getMinutes() < 30 ? '00' : '30';
|
|
key = `${hour.toString().padStart(2, '0')}:${minute}`;
|
|
break;
|
|
case 'daily':
|
|
key = date.toLocaleDateString('en-MY', {
|
|
timeZone: 'Asia/Kuala_Lumpur',
|
|
weekday: 'short',
|
|
day: '2-digit',
|
|
month: 'short',
|
|
});
|
|
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]) => ({
|
|
time,
|
|
value: values.reduce((sum, v) => sum + v, 0),
|
|
}));
|
|
}
|
|
|
|
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());
|
|
|
|
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);
|
|
} catch (error) {
|
|
console.error('Failed to fetch energy timeseries:', error);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [siteId, viewMode, selectedDate]);
|
|
|
|
const groupedConsumption = groupTimeSeries(consumption, viewMode);
|
|
const groupedGeneration = groupTimeSeries(generation, viewMode);
|
|
|
|
const allTimes = Array.from(new Set([
|
|
...groupedConsumption.map(d => d.time),
|
|
...groupedGeneration.map(d => d.time),
|
|
])).sort();
|
|
|
|
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 '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] ?? null);
|
|
const filteredGeneration = filteredLabels.map(t => generationMap[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 data = {
|
|
labels: filteredLabels.map(formatLabel),
|
|
datasets: [
|
|
{
|
|
label: 'Consumption',
|
|
data: filteredConsumption,
|
|
borderColor: '#8884d8',
|
|
tension: 0.4,
|
|
fill: false,
|
|
},
|
|
{
|
|
label: 'Generation',
|
|
data: filteredGeneration,
|
|
borderColor: '#82ca9d',
|
|
tension: 0.4,
|
|
fill: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
const options = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'top' as const },
|
|
zoom: {
|
|
zoom: {
|
|
wheel: { enabled: true },
|
|
pinch: { enabled: true },
|
|
mode: 'x' as const,
|
|
},
|
|
pan: { enabled: true, mode: 'x' as const },
|
|
},
|
|
tooltip: { enabled: true, mode: 'index' as const, intersect: false },
|
|
},
|
|
scales: {
|
|
x: {
|
|
title: {
|
|
display: true,
|
|
text:
|
|
viewMode === 'day'
|
|
? 'Time (HH:MM)'
|
|
: viewMode === 'daily'
|
|
? 'Day'
|
|
: viewMode === 'weekly'
|
|
? 'Week'
|
|
: viewMode === 'monthly'
|
|
? 'Month'
|
|
: 'Year',
|
|
font: { weight: 'bold' as const },
|
|
},
|
|
},
|
|
y: {
|
|
beginAtZero: true,
|
|
suggestedMax: yAxisSuggestedMax,
|
|
title: {
|
|
display: true,
|
|
text: 'Power (kW)',
|
|
font: { weight: 'bold' as const },
|
|
},
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
const handleResetZoom = () => {
|
|
chartRef.current?.resetZoom();
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-gray-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">
|
|
{viewMode === 'day' && (
|
|
<label className="font-medium">
|
|
Date:{' '}
|
|
<input
|
|
type="date"
|
|
value={selectedDate.toISOString().split('T')[0]}
|
|
onChange={(e) => setSelectedDate(new Date(e.target.value))}
|
|
className="border rounded p-1"
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
<label className="font-medium">
|
|
From:{' '}
|
|
<select
|
|
value={startIndex}
|
|
onChange={(e) => {
|
|
const val = Number(e.target.value);
|
|
setStartIndex(val <= endIndex ? val : endIndex);
|
|
}}
|
|
className="border 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="border 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="border 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;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|