All checks were successful
		
		
	
	Build and Deploy / build-and-deploy (push) Successful in 2m45s
				
			
		
			
				
	
	
		
			706 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			706 lines
		
	
	
		
			22 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, 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;// 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
 | ||
|       },
 | ||
|   },
 | ||
|       },
 | ||
|       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; |