ca-grow-ops-manager/frontend/src/components/facility3d/SmartRack.tsx
fullsizemalt a23a2c5582
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
feat: Sprint 5 - Aggregation & Drill-down Contextual Overlays
- SummaryOverlay.tsx: New component for room and section summaries
- Integrated SectionSummary in SmartRack with hover interaction
- Integrated RoomSummaryOverlay in RoomObject for room-level counts
- Aggregated plant counts and health scores per section/room
2025-12-19 13:24:15 -08:00

209 lines
8.3 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Text } from '@react-three/drei';
import * as THREE from 'three';
import type { Section3D, Position3D } from '../../lib/layoutApi';
import { PlantPosition, VisMode } from './types';
import { PlantSystem } from './PlantSystem';
import { SCALE } from './coordinates';
import { GridOverlay } from './GridOverlay';
import { SectionSummary } from './SummaryOverlay';
// Hierarchy context passed down from FacilityScene
export interface HierarchyContext {
facility?: string;
building?: string;
floor?: string;
room?: string;
}
interface SmartRackProps {
section: Section3D;
visMode: VisMode;
onPlantClick: (plant: PlantPosition) => void;
highlightedTags?: string[];
dimMode?: boolean;
hierarchy?: HierarchyContext;
showGrid?: boolean; // Toggle grid visibility
}
export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dimMode, hierarchy, showGrid = true }: SmartRackProps) {
const [isHovered, setIsHovered] = useState(false);
// Section positions are absolute (after our fix), scale them
const scaledSection = {
posX: section.posX * SCALE,
posY: section.posY * SCALE,
width: section.width * SCALE,
height: section.height * SCALE,
};
// Calculate grid dimensions and spacing
const maxCols = Math.max(...section.positions.map(p => p.column), 1);
const maxRows = Math.max(...section.positions.map(p => p.row), 1);
const colSpacing = scaledSection.width / (maxCols + 1);
const rowSpacing = scaledSection.height / (maxRows + 1);
// Calculate plant positions using consistent grid logic
const positions: PlantPosition[] = useMemo(() => {
return section.positions.map((pos: Position3D) => ({
...pos,
// Grid-based positioning within section (RAW coords, no floor centering here)
x: scaledSection.posX + (pos.column * colSpacing),
z: scaledSection.posY + (pos.row * rowSpacing),
y: 0.4 + (pos.tier * 0.6),
// Full hierarchy breadcrumb
breadcrumb: {
facility: hierarchy?.facility,
building: hierarchy?.building,
floor: hierarchy?.floor,
room: hierarchy?.room,
section: section.code || section.name,
tier: pos.tier,
},
}));
}, [section, scaledSection.posX, scaledSection.posY, colSpacing, rowSpacing, hierarchy]);
const distinctTiers = [...new Set(positions.map(p => p.tier))].sort((a, b) => a - b);
const distinctRows = [...new Set(positions.map(p => p.row))].sort((a, b) => a - b);
const distinctCols = [...new Set(positions.map(p => p.column))].sort((a, b) => a - b);
// Aggregate data for the summary overlay
const aggregates = useMemo(() => {
const plantsWithLife = section.positions.filter(p => p.plant);
const plantCount = plantsWithLife.length;
// Mock aggregates based on section data
const hash = (section.id || section.name).split('').reduce((a, b) => { a = ((a << 5) - a) + b.charCodeAt(0); return a & a }, 0);
const healthScore = plantCount > 0 ? 82 + (Math.abs(hash) % 15) : 0;
const avgTemp = 74 + (Math.abs(hash) % 6);
const avgHumidity = 52 + (Math.abs(hash) % 12);
const warnings = (Math.abs(hash) % 10) > 7 ? 1 : 0;
return { plantCount, healthScore, avgTemp, avgHumidity, warnings };
}, [section.positions, section.id, section.name]);
return (
<group
onPointerOver={(e) => { e.stopPropagation(); setIsHovered(true); }}
onPointerOut={() => setIsHovered(false)}
>
{/* Contextual Summary Overlay */}
<SectionSummary
position={[scaledSection.posX + scaledSection.width / 2, 2.5, scaledSection.posY + scaledSection.height / 2]}
name={section.code || section.name || 'Zone'}
plantCount={aggregates.plantCount}
healthScore={aggregates.healthScore}
avgTemp={aggregates.avgTemp}
avgHumidity={aggregates.avgHumidity}
warnings={aggregates.warnings}
isVisible={isHovered && visMode === 'STANDARD'}
/>
{/* Section/Table Label */}
{visMode === 'STANDARD' && (
<Text
position={[scaledSection.posX + scaledSection.width / 2, 3, scaledSection.posY + scaledSection.height / 2]}
fontSize={0.6}
color={isHovered ? "#22d3ee" : "#94a3b8"}
anchorX="center"
anchorY="bottom"
outlineColor="#000"
outlineWidth={0.03}
>
{section.code || section.name}
</Text>
)}
{/* Shelf/Tier surfaces */}
{distinctTiers.map(tier => (
<mesh
key={`shelf-${tier}`}
position={[
scaledSection.posX + scaledSection.width / 2,
0.35 + (tier * 0.6),
scaledSection.posY + scaledSection.height / 2
]}
rotation={[-Math.PI / 2, 0, 0]}
receiveShadow
>
<planeGeometry args={[scaledSection.width * 0.95, scaledSection.height * 0.95]} />
<meshStandardMaterial
color={isHovered ? "#334155" : "#475569"}
roughness={0.3}
metalness={0.5}
side={THREE.DoubleSide}
/>
</mesh>
))}
{/* Support posts */}
{[0, 1].map(xOffset =>
[0, 1].map(zOffset => (
<mesh
key={`support-${xOffset}-${zOffset}`}
position={[
scaledSection.posX + (xOffset * scaledSection.width),
(distinctTiers.length * 0.6) / 2 + 0.2,
scaledSection.posY + (zOffset * scaledSection.height)
]}
>
<boxGeometry args={[0.08, distinctTiers.length * 0.6 + 0.3, 0.08]} />
<meshStandardMaterial color="#374151" roughness={0.4} metalness={0.8} />
</mesh>
))
)}
{/* Row labels */}
{visMode === 'STANDARD' && distinctRows.map(row => (
<Text
key={`row-${row}`}
position={[scaledSection.posX - 0.2, 0.8, scaledSection.posY + row * rowSpacing]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={0.15}
color="#64748b"
anchorX="right"
>
R{row}
</Text>
))}
{/* Column labels */}
{visMode === 'STANDARD' && distinctCols.map(col => (
<Text
key={`col-${col}`}
position={[scaledSection.posX + col * colSpacing, 0.15, scaledSection.posY + scaledSection.height + 0.15]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={0.15}
color="#64748b"
anchorX="center"
>
C{col}
</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={isHovered ? 0.6 : 0.4}
/>
)}
<PlantSystem
positions={positions}
visMode={visMode}
onPlantClick={onPlantClick}
highlightedTags={highlightedTags}
dimMode={dimMode}
/>
</group>
);
}