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 */}
{/* 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 */}
+
+
+ {/* 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 (
+
+
+
+
+
+
+ );
+}