From 704fd9c79ab0ac31ae9f0a7d4b4bc738a389fa6b Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:14:08 -0800 Subject: [PATCH] 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) --- .../components/facility3d/PlantDataCard.tsx | 73 ++---- .../src/components/facility3d/VitalGauge.tsx | 247 ++++++++++++++++++ 2 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/facility3d/VitalGauge.tsx diff --git a/frontend/src/components/facility3d/PlantDataCard.tsx b/frontend/src/components/facility3d/PlantDataCard.tsx index 711e9c2..7104349 100644 --- a/frontend/src/components/facility3d/PlantDataCard.tsx +++ b/frontend/src/components/facility3d/PlantDataCard.tsx @@ -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) { - {/* Lifecycle Timeline - Using new SVG component */} + {/* Lifecycle Timeline */}
@@ -68,18 +73,18 @@ export function PlantDataCard({ plant, onClose }: PlantDataCardProps) {
- {/* Vitals Grid */} + {/* Vitals Grid - Now with sparklines */}
VITALS
-
- - - - -
+
{/* 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 ( -
-
{label}
-
- {decimals > 0 ? value.toFixed(decimals) : value} - {unit} -
- {/* Mini progress bar */} -
-
-
-
- ); -} diff --git a/frontend/src/components/facility3d/VitalGauge.tsx b/frontend/src/components/facility3d/VitalGauge.tsx new file mode 100644 index 0000000..21ddb47 --- /dev/null +++ b/frontend/src/components/facility3d/VitalGauge.tsx @@ -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 ( +
+ {/* Label */} +
{label}
+ + {/* Value with trend indicator */} +
+ + {decimals > 0 ? value.toFixed(decimals) : Math.round(value)} + + {unit} + + {/* Trend arrow */} + {showTrend && ( + + {trend === 'up' && } + {trend === 'down' && } + {trend === 'stable' && } + + )} +
+ + {/* Sparkline */} + + {/* Gradient definition */} + + + + + + + + {/* Area fill */} + + + {/* Line */} + + + {/* Current value dot */} + {dataPoints.length > 0 && ( + { + 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" + /> + )} + + + {/* Optimal indicator bar */} +
+ {/* Score fill */} +
+ {/* Optimal marker */} +
+
+ + {/* Tooltip on hover */} +
+ Optimal: {optimal}{unit} • Range: {dataPoints.length > 0 ? Math.min(...dataPoints).toFixed(decimals) : '?'} - {dataPoints.length > 0 ? Math.max(...dataPoints).toFixed(decimals) : '?'} +
+
+ ); +} + +// 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 ( +
+
+
+ ); +} + +// 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 ( +
+ + + + +
+ ); + } + + return ( +
+ + + + +
+ ); +}