feat: Sprint 1 - Plant icons and grid overlay
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

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:
fullsizemalt 2025-12-19 12:43:22 -08:00
parent 56f134f7f7
commit 953c9781d2
5 changed files with 782 additions and 17 deletions

View 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?

View 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>
);
}

View 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>
);
}

View file

@ -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>
); );
} }

View file

@ -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}