From 953c9781d27f705f8c459f5f6364cdd8f4df9e79 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:43:22 -0800 Subject: [PATCH] feat: Sprint 1 - Plant icons and grid overlay Part of 3D Viewer Enhancement: - PlantIcon.tsx: Stage-specific 2D shapes (leaf, flower, diamond) - GridOverlay.tsx: Row/column/tier grid lines and labels - PlantSystem.tsx: Refactored to use icons by default - SmartRack.tsx: Integrated grid overlay Icons: circle (clone), leaf (veg), flower (flowering), diamond (dry), square (cure) --- docs/3D-VIEWER-ENHANCEMENT-PLAN.md | 251 ++++++++++++++++++ .../src/components/facility3d/GridOverlay.tsx | 235 ++++++++++++++++ .../src/components/facility3d/PlantIcon.tsx | 216 +++++++++++++++ .../src/components/facility3d/PlantSystem.tsx | 78 ++++-- .../src/components/facility3d/SmartRack.tsx | 19 +- 5 files changed, 782 insertions(+), 17 deletions(-) create mode 100644 docs/3D-VIEWER-ENHANCEMENT-PLAN.md create mode 100644 frontend/src/components/facility3d/GridOverlay.tsx create mode 100644 frontend/src/components/facility3d/PlantIcon.tsx diff --git a/docs/3D-VIEWER-ENHANCEMENT-PLAN.md b/docs/3D-VIEWER-ENHANCEMENT-PLAN.md new file mode 100644 index 0000000..d2a36c8 --- /dev/null +++ b/docs/3D-VIEWER-ENHANCEMENT-PLAN.md @@ -0,0 +1,251 @@ +# 3D Facility Viewer Enhancement Plan + +## Overview + +Two major enhancements to transform the 3D facility viewer into a comprehensive, investor-ready data visualization platform. + +--- + +## Part 1: Visual Style Refactor + +### Goal + +Replace 3D spheres with informative 2D icons/shapes that convey plant stage and health at a glance, while adding grid overlays for human readability. + +### Current State + +- Plants are 3D spheres with color coding +- No visible grid structure +- Stage indicated only by color + +### Target State + +- Flat 2D icons/polygons per growth stage +- Visible row/column/tier grid lines +- Labels for navigation (R1, R2, C1, C2, T1, T2) +- Information-dense schematic view + +### Technical Approach + +#### Phase 1.1: Plant Icon System + +Replace sphereGeometry with stage-specific 2D shapes: + +| Stage | Shape | Color | Icon Concept | +|-------|-------|-------|--------------| +| CLONE_IN | Small circle | #3b82f6 Blue | Seedling dot | +| VEGETATIVE | Leaf polygon | #22c55e Green | 3-point leaf | +| FLOWERING | Flower polygon | #a855f7 Purple | 5-petal flower | +| DRYING | Diamond | #f97316 Orange | Hanging diamond | +| CURING | Square | #92400e Brown | Jar square | +| HARVESTED | Circle outline | #6b7280 Gray | Empty circle | + +Implementation: + +- Use @react-three/drei Billboard for camera-facing icons +- Create SVG paths for each stage +- Scale based on zoom level +- Add pulse animation for highlighted plants + +#### Phase 1.2: Grid Visualization + +Add visible structure lines: + +- Horizontal lines for rows (labeled R1-R8) +- Vertical lines for columns (labeled C1-C10) +- Tier indicators (T1-T3 vertical markers) +- Semi-transparent to not obscure content +- Toggle on/off via UI + +#### Phase 1.3: Section Improvements + +- Section boundaries with labeled borders +- Section name prominently displayed +- Row/column count indicator +- Plant count badge + +### Files to Modify + +- PlantSystem.tsx - Replace sphere with icon system +- SmartRack.tsx - Add grid overlay +- types.ts - Add icon type definitions +- New: PlantIcon.tsx, GridOverlay.tsx, SectionLabel.tsx + +--- + +## Part 2: Plant Data Visualization System + +### Goal + +Create a comprehensive data card with lifecycle timeline, sensor data, and hierarchical drill-down capabilities. + +### Components + +#### 2.1: PlantDataCard (Main Component) + +A glassmorphic panel with: + +1. Header - Entity name, METRC tag, quick status badges +2. Timeline - Visual lifecycle from clone to cure +3. Vitals Grid - Health, temp, VPD, humidity gauges +4. Activity Feed - Recent events/actions +5. Actions - Quick action buttons + +#### 2.2: Lifecycle Timeline Component + +SVG-animated timeline showing plant journey: + +- Horizontal timeline with stage nodes +- Filled nodes for completed stages +- Animated current stage indicator +- Day count labels +- Estimated completion +- Click stages to see historical data + +SVG Animation Ideas: + +- Pulse effect on current stage +- Growth animation between stages +- Color gradient transitions +- Hover: expand stage details + +#### 2.3: VitalGauge Component + +Radial or bar gauge for sensor readings: + +- Animated fill based on value +- Color coding: Green (optimal), Yellow (warning), Red (critical) +- Sparkline showing 24h/7d trend +- Threshold markers +- Animated value counter + +#### 2.4: Drill-Down Aggregation + +Levels: + +| Level | Data Shown | Aggregation | +|-------|-----------|-------------| +| Plant | Individual readings | Raw values | +| Tier | All plants on tier | Avg/Min/Max | +| Section | All tiers in section | Avg/Min/Max | +| Room | All sections in room | Avg/Range | +| Floor | All rooms | Summary counts | + +Implementation: + +- useAggregatedData(level, entityId) hook +- Calculates averages, ranges, outliers +- Returns unified data shape for PlantDataCard + +#### 2.5: Mock Data Generator + +Rich, realistic demo data including: + +- Stage history with dates +- Vitals: health, temperature, humidity, VPD with history +- Touch points, photos, notes, alerts +- Position data + +Data Generation: + +- Realistic stage duration distributions +- Seasonal temperature variations +- Circadian VPD patterns +- Random but plausible health events +- IPM schedule adherence +- Growth curve progression + +--- + +## Part 3: Implementation Phases + +### Sprint 1: Icon System & Grid (2-3 days) + +1. Create PlantIcon component with stage shapes +2. Implement GridOverlay with row/column lines +3. Add section labels with plant counts +4. Toggle for grid visibility +5. Test rendering performance + +### Sprint 2: Data Card Foundation (2-3 days) + +1. Create PlantDataCard shell component +2. Implement glassmorphic styling +3. Add header with entity info +4. Wire up to plant selection +5. Position card in UI (side panel or overlay) + +### Sprint 3: Lifecycle Timeline (1-2 days) + +1. Create LifecycleTimeline component +2. SVG path animations +3. Stage click handlers +4. Day calculation logic +5. Integration with data card + +### Sprint 4: Vital Gauges (1-2 days) + +1. Create VitalGauge component +2. Animated value display +3. Color thresholds +4. Sparkline mini-charts +5. Mock sensor data hookup + +### Sprint 5: Aggregation & Drill-down (2 days) + +1. Implement useAggregatedData hook +2. Level switcher UI +3. Data roll-up calculations +4. Update card content per level +5. Breadcrumb navigation integration + +### Sprint 6: Mock Data & Polish (1-2 days) + +1. Build comprehensive mock data generator +2. Performance optimization +3. Animation polish +4. Mobile responsive adjustments +5. Accessibility review + +--- + +## File Structure + +frontend/src/components/facility3d/ + +- coordinates.ts (existing) +- PlantSystem.tsx (refactor to use icons) +- PlantIcon.tsx (NEW - stage icons) +- GridOverlay.tsx (NEW - grid lines) +- SectionLabel.tsx (NEW - section info) +- PlantDataCard/ + - index.tsx + - LifecycleTimeline.tsx + - VitalGauge.tsx + - ActivityFeed.tsx + - ActionButtons.tsx +- hooks/ + - useAggregatedData.ts + - usePlantLifecycle.ts +- mocks/ + - plantDataGenerator.ts + +--- + +## Success Criteria + +1. Visual Clarity - Investors understand facility layout in <5 seconds +2. Stage Recognition - Growth stages identifiable without color dependency +3. Data Richness - All relevant plant data visible in single card +4. Performance - 60fps with 500+ plants rendered +5. Interactivity - Smooth animations, responsive drill-down +6. Realism - Mock data feels like real cultivation facility + +--- + +## Open Questions + +1. Should the data card be a side panel (persistent) or overlay (modal)? +2. Preferred icon style: geometric shapes, emoji, or custom SVG illustrations? +3. Priority between "pretty" vs "informative" if tradeoffs arise? +4. Any specific Metrc fields that MUST be displayed? diff --git a/frontend/src/components/facility3d/GridOverlay.tsx b/frontend/src/components/facility3d/GridOverlay.tsx new file mode 100644 index 0000000..b5ccab8 --- /dev/null +++ b/frontend/src/components/facility3d/GridOverlay.tsx @@ -0,0 +1,235 @@ +import { useMemo } from 'react'; +import { Line, Text } from '@react-three/drei'; +import * as THREE from 'three'; + +interface GridOverlayProps { + rows: number; + columns: number; + width: number; // Scaled width + height: number; // Scaled height + posX: number; // Section position X + posY: number; // Section position Y (maps to Z in 3D) + tiers: number; + currentTier?: number; + showLabels?: boolean; + opacity?: number; +} + +// Grid colors +const GRID_COLORS = { + rowLine: '#475569', // Slate-600 + colLine: '#475569', // Slate-600 + tierLine: '#64748b', // Slate-500 + labelText: '#94a3b8', // Slate-400 + highlight: '#06b6d4', // Cyan-500 +}; + +export function GridOverlay({ + rows, + columns, + width, + height, + posX, + posY, + tiers, + currentTier = 1, + showLabels = true, + opacity = 0.6, +}: GridOverlayProps) { + // Calculate spacing + const colSpacing = width / (columns + 1); + const rowSpacing = height / (rows + 1); + + // Generate grid line points + const gridLines = useMemo(() => { + const lines: Array<{ points: [number, number, number][]; color: string }> = []; + + // Row lines (horizontal, along Z axis in 3D) + for (let r = 1; r <= rows; r++) { + const z = posY + r * rowSpacing; + lines.push({ + points: [ + [posX, 0.02, z], + [posX + width, 0.02, z], + ], + color: GRID_COLORS.rowLine, + }); + } + + // Column lines (vertical, along X axis in 3D) + for (let c = 1; c <= columns; c++) { + const x = posX + c * colSpacing; + lines.push({ + points: [ + [x, 0.02, posY], + [x, 0.02, posY + height], + ], + color: GRID_COLORS.colLine, + }); + } + + return lines; + }, [rows, columns, width, height, posX, posY, colSpacing, rowSpacing]); + + // Generate row/column labels + const labels = useMemo(() => { + if (!showLabels) return []; + + const result: Array<{ text: string; position: [number, number, number]; type: 'row' | 'col' }> = []; + + // Row labels (R1, R2, R3...) + for (let r = 1; r <= rows; r++) { + result.push({ + text: `R${r}`, + position: [posX - 0.3, 0.1, posY + r * rowSpacing], + type: 'row', + }); + } + + // Column labels (C1, C2, C3...) + for (let c = 1; c <= columns; c++) { + result.push({ + text: `C${c}`, + position: [posX + c * colSpacing, 0.1, posY + height + 0.25], + type: 'col', + }); + } + + return result; + }, [rows, columns, posX, posY, rowSpacing, colSpacing, height, showLabels]); + + // Tier marker (vertical line showing tier level) + const tierMarkers = useMemo(() => { + if (tiers <= 1) return null; + + const markers: Array<{ tier: number; y: number }> = []; + for (let t = 1; t <= tiers; t++) { + markers.push({ + tier: t, + y: 0.4 + (t * 0.6), + }); + } + return markers; + }, [tiers]); + + return ( + + {/* Grid lines */} + {gridLines.map((line, i) => ( + + ))} + + {/* Row/Column labels */} + {labels.map((label, i) => ( + + {label.text} + + ))} + + {/* Tier indicators (side markers) */} + {tierMarkers && ( + + {tierMarkers.map(marker => ( + + {/* Tier line */} + + {/* Tier label */} + + T{marker.tier} + + + ))} + + )} + + {/* Section boundary outline */} + + + ); +} + +// Simplified grid for room-level view +interface RoomGridProps { + roomWidth: number; + roomHeight: number; + posX: number; + posY: number; + sectionCount: number; + totalPlants: number; +} + +export function RoomGrid({ roomWidth, roomHeight, posX, posY, sectionCount, totalPlants }: RoomGridProps) { + return ( + + {/* Room boundary */} + + + {/* Room stats in corner */} + + {sectionCount} sections • {totalPlants} plants + + + ); +} diff --git a/frontend/src/components/facility3d/PlantIcon.tsx b/frontend/src/components/facility3d/PlantIcon.tsx new file mode 100644 index 0000000..2433d4a --- /dev/null +++ b/frontend/src/components/facility3d/PlantIcon.tsx @@ -0,0 +1,216 @@ +import { useMemo } from 'react'; +import { Billboard } from '@react-three/drei'; +import * as THREE from 'three'; + +// Stage-specific icon shapes as SVG paths (camera-facing 2D) +// These are custom illustrations optimized for clarity at small sizes + +export type PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED'; + +interface PlantIconProps { + stage: PlantStage; + color: string; + position: [number, number, number]; + scale?: number; + isHighlighted?: boolean; + isDimmed?: boolean; + onClick?: () => void; +} + +// Icon configurations per stage +const STAGE_ICONS: Record = { + CLONE_IN: { shape: 'circle' }, // Small seedling dot + VEGETATIVE: { shape: 'leaf' }, // 3-point leaf shape + FLOWERING: { shape: 'flower', sides: 5 }, // 5-petal flower + DRYING: { shape: 'diamond' }, // Hanging diamond + CURING: { shape: 'square' }, // Jar/container square + HARVESTED: { shape: 'ring' }, // Empty circle + FINISHED: { shape: 'ring' }, // Empty circle +}; + +// Create geometry for each shape type +function createShapeGeometry(shape: string, sides?: number): THREE.BufferGeometry { + switch (shape) { + case 'circle': + return new THREE.CircleGeometry(0.15, 16); + + case 'leaf': { + // Custom leaf shape - pointed oval + const leafShape = new THREE.Shape(); + leafShape.moveTo(0, 0.2); + leafShape.bezierCurveTo(0.12, 0.15, 0.15, 0.05, 0.1, -0.15); + leafShape.lineTo(0, -0.2); + leafShape.lineTo(-0.1, -0.15); + leafShape.bezierCurveTo(-0.15, 0.05, -0.12, 0.15, 0, 0.2); + return new THREE.ShapeGeometry(leafShape); + } + + case 'flower': { + // 5-petal flower shape + const petalCount = sides || 5; + const flowerShape = new THREE.Shape(); + const innerRadius = 0.06; + const outerRadius = 0.18; + + for (let i = 0; i < petalCount; i++) { + const angle = (i / petalCount) * Math.PI * 2 - Math.PI / 2; + const nextAngle = ((i + 1) / petalCount) * Math.PI * 2 - Math.PI / 2; + const midAngle = (angle + nextAngle) / 2; + + const x1 = Math.cos(angle) * innerRadius; + const y1 = Math.sin(angle) * innerRadius; + const xMid = Math.cos(midAngle) * outerRadius; + const yMid = Math.sin(midAngle) * outerRadius; + + if (i === 0) { + flowerShape.moveTo(x1, y1); + } + flowerShape.quadraticCurveTo(xMid, yMid, Math.cos(nextAngle) * innerRadius, Math.sin(nextAngle) * innerRadius); + } + flowerShape.closePath(); + return new THREE.ShapeGeometry(flowerShape); + } + + case 'diamond': { + // Diamond/rhombus shape + const diamondShape = new THREE.Shape(); + diamondShape.moveTo(0, 0.2); + diamondShape.lineTo(0.12, 0); + diamondShape.lineTo(0, -0.2); + diamondShape.lineTo(-0.12, 0); + diamondShape.closePath(); + return new THREE.ShapeGeometry(diamondShape); + } + + case 'square': + return new THREE.PlaneGeometry(0.25, 0.25); + + case 'ring': { + // Ring/outline circle + const ringShape = new THREE.Shape(); + ringShape.absarc(0, 0, 0.15, 0, Math.PI * 2, false); + const hole = new THREE.Path(); + hole.absarc(0, 0, 0.1, 0, Math.PI * 2, true); + ringShape.holes.push(hole); + return new THREE.ShapeGeometry(ringShape); + } + + default: + return new THREE.CircleGeometry(0.15, 16); + } +} + +// Cache geometries to avoid recreation +const geometryCache = new Map(); + +function getGeometry(shape: string, sides?: number): THREE.BufferGeometry { + const key = `${shape}-${sides || 0}`; + if (!geometryCache.has(key)) { + geometryCache.set(key, createShapeGeometry(shape, sides)); + } + return geometryCache.get(key)!; +} + +export function PlantIcon({ + stage, + color, + position, + scale = 1, + isHighlighted = false, + isDimmed = false, + onClick +}: PlantIconProps) { + const iconConfig = STAGE_ICONS[stage] || STAGE_ICONS.VEGETATIVE; + const geometry = useMemo(() => getGeometry(iconConfig.shape, iconConfig.sides), [iconConfig]); + + // Adjust color for dimming/highlighting + const finalColor = useMemo(() => { + if (isDimmed) return '#3f3f46'; // Zinc-700 + return color; + }, [color, isDimmed]); + + // Scale adjustments + const finalScale = isHighlighted ? scale * 1.5 : scale; + + return ( + + { + e.stopPropagation(); + onClick?.(); + }} + > + + + + {/* Highlight ring for selected plants */} + {isHighlighted && ( + + + + + )} + + ); +} + +// Batch renderer for performance with many plants +interface PlantIconBatchProps { + plants: Array<{ + id: string; + stage: PlantStage; + color: string; + position: [number, number, number]; + isHighlighted?: boolean; + isDimmed?: boolean; + }>; + onPlantClick: (id: string) => void; +} + +export function PlantIconBatch({ plants, onPlantClick }: PlantIconBatchProps) { + // Group by stage for instanced rendering + const groupedByStage = useMemo(() => { + const groups: Record = { + CLONE_IN: [], + VEGETATIVE: [], + FLOWERING: [], + DRYING: [], + CURING: [], + HARVESTED: [], + FINISHED: [], + }; + + for (const plant of plants) { + const stage = plant.stage || 'VEGETATIVE'; + if (groups[stage]) { + groups[stage].push(plant); + } + } + + return groups; + }, [plants]); + + // For now, render individually (can optimize to instanced later if needed) + return ( + + {plants.map(plant => ( + onPlantClick(plant.id)} + /> + ))} + + ); +} diff --git a/frontend/src/components/facility3d/PlantSystem.tsx b/frontend/src/components/facility3d/PlantSystem.tsx index 46c92b2..b059ced 100644 --- a/frontend/src/components/facility3d/PlantSystem.tsx +++ b/frontend/src/components/facility3d/PlantSystem.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { Instances, Instance } from '@react-three/drei'; import * as THREE from 'three'; import { PlantPosition, VisMode, COLORS } from './types'; +import { PlantIcon, PlantStage } from './PlantIcon'; // Mock helpers const getMockPlantHealth = (_plantId: string) => { @@ -17,12 +18,27 @@ const lerpColor = (c1: string, c2: string, t: number) => { return '#' + c1c.lerp(c2c, Math.min(1, Math.max(0, t))).getHexString(); }; +// Map stage strings to PlantStage type +const normalizeStage = (stage?: string): PlantStage => { + if (!stage) return 'VEGETATIVE'; + const s = stage.toUpperCase(); + if (s === 'CLONE_IN') return 'CLONE_IN'; + if (s === 'VEGETATIVE') return 'VEGETATIVE'; + if (s === 'FLOWERING') return 'FLOWERING'; + if (s === 'DRYING') return 'DRYING'; + if (s === 'CURING') return 'CURING'; + if (s === 'HARVESTED') return 'HARVESTED'; + if (s === 'FINISHED') return 'FINISHED'; + return 'VEGETATIVE'; +}; + interface PlantSystemProps { positions: PlantPosition[]; visMode: VisMode; onPlantClick: (plant: PlantPosition) => void; highlightedTags?: string[]; // Tags to highlight dimMode?: boolean; // Whether to dim non-highlighted plants + useIcons?: boolean; // Use 2D icons instead of 3D spheres } export function PlantSystem({ @@ -30,7 +46,8 @@ export function PlantSystem({ visMode, onPlantClick, highlightedTags = [], - dimMode = false + dimMode = false, + useIcons = true // Default to new icon system }: PlantSystemProps) { if (!positions || positions.length === 0) return null; @@ -46,17 +63,17 @@ export function PlantSystem({ return plants.map(pos => { const isHighlighted = pos.plant && highlightSet.has(pos.plant.tagNumber); const shouldDim = dimMode && hasHighlights && !isHighlighted; + const stage = normalizeStage(pos.plant?.stage ?? undefined); let color: string; switch (visMode) { case 'STANDARD': // Match actual BatchStage enum values from schema - const stage = pos.plant?.stage; color = (stage === 'FLOWERING') ? COLORS.FLOWER : (stage === 'DRYING') ? COLORS.DRY : (stage === 'CURING') ? COLORS.CURE : - (stage === 'CLONE_IN' || stage === 'VEGETATIVE') ? COLORS.VEG : - COLORS.VEG; // Default fallback + (stage === 'CLONE_IN') ? '#3b82f6' : // Blue for clones + COLORS.VEG; break; case 'HEALTH': const status = getMockPlantHealth(pos.plant!.id); @@ -70,18 +87,46 @@ export function PlantSystem({ color = '#555'; } - // Apply dim effect - if (shouldDim) { - color = '#3f3f46'; // Zinc-700 - muted gray - } - - // Apply highlight boost - const scale = isHighlighted ? 2.0 : (shouldDim ? 1.0 : 1.5); - - return { pos, color, scale, isHighlighted }; + return { pos, color, isHighlighted, shouldDim, stage }; }); }, [plants, visMode, highlightSet, dimMode, hasHighlights]); + // New icon-based rendering + if (useIcons) { + return ( + + {plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => ( + onPlantClick(pos)} + /> + ))} + + {/* Empty slot markers - small dots */} + {emptySlots.length > 0 && visMode === 'STANDARD' && !dimMode && ( + + + + {emptySlots.map((pos, i) => ( + + ))} + + )} + + ); + } + + // Legacy sphere-based rendering (fallback) return ( {plants.length > 0 && ( @@ -97,12 +142,12 @@ export function PlantSystem({ > - {plantData.map(({ pos, color, scale }, i) => ( + {plantData.map(({ pos, color, isHighlighted, shouldDim }, i) => ( ))} @@ -124,3 +169,4 @@ export function PlantSystem({ ); } + diff --git a/frontend/src/components/facility3d/SmartRack.tsx b/frontend/src/components/facility3d/SmartRack.tsx index 844c2e8..81ec284 100644 --- a/frontend/src/components/facility3d/SmartRack.tsx +++ b/frontend/src/components/facility3d/SmartRack.tsx @@ -5,6 +5,7 @@ import type { Section3D, Position3D } from '../../lib/layoutApi'; import { PlantPosition, VisMode } from './types'; import { PlantSystem } from './PlantSystem'; import { SCALE } from './coordinates'; +import { GridOverlay } from './GridOverlay'; // Hierarchy context passed down from FacilityScene export interface HierarchyContext { @@ -21,9 +22,10 @@ interface SmartRackProps { highlightedTags?: string[]; dimMode?: boolean; hierarchy?: HierarchyContext; + showGrid?: boolean; // Toggle grid visibility } -export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dimMode, hierarchy }: SmartRackProps) { +export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dimMode, hierarchy, showGrid = true }: SmartRackProps) { // Section positions are absolute (after our fix), scale them const scaledSection = { posX: section.posX * SCALE, @@ -146,6 +148,21 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim ))} + {/* Grid overlay with row/column labels */} + {showGrid && visMode === 'STANDARD' && ( + + )} +