Files
dashboard/components/EnvironmentChart.tsx
T
Alberto-Audrix 32a36cceff
CI / lint-and-test (push) Canceled after 0s
first commit
2026-07-28 08:55:05 +07:00

469 lines
17 KiB
TypeScript

import React, { useMemo, useState, useEffect } from 'react';
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Area,
AreaChart,
ReferenceLine,
} from 'recharts';
import { EnvironmentData } from '../types.ts';
import { Thermometer, Droplets, Wind, Clock, ChevronDown } from 'lucide-react';
interface EnvironmentChartProps {
data: EnvironmentData[];
}
type TimeRange = '1h' | '6h' | '12h' | '24h' | '3d' | '7d';
interface TimeRangeOption {
value: TimeRange;
label: string;
hours: number;
}
const TIME_RANGE_OPTIONS: TimeRangeOption[] = [
{ value: '1h', label: '1 Jam Terakhir', hours: 1 },
{ value: '6h', label: '6 Jam Terakhir', hours: 6 },
{ value: '12h', label: '12 Jam Terakhir', hours: 12 },
{ value: '24h', label: '24 Jam Terakhir', hours: 24 },
{ value: '3d', label: '3 Hari Terakhir', hours: 72 },
{ value: '7d', label: '1 Minggu Terakhir', hours: 168 },
];
const EnvironmentChart: React.FC<EnvironmentChartProps> = ({ data }) => {
// Live WIB timestamp that updates every second
const [currentWibTime, setCurrentWibTime] = useState('');
const [timeRange, setTimeRange] = useState<TimeRange>('12h');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
useEffect(() => {
const updateTime = () => {
const wibTime = new Date().toLocaleString('id-ID', {
timeZone: 'Asia/Jakarta',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: 'short',
year: 'numeric',
});
setCurrentWibTime(wibTime);
};
// Update immediately
updateTime();
// Update every second
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, []);
// Filter data based on selected time range
const filteredData = useMemo(() => {
if (!data || data.length === 0) return [];
const selectedRange = TIME_RANGE_OPTIONS.find((opt) => opt.value === timeRange);
if (!selectedRange) return data;
const hoursToShow = selectedRange.hours;
const now = new Date();
const cutoffTime = new Date(now.getTime() - hoursToShow * 60 * 60 * 1000);
// Filter data within the time range
return data.filter((item) => {
// Parse the time string (format: "HH:MM" or "DD MMM HH:MM")
// This is a simple implementation - adjust based on your actual time format
const itemTime = new Date(item.time);
return itemTime >= cutoffTime;
});
}, [data, timeRange]);
// Calculate current values (latest reading from filtered data)
const currentValues = useMemo(() => {
if (!filteredData || filteredData.length === 0) return null;
const latest = filteredData[filteredData.length - 1];
if (!latest) return null;
return {
suhu: latest.Suhu,
kelembapan: latest.Kelembapan,
amonia: latest.Amonia,
time: latest.time,
};
}, [filteredData]);
// Calculate status for each metric
const getTemperatureStatus = (temp: number) => {
if (temp < 28 || temp > 34) return 'critical';
if (temp < 30 || temp > 32) return 'warning';
return 'ok';
};
const getHumidityStatus = (humidity: number) => {
if (humidity < 50 || humidity > 70) return 'warning';
if (humidity < 40 || humidity > 80) return 'critical';
return 'ok';
};
const getAmmoniaStatus = (ammonia: number) => {
if (ammonia > 25) return 'critical';
if (ammonia > 15) return 'warning';
return 'ok';
};
// Get evenly spaced ticks (show every 2 hours or available data)
const getTicks = () => {
if (!filteredData || filteredData.length === 0) return [];
// Get all hourly marks
const hourlyData = filteredData.filter((item) => item.time.endsWith(':00'));
// If we have hourly data, show every 2 hours to avoid crowding
if (hourlyData.length > 6) {
return hourlyData.filter((_item, index) => index % 2 === 0).map((item) => item.time);
}
// Otherwise show all hourly marks
if (hourlyData.length > 0) {
return hourlyData.map((item) => item.time);
}
// If no hourly marks, show evenly spaced data points
const step = Math.ceil(filteredData.length / 6);
return filteredData.filter((_item, index) => index % step === 0).map((item) => item.time);
};
// Custom tooltip
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-lg p-3">
<p className="text-sm font-semibold text-gray-700 mb-2">{payload[0].payload.time}</p>
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2 text-sm">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-gray-600">{entry.name}:</span>
<span className="font-semibold text-gray-900">
{typeof entry.value === 'number' ? entry.value.toFixed(1) : entry.value}
</span>
</div>
))}
</div>
);
}
return null;
};
const hasNoData = !filteredData || filteredData.length === 0;
const selectedRangeOption = TIME_RANGE_OPTIONS.find((opt) => opt.value === timeRange);
const handleTimeRangeChange = (value: TimeRange) => {
setTimeRange(value);
setIsDropdownOpen(false);
};
return (
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<h2 className="text-xl font-bold text-gray-800">Monitoring Lingkungan</h2>
<p className="text-sm text-gray-600 flex items-center gap-1 mt-1">
<Clock className="w-4 h-4" />
{selectedRangeOption?.label}
{currentValues && (
<span className="ml-2 text-gray-500">
• Update terakhir: {currentValues.time}
</span>
)}
</p>
</div>
{/* Time Range Dropdown */}
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center gap-2 px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700 shadow-sm"
>
<Clock className="w-4 h-4" />
<span>{selectedRangeOption?.label}</span>
<ChevronDown
className={`w-4 h-4 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{isDropdownOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setIsDropdownOpen(false)}
></div>
{/* Dropdown Menu */}
<div className="absolute top-full left-0 mt-2 w-56 bg-white border border-gray-200 rounded-lg shadow-lg z-20 overflow-hidden">
{TIME_RANGE_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => handleTimeRangeChange(option.value)}
className={`w-full px-4 py-2.5 text-left text-sm hover:bg-gray-50 transition-colors ${
timeRange === option.value
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-700'
}`}
>
{option.label}
</button>
))}
</div>
</>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
{currentValues && (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs text-gray-600">Live</span>
</div>
)}
{currentWibTime && (
<div className="text-xs font-mono text-gray-600 bg-white/50 px-2 py-1 rounded">
WIB: {currentWibTime}
</div>
)}
</div>
</div>
</div>
{/* Current Values Summary */}
{currentValues && (
<div className="grid grid-cols-3 gap-4 px-6 py-4 bg-gray-50 border-b border-gray-200">
{/* Temperature */}
<div className="flex items-center gap-3">
<div
className={`p-2.5 rounded-lg ${
getTemperatureStatus(currentValues.suhu) === 'critical'
? 'bg-red-100'
: getTemperatureStatus(currentValues.suhu) === 'warning'
? 'bg-yellow-100'
: 'bg-green-100'
}`}
>
<Thermometer
className={`w-5 h-5 ${
getTemperatureStatus(currentValues.suhu) === 'critical'
? 'text-red-600'
: getTemperatureStatus(currentValues.suhu) === 'warning'
? 'text-yellow-600'
: 'text-green-600'
}`}
/>
</div>
<div>
<p className="text-xs text-gray-500 font-medium">Suhu</p>
<p className="text-lg font-bold text-gray-900">{currentValues.suhu.toFixed(1)}°C</p>
</div>
</div>
{/* Humidity */}
<div className="flex items-center gap-3">
<div
className={`p-2.5 rounded-lg ${
getHumidityStatus(currentValues.kelembapan) === 'critical'
? 'bg-red-100'
: getHumidityStatus(currentValues.kelembapan) === 'warning'
? 'bg-yellow-100'
: 'bg-green-100'
}`}
>
<Droplets
className={`w-5 h-5 ${
getHumidityStatus(currentValues.kelembapan) === 'critical'
? 'text-red-600'
: getHumidityStatus(currentValues.kelembapan) === 'warning'
? 'text-yellow-600'
: 'text-green-600'
}`}
/>
</div>
<div>
<p className="text-xs text-gray-500 font-medium">Kelembapan</p>
<p className="text-lg font-bold text-gray-900">
{currentValues.kelembapan.toFixed(1)}%
</p>
</div>
</div>
{/* Ammonia */}
<div className="flex items-center gap-3">
<div
className={`p-2.5 rounded-lg ${
getAmmoniaStatus(currentValues.amonia) === 'critical'
? 'bg-red-100'
: getAmmoniaStatus(currentValues.amonia) === 'warning'
? 'bg-yellow-100'
: 'bg-green-100'
}`}
>
<Wind
className={`w-5 h-5 ${
getAmmoniaStatus(currentValues.amonia) === 'critical'
? 'text-red-600'
: getAmmoniaStatus(currentValues.amonia) === 'warning'
? 'text-yellow-600'
: 'text-green-600'
}`}
/>
</div>
<div>
<p className="text-xs text-gray-500 font-medium">Amonia</p>
<p className="text-lg font-bold text-gray-900">
{currentValues.amonia.toFixed(1)} ppm
</p>
</div>
</div>
</div>
)}
{/* Chart */}
<div className="p-6">
<div className="h-80">
{hasNoData ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Thermometer className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-500 text-lg font-medium">Tidak ada data tersedia</p>
<p className="text-gray-400 text-sm mt-2">
Tidak ada data IoT dalam rentang waktu ini
</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={filteredData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorSuhu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorKelembapan" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorAmonia" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f97316" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="time"
fontSize={11}
stroke="#6b7280"
ticks={getTicks()}
angle={-45}
textAnchor="end"
height={60}
label={{
value: 'Waktu (WIB)',
position: 'insideBottom',
offset: -5,
style: { fontSize: 11, fill: '#6b7280' },
}}
/>
<YAxis
yAxisId="left"
fontSize={11}
stroke="#6b7280"
label={{
value: '°C / %',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: '#6b7280' },
}}
/>
<YAxis
yAxisId="right"
orientation="right"
fontSize={11}
stroke="#6b7280"
label={{
value: 'ppm',
angle: 90,
position: 'insideRight',
style: { fontSize: 11, fill: '#6b7280' },
}}
/>
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ paddingTop: '20px' }} iconType="line" />
{/* Optimal range indicators */}
<ReferenceLine
yAxisId="left"
y={30}
stroke="#10b981"
strokeDasharray="3 3"
strokeOpacity={0.3}
/>
<ReferenceLine
yAxisId="left"
y={32}
stroke="#10b981"
strokeDasharray="3 3"
strokeOpacity={0.3}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="Suhu"
stroke="#ef4444"
strokeWidth={3}
fill="url(#colorSuhu)"
name="Suhu (°C)"
dot={false}
activeDot={{ r: 6, strokeWidth: 2, stroke: '#fff' }}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="Kelembapan"
stroke="#3b82f6"
strokeWidth={3}
fill="url(#colorKelembapan)"
name="Kelembapan (%)"
dot={false}
activeDot={{ r: 6, strokeWidth: 2, stroke: '#fff' }}
/>
<Area
yAxisId="right"
type="monotone"
dataKey="Amonia"
stroke="#f97316"
strokeWidth={3}
fill="url(#colorAmonia)"
name="Amonia (ppm)"
dot={false}
activeDot={{ r: 6, strokeWidth: 2, stroke: '#fff' }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
);
};
export default EnvironmentChart;