- VitalGauge.tsx: New component with: - SVG sparkline charts showing historical trend - Gradient fill under sparkline - Trend arrows (up/down/stable) - Pulsing current value dot - Hover tooltips with optimal/range info - Color-coded status (green/yellow/red) - VitalsGrid: Convenient wrapper for common vitals - VitalGaugeMini: Compact version for lists - Consistent mock values per plant (seeded by ID)
247 lines
9.3 KiB
TypeScript
247 lines
9.3 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
|
|
|
interface VitalGaugeProps {
|
|
label: string;
|
|
value: number;
|
|
unit: string;
|
|
optimal: number;
|
|
min?: number;
|
|
max?: number;
|
|
history?: number[]; // Last N readings for sparkline
|
|
decimals?: number;
|
|
showTrend?: boolean;
|
|
}
|
|
|
|
// Generate mock history if not provided
|
|
const generateMockHistory = (current: number, optimal: number, points: number = 12): number[] => {
|
|
const history: number[] = [];
|
|
const variance = optimal * 0.15;
|
|
|
|
for (let i = 0; i < points; i++) {
|
|
// Trend towards current value
|
|
const progress = i / (points - 1);
|
|
const base = optimal + (current - optimal) * progress;
|
|
const noise = (Math.random() - 0.5) * variance;
|
|
history.push(base + noise);
|
|
}
|
|
return history;
|
|
};
|
|
|
|
export function VitalGauge({
|
|
label,
|
|
value,
|
|
unit,
|
|
optimal,
|
|
min,
|
|
max,
|
|
history,
|
|
decimals = 0,
|
|
showTrend = true
|
|
}: VitalGaugeProps) {
|
|
// Generate history if not provided
|
|
const dataPoints = useMemo(() => {
|
|
return history || generateMockHistory(value, optimal);
|
|
}, [history, value, optimal]);
|
|
|
|
// Calculate how close to optimal (0 = far, 1 = optimal)
|
|
const diff = Math.abs(value - optimal);
|
|
const maxDiff = optimal * 0.3;
|
|
const score = Math.max(0, 1 - diff / maxDiff);
|
|
|
|
// Determine status color
|
|
const status = score > 0.7 ? 'good' : score > 0.4 ? 'warning' : 'critical';
|
|
const colorClasses = {
|
|
good: { text: 'text-green-400', bg: 'bg-green-500', stroke: '#22c55e' },
|
|
warning: { text: 'text-yellow-400', bg: 'bg-yellow-500', stroke: '#eab308' },
|
|
critical: { text: 'text-red-400', bg: 'bg-red-500', stroke: '#ef4444' },
|
|
};
|
|
const colors = colorClasses[status];
|
|
|
|
// Calculate trend from history
|
|
const trend = useMemo(() => {
|
|
if (dataPoints.length < 2) return 'stable';
|
|
const recentHalf = dataPoints.slice(-Math.floor(dataPoints.length / 2));
|
|
const olderHalf = dataPoints.slice(0, Math.floor(dataPoints.length / 2));
|
|
const recentAvg = recentHalf.reduce((a, b) => a + b, 0) / recentHalf.length;
|
|
const olderAvg = olderHalf.reduce((a, b) => a + b, 0) / olderHalf.length;
|
|
const change = (recentAvg - olderAvg) / olderAvg;
|
|
|
|
if (change > 0.05) return 'up';
|
|
if (change < -0.05) return 'down';
|
|
return 'stable';
|
|
}, [dataPoints]);
|
|
|
|
// SVG sparkline dimensions
|
|
const sparkWidth = 50;
|
|
const sparkHeight = 16;
|
|
const padding = 1;
|
|
|
|
// Generate sparkline path
|
|
const sparklinePath = useMemo(() => {
|
|
if (dataPoints.length < 2) return '';
|
|
|
|
const minVal = min ?? Math.min(...dataPoints) * 0.9;
|
|
const maxVal = max ?? Math.max(...dataPoints) * 1.1;
|
|
const range = maxVal - minVal || 1;
|
|
|
|
const points = dataPoints.map((val, i) => {
|
|
const x = (i / (dataPoints.length - 1)) * (sparkWidth - padding * 2) + padding;
|
|
const y = sparkHeight - padding - ((val - minVal) / range) * (sparkHeight - padding * 2);
|
|
return `${x},${y}`;
|
|
});
|
|
|
|
return `M${points.join(' L')}`;
|
|
}, [dataPoints, min, max]);
|
|
|
|
// Gradient fill path (area under line)
|
|
const areaPath = useMemo(() => {
|
|
if (!sparklinePath) return '';
|
|
return `${sparklinePath} L${sparkWidth - padding},${sparkHeight - padding} L${padding},${sparkHeight - padding} Z`;
|
|
}, [sparklinePath]);
|
|
|
|
return (
|
|
<div className="flex flex-col items-center group relative">
|
|
{/* Label */}
|
|
<div className="text-[9px] text-slate-500 uppercase mb-0.5 tracking-wide">{label}</div>
|
|
|
|
{/* Value with trend indicator */}
|
|
<div className="flex items-center gap-0.5">
|
|
<span className={`text-sm font-bold ${colors.text}`}>
|
|
{decimals > 0 ? value.toFixed(decimals) : Math.round(value)}
|
|
</span>
|
|
<span className="text-[8px] text-slate-500">{unit}</span>
|
|
|
|
{/* Trend arrow */}
|
|
{showTrend && (
|
|
<span className="ml-0.5">
|
|
{trend === 'up' && <TrendingUp className="w-2.5 h-2.5 text-green-400" />}
|
|
{trend === 'down' && <TrendingDown className="w-2.5 h-2.5 text-red-400" />}
|
|
{trend === 'stable' && <Minus className="w-2.5 h-2.5 text-slate-500" />}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sparkline */}
|
|
<svg
|
|
viewBox={`0 0 ${sparkWidth} ${sparkHeight}`}
|
|
className="w-full h-4 mt-0.5"
|
|
preserveAspectRatio="none"
|
|
>
|
|
{/* Gradient definition */}
|
|
<defs>
|
|
<linearGradient id={`spark-gradient-${label}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={colors.stroke} stopOpacity="0.3" />
|
|
<stop offset="100%" stopColor={colors.stroke} stopOpacity="0.05" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Area fill */}
|
|
<path
|
|
d={areaPath}
|
|
fill={`url(#spark-gradient-${label})`}
|
|
/>
|
|
|
|
{/* Line */}
|
|
<path
|
|
d={sparklinePath}
|
|
fill="none"
|
|
stroke={colors.stroke}
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
|
|
{/* Current value dot */}
|
|
{dataPoints.length > 0 && (
|
|
<circle
|
|
cx={sparkWidth - padding}
|
|
cy={(() => {
|
|
const minVal = min ?? Math.min(...dataPoints) * 0.9;
|
|
const maxVal = max ?? Math.max(...dataPoints) * 1.1;
|
|
const range = maxVal - minVal || 1;
|
|
return sparkHeight - padding - ((dataPoints[dataPoints.length - 1] - minVal) / range) * (sparkHeight - padding * 2);
|
|
})()}
|
|
r="2"
|
|
fill={colors.stroke}
|
|
className="animate-pulse"
|
|
/>
|
|
)}
|
|
</svg>
|
|
|
|
{/* Optimal indicator bar */}
|
|
<div className="w-full h-1 bg-slate-700/50 rounded-full mt-1 overflow-hidden relative">
|
|
{/* Score fill */}
|
|
<div
|
|
className={`h-full ${colors.bg} transition-all duration-500 rounded-full`}
|
|
style={{ width: `${Math.min(100, score * 100)}%` }}
|
|
/>
|
|
{/* Optimal marker */}
|
|
<div
|
|
className="absolute top-0 w-0.5 h-1 bg-white/30"
|
|
style={{ left: '70%' }}
|
|
title={`Optimal: ${optimal}${unit}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Tooltip on hover */}
|
|
<div className="absolute -top-12 left-1/2 -translate-x-1/2 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-[10px] text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 shadow-lg">
|
|
Optimal: {optimal}{unit} • Range: {dataPoints.length > 0 ? Math.min(...dataPoints).toFixed(decimals) : '?'} - {dataPoints.length > 0 ? Math.max(...dataPoints).toFixed(decimals) : '?'}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Compact version for lists
|
|
export function VitalGaugeMini({ value, optimal, status }: { value: number; optimal: number; status?: 'good' | 'warning' | 'critical' }) {
|
|
const diff = Math.abs(value - optimal);
|
|
const maxDiff = optimal * 0.3;
|
|
const score = Math.max(0, 1 - diff / maxDiff);
|
|
const computed = status || (score > 0.7 ? 'good' : score > 0.4 ? 'warning' : 'critical');
|
|
|
|
const colorClasses = {
|
|
good: 'bg-green-500',
|
|
warning: 'bg-yellow-500',
|
|
critical: 'bg-red-500',
|
|
};
|
|
|
|
return (
|
|
<div className="w-6 h-1 bg-slate-700 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full ${colorClasses[computed]} transition-all`}
|
|
style={{ width: `${Math.min(100, score * 100)}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Group of vitals for a plant/section
|
|
interface VitalsGridProps {
|
|
health?: number;
|
|
temperature?: number;
|
|
humidity?: number;
|
|
vpd?: number;
|
|
compact?: boolean;
|
|
}
|
|
|
|
export function VitalsGrid({ health = 85, temperature = 76, humidity = 60, vpd = 1.2, compact = false }: VitalsGridProps) {
|
|
if (compact) {
|
|
return (
|
|
<div className="flex gap-1">
|
|
<VitalGaugeMini value={health} optimal={85} />
|
|
<VitalGaugeMini value={temperature} optimal={76} />
|
|
<VitalGaugeMini value={humidity} optimal={60} />
|
|
<VitalGaugeMini value={vpd} optimal={1.2} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<VitalGauge label="Health" value={health} unit="%" optimal={85} />
|
|
<VitalGauge label="Temp" value={temperature} unit="°F" optimal={76} />
|
|
<VitalGauge label="RH" value={humidity} unit="%" optimal={60} />
|
|
<VitalGauge label="VPD" value={vpd} unit="kPa" optimal={1.2} decimals={2} />
|
|
</div>
|
|
);
|
|
}
|