import React, { useRef, useEffect, useState, useCallback } 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 DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; import './datepicker-dark.css'; 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 []; 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 { 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(); for (const entry of data) { const date = new Date(entry.time); let key = ''; switch (mode) { case 'day': { const local = new Date( date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) ); 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 }; }); } 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(null); const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day'); const [selectedDate, setSelectedDate] = useState(new Date()); const [consumption, setConsumption] = useState([]); const [generation, setGeneration] = useState([]); const [forecast, setForecast] = useState([]); const [startIndex, setStartIndex] = useState(0); const [endIndex, setEndIndex] = useState(0); const LIVE_REFRESH_MS = 300000; const SLOW_REFRESH_MS = 600000; const fetchAndSet = 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); 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) { console.error('Failed to fetch energy timeseries:', error); } }, [siteId, viewMode, selectedDate]); useEffect(() => { let timer: number | undefined; const tick = async () => { if (!document.hidden) { await fetchAndSet(); } const ms = viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS; timer = window.setTimeout(tick, ms); }; fetchAndSet(); timer = window.setTimeout(tick, viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS); const onVis = () => { if (!document.hidden) { 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; } const isEnergyView = viewMode !== 'day'; const consumptionForGrouping = isEnergyView ? powerSeriesToEnergySeries(consumption, 30) : consumption; const generationForGrouping = isEnergyView ? powerSeriesToEnergySeries(generation, 30) : generation; const forecastForGrouping = isEnergyView ? powerSeriesToEnergySeries(forecast, 60) : forecast; 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 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 = [ ...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 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(selectableIndices[0]); setEndIndex(selectableIndices[selectableIndices.length - 1]); } else { setStartIndex(0); setEndIndex(allTimes.length > 0 ? allTimes.length - 1 : 0); } }, [allTimes, selectableIndices]); useEffect(() => { if (typeof window !== 'undefined') { import('hammerjs'); } }, []); const formatLabel = useCallback((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('-', ' '); case 'daily': return new Date(key).toLocaleDateString('en-MY', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric', }); case 'yearly': return key; default: return key; } }, [viewMode]); const filteredLabels = allTimes.slice(startIndex, endIndex + 1); 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'; 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) { const { ctx: g, chartArea } = ctx.chart; if (!chartArea) return hex; const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); 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; } const data = { labels: filteredLabels, datasets: [ { label: 'Consumption', data: filteredConsumption, borderColor: consumptionColor, backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), fill: true, tension: 0.2, spanGaps: true, pointRadius: 0, pointHoverRadius: 4, borderWidth: 2, }, { label: 'Generation', data: filteredGeneration, borderColor: generationColor, backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), fill: true, tension: 0.2, spanGaps: true, pointRadius: 0, pointHoverRadius: 4, 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: 2, pointHoverRadius: 4, borderWidth: 2, }, ], }; const xClampMin = filteredLabels.length ? Date.parse(filteredLabels[0]) : undefined; const xClampMax = filteredLabels.length ? Date.parse(filteredLabels[filteredLabels.length - 1]) : undefined; const options = { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { color: axisColor }, }, zoom: { // ✅ limits, zoom and pan are siblings here limits: { x: { min: xClampMin, max: xClampMax, // minRange: 60 * 1000, // optional: prevent zooming past 1 minute }, }, 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: '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: { display: true, color: axisColor, text: viewMode === 'day' ? 'Time' : viewMode === 'daily' ? 'Day' : viewMode === 'weekly' ? 'Week' : viewMode === 'monthly' ? 'Month' : 'Year', font: { weight: 'normal' as const }, }, }, 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 (

Energy Consumption & Generation

{viewMode === 'day' && ( )}
); }; export default EnergyLineChart;