All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m2s
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: 2, // 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; |