feat: add hierarchy breadcrumb and improve plant positioning
- 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:
parent
debc5d9447
commit
eaa32c05fe
5 changed files with 114 additions and 7 deletions
56
frontend/src/components/facility3d/HierarchyBreadcrumb.tsx
Normal file
56
frontend/src/components/facility3d/HierarchyBreadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -106,6 +106,7 @@ export function RoomObject({ room, visMode, onPlantClick, highlightedTags, dimMo
|
|||
onPlantClick={onPlantClick}
|
||||
highlightedTags={highlightedTags}
|
||||
dimMode={dimMode}
|
||||
roomName={room.name}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue