feat: Sprint 4 - Enhanced Vital Gauges with Sparklines
- 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)
This commit is contained in:
parent
097b2ba0c8
commit
704fd9c79a
2 changed files with 268 additions and 52 deletions
|
|
@ -1,24 +1,29 @@
|
|||
import { X, MapPin, Leaf, Activity, Calendar } from 'lucide-react';
|
||||
import type { PlantPosition } from './types';
|
||||
import { LifecycleTimeline } from './LifecycleTimeline';
|
||||
import { VitalsGrid } from './VitalGauge';
|
||||
|
||||
interface PlantDataCardProps {
|
||||
plant: PlantPosition | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Mock vitals for demo
|
||||
const getMockVitals = () => ({
|
||||
health: 75 + Math.floor(Math.random() * 20),
|
||||
temperature: 72 + Math.floor(Math.random() * 8),
|
||||
humidity: 55 + Math.floor(Math.random() * 15),
|
||||
vpd: 1.0 + Math.random() * 0.4,
|
||||
});
|
||||
// Mock vitals for demo - now with consistent values per plant
|
||||
const getMockVitals = (plantId?: string) => {
|
||||
// Use plant ID to seed pseudo-random values for consistency
|
||||
const seed = plantId ? plantId.charCodeAt(plantId.length - 1) : 42;
|
||||
return {
|
||||
health: 75 + (seed % 20),
|
||||
temperature: 72 + (seed % 8),
|
||||
humidity: 55 + ((seed * 7) % 15),
|
||||
vpd: 1.0 + ((seed % 10) / 25),
|
||||
};
|
||||
};
|
||||
|
||||
export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
|
||||
if (!plant?.plant) return null;
|
||||
|
||||
const vitals = getMockVitals();
|
||||
const vitals = getMockVitals(plant.plant.id);
|
||||
|
||||
// Format position breadcrumb
|
||||
const position = plant.breadcrumb
|
||||
|
|
@ -59,7 +64,7 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lifecycle Timeline - Using new SVG component */}
|
||||
{/* Lifecycle Timeline */}
|
||||
<div className="px-4 py-3 border-b border-slate-700/30">
|
||||
<div className="text-xs text-slate-400 mb-2 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
|
|
@ -68,18 +73,18 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
|
|||
<LifecycleTimeline currentStage={plant.plant.stage ?? undefined} />
|
||||
</div>
|
||||
|
||||
{/* Vitals Grid */}
|
||||
{/* Vitals Grid - Now with sparklines */}
|
||||
<div className="px-4 py-3 border-b border-slate-700/30">
|
||||
<div className="text-xs text-slate-400 mb-2 flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
VITALS
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<VitalGauge label="Health" value={vitals.health} unit="%" optimal={85} />
|
||||
<VitalGauge label="Temp" value={vitals.temperature} unit="°F" optimal={76} />
|
||||
<VitalGauge label="RH" value={vitals.humidity} unit="%" optimal={60} />
|
||||
<VitalGauge label="VPD" value={vitals.vpd} unit="kPa" optimal={1.2} decimals={2} />
|
||||
</div>
|
||||
<VitalsGrid
|
||||
health={vitals.health}
|
||||
temperature={vitals.temperature}
|
||||
humidity={vitals.humidity}
|
||||
vpd={vitals.vpd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
|
|
@ -98,39 +103,3 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// Simple vital gauge component
|
||||
interface VitalGaugeProps {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
optimal: number;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
function VitalGauge({ label, value, unit, optimal, decimals = 0 }: VitalGaugeProps) {
|
||||
// Calculate how close to optimal (0 = far, 1 = optimal)
|
||||
const diff = Math.abs(value - optimal);
|
||||
const maxDiff = optimal * 0.3; // 30% away is "far"
|
||||
const score = Math.max(0, 1 - diff / maxDiff);
|
||||
|
||||
// Color based on score
|
||||
const color = score > 0.7 ? 'text-green-400' : score > 0.4 ? 'text-yellow-400' : 'text-red-400';
|
||||
const bg = score > 0.7 ? 'bg-green-500' : score > 0.4 ? 'bg-yellow-500' : 'bg-red-500';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[9px] text-slate-500 uppercase mb-1">{label}</div>
|
||||
<div className={`text-sm font-bold ${color}`}>
|
||||
{decimals > 0 ? value.toFixed(decimals) : value}
|
||||
<span className="text-[9px] text-slate-500 ml-0.5">{unit}</span>
|
||||
</div>
|
||||
{/* Mini progress bar */}
|
||||
<div className="w-full h-1 bg-slate-700 rounded-full mt-1 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${bg} transition-all duration-500`}
|
||||
style={{ width: `${Math.min(100, score * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
247
frontend/src/components/facility3d/VitalGauge.tsx
Normal file
247
frontend/src/components/facility3d/VitalGauge.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue