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,25 +240,99 @@ 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([ |  | ||||||
|     ...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 = [ |     const dataTimesDay = [ | ||||||
|     ...groupedConsumption.map(d => Date.parse(d.time)), |     ...groupedConsumption.map(d => Date.parse(d.time)), | ||||||
| @ -265,9 +345,21 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|   : []; |   : []; | ||||||
|    |    | ||||||
|        |        | ||||||
|  |   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 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; | ||||||
| 
 | 
 | ||||||
| @ -288,14 +380,17 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (selectableIndices.length > 0) { |   if (selectableIndices.length === 0) { | ||||||
|       setStartIndex(selectableIndices[0]); |  | ||||||
|       setEndIndex(selectableIndices[selectableIndices.length - 1]); |  | ||||||
|     } else { |  | ||||||
|     setStartIndex(0); |     setStartIndex(0); | ||||||
|       setEndIndex(allTimes.length > 0 ? allTimes.length - 1 : 0); |     setEndIndex(Math.max(0, allTimes.length - 1)); | ||||||
|  |     return; | ||||||
|   } |   } | ||||||
|   }, [allTimes, selectableIndices]); |   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(() => { |   useEffect(() => { | ||||||
|     if (typeof window !== 'undefined') { |     if (typeof window !== 'undefined') { | ||||||
| @ -303,28 +398,50 @@ 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)); | ||||||
| @ -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,32 +509,30 @@ 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 xClampMax = filteredLabels.length ? Date.parse(filteredLabels[filteredLabels.length - 1]) : undefined; |  | ||||||
|   const options = { |   const options = { | ||||||
|     responsive: true, |     responsive: true, | ||||||
|     maintainAspectRatio: false, |     maintainAspectRatio: false, | ||||||
|  |     normalized: true,                // faster lookup
 | ||||||
|     plugins: { |     plugins: { | ||||||
|  |       decimation: { | ||||||
|  |       enabled: true, | ||||||
|  |       algorithm: 'lttb',           // best visual fidelity
 | ||||||
|  |       samples: 400,                // cap points actually drawn (~400 is a good default)
 | ||||||
|  |     }, | ||||||
|       legend: { |       legend: { | ||||||
|         position: 'top', |         position: 'top', | ||||||
|       labels: { color: axisColor }, |         labels: { | ||||||
|  |           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 }, | ||||||
| @ -440,89 +560,64 @@ const options = { | |||||||
|     }, |     }, | ||||||
|     scales: { |     scales: { | ||||||
|       x: { |       x: { | ||||||
|   type: 'time', |         type: 'category' as const, | ||||||
|   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' ? 'Time' |             viewMode === 'day' | ||||||
|           : viewMode === 'daily' ? 'Day' |               ? 'Time (HH:MM)' | ||||||
|           : viewMode === 'weekly' ? 'Week' |               : viewMode === 'daily' | ||||||
|           : viewMode === 'monthly' ? 'Month' |               ? 'Day' | ||||||
|  |               : viewMode === 'weekly' | ||||||
|  |               ? 'Week' | ||||||
|  |               : viewMode === 'monthly' | ||||||
|  |               ? 'Month' | ||||||
|               : 'Year', |               : 'Year', | ||||||
|           font: { weight: 'normal' as const }, |           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: { |       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: { color: axisColor }, |         ticks: { | ||||||
|  |           color: axisColor, | ||||||
|  |         }, | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|   } as const; |   } as const; | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|   const handleResetZoom = () => { |   const handleResetZoom = () => { | ||||||
|     chartRef.current?.resetZoom(); |     chartRef.current?.resetZoom(); | ||||||
|   }; |   }; | ||||||
| @ -536,6 +631,7 @@ const options = { | |||||||
|             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