diff --git a/src/components/panels/MosaicDashboard.tsx b/src/components/panels/MosaicDashboard.tsx index fea6482..a1d4be6 100644 --- a/src/components/panels/MosaicDashboard.tsx +++ b/src/components/panels/MosaicDashboard.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { useState, ReactElement } from 'react'; +import React, { useState, ReactElement, useContext, useRef, useEffect, memo } from 'react'; import { Mosaic, MosaicWindow, MosaicNode, MosaicPath, + MosaicContext, } from 'react-mosaic-component'; import 'react-mosaic-component/react-mosaic-component.css'; import MapView from './MapView'; @@ -17,37 +18,184 @@ import GasSensor from './GasSensor'; import NetworkHealthTelemetryPanel from './NetworkHealthTelemetryPanel'; import VideoControls from './VideoControls'; -type MosaicKey = 'mapView' | 'rosMonitor' | 'waypointList' | 'videoControls' | 'gasSensor' | 'orientationDisplay' | 'goalSetter' | 'networkHealthMonitor'; +type TileType = + | 'mapView' + | 'rosMonitor' + | 'waypointList' + | 'videoControls' + | 'gasSensor' + | 'orientationDisplay' + | 'goalSetter' + | 'networkHealthMonitor'; + +type TileId = `${TileType}:${string}`; + +const TILE_DISPLAY_NAMES: Record = { + mapView: 'Map View', + rosMonitor: 'System Telemetry', + waypointList: 'Waypoint List', + videoControls: 'Video Stream', + gasSensor: 'Science', + orientationDisplay: 'Rover Orientation', + goalSetter: 'Nav2', + networkHealthMonitor: 'Connection Health', +}; + +const ALL_TILE_TYPES: TileType[] = [ + 'mapView', + 'rosMonitor', + 'networkHealthMonitor', + 'orientationDisplay', + 'videoControls', + 'waypointList', + 'gasSensor', + 'goalSetter', +]; + +function makeTileId(type: TileType): TileId { + const uid = crypto.randomUUID(); + return `${type}:${uid}`; +} + +function tileTypeOf(id: TileId): TileType { + return id.split(':', 1)[0] as TileType; +} + +type PendingAdd = + | { + pathKey: string; + path: MosaicPath; + direction: 'row' | 'column'; + } + | null; + +const Controls = memo<{ + id: TileId; + path: MosaicPath; + pendingAdd: PendingAdd; + setPendingAdd: (value: PendingAdd) => void; +}>(({ id, path, pendingAdd, setPendingAdd }) => { + const { mosaicActions } = useContext(MosaicContext); + const pathKey = JSON.stringify(path); + const showDropdown = pendingAdd?.pathKey === pathKey; + const dropdownRef = useRef(null); + + const splitAndAdd = (direction: 'row' | 'column', newType: TileType) => { + + const newId = makeTileId(newType); + + const splitNode: MosaicNode = { + direction, + first: id, + second: newId, + splitPercentage: 60, + }; + + mosaicActions.replaceWith(path, splitNode); + setPendingAdd(null); + }; + + useEffect(() => { + if (!showDropdown) return; + + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setPendingAdd(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showDropdown, setPendingAdd]); + + const currentType = tileTypeOf(id); + + return ( +
+ + + + + {showDropdown ? ( + + ) : null} +
+ ); +}); + +Controls.displayName = 'Controls'; const MosaicDashboard: React.FC = () => { - // TODO: paramaterize layout for custom layout configs - const [mosaicLayout, setMosaicLayout] = useState | null>({ + const [mosaicLayout, setMosaicLayout] = useState | null>({ direction: 'row', first: { direction: 'column', - first: 'mapView', + first: makeTileId('mapView'), second: { direction: 'row', first: { direction: 'row', - first: 'rosMonitor', - second: 'networkHealthMonitor', + first: makeTileId('rosMonitor'), + second: makeTileId('networkHealthMonitor'), }, - second: 'orientationDisplay', + second: makeTileId('orientationDisplay'), splitPercentage: 55, }, splitPercentage: 55, }, second: { direction: 'column', - first: 'videoControls', + first: makeTileId('videoControls'), second: { direction: 'row', - first: 'waypointList', + first: makeTileId('waypointList'), second: { direction: 'row', - first: 'gasSensor', - second: 'goalSetter', + first: makeTileId('gasSensor'), + second: makeTileId('goalSetter'), }, splitPercentage: 50, }, @@ -56,59 +204,117 @@ const MosaicDashboard: React.FC = () => { splitPercentage: 60, }); - const renderTile = (id: MosaicKey, path: MosaicPath): ReactElement => { - switch (id) { + const [pendingAdd, setPendingAdd] = useState(null); + + const renderTile = (id: TileId, path: MosaicPath): ReactElement => { + const type = tileTypeOf(id); + switch (type) { case 'mapView': return ( - title="Map View" path={path}> + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + >
- +
); + case 'waypointList': return ( - title="Waypoint List" path={path}> + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > ); + case 'videoControls': return ( - title="Video Stream" path={path}> + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > ); + case 'rosMonitor': return ( - title="System Telemetry" path={path}> + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > ); + case 'networkHealthMonitor': return ( - title="Connection Health" path={path}> + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > ); + case 'orientationDisplay': return ( - title="Rover Orientation" path={path}> + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > ); + case 'gasSensor': - return ( - title="Science" path={path}> - - - ); - + return ( + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > + + + ); + case 'goalSetter': return ( - title="Nav2" path={path}> + + title={TILE_DISPLAY_NAMES[type]} + path={path} + additionalControls={ + + } + > ); + default: return
Unknown tile
; } @@ -116,10 +322,10 @@ const MosaicDashboard: React.FC = () => { return (
- - renderTile={renderTile} - initialValue={mosaicLayout} + + value={mosaicLayout} onChange={setMosaicLayout} + renderTile={renderTile} blueprintNamespace="bp5" />
);