469 lines
17 KiB
TypeScript
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;
|