linechart update
This commit is contained in:
parent
efdad6dfcc
commit
5a17af8486
@ -25,6 +25,11 @@ const AdminDashboard = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const siteIdMap: Record<SiteName, string> = {
|
||||
'Site A': 'site_01',
|
||||
'Site B': 'site_02',
|
||||
'Site C': 'site_03',
|
||||
};
|
||||
|
||||
const siteParam = searchParams?.get('site');
|
||||
const validSiteNames: SiteName[] = ['Site A', 'Site B', 'Site C'];
|
||||
@ -54,11 +59,6 @@ const AdminDashboard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const siteIdMap: Record<SiteName, string> = {
|
||||
'Site A': 'site_01',
|
||||
'Site B': 'site_02',
|
||||
'Site C': 'site_03',
|
||||
};
|
||||
|
||||
const siteId = siteIdMap[selectedSite];
|
||||
const today = new Date();
|
||||
@ -190,11 +190,7 @@ const AdminDashboard = () => {
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 lg:flex-col justify-center">
|
||||
<div ref={energyChartRef} className="pb-5">
|
||||
<EnergyLineChart
|
||||
consumption={timeSeriesData.consumption}
|
||||
generation={timeSeriesData.generation}
|
||||
/>
|
||||
|
||||
<EnergyLineChart siteId={siteIdMap[selectedSite]} />
|
||||
|
||||
</div>
|
||||
<div ref={monthlyChartRef} className="pb-5">
|
||||
|
@ -2,6 +2,18 @@ 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);
|
||||
|
||||
@ -11,22 +23,116 @@ interface TimeSeriesEntry {
|
||||
}
|
||||
|
||||
interface EnergyLineChartProps {
|
||||
consumption: TimeSeriesEntry[];
|
||||
generation: TimeSeriesEntry[];
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
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);
|
||||
|
||||
// Generate sorted unique time labels from both series
|
||||
const allTimes = Array.from(new Set([
|
||||
...consumption.map(d => d.time),
|
||||
...generation.map(d => d.time),
|
||||
])).sort(); // e.g., ["00:00", "00:30", "01:00", ...]
|
||||
...groupedConsumption.map(d => d.time),
|
||||
...groupedGeneration.map(d => d.time),
|
||||
])).sort();
|
||||
|
||||
// Map times to values
|
||||
const consumptionMap = Object.fromEntries(consumption.map(d => [d.time, d.value]));
|
||||
const generationMap = Object.fromEntries(generation.map(d => [d.time, d.value]));
|
||||
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);
|
||||
@ -37,6 +143,22 @@ const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
@ -46,7 +168,7 @@ const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
const yAxisSuggestedMax = maxValue * 1.15;
|
||||
|
||||
const data = {
|
||||
labels: filteredLabels,
|
||||
labels: filteredLabels.map(formatLabel),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Consumption',
|
||||
@ -66,44 +188,48 @@ const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' as const },
|
||||
zoom: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' as const },
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: 'x' as const,
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: 'x' as const,
|
||||
},
|
||||
pan: { enabled: true, mode: 'x' as const },
|
||||
},
|
||||
pan: { enabled: true, mode: 'x' as const },
|
||||
tooltip: { enabled: true, mode: 'index' as const, intersect: false },
|
||||
},
|
||||
tooltip: { enabled: true, mode: 'index' as const, intersect: false },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time (HH:MM)',
|
||||
font: {
|
||||
weight: 'bold' as const, // ✅ FIX: cast as 'const'
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
suggestedMax: yAxisSuggestedMax,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Power (kW)',
|
||||
font: {
|
||||
weight: 'bold' as const, // ✅ FIX: cast as 'const'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const; // ✅ Ensures compatibility with chart.js types
|
||||
|
||||
} as const;
|
||||
|
||||
const handleResetZoom = () => {
|
||||
chartRef.current?.resetZoom();
|
||||
@ -119,9 +245,20 @@ const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time range selectors */}
|
||||
<div className="mb-4 flex gap-4 items-center">
|
||||
<label className='font-medium'>
|
||||
{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}
|
||||
@ -132,11 +269,11 @@ const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
className="border rounded p-1"
|
||||
>
|
||||
{allTimes.map((label, idx) => (
|
||||
<option key={idx} value={idx}>{label}</option>
|
||||
<option key={idx} value={idx}>{formatLabel(label)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className='font-medium'>
|
||||
<label className="font-medium">
|
||||
To:{' '}
|
||||
<select
|
||||
value={endIndex}
|
||||
@ -147,10 +284,24 @@ const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
className="border rounded p-1"
|
||||
>
|
||||
{allTimes.map((label, idx) => (
|
||||
<option key={idx} value={idx}>{label}</option>
|
||||
<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">
|
||||
@ -164,3 +315,8 @@ const EnergyLineChart = ({ consumption, generation }: EnergyLineChartProps) => {
|
||||
export default EnergyLineChart;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
16
package-lock.json
generated
16
package-lock.json
generated
@ -23,6 +23,7 @@
|
||||
"chart.js": "^4.4.9",
|
||||
"chartjs-plugin-zoom": "^2.2.0",
|
||||
"cookie": "^1.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-config-next": "13.1.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
@ -1974,6 +1975,16 @@
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
@ -7633,6 +7644,11 @@
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
@ -24,6 +24,7 @@
|
||||
"chart.js": "^4.4.9",
|
||||
"chartjs-plugin-zoom": "^2.2.0",
|
||||
"cookie": "^1.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-config-next": "13.1.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
Loading…
x
Reference in New Issue
Block a user