- 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
209 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|