granularity check
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m2s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m2s
This commit is contained in:
parent
418f23586b
commit
f5b41dd230
@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import ChartJS from 'chart.js/auto';
|
import ChartJS from 'chart.js/auto';
|
||||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||||
@ -14,11 +14,14 @@ import {
|
|||||||
endOfYear,
|
endOfYear,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { fetchPowerTimeseries, fetchForecast } from '@/app/utils/api';
|
import { fetchPowerTimeseries, fetchForecast } from '@/app/utils/api';
|
||||||
|
import { color } from 'html2canvas/dist/types/css/types/color';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import './datepicker-dark.css';
|
import './datepicker-dark.css'; // custom dark mode styles
|
||||||
import 'chartjs-adapter-date-fns';
|
import 'chartjs-adapter-date-fns';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ChartJS.register(zoomPlugin);
|
ChartJS.register(zoomPlugin);
|
||||||
|
|
||||||
interface TimeSeriesEntry {
|
interface TimeSeriesEntry {
|
||||||
@ -36,6 +39,7 @@ function powerSeriesToEnergySeries(
|
|||||||
): TimeSeriesEntry[] {
|
): TimeSeriesEntry[] {
|
||||||
if (!data?.length) return [];
|
if (!data?.length) return [];
|
||||||
|
|
||||||
|
// Ensure ascending by time
|
||||||
const sorted = [...data].sort(
|
const sorted = [...data].sort(
|
||||||
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
|
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
|
||||||
);
|
);
|
||||||
@ -46,18 +50,23 @@ function powerSeriesToEnergySeries(
|
|||||||
for (let i = 0; i < sorted.length; i++) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
const t0 = new Date(sorted[i].time).getTime();
|
const t0 = new Date(sorted[i].time).getTime();
|
||||||
const p0 = sorted[i].value; // kW
|
const p0 = sorted[i].value; // kW
|
||||||
|
|
||||||
let deltaMs: number;
|
let deltaMs: number;
|
||||||
if (i < sorted.length - 1) {
|
if (i < sorted.length - 1) {
|
||||||
const t1 = new Date(sorted[i + 1].time).getTime();
|
const t1 = new Date(sorted[i + 1].time).getTime();
|
||||||
deltaMs = Math.max(0, t1 - t0);
|
deltaMs = Math.max(0, t1 - t0);
|
||||||
if (deltaMs > 0) lastDeltaMs = deltaMs;
|
if (deltaMs > 0) lastDeltaMs = deltaMs;
|
||||||
} else {
|
} else {
|
||||||
|
// For the last point, assume previous cadence or a guess
|
||||||
deltaMs = lastDeltaMs ?? guessMinutes * 60 * 1000;
|
deltaMs = lastDeltaMs ?? guessMinutes * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = deltaMs / (1000 * 60 * 60);
|
const hours = deltaMs / (1000 * 60 * 60);
|
||||||
const kwh = p0 * hours; // kW * h = kWh
|
const kwh = p0 * hours; // kW * h = kWh
|
||||||
|
|
||||||
out.push({ time: sorted[i].time, value: kwh });
|
out.push({ time: sorted[i].time, value: kwh });
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,11 +76,14 @@ function groupTimeSeries(
|
|||||||
agg: 'mean' | 'max' | 'sum' = 'mean'
|
agg: 'mean' | 'max' | 'sum' = 'mean'
|
||||||
): TimeSeriesEntry[] {
|
): TimeSeriesEntry[] {
|
||||||
const groupMap = new Map<string, number[]>();
|
const groupMap = new Map<string, number[]>();
|
||||||
|
|
||||||
for (const entry of data) {
|
for (const entry of data) {
|
||||||
const date = new Date(entry.time);
|
const date = new Date(entry.time);
|
||||||
let key = '';
|
let key = '';
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'day': {
|
case 'day': {
|
||||||
|
// Snap to 5-minute buckets in local (KL) time
|
||||||
const local = new Date(
|
const local = new Date(
|
||||||
date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
|
date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
|
||||||
);
|
);
|
||||||
@ -98,9 +110,11 @@ function groupTimeSeries(
|
|||||||
key = date.getFullYear().toString();
|
key = date.getFullYear().toString();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!groupMap.has(key)) groupMap.set(key, []);
|
if (!groupMap.has(key)) groupMap.set(key, []);
|
||||||
groupMap.get(key)!.push(entry.value);
|
groupMap.get(key)!.push(entry.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groupMap.entries()).map(([time, values]) => {
|
return Array.from(groupMap.entries()).map(([time, values]) => {
|
||||||
if (agg === 'sum') {
|
if (agg === 'sum') {
|
||||||
const sum = values.reduce((a, b) => a + b, 0);
|
const sum = values.reduce((a, b) => a + b, 0);
|
||||||
@ -112,6 +126,7 @@ function groupTimeSeries(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- NEW: build a 5-minute time grid for the day view
|
||||||
function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] {
|
function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] {
|
||||||
const grid: string[] = [];
|
const grid: string[] = [];
|
||||||
const t = new Date(start);
|
const t = new Date(start);
|
||||||
@ -126,17 +141,15 @@ function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] {
|
|||||||
const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
||||||
const chartRef = useRef<any>(null);
|
const chartRef = useRef<any>(null);
|
||||||
const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day');
|
const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day');
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
|
||||||
const [consumption, setConsumption] = useState<TimeSeriesEntry[]>([]);
|
const [consumption, setConsumption] = useState<TimeSeriesEntry[]>([]);
|
||||||
const [generation, setGeneration] = useState<TimeSeriesEntry[]>([]);
|
const [generation, setGeneration] = useState<TimeSeriesEntry[]>([]);
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
|
const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
|
||||||
const [startIndex, setStartIndex] = useState(0);
|
|
||||||
const [endIndex, setEndIndex] = useState(0);
|
|
||||||
|
|
||||||
const LIVE_REFRESH_MS = 300000;
|
const LIVE_REFRESH_MS = 300000; // 5min when viewing a single day
|
||||||
const SLOW_REFRESH_MS = 600000;
|
const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly
|
||||||
|
|
||||||
const fetchAndSet = useCallback(async () => {
|
const fetchAndSet = React.useCallback(async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let start: Date;
|
let start: Date;
|
||||||
let end: Date;
|
let end: Date;
|
||||||
@ -171,27 +184,17 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
|
const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
|
||||||
setConsumption(res.consumption);
|
setConsumption(res.consumption);
|
||||||
setGeneration(res.generation);
|
setGeneration(res.generation);
|
||||||
|
|
||||||
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))
|
|
||||||
.map(({ time, forecast }) => ({
|
|
||||||
time,
|
|
||||||
value: forecast
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch energy timeseries:', error);
|
console.error('Failed to fetch energy timeseries:', error);
|
||||||
}
|
}
|
||||||
}, [siteId, viewMode, selectedDate]);
|
}, [siteId, viewMode, selectedDate]);
|
||||||
|
|
||||||
|
// 3) Auto-refresh effect: initial load + interval (pauses when tab hidden)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
|
// Avoid wasted calls when the tab is in the background
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
await fetchAndSet();
|
await fetchAndSet();
|
||||||
}
|
}
|
||||||
@ -199,12 +202,15 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
timer = window.setTimeout(tick, ms);
|
timer = window.setTimeout(tick, ms);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// initial load
|
||||||
fetchAndSet();
|
fetchAndSet();
|
||||||
|
|
||||||
|
// schedule next cycles
|
||||||
timer = window.setTimeout(tick, viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS);
|
timer = window.setTimeout(tick, viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS);
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
|
// kick immediately when user returns
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
tick();
|
tick();
|
||||||
}
|
}
|
||||||
@ -234,27 +240,101 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
return isDark;
|
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';
|
const isEnergyView = viewMode !== 'day';
|
||||||
|
|
||||||
const consumptionForGrouping = isEnergyView ? powerSeriesToEnergySeries(consumption, 30) : consumption;
|
// Convert to energy series for aggregated views
|
||||||
const generationForGrouping = isEnergyView ? powerSeriesToEnergySeries(generation, 30) : generation;
|
const consumptionForGrouping = isEnergyView
|
||||||
const forecastForGrouping = isEnergyView ? powerSeriesToEnergySeries(forecast, 60) : forecast;
|
? powerSeriesToEnergySeries(consumption, 30)
|
||||||
|
: consumption;
|
||||||
|
|
||||||
const groupedConsumption = groupTimeSeries(consumptionForGrouping, viewMode, isEnergyView ? 'sum' : 'mean');
|
const generationForGrouping = isEnergyView
|
||||||
const groupedGeneration = groupTimeSeries(generationForGrouping, viewMode, isEnergyView ? 'sum' : 'mean');
|
? powerSeriesToEnergySeries(generation, 30)
|
||||||
const groupedForecast = groupTimeSeries(forecastForGrouping, viewMode, isEnergyView ? 'sum' : 'mean');
|
: 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 forecastMap = Object.fromEntries(groupedForecast.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 unionTimes = Array.from(new Set([
|
const dataTimesDay = [
|
||||||
...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 dataTimesDay = [
|
|
||||||
...groupedConsumption.map(d => Date.parse(d.time)),
|
...groupedConsumption.map(d => Date.parse(d.time)),
|
||||||
...groupedGeneration.map(d => Date.parse(d.time)),
|
...groupedGeneration.map(d => Date.parse(d.time)),
|
||||||
...groupedForecast.map(d => Date.parse(d.time)),
|
...groupedForecast.map(d => Date.parse(d.time)),
|
||||||
@ -263,39 +343,54 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
const dayGrid = viewMode === 'day'
|
const dayGrid = viewMode === 'day'
|
||||||
? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1)
|
? 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 allTimes = viewMode === 'day' ? dayGrid : unionTimes;
|
||||||
|
|
||||||
const hasDataAt = (t: string) =>
|
const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value]));
|
||||||
t in consumptionMap || t in generationMap || t in forecastMap;
|
const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value]));
|
||||||
|
|
||||||
const firstAvailableIndex = allTimes.findIndex(hasDataAt);
|
const [startIndex, setStartIndex] = useState(0);
|
||||||
const lastAvailableIndex = (() => {
|
const [endIndex, setEndIndex] = useState(allTimes.length - 1);
|
||||||
for (let i = allTimes.length - 1; i >= 0; i--) {
|
|
||||||
if (hasDataAt(allTimes[i])) return i;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const selectableIndices =
|
// after allTimes, consumptionMap, generationMap, forecastMap
|
||||||
firstAvailableIndex === -1 || lastAvailableIndex === -1
|
const hasDataAt = (t: string) =>
|
||||||
? []
|
t in consumptionMap || t in generationMap || t in forecastMap;
|
||||||
: Array.from(
|
|
||||||
{ length: lastAvailableIndex - firstAvailableIndex + 1 },
|
const firstAvailableIndex = allTimes.findIndex(hasDataAt);
|
||||||
(_, k) => firstAvailableIndex + k
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectableIndices.length > 0) {
|
if (selectableIndices.length === 0) {
|
||||||
setStartIndex(selectableIndices[0]);
|
setStartIndex(0);
|
||||||
setEndIndex(selectableIndices[selectableIndices.length - 1]);
|
setEndIndex(Math.max(0, allTimes.length - 1));
|
||||||
} else {
|
return;
|
||||||
setStartIndex(0);
|
}
|
||||||
setEndIndex(allTimes.length > 0 ? allTimes.length - 1 : 0);
|
const minIdx = selectableIndices[0];
|
||||||
}
|
const maxIdx = selectableIndices[selectableIndices.length - 1];
|
||||||
}, [allTimes, selectableIndices]);
|
setStartIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
|
||||||
|
setEndIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
|
||||||
|
}, [viewMode, allTimes.length, firstAvailableIndex, lastAvailableIndex]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@ -303,31 +398,53 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatLabel = useCallback((key: string) => {
|
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) {
|
switch (viewMode) {
|
||||||
case 'day':
|
case 'day':
|
||||||
return new Date(key).toLocaleTimeString('en-GB', {
|
return new Date(key).toLocaleTimeString('en-GB', {
|
||||||
hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Kuala_Lumpur',
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: 'Asia/Kuala_Lumpur',
|
||||||
});
|
});
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
return new Date(`${key}-01`).toLocaleString('en-GB', { month: 'short', year: 'numeric' });
|
return new Date(`${key}-01`).toLocaleString('en-GB', { month: 'short', year: 'numeric' });
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
return key.replace('-', ' ');
|
return key.replace('-', ' ');
|
||||||
case 'daily':
|
|
||||||
return new Date(key).toLocaleDateString('en-MY', {
|
|
||||||
weekday: 'short', day: '2-digit', month: 'short', year: 'numeric',
|
|
||||||
});
|
|
||||||
case 'yearly':
|
|
||||||
return key;
|
|
||||||
default:
|
default:
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
}, [viewMode]);
|
};
|
||||||
|
|
||||||
const filteredLabels = allTimes.slice(startIndex, endIndex + 1);
|
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 filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null));
|
||||||
const filteredGeneration = filteredLabels.map(t => (t in generationMap ? generationMap[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 filteredForecast = filteredLabels.map(t => (t in forecastMap ? forecastMap[t] : null));
|
||||||
|
|
||||||
const allValues = [...filteredConsumption, ...filteredGeneration].filter(
|
const allValues = [...filteredConsumption, ...filteredGeneration].filter(
|
||||||
(v): v is number => v !== null
|
(v): v is number => v !== null
|
||||||
@ -336,23 +453,28 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
const yAxisSuggestedMax = maxValue * 1.15;
|
const yAxisSuggestedMax = maxValue * 1.15;
|
||||||
|
|
||||||
const isDark = useIsDarkMode();
|
const isDark = useIsDarkMode();
|
||||||
|
|
||||||
const axisColor = isDark ? '#fff' : '#222';
|
const axisColor = isDark ? '#fff' : '#222';
|
||||||
const consumptionColor = isDark ? '#B80F0A' : '#EF4444';
|
|
||||||
const generationColor = isDark ? '#48A860' : '#22C55E';
|
|
||||||
const yUnit = isEnergyView ? 'kWh' : 'kW';
|
|
||||||
const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|
||||||
|
|
||||||
function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
|
function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
|
||||||
const { ctx: g, chartArea } = ctx.chart;
|
const { ctx: g, chartArea } = ctx.chart;
|
||||||
if (!chartArea) return hex;
|
if (!chartArea) return hex; // initial render fallback
|
||||||
const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
|
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(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0'));
|
||||||
gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
|
gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
|
||||||
return gradient;
|
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 = {
|
const data = {
|
||||||
labels: filteredLabels,
|
labels: filteredLabels.map(formatLabel),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Consumption',
|
label: 'Consumption',
|
||||||
@ -362,8 +484,8 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.2,
|
tension: 0.2,
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
pointRadius: 0,
|
pointRadius: 0.7, // default is 3, make smaller
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4, // a bit bigger on hover
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -374,8 +496,8 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.2,
|
tension: 0.2,
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
pointRadius: 0,
|
pointRadius: 0.7, // default is 3, make smaller
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4, // a bit bigger on hover
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -387,141 +509,114 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
|
|||||||
borderDash: [5, 5],
|
borderDash: [5, 5],
|
||||||
fill: true,
|
fill: true,
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
pointRadius: 2,
|
pointRadius: 2, // default is 3, make smaller
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: 4, // a bit bigger on hover
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const xClampMin = filteredLabels.length ? Date.parse(filteredLabels[0]) : undefined;
|
const options = {
|
||||||
const xClampMax = filteredLabels.length ? Date.parse(filteredLabels[filteredLabels.length - 1]) : undefined;
|
responsive: true,
|
||||||
const options = {
|
maintainAspectRatio: false,
|
||||||
responsive: true,
|
normalized: true, // faster lookup
|
||||||
maintainAspectRatio: false,
|
plugins: {
|
||||||
plugins: {
|
decimation: {
|
||||||
legend: {
|
enabled: true,
|
||||||
position: 'top',
|
algorithm: 'lttb', // best visual fidelity
|
||||||
labels: { color: axisColor },
|
samples: 400, // cap points actually drawn (~400 is a good default)
|
||||||
},
|
},
|
||||||
zoom: {
|
legend: {
|
||||||
// ✅ limits, zoom and pan are siblings here
|
position: 'top',
|
||||||
limits: {
|
labels: {
|
||||||
x: {
|
color: axisColor, // legend text color
|
||||||
min: xClampMin,
|
|
||||||
max: xClampMax,
|
|
||||||
// minRange: 60 * 1000, // optional: prevent zooming past 1 minute
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
zoom: {
|
zoom: {
|
||||||
wheel: { enabled: true },
|
zoom: {
|
||||||
pinch: { enabled: true },
|
wheel: { enabled: true },
|
||||||
mode: 'x' as const,
|
pinch: { enabled: true },
|
||||||
|
mode: 'x' as const,
|
||||||
|
},
|
||||||
|
pan: { enabled: true, mode: 'x' as const },
|
||||||
},
|
},
|
||||||
pan: { enabled: true, mode: 'x' as const },
|
tooltip: {
|
||||||
},
|
enabled: true,
|
||||||
tooltip: {
|
mode: 'index',
|
||||||
enabled: true,
|
intersect: false,
|
||||||
mode: 'index',
|
backgroundColor: isDark ? '#232b3e' : '#fff',
|
||||||
intersect: false,
|
titleColor: axisColor,
|
||||||
backgroundColor: isDark ? '#232b3e' : '#fff',
|
bodyColor: axisColor,
|
||||||
titleColor: axisColor,
|
borderColor: isDark ? '#444' : '#ccc',
|
||||||
bodyColor: axisColor,
|
borderWidth: 1,
|
||||||
borderColor: isDark ? '#444' : '#ccc',
|
callbacks: {
|
||||||
borderWidth: 1,
|
label: (ctx: any) => {
|
||||||
callbacks: {
|
const dsLabel = ctx.dataset.label || '';
|
||||||
label: (ctx: any) => {
|
const val = ctx.parsed.y;
|
||||||
const dsLabel = ctx.dataset.label || '';
|
return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
|
||||||
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;// 2–6h
|
||||||
|
|
||||||
|
// 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
|
||||||
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
},
|
||||||
x: {
|
y: {
|
||||||
type: 'time',
|
beginAtZero: true,
|
||||||
min: xClampMin,
|
suggestedMax: yAxisSuggestedMax,
|
||||||
max: xClampMax,
|
title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
|
||||||
time: {
|
ticks: {
|
||||||
unit:
|
color: axisColor,
|
||||||
viewMode === 'day' ? 'minute'
|
},
|
||||||
: viewMode === 'daily' ? 'day'
|
|
||||||
: viewMode === 'weekly' ? 'week'
|
|
||||||
: 'month',
|
|
||||||
tooltipFormat: 'dd MMM yyyy, HH:mm',
|
|
||||||
displayFormats: {
|
|
||||||
minute: 'HH:mm',
|
|
||||||
hour: 'HH:mm',
|
|
||||||
day: 'dd MMM',
|
|
||||||
week: 'w-yyyy',
|
|
||||||
month: 'MMM yyyy',
|
|
||||||
year: 'yyyy',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
color: axisColor,
|
|
||||||
autoSkip: false, // we control density ourselves
|
|
||||||
source: 'labels', // use your label array
|
|
||||||
// IMPORTANT: use function syntax so "this" is the scale
|
|
||||||
callback: function (
|
|
||||||
this: any,
|
|
||||||
tickValue: string | number,
|
|
||||||
index: number,
|
|
||||||
_ticks: any[]
|
|
||||||
) {
|
|
||||||
// current visible range
|
|
||||||
const min = typeof this.min === 'number' ? this.min : Date.parse(this.min as string);
|
|
||||||
const max = typeof this.max === 'number' ? this.max : Date.parse(this.max as string);
|
|
||||||
const rangeMs = max - min;
|
|
||||||
|
|
||||||
const ms =
|
|
||||||
typeof tickValue === 'number' ? tickValue : Date.parse(tickValue as string);
|
|
||||||
|
|
||||||
// dynamic granularity
|
|
||||||
const H = 60 * 60 * 1000;
|
|
||||||
let stepMinutes = 60; // default when zoomed out
|
|
||||||
if (rangeMs <= 6 * H) stepMinutes = 5;
|
|
||||||
else if (rangeMs <= 12 * H) stepMinutes = 15;
|
|
||||||
|
|
||||||
const d = new Date(ms);
|
|
||||||
// show only ticks aligned to stepMinutes (KL time)
|
|
||||||
const minutes = Number(
|
|
||||||
d.toLocaleString('en-GB', { timeZone: 'Asia/Kuala_Lumpur', minute: '2-digit' }).slice(-2)
|
|
||||||
);
|
|
||||||
if (minutes % stepMinutes !== 0) return '';
|
|
||||||
|
|
||||||
return d.toLocaleTimeString('en-GB', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
timeZone: 'Asia/Kuala_Lumpur',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
maxTicksLimit: 100, // safety
|
|
||||||
},
|
|
||||||
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
color: axisColor,
|
|
||||||
text:
|
|
||||||
viewMode === 'day' ? 'Time'
|
|
||||||
: viewMode === 'daily' ? 'Day'
|
|
||||||
: viewMode === 'weekly' ? 'Week'
|
|
||||||
: viewMode === 'monthly' ? 'Month'
|
|
||||||
: 'Year',
|
|
||||||
font: { weight: 'normal' as const },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
y: {
|
} as const;
|
||||||
beginAtZero: true,
|
|
||||||
suggestedMax: yAxisSuggestedMax,
|
|
||||||
title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
|
|
||||||
ticks: { color: axisColor },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
|
|
||||||
const handleResetZoom = () => {
|
const handleResetZoom = () => {
|
||||||
chartRef.current?.resetZoom();
|
chartRef.current?.resetZoom();
|
||||||
@ -529,13 +624,14 @@ const options = {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
|
<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="h-98 w-full" onDoubleClick={handleResetZoom}>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2>
|
<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">
|
<button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm">
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 flex gap-4 items-center dark:text-white">
|
<div className="mb-4 flex gap-4 items-center dark:text-white">
|
||||||
{viewMode === 'day' && (
|
{viewMode === 'day' && (
|
||||||
<label className="font-medium">
|
<label className="font-medium">
|
||||||
@ -543,11 +639,12 @@ const options = {
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
selected={selectedDate}
|
selected={selectedDate}
|
||||||
onChange={(date) => setSelectedDate(date!)}
|
onChange={(date) => setSelectedDate(date!)}
|
||||||
dateFormat="dd/MM/yyyy"
|
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"
|
className="dark:bg-rtgray-700 dark:text-white bg-white border border-rounded dark:border-rtgray-700 text-black p-1 rounded"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="font-medium ">
|
<label className="font-medium ">
|
||||||
From:{' '}
|
From:{' '}
|
||||||
<select
|
<select
|
||||||
@ -556,7 +653,7 @@ const options = {
|
|||||||
const val = Number(e.target.value);
|
const val = Number(e.target.value);
|
||||||
setStartIndex(val <= endIndex ? val : endIndex);
|
setStartIndex(val <= endIndex ? val : endIndex);
|
||||||
}}
|
}}
|
||||||
disabled={!selectableIndices.length}
|
disabled={selectableIndices.length === 0}
|
||||||
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
|
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
|
||||||
>
|
>
|
||||||
{selectableIndices.map((absIdx) => (
|
{selectableIndices.map((absIdx) => (
|
||||||
@ -564,6 +661,7 @@ const options = {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="font-medium ">
|
<label className="font-medium ">
|
||||||
To:{' '}
|
To:{' '}
|
||||||
<select
|
<select
|
||||||
@ -572,7 +670,7 @@ const options = {
|
|||||||
const val = Number(e.target.value);
|
const val = Number(e.target.value);
|
||||||
setEndIndex(val >= startIndex ? val : startIndex);
|
setEndIndex(val >= startIndex ? val : startIndex);
|
||||||
}}
|
}}
|
||||||
disabled={!selectableIndices.length}
|
disabled={selectableIndices.length === 0}
|
||||||
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
|
className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
|
||||||
>
|
>
|
||||||
{selectableIndices.map((absIdx) => (
|
{selectableIndices.map((absIdx) => (
|
||||||
@ -580,6 +678,7 @@ const options = {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="font-medium">
|
<label className="font-medium">
|
||||||
View:{' '}
|
View:{' '}
|
||||||
<select
|
<select
|
||||||
@ -595,6 +694,7 @@ const options = {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-96 w-full">
|
<div className="h-96 w-full">
|
||||||
<Line ref={chartRef} data={data} options={options} />
|
<Line ref={chartRef} data={data} options={options} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user