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}
|
onPlantClick={onPlantClick}
|
||||||
highlightedTags={highlightedTags}
|
highlightedTags={highlightedTags}
|
||||||
dimMode={dimMode}
|
dimMode={dimMode}
|
||||||
|
roomName={room.name}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
|
|
||||||
|
|
@ -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}`}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue