feat: add hierarchical drilldown navigation
- 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:
parent
74745ee6ec
commit
6d107c637b
2 changed files with 167 additions and 21 deletions
159
frontend/src/components/facility3d/HierarchyNav.tsx
Normal file
159
frontend/src/components/facility3d/HierarchyNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { Link, useSearchParams } from 'react-router-dom';
|
|||
import { FacilityScene } from '../components/facility3d/FacilityScene';
|
||||
import { PlantSearch } from '../components/facility3d/PlantSearch';
|
||||
import { PlantLibrary } from '../components/facility3d/PlantLibrary';
|
||||
import { HierarchyNav } from '../components/facility3d/HierarchyNav';
|
||||
import { TimelineSlider } from '../components/facility3d/TimelineSlider';
|
||||
import { PlantPosition, VisMode, COLORS } from '../components/facility3d/types';
|
||||
import { CameraPreset, CameraPresetSelector } from '../components/facility3d/CameraPresets';
|
||||
|
|
@ -318,27 +319,13 @@ export default function Facility3DViewerPage() {
|
|||
|
||||
{/* Room Navigation Sidebar */}
|
||||
{floorData && (
|
||||
<div className="absolute top-24 left-4 z-10 w-44 pointer-events-auto">
|
||||
<div className="bg-black/50 backdrop-blur rounded-lg p-3 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Rooms</span>
|
||||
<button onClick={resetView} className="hover:text-accent transition-colors" title="Reset View">
|
||||
<Maximize size={14} />
|
||||
</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>
|
||||
<div className="absolute top-24 left-4 z-10 w-52 pointer-events-auto space-y-2">
|
||||
{/* Hierarchical Navigation */}
|
||||
<HierarchyNav
|
||||
floorData={floorData}
|
||||
onRoomSelect={focusRoom}
|
||||
onResetView={resetView}
|
||||
/>
|
||||
|
||||
{/* Focus Mode Toggle */}
|
||||
{highlightedTags.length > 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue