feat: add hierarchy breadcrumb and improve plant positioning
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

- Create HierarchyBreadcrumb component for navigation
- Fix SmartRack plant positioning within section bounds
- Add breadcrumb data to PlantPosition type
- Display breadcrumb path in plant selection panel
- Pass roomName through component hierarchy
This commit is contained in:
fullsizemalt 2025-12-18 19:45:57 -08:00
parent debc5d9447
commit eaa32c05fe
5 changed files with 114 additions and 7 deletions

View file

@ -0,0 +1,56 @@
import { ChevronRight, Building2, Layers, LayoutGrid, Box, Grid3X3 } from 'lucide-react';
export interface BreadcrumbData {
facility?: string;
building?: string;
floor?: string;
room?: string;
section?: string;
tier?: number;
position?: string;
}
interface HierarchyBreadcrumbProps {
data: BreadcrumbData;
onNavigate?: (level: keyof BreadcrumbData) => void;
}
const levelIcons: Record<string, React.ReactNode> = {
facility: <Building2 size={14} />,
building: <Building2 size={14} />,
floor: <Layers size={14} />,
room: <LayoutGrid size={14} />,
section: <Grid3X3 size={14} />,
tier: <Box size={14} />,
};
export function HierarchyBreadcrumb({ data, onNavigate }: HierarchyBreadcrumbProps) {
const levels: { key: keyof BreadcrumbData; label: string }[] = [
{ key: 'facility', label: data.facility || '' },
{ key: 'building', label: data.building || '' },
{ key: 'floor', label: data.floor || '' },
{ key: 'room', label: data.room || '' },
{ key: 'section', label: data.section || '' },
{ key: 'tier', label: data.tier ? `Tier ${data.tier}` : '' },
].filter(l => l.label);
if (levels.length === 0) return null;
return (
<div className="flex items-center gap-1 text-xs bg-black/60 px-3 py-1.5 rounded-lg backdrop-blur border border-white/10">
{levels.map((level, i) => (
<div key={level.key} className="flex items-center gap-1">
{i > 0 && <ChevronRight size={12} className="text-slate-500" />}
<button
onClick={() => onNavigate?.(level.key)}
className="flex items-center gap-1 hover:text-cyan-400 transition-colors"
title={`Navigate to ${level.key}`}
>
<span className="text-slate-400">{levelIcons[level.key]}</span>
<span className="text-slate-200">{level.label}</span>
</button>
</div>
))}
</div>
);
}

View file

@ -106,6 +106,7 @@ export function RoomObject({ room, visMode, onPlantClick, highlightedTags, dimMo
onPlantClick={onPlantClick} onPlantClick={onPlantClick}
highlightedTags={highlightedTags} highlightedTags={highlightedTags}
dimMode={dimMode} dimMode={dimMode}
roomName={room.name}
/> />
))} ))}
</group> </group>

View file

@ -14,10 +14,12 @@ interface SmartRackProps {
onPlantClick: (plant: PlantPosition) => void; onPlantClick: (plant: PlantPosition) => void;
highlightedTags?: string[]; highlightedTags?: string[];
dimMode?: boolean; dimMode?: boolean;
roomName?: string; // For breadcrumb
} }
export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dimMode }: SmartRackProps) { export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dimMode, roomName }: SmartRackProps) {
// Scale section dimensions to world units // Scale section dimensions to world units
// Section posX/posY are RELATIVE to room, so we scale them for placement within room group
const scaledSection = { const scaledSection = {
posX: section.posX * SCALE, posX: section.posX * SCALE,
posY: section.posY * SCALE, posY: section.posY * SCALE,
@ -25,16 +27,31 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
height: section.height * SCALE, height: section.height * SCALE,
}; };
// Calculate plant positions RELATIVE to section position (within room group)
const positions: PlantPosition[] = useMemo(() => { const positions: PlantPosition[] = useMemo(() => {
const plantSpacing = 0.5; // Spacing between plants in world units const plantSpacing = 0.5; // Spacing between plants in world units
// Calculate how many columns/rows fit based on section dimensions
const maxCols = Math.max(...section.positions.map(p => p.column)) || 1;
const maxRows = Math.max(...section.positions.map(p => p.row)) || 1;
// Calculate actual spacing based on section dimensions
const colSpacing = scaledSection.width / (maxCols + 1);
const rowSpacing = scaledSection.height / (maxRows + 1);
const actualSpacing = Math.min(colSpacing, rowSpacing, plantSpacing);
return section.positions.map((pos: Position3D) => ({ return section.positions.map((pos: Position3D) => ({
...pos, ...pos,
// Position plants within the scaled section bounds // Position plants within the scaled section bounds
x: scaledSection.posX + (pos.column * plantSpacing), x: scaledSection.posX + (pos.column * actualSpacing),
z: scaledSection.posY + (pos.row * plantSpacing), z: scaledSection.posY + (pos.row * actualSpacing),
y: 0.4 + (pos.tier * 0.6), y: 0.4 + (pos.tier * 0.6),
// Add breadcrumb data
breadcrumb: {
section: section.code || section.name,
room: roomName,
},
})); }));
}, [section, scaledSection]); }, [section, scaledSection, roomName]);
const distinctTiers = [...new Set(positions.map(p => p.tier))].sort((a, b) => a - b); 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 distinctRows = [...new Set(positions.map(p => p.row))].sort((a, b) => a - b);
@ -44,6 +61,7 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
return ( return (
<group> <group>
{/* Section/Table Label */}
{visMode === 'STANDARD' && ( {visMode === 'STANDARD' && (
<Text <Text
position={[scaledSection.posX + scaledSection.width / 2, 3, scaledSection.posY + scaledSection.height / 2]} position={[scaledSection.posX + scaledSection.width / 2, 3, scaledSection.posY + scaledSection.height / 2]}
@ -54,10 +72,11 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
outlineColor="#000" outlineColor="#000"
outlineWidth={0.03} outlineWidth={0.03}
> >
{section.code} {section.code || section.name}
</Text> </Text>
)} )}
{/* Shelf/Tier surfaces */}
{distinctTiers.map(tier => ( {distinctTiers.map(tier => (
<mesh <mesh
key={`shelf-${tier}`} key={`shelf-${tier}`}
@ -79,6 +98,7 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
</mesh> </mesh>
))} ))}
{/* Support posts */}
{[0, 1].map(xOffset => {[0, 1].map(xOffset =>
[0, 1].map(zOffset => ( [0, 1].map(zOffset => (
<mesh <mesh
@ -95,6 +115,7 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
)) ))
)} )}
{/* Row labels */}
{visMode === 'STANDARD' && distinctRows.map(row => ( {visMode === 'STANDARD' && distinctRows.map(row => (
<Text <Text
key={`row-${row}`} key={`row-${row}`}
@ -108,6 +129,7 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
</Text> </Text>
))} ))}
{/* Column labels */}
{visMode === 'STANDARD' && distinctCols.map(col => ( {visMode === 'STANDARD' && distinctCols.map(col => (
<Text <Text
key={`col-${col}`} key={`col-${col}`}

View file

@ -3,6 +3,16 @@ import type { Room3D, Section3D, Position3D, Floor3DData } from '../../lib/layou
export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY'; export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
// Breadcrumb data for hierarchy navigation
export interface PlantBreadcrumb {
facility?: string;
building?: string;
floor?: string;
room?: string;
section?: string;
tier?: number;
}
// Re-export API types under cleaner names // Re-export API types under cleaner names
export type RoomData = Room3D; export type RoomData = Room3D;
export type SectionData = Section3D; export type SectionData = Section3D;
@ -10,6 +20,7 @@ export type PlantPosition = Position3D & {
x: number; x: number;
y: number; y: number;
z: number; z: number;
breadcrumb?: PlantBreadcrumb;
}; };
export const COLORS = { export const COLORS = {

View file

@ -9,6 +9,7 @@ import { PlantSearch } from '../components/facility3d/PlantSearch';
import { TimelineSlider } from '../components/facility3d/TimelineSlider'; import { TimelineSlider } from '../components/facility3d/TimelineSlider';
import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types'; import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets'; import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets';
import { HierarchyBreadcrumb } from '../components/facility3d/HierarchyBreadcrumb';
// --- Error Boundary --- // --- Error Boundary ---
interface ErrorBoundaryState { hasError: boolean; error: Error | null; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
@ -381,7 +382,23 @@ export default function Facility3DViewerPage() {
{/* Plant Selection Panel */} {/* Plant Selection Panel */}
{selectedPlant?.plant && ( {selectedPlant?.plant && (
<div className="absolute bottom-4 right-4 z-20 w-72 bg-slate-900/95 border border-slate-700 rounded-lg p-4 shadow-xl backdrop-blur-md pointer-events-auto"> <div className="absolute bottom-4 right-4 z-20 w-80 bg-slate-900/95 border border-slate-700 rounded-lg p-4 shadow-xl backdrop-blur-md pointer-events-auto">
{/* Breadcrumb */}
{selectedPlant.breadcrumb && (
<div className="mb-3">
<HierarchyBreadcrumb
data={{
facility: floorData?.floor.property,
building: floorData?.floor.building,
floor: floorData?.floor.name,
room: selectedPlant.breadcrumb.room,
section: selectedPlant.breadcrumb.section,
tier: selectedPlant.tier,
}}
/>
</div>
)}
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div> <div>
<span className="text-[10px] uppercase tracking-wider text-slate-500"> <span className="text-[10px] uppercase tracking-wider text-slate-500">
@ -399,7 +416,7 @@ export default function Facility3DViewerPage() {
</div> </div>
<div className="bg-black/30 p-2 rounded"> <div className="bg-black/30 p-2 rounded">
<span className="text-slate-500 text-xs block">Stage</span> <span className="text-slate-500 text-xs block">Stage</span>
<span className={`badge badge-xs ${selectedPlant.plant.stage === 'FLOWER' ? 'badge-primary' : 'badge-accent'}`}> <span className={`badge badge-xs ${selectedPlant.plant.stage === 'FLOWERING' ? 'badge-primary' : 'badge-accent'}`}>
{selectedPlant.plant.stage || 'N/A'} {selectedPlant.plant.stage || 'N/A'}
</span> </span>
</div> </div>