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}
highlightedTags={highlightedTags}
dimMode={dimMode}
roomName={room.name}
/>
))}
</group>

View file

@ -14,10 +14,12 @@ interface SmartRackProps {
onPlantClick: (plant: PlantPosition) => void;
highlightedTags?: string[];
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
// Section posX/posY are RELATIVE to room, so we scale them for placement within room group
const scaledSection = {
posX: section.posX * SCALE,
posY: section.posY * SCALE,
@ -25,16 +27,31 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
height: section.height * SCALE,
};
// Calculate plant positions RELATIVE to section position (within room group)
const positions: PlantPosition[] = useMemo(() => {
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) => ({
...pos,
// Position plants within the scaled section bounds
x: scaledSection.posX + (pos.column * plantSpacing),
z: scaledSection.posY + (pos.row * plantSpacing),
x: scaledSection.posX + (pos.column * actualSpacing),
z: scaledSection.posY + (pos.row * actualSpacing),
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 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 (
<group>
{/* Section/Table Label */}
{visMode === 'STANDARD' && (
<Text
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"
outlineWidth={0.03}
>
{section.code}
{section.code || section.name}
</Text>
)}
{/* Shelf/Tier surfaces */}
{distinctTiers.map(tier => (
<mesh
key={`shelf-${tier}`}
@ -79,6 +98,7 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
</mesh>
))}
{/* Support posts */}
{[0, 1].map(xOffset =>
[0, 1].map(zOffset => (
<mesh
@ -95,6 +115,7 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
))
)}
{/* Row labels */}
{visMode === 'STANDARD' && distinctRows.map(row => (
<Text
key={`row-${row}`}
@ -108,6 +129,7 @@ export function SmartRack({ section, visMode, onPlantClick, highlightedTags, dim
</Text>
))}
{/* Column labels */}
{visMode === 'STANDARD' && distinctCols.map(col => (
<Text
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';
// 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
export type RoomData = Room3D;
export type SectionData = Section3D;
@ -10,6 +20,7 @@ export type PlantPosition = Position3D & {
x: number;
y: number;
z: number;
breadcrumb?: PlantBreadcrumb;
};
export const COLORS = {

View file

@ -9,6 +9,7 @@ import { PlantSearch } from '../components/facility3d/PlantSearch';
import { TimelineSlider } from '../components/facility3d/TimelineSlider';
import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets';
import { HierarchyBreadcrumb } from '../components/facility3d/HierarchyBreadcrumb';
// --- Error Boundary ---
interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
@ -381,7 +382,23 @@ export default function Facility3DViewerPage() {
{/* Plant Selection Panel */}
{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>
<span className="text-[10px] uppercase tracking-wider text-slate-500">
@ -399,7 +416,7 @@ export default function Facility3DViewerPage() {
</div>
<div className="bg-black/30 p-2 rounded">
<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'}
</span>
</div>