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)
This commit is contained in:
parent
56f134f7f7
commit
953c9781d2
5 changed files with 782 additions and 17 deletions
251
docs/3D-VIEWER-ENHANCEMENT-PLAN.md
Normal file
251
docs/3D-VIEWER-ENHANCEMENT-PLAN.md
Normal file
|
|
@ -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?
|
||||||
235
frontend/src/components/facility3d/GridOverlay.tsx
Normal file
235
frontend/src/components/facility3d/GridOverlay.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<group>
|
||||||
|
{/* Grid lines */}
|
||||||
|
{gridLines.map((line, i) => (
|
||||||
|
<Line
|
||||||
|
key={`grid-${i}`}
|
||||||
|
points={line.points}
|
||||||
|
color={line.color}
|
||||||
|
lineWidth={1}
|
||||||
|
transparent
|
||||||
|
opacity={opacity * 0.5}
|
||||||
|
dashed
|
||||||
|
dashScale={10}
|
||||||
|
dashSize={0.1}
|
||||||
|
gapSize={0.05}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row/Column labels */}
|
||||||
|
{labels.map((label, i) => (
|
||||||
|
<Text
|
||||||
|
key={`label-${i}`}
|
||||||
|
position={label.position}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={0.12}
|
||||||
|
color={GRID_COLORS.labelText}
|
||||||
|
anchorX={label.type === 'row' ? 'right' : 'center'}
|
||||||
|
anchorY="middle"
|
||||||
|
fillOpacity={opacity}
|
||||||
|
>
|
||||||
|
{label.text}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tier indicators (side markers) */}
|
||||||
|
{tierMarkers && (
|
||||||
|
<group position={[posX - 0.15, 0, posY + height / 2]}>
|
||||||
|
{tierMarkers.map(marker => (
|
||||||
|
<group key={`tier-${marker.tier}`}>
|
||||||
|
{/* Tier line */}
|
||||||
|
<Line
|
||||||
|
points={[
|
||||||
|
[-0.1, marker.y - 0.1, 0],
|
||||||
|
[-0.1, marker.y + 0.1, 0],
|
||||||
|
]}
|
||||||
|
color={marker.tier === currentTier ? GRID_COLORS.highlight : GRID_COLORS.tierLine}
|
||||||
|
lineWidth={2}
|
||||||
|
/>
|
||||||
|
{/* Tier label */}
|
||||||
|
<Text
|
||||||
|
position={[-0.25, marker.y, 0]}
|
||||||
|
fontSize={0.1}
|
||||||
|
color={marker.tier === currentTier ? GRID_COLORS.highlight : GRID_COLORS.labelText}
|
||||||
|
anchorX="right"
|
||||||
|
anchorY="middle"
|
||||||
|
>
|
||||||
|
T{marker.tier}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section boundary outline */}
|
||||||
|
<Line
|
||||||
|
points={[
|
||||||
|
[posX, 0.01, posY],
|
||||||
|
[posX + width, 0.01, posY],
|
||||||
|
[posX + width, 0.01, posY + height],
|
||||||
|
[posX, 0.01, posY + height],
|
||||||
|
[posX, 0.01, posY],
|
||||||
|
]}
|
||||||
|
color="#64748b"
|
||||||
|
lineWidth={1.5}
|
||||||
|
transparent
|
||||||
|
opacity={opacity * 0.8}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<group>
|
||||||
|
{/* Room boundary */}
|
||||||
|
<Line
|
||||||
|
points={[
|
||||||
|
[posX, 0.005, posY],
|
||||||
|
[posX + roomWidth, 0.005, posY],
|
||||||
|
[posX + roomWidth, 0.005, posY + roomHeight],
|
||||||
|
[posX, 0.005, posY + roomHeight],
|
||||||
|
[posX, 0.005, posY],
|
||||||
|
]}
|
||||||
|
color="#334155"
|
||||||
|
lineWidth={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Room stats in corner */}
|
||||||
|
<Text
|
||||||
|
position={[posX + 0.3, 0.15, posY + 0.3]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
fontSize={0.15}
|
||||||
|
color="#cbd5e1"
|
||||||
|
anchorX="left"
|
||||||
|
anchorY="bottom"
|
||||||
|
>
|
||||||
|
{sectionCount} sections • {totalPlants} plants
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
frontend/src/components/facility3d/PlantIcon.tsx
Normal file
216
frontend/src/components/facility3d/PlantIcon.tsx
Normal file
|
|
@ -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<PlantStage, { shape: 'circle' | 'leaf' | 'flower' | 'diamond' | 'square' | 'ring'; sides?: number }> = {
|
||||||
|
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<string, THREE.BufferGeometry>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Billboard position={position} follow={true} lockX={false} lockY={false} lockZ={false}>
|
||||||
|
<mesh
|
||||||
|
geometry={geometry}
|
||||||
|
scale={[finalScale, finalScale, 1]}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={finalColor}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
transparent={isDimmed}
|
||||||
|
opacity={isDimmed ? 0.4 : 1}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Highlight ring for selected plants */}
|
||||||
|
{isHighlighted && (
|
||||||
|
<mesh scale={[finalScale * 1.3, finalScale * 1.3, 1]}>
|
||||||
|
<ringGeometry args={[0.18, 0.22, 24]} />
|
||||||
|
<meshBasicMaterial color="#22d3ee" transparent opacity={0.8} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</Billboard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<PlantStage, typeof plants> = {
|
||||||
|
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 (
|
||||||
|
<group>
|
||||||
|
{plants.map(plant => (
|
||||||
|
<PlantIcon
|
||||||
|
key={plant.id}
|
||||||
|
stage={plant.stage}
|
||||||
|
color={plant.color}
|
||||||
|
position={plant.position}
|
||||||
|
isHighlighted={plant.isHighlighted}
|
||||||
|
isDimmed={plant.isDimmed}
|
||||||
|
onClick={() => onPlantClick(plant.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||||
import { Instances, Instance } from '@react-three/drei';
|
import { Instances, Instance } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { PlantPosition, VisMode, COLORS } from './types';
|
import { PlantPosition, VisMode, COLORS } from './types';
|
||||||
|
import { PlantIcon, PlantStage } from './PlantIcon';
|
||||||
|
|
||||||
// Mock helpers
|
// Mock helpers
|
||||||
const getMockPlantHealth = (_plantId: string) => {
|
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();
|
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 {
|
interface PlantSystemProps {
|
||||||
positions: PlantPosition[];
|
positions: PlantPosition[];
|
||||||
visMode: VisMode;
|
visMode: VisMode;
|
||||||
onPlantClick: (plant: PlantPosition) => void;
|
onPlantClick: (plant: PlantPosition) => void;
|
||||||
highlightedTags?: string[]; // Tags to highlight
|
highlightedTags?: string[]; // Tags to highlight
|
||||||
dimMode?: boolean; // Whether to dim non-highlighted plants
|
dimMode?: boolean; // Whether to dim non-highlighted plants
|
||||||
|
useIcons?: boolean; // Use 2D icons instead of 3D spheres
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlantSystem({
|
export function PlantSystem({
|
||||||
|
|
@ -30,7 +46,8 @@ export function PlantSystem({
|
||||||
visMode,
|
visMode,
|
||||||
onPlantClick,
|
onPlantClick,
|
||||||
highlightedTags = [],
|
highlightedTags = [],
|
||||||
dimMode = false
|
dimMode = false,
|
||||||
|
useIcons = true // Default to new icon system
|
||||||
}: PlantSystemProps) {
|
}: PlantSystemProps) {
|
||||||
if (!positions || positions.length === 0) return null;
|
if (!positions || positions.length === 0) return null;
|
||||||
|
|
||||||
|
|
@ -46,17 +63,17 @@ export function PlantSystem({
|
||||||
return plants.map(pos => {
|
return plants.map(pos => {
|
||||||
const isHighlighted = pos.plant && highlightSet.has(pos.plant.tagNumber);
|
const isHighlighted = pos.plant && highlightSet.has(pos.plant.tagNumber);
|
||||||
const shouldDim = dimMode && hasHighlights && !isHighlighted;
|
const shouldDim = dimMode && hasHighlights && !isHighlighted;
|
||||||
|
const stage = normalizeStage(pos.plant?.stage ?? undefined);
|
||||||
|
|
||||||
let color: string;
|
let color: string;
|
||||||
switch (visMode) {
|
switch (visMode) {
|
||||||
case 'STANDARD':
|
case 'STANDARD':
|
||||||
// Match actual BatchStage enum values from schema
|
// Match actual BatchStage enum values from schema
|
||||||
const stage = pos.plant?.stage;
|
|
||||||
color = (stage === 'FLOWERING') ? COLORS.FLOWER :
|
color = (stage === 'FLOWERING') ? COLORS.FLOWER :
|
||||||
(stage === 'DRYING') ? COLORS.DRY :
|
(stage === 'DRYING') ? COLORS.DRY :
|
||||||
(stage === 'CURING') ? COLORS.CURE :
|
(stage === 'CURING') ? COLORS.CURE :
|
||||||
(stage === 'CLONE_IN' || stage === 'VEGETATIVE') ? COLORS.VEG :
|
(stage === 'CLONE_IN') ? '#3b82f6' : // Blue for clones
|
||||||
COLORS.VEG; // Default fallback
|
COLORS.VEG;
|
||||||
break;
|
break;
|
||||||
case 'HEALTH':
|
case 'HEALTH':
|
||||||
const status = getMockPlantHealth(pos.plant!.id);
|
const status = getMockPlantHealth(pos.plant!.id);
|
||||||
|
|
@ -70,18 +87,46 @@ export function PlantSystem({
|
||||||
color = '#555';
|
color = '#555';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply dim effect
|
return { pos, color, isHighlighted, shouldDim, stage };
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
}, [plants, visMode, highlightSet, dimMode, hasHighlights]);
|
}, [plants, visMode, highlightSet, dimMode, hasHighlights]);
|
||||||
|
|
||||||
|
// New icon-based rendering
|
||||||
|
if (useIcons) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => (
|
||||||
|
<PlantIcon
|
||||||
|
key={pos.id || i}
|
||||||
|
stage={stage}
|
||||||
|
color={color}
|
||||||
|
position={[pos.x, pos.y + 0.3, pos.z]}
|
||||||
|
scale={1.2}
|
||||||
|
isHighlighted={isHighlighted || false}
|
||||||
|
isDimmed={shouldDim}
|
||||||
|
onClick={() => onPlantClick(pos)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty slot markers - small dots */}
|
||||||
|
{emptySlots.length > 0 && visMode === 'STANDARD' && !dimMode && (
|
||||||
|
<Instances range={emptySlots.length}>
|
||||||
|
<circleGeometry args={[0.05, 8]} />
|
||||||
|
<meshBasicMaterial color={COLORS.EMPTY_SLOT} transparent opacity={0.3} />
|
||||||
|
{emptySlots.map((pos, i) => (
|
||||||
|
<Instance
|
||||||
|
key={pos.id || i}
|
||||||
|
position={[pos.x, pos.y + 0.05, pos.z]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Instances>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy sphere-based rendering (fallback)
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{plants.length > 0 && (
|
{plants.length > 0 && (
|
||||||
|
|
@ -97,12 +142,12 @@ export function PlantSystem({
|
||||||
>
|
>
|
||||||
<sphereGeometry args={[0.2, 12, 8]} />
|
<sphereGeometry args={[0.2, 12, 8]} />
|
||||||
<meshStandardMaterial roughness={0.5} />
|
<meshStandardMaterial roughness={0.5} />
|
||||||
{plantData.map(({ pos, color, scale }, i) => (
|
{plantData.map(({ pos, color, isHighlighted, shouldDim }, i) => (
|
||||||
<Instance
|
<Instance
|
||||||
key={pos.id || i}
|
key={pos.id || i}
|
||||||
position={[pos.x, pos.y + 0.25, pos.z]}
|
position={[pos.x, pos.y + 0.25, pos.z]}
|
||||||
color={color}
|
color={shouldDim ? '#3f3f46' : color}
|
||||||
scale={scale}
|
scale={isHighlighted ? 2.0 : (shouldDim ? 1.0 : 1.5)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Instances>
|
</Instances>
|
||||||
|
|
@ -124,3 +169,4 @@ export function PlantSystem({
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { Section3D, Position3D } from '../../lib/layoutApi';
|
||||||
import { PlantPosition, VisMode } from './types';
|
import { PlantPosition, VisMode } from './types';
|
||||||
import { PlantSystem } from './PlantSystem';
|
import { PlantSystem } from './PlantSystem';
|
||||||
import { SCALE } from './coordinates';
|
import { SCALE } from './coordinates';
|
||||||
|
import { GridOverlay } from './GridOverlay';
|
||||||
|
|
||||||
// Hierarchy context passed down from FacilityScene
|
// Hierarchy context passed down from FacilityScene
|
||||||
export interface HierarchyContext {
|
export interface HierarchyContext {
|
||||||
|
|
@ -21,9 +22,10 @@ interface SmartRackProps {
|
||||||
highlightedTags?: string[];
|
highlightedTags?: string[];
|
||||||
dimMode?: boolean;
|
dimMode?: boolean;
|
||||||
hierarchy?: HierarchyContext;
|
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
|
// Section positions are absolute (after our fix), scale them
|
||||||
const scaledSection = {
|
const scaledSection = {
|
||||||
posX: section.posX * SCALE,
|
posX: section.posX * SCALE,
|
||||||
|
|
@ -146,6 +148,21 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Grid overlay with row/column labels */}
|
||||||
|
{showGrid && visMode === 'STANDARD' && (
|
||||||
|
<GridOverlay
|
||||||
|
rows={maxRows}
|
||||||
|
columns={maxCols}
|
||||||
|
width={scaledSection.width}
|
||||||
|
height={scaledSection.height}
|
||||||
|
posX={scaledSection.posX}
|
||||||
|
posY={scaledSection.posY}
|
||||||
|
tiers={distinctTiers.length}
|
||||||
|
showLabels={false} // We already have row/col labels above
|
||||||
|
opacity={0.4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<PlantSystem
|
<PlantSystem
|
||||||
positions={positions}
|
positions={positions}
|
||||||
visMode={visMode}
|
visMode={visMode}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue