Files
lms-web-fe/src/lms-renderer/features/workspace/useWorkspaceVisualization.ts
T
2026-08-07 10:26:26 +09:00

220 lines
9.2 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
export type Nav2VisualizationMode = 'slam' | 'navigation'
const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
id: 'default-nav2-visualization',
week: 0,
platformKey: 'futuredrive-local-pc',
profileType: 'NAV2',
name: 'NAV2',
config: {
type: 'NAV2',
target: 'local',
viewer: { stack: 'nav2' },
bridge: {
name: 'nav2-ros-bridge',
port: 8765,
image: 'slam-ros-bridge:latest',
buildContext: 'resources/slam-ros-bridge',
env: { ROBOT_STACK: 'nav2' }
}
}
}
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase()
if (type === 'real_slam') return 'slam'
if (type === 'real_navigation') return 'nav2'
return type === 'autoware' || type === 'nav2' || type === 'slam' ? type : null
}
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode {
return profile?.profileType.toLowerCase() === 'real_navigation' ? 'navigation' : 'slam'
}
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
const type = profile?.profileType.toLowerCase()
return type === 'real_slam' || type === 'real_navigation'
}
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
const matchingProfiles = profiles.filter((profile) => (
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile)
))
return matchingProfiles.length ? matchingProfiles : profiles
}
type NoticeTone = 'info' | 'warning' | 'error'
type UseWorkspaceVisualizationOptions = {
profiles: EnvironmentProfile[]
clonePathPreview: string
rosDomainId: string
remoteWorkspace: boolean
mapKey?: string | null
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
}
type UseWorkspaceVisualizationResult = {
visualizationStacks: VisualizationStack[]
visualizationStack: VisualizationStack
setVisualizationStack: (stack: VisualizationStack) => void
visualizationRunning: boolean
visualizationBusy: boolean
visualizationViewerUrl: string
nav2VisualizationMode: Nav2VisualizationMode
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
activeEnvironmentProfile: EnvironmentProfile | null
activeRuntimeImages: RuntimeImageArtifact[]
refreshVisualizationStatus: () => Promise<void>
startVisualizationBridge: () => Promise<void>
stopVisualizationBridge: () => Promise<void>
}
function formatVisualizationStackLabel(stack: VisualizationStack | null | undefined): string {
if (stack === 'nav2') return 'Nav2'
if (stack === 'slam') return 'SLAM'
return 'Autoware'
}
export function useWorkspaceVisualization({
profiles,
clonePathPreview,
rosDomainId,
remoteWorkspace,
mapKey,
worldLayoutVersion,
onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => {
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
const nav2Profiles = candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
return nav2Profiles.length ? nav2Profiles : [DEFAULT_NAV2_VISUALIZATION_PROFILE]
}, [profiles, remoteWorkspace])
const visualizationStacks = useMemo(() => (
runtimeProfiles
.map((profile) => resolveVisualizationStack(profile.profileType))
.filter((stack): stack is VisualizationStack => stack !== null)
), [runtimeProfiles])
const activeEnvironmentProfile = runtimeProfiles.find((profile) => resolveVisualizationStack(profile.profileType) === visualizationStack) ?? runtimeProfiles[0] ?? null
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
typeof image === 'object' && image !== null && typeof (image as RuntimeImageArtifact).image === 'string'
))
: []
const activeEnvironmentAgent = typeof activeEnvironmentConfig.agent === 'object' && activeEnvironmentConfig.agent !== null
? activeEnvironmentConfig.agent as Record<string, unknown>
: null
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
? activeEnvironmentAgent.url.replace(/\/+$/, '')
: ''
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl
const visualizationRemoteBridgeHttpUrl = (() => {
if (!visualizationRemote) return ''
try {
const url = new URL(activeEnvironmentAgentUrl)
url.port = '8765'
return url.toString().replace(/\/+$/, '')
} catch {
return ''
}
})()
const visualizationBridgeHttpUrl = visualizationRemoteBridgeHttpUrl || 'http://localhost:8765'
const visualizationBridgeUrl = visualizationRemote
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
: 'ws://localhost:8765'
const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'nav2'
? `/slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}`
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => {
const stack = runtimeProfiles[0] ? resolveVisualizationStack(runtimeProfiles[0].profileType) : null
if (stack) setVisualizationStack(stack)
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [runtimeProfiles])
const refreshVisualizationStatus = async (): Promise<void> => {
const status = await window.localWorkspace.visualization.status().catch((error) => ({
ok: false,
running: false,
stack: null,
message: error instanceof Error ? error.message : String(error)
}))
setVisualizationRunning(Boolean(status.running))
if (status.stack) setVisualizationStack(status.stack)
setVisualizationStatusMessage(status.running
? formatVisualizationStackLabel(status.stack)
: status.message
? status.message
: '대기')
}
const startVisualizationBridge = async (): Promise<void> => {
if (visualizationBusy) return
setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중')
try {
const status = await window.localWorkspace.visualization.start(visualizationStack, clonePathPreview, rosDomainId, activeEnvironmentProfile.config)
if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running))
if (status.stack) setVisualizationStack(status.stack)
setVisualizationStatusMessage(formatVisualizationStackLabel(status.stack))
onTitlebarNotice('화면이 시작되었습니다', 'info')
} catch (error) {
setVisualizationRunning(false)
setVisualizationStatusMessage(error instanceof Error ? error.message : '시작 실패')
onTitlebarNotice('화면 시작에 실패했습니다', 'error')
} finally {
setVisualizationBusy(false)
}
}
const stopVisualizationBridge = async (): Promise<void> => {
if (visualizationBusy) return
setVisualizationBusy(true)
setVisualizationStatusMessage('종료 중')
try {
const status = await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config)
if (!status.ok) throw new Error(status.message ?? '종료 실패')
setVisualizationRunning(false)
setVisualizationStatusMessage('대기')
onTitlebarNotice('화면이 종료되었습니다', 'info')
} catch (error) {
setVisualizationStatusMessage(error instanceof Error ? error.message : '종료 실패')
onTitlebarNotice('화면 종료에 실패했습니다', 'error')
} finally {
setVisualizationBusy(false)
}
}
return {
visualizationStacks,
visualizationStack,
setVisualizationStack,
visualizationRunning,
visualizationBusy,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
activeEnvironmentProfile,
activeRuntimeImages,
refreshVisualizationStatus,
startVisualizationBridge,
stopVisualizationBridge
}
}