link to gdrive + granularity fixes

This commit is contained in:
Syasya 2025-08-27 15:44:31 +08:00
parent 86682398db
commit 418f23586b
2 changed files with 229 additions and 295 deletions

View File

@ -459,9 +459,21 @@ useEffect(() => {
/> />
<div className="flex flex-col md:flex-row gap-4 justify-center"> <div className="flex flex-col md:flex-row gap-4 justify-center">
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> <button
onClick={handlePDFExport}
className="text-sm lg:text-lg btn-primary"
>
Export Chart Images to PDF Export Chart Images to PDF
</button> </button>
<a
href={`https://drive.google.com/drive/folders/${process.env.NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm lg:text-lg btn-primary"
>
View Excel Logs
</a>
</div> </div>
</> </>
)} )}

View File

@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState, useCallback } 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,14 +14,11 @@ 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'; // custom dark mode styles import './datepicker-dark.css';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
ChartJS.register(zoomPlugin); ChartJS.register(zoomPlugin);
interface TimeSeriesEntry { interface TimeSeriesEntry {
@ -39,7 +36,6 @@ 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()
); );
@ -50,23 +46,18 @@ 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;
} }
@ -76,19 +67,15 @@ 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' })
); );
const snappedMin = Math.floor(local.getMinutes() / 5) * 5; local.setSeconds(0, 0);
local.setMinutes(snappedMin, 0, 0);
key = local.toISOString(); key = local.toISOString();
break; break;
} }
@ -111,11 +98,9 @@ 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);
@ -127,7 +112,6 @@ 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);
@ -142,15 +126,17 @@ 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; // 5min when viewing a single day const LIVE_REFRESH_MS = 300000;
const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly const SLOW_REFRESH_MS = 600000;
const fetchAndSet = React.useCallback(async () => { const fetchAndSet = useCallback(async () => {
const now = new Date(); const now = new Date();
let start: Date; let start: Date;
let end: Date; let end: Date;
@ -185,17 +171,27 @@ 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();
} }
@ -203,15 +199,12 @@ 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();
} }
@ -241,122 +234,19 @@ 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';
// Convert to energy series for aggregated views const consumptionForGrouping = isEnergyView ? powerSeriesToEnergySeries(consumption, 30) : consumption;
const consumptionForGrouping = isEnergyView const generationForGrouping = isEnergyView ? powerSeriesToEnergySeries(generation, 30) : generation;
? powerSeriesToEnergySeries(consumption, 30) const forecastForGrouping = isEnergyView ? powerSeriesToEnergySeries(forecast, 60) : forecast;
: consumption;
const generationForGrouping = isEnergyView const groupedConsumption = groupTimeSeries(consumptionForGrouping, viewMode, isEnergyView ? 'sum' : 'mean');
? powerSeriesToEnergySeries(generation, 30) const groupedGeneration = groupTimeSeries(generationForGrouping, viewMode, isEnergyView ? 'sum' : 'mean');
: generation; const groupedForecast = groupTimeSeries(forecastForGrouping, viewMode, isEnergyView ? 'sum' : 'mean');
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 dataTimesDay = [ const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value]));
...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);
// ---- CHANGED: use a 5-minute grid for day view
const dayGrid =
viewMode === 'day'
? (() => {
const dayStart = startOfDay(selectedDate).getTime();
const dayEnd = endOfDay(selectedDate).getTime();
if (dataTimesDay.length) {
const minT = Math.max(dayStart, dataTimesDay[0]);
const maxT = Math.min(dayEnd, dataTimesDay[dataTimesDay.length - 1]);
return buildTimeGrid(new Date(minT), new Date(maxT), 5)
}
// no data → keep full day
return buildTimeGrid(new Date(dayStart), new Date(dayEnd), 5);
})()
: [];
const unionTimes = Array.from(new Set([ const unionTimes = Array.from(new Set([
...groupedConsumption.map(d => d.time), ...groupedConsumption.map(d => d.time),
@ -364,15 +254,20 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
...groupedForecast.map(d => d.time), ...groupedForecast.map(d => d.time),
])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); ])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
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 allTimes = viewMode === 'day' ? dayGrid : unionTimes; 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) => const hasDataAt = (t: string) =>
t in consumptionMap || t in generationMap || t in forecastMap; t in consumptionMap || t in generationMap || t in forecastMap;
@ -393,17 +288,14 @@ const selectableIndices =
); );
useEffect(() => { useEffect(() => {
if (selectableIndices.length === 0) { if (selectableIndices.length > 0) {
setStartIndex(selectableIndices[0]);
setEndIndex(selectableIndices[selectableIndices.length - 1]);
} else {
setStartIndex(0); setStartIndex(0);
setEndIndex(Math.max(0, allTimes.length - 1)); setEndIndex(allTimes.length > 0 ? allTimes.length - 1 : 0);
return;
} }
const minIdx = selectableIndices[0]; }, [allTimes, selectableIndices]);
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(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -411,41 +303,28 @@ const selectableIndices =
} }
}, []); }, []);
useEffect(() => { const formatLabel = useCallback((key: string) => {
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', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Kuala_Lumpur',
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);
// ---- 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));
@ -457,28 +336,23 @@ const selectableIndices =
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; // initial render fallback if (!chartArea) return hex;
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.map(formatLabel), labels: filteredLabels,
datasets: [ datasets: [
{ {
label: 'Consumption', label: 'Consumption',
@ -486,10 +360,10 @@ const selectableIndices =
borderColor: consumptionColor, borderColor: consumptionColor,
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
fill: true, fill: true,
tension: 0.4, tension: 0.2,
spanGaps: true, spanGaps: true,
pointRadius: 1, // default is 3, make smaller pointRadius: 0,
pointHoverRadius: 4, // a bit bigger on hover pointHoverRadius: 4,
borderWidth: 2, borderWidth: 2,
}, },
{ {
@ -498,10 +372,10 @@ const selectableIndices =
borderColor: generationColor, borderColor: generationColor,
backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
fill: true, fill: true,
tension: 0.4, tension: 0.2,
spanGaps: true, spanGaps: true,
pointRadius: 1, // default is 3, make smaller pointRadius: 0,
pointHoverRadius: 4, // a bit bigger on hover pointHoverRadius: 4,
borderWidth: 2, borderWidth: 2,
}, },
{ {
@ -513,24 +387,32 @@ const selectableIndices =
borderDash: [5, 5], borderDash: [5, 5],
fill: true, fill: true,
spanGaps: true, spanGaps: true,
pointRadius: 2, // default is 3, make smaller pointRadius: 2,
pointHoverRadius: 4, // a bit bigger on hover pointHoverRadius: 4,
borderWidth: 2, borderWidth: 2,
} },
], ],
}; };
const xClampMin = filteredLabels.length ? Date.parse(filteredLabels[0]) : undefined;
const xClampMax = filteredLabels.length ? Date.parse(filteredLabels[filteredLabels.length - 1]) : undefined;
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: {
position: 'top', position: 'top',
labels: { labels: { color: axisColor },
color: axisColor, // legend text color
},
}, },
zoom: { zoom: {
// ✅ limits, zoom and pan are siblings here
limits: {
x: {
min: xClampMin,
max: xClampMax,
// minRange: 60 * 1000, // optional: prevent zooming past 1 minute
},
},
zoom: { zoom: {
wheel: { enabled: true }, wheel: { enabled: true },
pinch: { enabled: true }, pinch: { enabled: true },
@ -558,36 +440,89 @@ const selectableIndices =
}, },
scales: { scales: {
x: { x: {
type: 'time',
min: xClampMin,
max: xClampMax,
time: {
unit:
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: { title: {
display: true, display: true,
color: axisColor, color: axisColor,
text: text:
viewMode === 'day' viewMode === 'day' ? 'Time'
? 'Time (HH:MM)' : viewMode === 'daily' ? 'Day'
: viewMode === 'daily' : viewMode === 'weekly' ? 'Week'
? 'Day' : viewMode === 'monthly' ? 'Month'
: viewMode === 'weekly'
? 'Week'
: viewMode === 'monthly'
? 'Month'
: 'Year', : 'Year',
font: { weight: 'normal' as const }, font: { weight: 'normal' as const },
}, },
ticks: {
color: axisColor,
},
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
suggestedMax: yAxisSuggestedMax, suggestedMax: yAxisSuggestedMax,
title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
ticks: { ticks: { color: axisColor },
color: axisColor,
},
}, },
}, },
} as const; } as const;
const handleResetZoom = () => { const handleResetZoom = () => {
chartRef.current?.resetZoom(); chartRef.current?.resetZoom();
}; };
@ -601,7 +536,6 @@ const selectableIndices =
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">
@ -609,12 +543,11 @@ const selectableIndices =
<DatePicker <DatePicker
selected={selectedDate} selected={selectedDate}
onChange={(date) => setSelectedDate(date!)} onChange={(date) => setSelectedDate(date!)}
dateFormat="dd/MM/yyyy" // ✅ sets correct format dateFormat="dd/MM/yyyy"
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
@ -623,7 +556,7 @@ const selectableIndices =
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 === 0} disabled={!selectableIndices.length}
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) => (
@ -631,7 +564,6 @@ const selectableIndices =
))} ))}
</select> </select>
</label> </label>
<label className="font-medium "> <label className="font-medium ">
To:{' '} To:{' '}
<select <select
@ -640,7 +572,7 @@ const selectableIndices =
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 === 0} disabled={!selectableIndices.length}
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) => (
@ -648,7 +580,6 @@ const selectableIndices =
))} ))}
</select> </select>
</label> </label>
<label className="font-medium"> <label className="font-medium">
View:{' '} View:{' '}
<select <select
@ -664,7 +595,6 @@ const selectableIndices =
</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>
@ -674,11 +604,3 @@ const selectableIndices =
}; };
export default EnergyLineChart; export default EnergyLineChart;