feat: add hierarchical drilldown navigation
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

- New HierarchyNav component with Facility→Building→Floor→Rooms path
- Back button and breadcrumb trail in nav header
- Replace old room list with drilldown navigation
- Each level shows children with chevron indicators
This commit is contained in:
fullsizemalt 2025-12-19 10:56:42 -08:00
parent 74745ee6ec
commit 6d107c637b
2 changed files with 167 additions and 21 deletions

View file

@ -0,0 +1,159 @@
import { useState } from 'react';
import { ChevronRight, ChevronLeft, Building2, Layers, LayoutGrid, Home, Maximize } from 'lucide-react';
import type { Floor3DData, Room3D } from '../../lib/layoutApi';
interface HierarchyNavProps {
floorData: Floor3DData;
onRoomSelect: (room: Room3D) => void;
onResetView: () => void;
}
type NavLevel = 'facility' | 'building' | 'floor' | 'rooms';
export function HierarchyNav({ floorData, onRoomSelect, onResetView }: HierarchyNavProps) {
const [currentLevel, setCurrentLevel] = useState<NavLevel>('rooms');
const [selectedRoom, setSelectedRoom] = useState<Room3D | null>(null);
const facility = floorData.floor.property || 'Facility';
const building = floorData.floor.building || 'Building';
const floor = floorData.floor.name || 'Floor';
const handleBack = () => {
switch (currentLevel) {
case 'rooms':
setCurrentLevel('floor');
break;
case 'floor':
setCurrentLevel('building');
break;
case 'building':
setCurrentLevel('facility');
break;
}
};
const handleForward = (level: NavLevel) => {
setCurrentLevel(level);
};
const handleRoomClick = (room: Room3D) => {
setSelectedRoom(room);
onRoomSelect(room);
};
// Breadcrumb path based on current level
const getBreadcrumb = () => {
const parts: { label: string; level: NavLevel }[] = [];
parts.push({ label: facility, level: 'facility' });
if (currentLevel !== 'facility') {
parts.push({ label: building, level: 'building' });
}
if (currentLevel === 'floor' || currentLevel === 'rooms') {
parts.push({ label: floor, level: 'floor' });
}
if (currentLevel === 'rooms') {
parts.push({ label: 'Rooms', level: 'rooms' });
}
return parts;
};
return (
<div className="bg-black/50 backdrop-blur rounded-lg border border-slate-700 overflow-hidden">
{/* Header with breadcrumb */}
<div className="p-2 border-b border-slate-700 flex items-center gap-2">
{currentLevel !== 'facility' && (
<button
onClick={handleBack}
className="p-1 hover:bg-slate-700 rounded"
title="Back"
>
<ChevronLeft size={16} />
</button>
)}
<div className="flex items-center gap-1 text-xs overflow-x-auto flex-1">
{getBreadcrumb().map((part, i) => (
<span key={part.level} className="flex items-center gap-1 whitespace-nowrap">
{i > 0 && <ChevronRight size={10} className="text-slate-500" />}
<button
onClick={() => setCurrentLevel(part.level)}
className={`hover:text-cyan-400 ${part.level === currentLevel ? 'text-white font-medium' : 'text-slate-400'}`}
>
{part.label}
</button>
</span>
))}
</div>
<button
onClick={onResetView}
className="p-1 hover:bg-slate-700 rounded"
title="Reset View"
>
<Maximize size={14} />
</button>
</div>
{/* Content based on level */}
<div className="max-h-[50vh] overflow-y-auto">
{currentLevel === 'facility' && (
<div className="p-2">
<button
onClick={() => handleForward('building')}
className="w-full text-left px-3 py-2 rounded hover:bg-slate-800 flex items-center gap-2"
>
<Building2 size={16} className="text-blue-400" />
<span>{building}</span>
<ChevronRight size={14} className="ml-auto text-slate-500" />
</button>
</div>
)}
{currentLevel === 'building' && (
<div className="p-2">
<button
onClick={() => handleForward('floor')}
className="w-full text-left px-3 py-2 rounded hover:bg-slate-800 flex items-center gap-2"
>
<Layers size={16} className="text-green-400" />
<span>{floor}</span>
<ChevronRight size={14} className="ml-auto text-slate-500" />
</button>
</div>
)}
{currentLevel === 'floor' && (
<div className="p-2">
<button
onClick={() => handleForward('rooms')}
className="w-full text-left px-3 py-2 rounded hover:bg-slate-800 flex items-center gap-2"
>
<LayoutGrid size={16} className="text-purple-400" />
<span>View Rooms ({floorData.rooms.length})</span>
<ChevronRight size={14} className="ml-auto text-slate-500" />
</button>
</div>
)}
{currentLevel === 'rooms' && (
<div className="p-1">
{floorData.rooms.map((room) => (
<button
key={room.id}
onClick={() => handleRoomClick(room)}
className={`w-full text-left px-3 py-2 rounded flex items-center gap-2 transition-colors ${selectedRoom?.id === room.id
? 'bg-blue-600/30 text-blue-300'
: 'hover:bg-slate-800'
}`}
>
<LayoutGrid size={14} className="text-slate-400" />
<span className="truncate flex-1">{room.name}</span>
<span className="text-xs text-slate-500">
{room.sections?.reduce((acc, s) => acc + (s.positions?.length || 0), 0) || 0}
</span>
</button>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { FacilityScene } from '../components/facility3d/FacilityScene'; import { FacilityScene } from '../components/facility3d/FacilityScene';
import { PlantSearch } from '../components/facility3d/PlantSearch'; import { PlantSearch } from '../components/facility3d/PlantSearch';
import { PlantLibrary } from '../components/facility3d/PlantLibrary'; import { PlantLibrary } from '../components/facility3d/PlantLibrary';
import { HierarchyNav } from '../components/facility3d/HierarchyNav';
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';
@ -318,27 +319,13 @@ export default function Facility3DViewerPage() {
{/* Room Navigation Sidebar */} {/* Room Navigation Sidebar */}
{floorData && ( {floorData && (
<div className="absolute top-24 left-4 z-10 w-44 pointer-events-auto"> <div className="absolute top-24 left-4 z-10 w-52 pointer-events-auto space-y-2">
<div className="bg-black/50 backdrop-blur rounded-lg p-3 border border-slate-700"> {/* Hierarchical Navigation */}
<div className="flex items-center justify-between mb-2"> <HierarchyNav
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Rooms</span> floorData={floorData}
<button onClick={resetView} className="hover:text-accent transition-colors" title="Reset View"> onRoomSelect={focusRoom}
<Maximize size={14} /> onResetView={resetView}
</button> />
</div>
<div className="space-y-1 max-h-[45vh] overflow-y-auto">
{floorData.rooms.map((room: Room3D) => (
<button
key={room.id}
onClick={() => focusRoom(room)}
className="w-full text-left text-sm px-2 py-1.5 rounded hover:bg-slate-800 transition-colors flex items-center justify-between group"
>
<span className="truncate">{room.name}</span>
<MousePointer2 size={12} className="opacity-0 group-hover:opacity-100 text-accent" />
</button>
))}
</div>
</div>
{/* Focus Mode Toggle */} {/* Focus Mode Toggle */}
{highlightedTags.length > 0 && ( {highlightedTags.length > 0 && (