patch..
This commit is contained in:
@@ -9,6 +9,7 @@ type DeletableModule = {
|
||||
type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
|
||||
dark: boolean
|
||||
module: Module
|
||||
allowRepositoryDeletion?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (module: Module, options: { deleteRepository: boolean }) => void
|
||||
}
|
||||
@@ -16,6 +17,7 @@ type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
|
||||
export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
dark,
|
||||
module,
|
||||
allowRepositoryDeletion = true,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
|
||||
@@ -40,7 +42,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
|
||||
워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
|
||||
</p>
|
||||
<label
|
||||
{allowRepositoryDeletion && <label
|
||||
className={[
|
||||
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
|
||||
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
|
||||
@@ -60,7 +62,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -74,7 +76,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(module, { deleteRepository })}
|
||||
onClick={() => onConfirm(module, { deleteRepository: allowRepositoryDeletion && deleteRepository })}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
|
||||
>
|
||||
삭제
|
||||
|
||||
@@ -6,12 +6,10 @@ type ModuleListHeaderProps = {
|
||||
|
||||
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
|
||||
<div className={['grid h-9 grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
|
||||
<span className="text-center">상태</span>
|
||||
<span className="text-center">분류</span>
|
||||
<span>모듈명</span>
|
||||
<span className="text-center">CPU</span>
|
||||
<span className="text-center">메모리</span>
|
||||
<span className="text-center">가동시간</span>
|
||||
<span className="col-span-2 text-center">제어</span>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ type ModuleListRowProps<Module extends ModuleListRowItem> = {
|
||||
module: Module
|
||||
installing: boolean
|
||||
controlling: boolean
|
||||
bulkControlling: boolean
|
||||
onAddModuleTab: (module: Module) => void
|
||||
onOpenSource: (module: Module) => void
|
||||
onOpenParameters: (module: Module) => void
|
||||
@@ -80,6 +81,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
module,
|
||||
installing,
|
||||
controlling,
|
||||
bulkControlling,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onOpenParameters,
|
||||
@@ -92,6 +94,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
}: ModuleListRowProps<Module>): React.JSX.Element {
|
||||
const selectable = module.state !== 'uninstalled'
|
||||
const busy = installing || controlling
|
||||
const parameters = Array.isArray(module.parameters) ? module.parameters : []
|
||||
const stateStyle: Record<ModuleListRowItem['state'], string> = {
|
||||
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
|
||||
@@ -121,6 +124,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
const installAction = module.state === 'uninstalled'
|
||||
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
|
||||
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
|
||||
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -128,7 +132,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
if (selectable) onOpenSource(module)
|
||||
}}
|
||||
className={[
|
||||
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
|
||||
'grid h-[58px] grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center px-3 text-[12px] transition',
|
||||
'cursor-default',
|
||||
selectable
|
||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
|
||||
@@ -158,37 +162,9 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
|
||||
].join(' ')}
|
||||
>
|
||||
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'}
|
||||
{parameters.length ? parameters.join(' ') : '파라미터 설정'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{module.cpu === null ? (
|
||||
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{module.memory === null ? (
|
||||
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
|
||||
{module.runtime}
|
||||
</span>
|
||||
@@ -215,7 +191,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
{controlActions.map(({ label, Icon, tone, action }) => (
|
||||
(() => {
|
||||
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
|
||||
const disabled = busy && !canCancelBuild
|
||||
const disabled = bulkControlling || (busy && !canCancelBuild)
|
||||
return (
|
||||
<button
|
||||
key={`${module.name}-${label}`}
|
||||
@@ -238,7 +214,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<span className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
disabled={installActionDisabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (module.state === 'uninstalled') {
|
||||
@@ -248,7 +224,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
onDeleteModule(module)
|
||||
}}
|
||||
aria-label={installAction.label}
|
||||
className={actionButtonClass(dark, installAction.tone, busy)}
|
||||
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
|
||||
>
|
||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
|
||||
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
|
||||
|
||||
@@ -15,6 +15,7 @@ type ModuleListToolbarProps = {
|
||||
onInstallAll: () => void
|
||||
onStartAll: () => void
|
||||
onStopAll: () => void
|
||||
onOpenWorldEditor: () => void
|
||||
}
|
||||
|
||||
const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [
|
||||
@@ -35,14 +36,15 @@ export default function ModuleListToolbar({
|
||||
onFilterChange,
|
||||
onInstallAll,
|
||||
onStartAll,
|
||||
onStopAll
|
||||
onStopAll,
|
||||
onOpenWorldEditor
|
||||
}: ModuleListToolbarProps): React.JSX.Element {
|
||||
const installingAll = installingModuleName === '__all__'
|
||||
const startingAll = installingModuleName === '__start_all__'
|
||||
const stoppingAll = installingModuleName === '__stop_all__'
|
||||
|
||||
const bulkActions = [
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: undefined },
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
|
||||
{
|
||||
label: '전체 설치',
|
||||
Icon: Download,
|
||||
|
||||
@@ -17,6 +17,7 @@ type ModuleListViewProps = {
|
||||
controllingModuleNames: string[]
|
||||
listError: string | null
|
||||
installError: string | null
|
||||
allowRepositoryDeletion?: boolean
|
||||
onAddModuleTab: (module: ModuleRow) => void
|
||||
onOpenSource: (module: ModuleRow) => void
|
||||
onInstallModule: (module: ModuleRow) => void
|
||||
@@ -29,6 +30,7 @@ type ModuleListViewProps = {
|
||||
onInstallAll: () => void
|
||||
onStartAll: () => void
|
||||
onStopAll: () => void
|
||||
onOpenWorldEditor: () => void
|
||||
}
|
||||
|
||||
export default function ModuleListView({
|
||||
@@ -39,6 +41,7 @@ export default function ModuleListView({
|
||||
controllingModuleNames,
|
||||
listError,
|
||||
installError,
|
||||
allowRepositoryDeletion = true,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onInstallModule,
|
||||
@@ -50,7 +53,8 @@ export default function ModuleListView({
|
||||
onRestartModule,
|
||||
onInstallAll,
|
||||
onStartAll,
|
||||
onStopAll
|
||||
onStopAll,
|
||||
onOpenWorldEditor
|
||||
}: ModuleListViewProps): React.JSX.Element {
|
||||
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
|
||||
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
|
||||
@@ -60,6 +64,7 @@ export default function ModuleListView({
|
||||
const runningCount = modules.filter((row) => row.state === 'running').length
|
||||
const stoppedCount = modules.filter((row) => row.state === 'stopped').length
|
||||
const installingAll = installingModuleName === '__all__'
|
||||
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent): void => {
|
||||
@@ -102,6 +107,7 @@ export default function ModuleListView({
|
||||
onInstallAll={onInstallAll}
|
||||
onStartAll={onStartAll}
|
||||
onStopAll={onStopAll}
|
||||
onOpenWorldEditor={onOpenWorldEditor}
|
||||
/>
|
||||
<ModuleListHeader dark={dark} />
|
||||
{(listError || installError) && (
|
||||
@@ -122,6 +128,7 @@ export default function ModuleListView({
|
||||
module={row}
|
||||
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
|
||||
controlling={controllingModuleNames.includes(row.name)}
|
||||
bulkControlling={bulkControlling}
|
||||
onAddModuleTab={onAddModuleTab}
|
||||
onOpenSource={onOpenSource}
|
||||
onOpenParameters={(module) => {
|
||||
@@ -148,6 +155,7 @@ export default function ModuleListView({
|
||||
<ModuleDeleteConfirm
|
||||
dark={dark}
|
||||
module={deleteConfirmModule}
|
||||
allowRepositoryDeletion={allowRepositoryDeletion}
|
||||
onCancel={() => setDeleteConfirmModule(null)}
|
||||
onConfirm={(module, options) => {
|
||||
onDeleteModule(module, options)
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function WorkspaceFilePanel({
|
||||
}: WorkspaceFilePanelProps): React.JSX.Element {
|
||||
return (
|
||||
<aside
|
||||
className={['min-h-0 border-r transition-opacity', searchActive ? 'flex flex-col' : '', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
|
||||
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
|
||||
>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
|
||||
@@ -396,7 +396,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className={['px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
|
||||
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectRoot}
|
||||
|
||||
@@ -16,6 +16,7 @@ type WorkspaceTopBarProps = {
|
||||
previewOpen: boolean
|
||||
showControlCountdown: boolean
|
||||
controlCountdownDanger: boolean
|
||||
canExtendControl: boolean
|
||||
controlEndsAt: string | null
|
||||
submitVisible: boolean
|
||||
submitDisabled: boolean
|
||||
@@ -28,6 +29,7 @@ type WorkspaceTopBarProps = {
|
||||
onTogglePreview: () => void
|
||||
onSubmit: () => void
|
||||
onOpenPowerActions: () => void
|
||||
onRequestControlExtension: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ export default function WorkspaceTopBar({
|
||||
previewOpen,
|
||||
showControlCountdown,
|
||||
controlCountdownDanger,
|
||||
canExtendControl,
|
||||
controlEndsAt,
|
||||
submitVisible,
|
||||
submitDisabled,
|
||||
@@ -57,6 +60,7 @@ export default function WorkspaceTopBar({
|
||||
onTogglePreview,
|
||||
onSubmit,
|
||||
onOpenPowerActions,
|
||||
onRequestControlExtension,
|
||||
onClose
|
||||
}: WorkspaceTopBarProps): React.JSX.Element {
|
||||
return (
|
||||
@@ -101,6 +105,15 @@ export default function WorkspaceTopBar({
|
||||
<Clock3 size={13} />
|
||||
{formatControlRemainingTime(controlEndsAt)}
|
||||
</span>
|
||||
{controlCountdownDanger && canExtendControl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestControlExtension}
|
||||
className="h-6 cursor-pointer rounded border border-[#ef4444]/45 px-2 text-[11px] font-bold text-[#ff8b8b] transition hover:bg-[#ef4444]/12"
|
||||
>
|
||||
연장
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
export type VisualizationStack = 'autoware' | 'nav2' | 'slam'
|
||||
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
||||
|
||||
type WorkspaceVisualizationPanelProps = {
|
||||
dark: boolean
|
||||
@@ -16,11 +17,13 @@ type WorkspaceVisualizationPanelProps = {
|
||||
running: boolean
|
||||
busy: boolean
|
||||
viewerUrl: string
|
||||
nav2Mode: Nav2VisualizationMode
|
||||
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onToggleLarge: () => void
|
||||
onClose: () => void
|
||||
onStackChange: (stack: VisualizationStack) => void
|
||||
onStart: () => void
|
||||
onStartNav2: (mode: Nav2VisualizationMode) => void
|
||||
onStop: () => void
|
||||
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
|
||||
}
|
||||
@@ -38,14 +41,17 @@ export default function WorkspaceVisualizationPanel({
|
||||
running,
|
||||
busy,
|
||||
viewerUrl,
|
||||
nav2Mode,
|
||||
onDragStart,
|
||||
onToggleLarge,
|
||||
onClose,
|
||||
onStackChange,
|
||||
onStart,
|
||||
onStartNav2,
|
||||
onStop,
|
||||
onResizeStart
|
||||
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
|
||||
const isNav2Visualization = activeStack === 'nav2' || activeStack === 'slam'
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
@@ -127,21 +133,58 @@ export default function WorkspaceVisualizationPanel({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={() => {
|
||||
if (running) onStop()
|
||||
else onStart()
|
||||
}}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
||||
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? '처리 중' : running ? '종료' : '시작'}
|
||||
</button>
|
||||
{running ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={onStop}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? '처리 중' : '종료'}
|
||||
</button>
|
||||
) : isNav2Visualization ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={() => onStartNav2('slam')}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
||||
nav2Mode === 'slam' ? 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy && nav2Mode === 'slam' ? '처리 중' : 'SLAM 시작'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={() => onStartNav2('navigation')}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
||||
nav2Mode === 'navigation' ? 'bg-[#2563eb] text-white hover:bg-[#3b82f6]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy && nav2Mode === 'navigation' ? '처리 중' : '주행 시작'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={onStart}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? '처리 중' : '시작'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { Box, Cylinder, LoaderCircle, Maximize2, Move3D, PanelsTopLeft, Rotate3D, RotateCcw, Save, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
|
||||
|
||||
type WorldObjectType = WorldLayoutObject['type']
|
||||
|
||||
type WorldEditorModalProps = {
|
||||
workspaceRoot: string
|
||||
onClose: () => void
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
type TransformMode = 'translate' | 'rotate' | 'scale'
|
||||
|
||||
const objectLabels: Record<WorldObjectType, string> = {
|
||||
pillar: '기둥',
|
||||
wall: '벽',
|
||||
box: '박스'
|
||||
}
|
||||
|
||||
const defaultsByType: Record<WorldObjectType, Omit<WorldLayoutObject, 'id' | 'type'>> = {
|
||||
pillar: { position: [0, 0, 1], rotation: [0, 0, 0], size: [0.5, 0.5, 2] },
|
||||
wall: { position: [0, 0, 1], rotation: [0, 0, 0], size: [4, 0.2, 2] },
|
||||
box: { position: [0, 0, 0.5], rotation: [0, 0, 0], size: [1, 1, 1] }
|
||||
}
|
||||
|
||||
function createObject(type: WorldObjectType, index: number): WorldLayoutObject {
|
||||
const template = defaultsByType[type]
|
||||
return {
|
||||
id: `${type}-${Date.now()}-${index}`,
|
||||
type,
|
||||
position: [template.position[0] + index * 0.7, template.position[1] + index * 0.45, template.position[2]],
|
||||
rotation: [...template.rotation],
|
||||
size: [...template.size]
|
||||
}
|
||||
}
|
||||
|
||||
function objectMaterial(type: WorldObjectType): THREE.MeshStandardMaterial {
|
||||
const color = type === 'pillar' ? '#fb923c' : type === 'wall' ? '#60a5fa' : '#a78bfa'
|
||||
return new THREE.MeshStandardMaterial({ color, roughness: 0.68, metalness: 0.05 })
|
||||
}
|
||||
|
||||
function createMesh(item: WorldLayoutObject): THREE.Mesh {
|
||||
const [width, depth, height] = item.size
|
||||
const geometry = item.type === 'pillar'
|
||||
? new THREE.CylinderGeometry(Math.max(width, depth) / 2, Math.max(width, depth) / 2, height, 32)
|
||||
: new THREE.BoxGeometry(width, depth, height)
|
||||
const mesh = new THREE.Mesh(geometry, objectMaterial(item.type))
|
||||
mesh.position.set(item.position[0], item.position[1], item.position[2])
|
||||
mesh.rotation.set(item.rotation[0], item.rotation[1], item.rotation[2])
|
||||
if (item.type === 'pillar') mesh.rotateX(Math.PI / 2)
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
mesh.userData.objectId = item.id
|
||||
return mesh
|
||||
}
|
||||
|
||||
const modelRoot = '/slam-viewer/turtlebot3_world_model/turtlebot3_world'
|
||||
const modelRoots: Record<string, string> = {
|
||||
turtlebot3_world: modelRoot,
|
||||
turtlebot3_house: '/slam-viewer/turtlebot3_world_model/turtlebot3_house',
|
||||
turtlebot3_common: '/slam-viewer/turtlebot3_world_model/turtlebot3_common'
|
||||
}
|
||||
|
||||
function parseNumbers(value: string | null | undefined, count = 0): number[] {
|
||||
const numbers = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isFinite)
|
||||
while (numbers.length < count) numbers.push(0)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function directChild(element: Element | null | undefined, name: string): Element | null {
|
||||
return Array.from(element?.children ?? []).find((child) => child.tagName === name) ?? null
|
||||
}
|
||||
|
||||
function childText(element: Element | null | undefined, name: string, fallback = ''): string {
|
||||
return directChild(element, name)?.textContent?.trim() || fallback
|
||||
}
|
||||
|
||||
function applySdfPose(target: THREE.Object3D, value: string): void {
|
||||
const [x, y, z, roll, pitch, yaw] = parseNumbers(value, 6)
|
||||
target.position.set(x, y, z)
|
||||
target.rotation.set(roll, pitch, yaw, 'XYZ')
|
||||
}
|
||||
|
||||
function worldMaterial(visual: Element): THREE.MeshLambertMaterial {
|
||||
const text = `${visual.getAttribute('name') ?? ''} ${Array.from(visual.querySelectorAll('uri, script name')).map((item) => item.textContent ?? '').join(' ')}`.toLowerCase()
|
||||
const ambient = visual.querySelector('ambient')?.textContent
|
||||
const [red, green, blue] = parseNumbers(ambient, 3)
|
||||
const color = ambient ? new THREE.Color(red, green, blue) : text.includes('wall') ? '#171717' : text.includes('green') ? '#16a34a' : '#e5e7eb'
|
||||
// TurtleBot3 기본 월드는 외곽 벽 메시의 안쪽에서 보게 된다.
|
||||
// 내부 면도 표시해야 다각형 전체 벽 구조가 보인다.
|
||||
return new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide })
|
||||
}
|
||||
|
||||
function gazeboAssetPath(uri: string): string {
|
||||
return uri.replace(/^model:\/\/(\w+)\//, (_match, name: string) => `${modelRoots[name] ?? `/slam-viewer/turtlebot3_world_model/${name}`}/`)
|
||||
}
|
||||
|
||||
async function loadWorldSdf(parent: THREE.Group): Promise<void> {
|
||||
const response = await fetch(`${modelRoot}/model.sdf`)
|
||||
if (!response.ok) throw new Error(`기본 월드 모델을 불러오지 못했습니다. (${response.status})`)
|
||||
const documentNode = new DOMParser().parseFromString(await response.text(), 'application/xml')
|
||||
const model = documentNode.querySelector('model')
|
||||
if (!model) throw new Error('기본 월드 모델 형식이 올바르지 않습니다.')
|
||||
|
||||
const colladaLoader = new ColladaLoader()
|
||||
const stlLoader = new STLLoader()
|
||||
const modelGroup = new THREE.Group()
|
||||
applySdfPose(modelGroup, childText(model, 'pose', '0 0 0 0 0 0'))
|
||||
parent.add(modelGroup)
|
||||
|
||||
const loadMesh = async (target: THREE.Group, visual: Element, geometry: Element): Promise<void> => {
|
||||
const meshNode = directChild(geometry, 'mesh')
|
||||
const uri = childText(meshNode, 'uri')
|
||||
if (!uri) return
|
||||
const scale = parseNumbers(childText(meshNode, 'scale', '1 1 1'), 3)
|
||||
const url = gazeboAssetPath(uri)
|
||||
const material = worldMaterial(visual)
|
||||
if (url.endsWith('.stl')) {
|
||||
const geometryValue = await stlLoader.loadAsync(url)
|
||||
geometryValue.computeVertexNormals()
|
||||
const mesh = new THREE.Mesh(geometryValue, material)
|
||||
mesh.scale.set(scale[0], scale[1], scale[2])
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
target.add(mesh)
|
||||
return
|
||||
}
|
||||
const collada = await colladaLoader.loadAsync(url)
|
||||
collada.scene.scale.set(scale[0], scale[1], scale[2])
|
||||
collada.scene.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.material = material.clone()
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
})
|
||||
target.add(collada.scene)
|
||||
}
|
||||
|
||||
for (const link of Array.from(model.querySelectorAll(':scope > link'))) {
|
||||
const linkGroup = new THREE.Group()
|
||||
applySdfPose(linkGroup, childText(link, 'pose', '0 0 0 0 0 0'))
|
||||
modelGroup.add(linkGroup)
|
||||
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
|
||||
const visualGroup = new THREE.Group()
|
||||
applySdfPose(visualGroup, childText(visual, 'pose', '0 0 0 0 0 0'))
|
||||
linkGroup.add(visualGroup)
|
||||
const geometry = directChild(visual, 'geometry')
|
||||
if (!geometry) continue
|
||||
const material = worldMaterial(visual)
|
||||
if (directChild(geometry, 'mesh')) {
|
||||
await loadMesh(visualGroup, visual, geometry).catch(() => undefined)
|
||||
continue
|
||||
}
|
||||
const box = directChild(geometry, 'box')
|
||||
const cylinder = directChild(geometry, 'cylinder')
|
||||
if (box) {
|
||||
const [width, depth, height] = parseNumbers(childText(box, 'size', '1 1 1'), 3)
|
||||
visualGroup.add(new THREE.Mesh(new THREE.BoxGeometry(width, depth, height), material))
|
||||
} else if (cylinder) {
|
||||
const radius = Number(childText(cylinder, 'radius', '0.1')) || 0.1
|
||||
const height = Number(childText(cylinder, 'length', '1')) || 1
|
||||
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, 32), material)
|
||||
mesh.rotateX(Math.PI / 2)
|
||||
visualGroup.add(mesh)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
|
||||
const canvasHostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<HTMLIFrameElement>(null)
|
||||
const worldRef = useRef<WorldLayout>({ version: 1, objects: [] })
|
||||
const selectedIdRef = useRef<string | null>(null)
|
||||
const renderSceneRef = useRef<(() => void) | null>(null)
|
||||
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [transformMode, setTransformMode] = useState<TransformMode>('translate')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.localWorkspace.worldLayout.read(workspaceRoot)
|
||||
.then((nextLayout) => {
|
||||
if (!active) return
|
||||
worldRef.current = nextLayout
|
||||
setLayout(nextLayout)
|
||||
setSelectedId(nextLayout.objects[0]?.id ?? null)
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (!active) return
|
||||
setError(cause instanceof Error ? cause.message : '월드 정보를 불러오지 못했습니다.')
|
||||
})
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => { active = false }
|
||||
}, [workspaceRoot])
|
||||
|
||||
useEffect(() => {
|
||||
worldRef.current = layout
|
||||
}, [layout])
|
||||
|
||||
useEffect(() => {
|
||||
selectedIdRef.current = selectedId
|
||||
renderSceneRef.current?.()
|
||||
sendLayoutToViewer(worldRef.current)
|
||||
}, [selectedId])
|
||||
|
||||
const sendLayoutToViewer = (nextLayout: WorldLayout): void => {
|
||||
viewerRef.current?.contentWindow?.postMessage({
|
||||
source: 'autonomy-world-editor',
|
||||
type: 'set-layout',
|
||||
layout: nextLayout,
|
||||
selectedId: selectedIdRef.current,
|
||||
}, '*')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
sendLayoutToViewer(layout)
|
||||
}, [layout])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent): void => {
|
||||
if (event.data?.source !== 'autonomy-world-editor') return
|
||||
if (event.data.type === 'ready') {
|
||||
sendLayoutToViewer(worldRef.current)
|
||||
return
|
||||
}
|
||||
if (event.data.type === 'select-object') setSelectedId(typeof event.data.id === 'string' ? event.data.id : null)
|
||||
if (event.data.type === 'transform-object' && typeof event.data.id === 'string' && event.data.patch) {
|
||||
const patch = event.data.patch as Partial<WorldLayoutObject>
|
||||
setLayout((current) => {
|
||||
const next = {
|
||||
...current,
|
||||
objects: current.objects.map((item) => item.id === event.data.id ? { ...item, ...patch } : item),
|
||||
}
|
||||
worldRef.current = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const host = canvasHostRef.current
|
||||
// The editor iframe owns the actual TurtleBot3 world rendering. Keep the
|
||||
// previous canvas path dormant while the shared viewer is mounted.
|
||||
if (!host || loading || viewerRef.current) return
|
||||
const scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color('#0b1220')
|
||||
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 100)
|
||||
camera.position.set(4, 6, 5)
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.shadowMap.enabled = true
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||
host.replaceChildren(renderer.domElement)
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.target.set(0, 0, 0)
|
||||
controls.enableDamping = true
|
||||
controls.maxPolarAngle = Math.PI / 2.08
|
||||
controls.update()
|
||||
|
||||
scene.add(new THREE.HemisphereLight('#dbeafe', '#0b1220', 1.7))
|
||||
const keyLight = new THREE.DirectionalLight('#ffffff', 1.8)
|
||||
keyLight.position.set(6, -5, 11)
|
||||
keyLight.castShadow = true
|
||||
scene.add(keyLight)
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 20),
|
||||
new THREE.MeshStandardMaterial({ color: '#17233a', roughness: 0.95, metalness: 0 })
|
||||
)
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.receiveShadow = true
|
||||
scene.add(ground)
|
||||
scene.add(new THREE.GridHelper(20, 20, '#3b82f6', '#24324c'))
|
||||
|
||||
// Gazebo SDF uses Z-up, while Three.js uses Y-up.
|
||||
const sdfRoot = new THREE.Group()
|
||||
sdfRoot.rotation.x = -Math.PI / 2
|
||||
scene.add(sdfRoot)
|
||||
const objectGroup = new THREE.Group()
|
||||
const selectionGroup = new THREE.Group()
|
||||
sdfRoot.add(objectGroup)
|
||||
scene.add(selectionGroup)
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const pointer = new THREE.Vector2()
|
||||
|
||||
const renderScene = (): void => {
|
||||
objectGroup.clear()
|
||||
selectionGroup.clear()
|
||||
for (const item of worldRef.current.objects) {
|
||||
const mesh = createMesh(item)
|
||||
objectGroup.add(mesh)
|
||||
if (item.id === selectedIdRef.current) {
|
||||
const outline = new THREE.Box3().setFromObject(mesh)
|
||||
const size = outline.getSize(new THREE.Vector3())
|
||||
const center = outline.getCenter(new THREE.Vector3())
|
||||
const helper = new THREE.Box3Helper(new THREE.Box3().setFromCenterAndSize(center, size.multiplyScalar(1.08)), '#93c5fd')
|
||||
selectionGroup.add(helper)
|
||||
}
|
||||
}
|
||||
}
|
||||
renderSceneRef.current = renderScene
|
||||
renderScene()
|
||||
let disposed = false
|
||||
void loadWorldSdf(sdfRoot).catch((cause: unknown) => {
|
||||
if (!disposed) setError(cause instanceof Error ? cause.message : '기본 TurtleBot3 월드 모델을 불러오지 못했습니다.')
|
||||
})
|
||||
|
||||
const resize = (): void => {
|
||||
const { width, height } = host.getBoundingClientRect()
|
||||
if (!width || !height) return
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(width, height, false)
|
||||
}
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(host)
|
||||
resize()
|
||||
|
||||
const handlePointerDown = (event: PointerEvent): void => {
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
const hit = raycaster.intersectObjects(objectGroup.children, false)[0]
|
||||
setSelectedId(typeof hit?.object.userData.objectId === 'string' ? hit.object.userData.objectId : null)
|
||||
}
|
||||
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
|
||||
|
||||
let frameId = 0
|
||||
const animate = (): void => {
|
||||
controls.update()
|
||||
renderer.render(scene, camera)
|
||||
frameId = window.requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
renderSceneRef.current = null
|
||||
window.cancelAnimationFrame(frameId)
|
||||
observer.disconnect()
|
||||
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
|
||||
controls.dispose()
|
||||
renderer.dispose()
|
||||
host.replaceChildren()
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
const selectTransformMode = (mode: TransformMode): void => {
|
||||
setTransformMode(mode)
|
||||
viewerRef.current?.contentWindow?.postMessage({
|
||||
source: 'autonomy-world-editor',
|
||||
type: 'set-transform-mode',
|
||||
mode,
|
||||
}, '*')
|
||||
}
|
||||
|
||||
const selected = layout.objects.find((item) => item.id === selectedId) ?? null
|
||||
const updateObject = (id: string, patch: Partial<WorldLayoutObject>): void => {
|
||||
setLayout((current) => {
|
||||
const next = { ...current, objects: current.objects.map((item) => item.id === id ? { ...item, ...patch } : item) }
|
||||
worldRef.current = next
|
||||
return next
|
||||
})
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const updateTuple = (key: 'position' | 'rotation' | 'size', index: number, value: string): void => {
|
||||
if (!selected) return
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return
|
||||
const next = [...selected[key]] as [number, number, number]
|
||||
next[index] = key === 'size' ? Math.max(0.1, numeric) : numeric
|
||||
updateObject(selected.id, { [key]: next })
|
||||
}
|
||||
const addObject = (type: WorldObjectType): void => {
|
||||
const object = createObject(type, layout.objects.length)
|
||||
setLayout((current) => {
|
||||
const next = { ...current, objects: [...current.objects, object] }
|
||||
worldRef.current = next
|
||||
return next
|
||||
})
|
||||
setSelectedId(object.id)
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const removeSelected = (): void => {
|
||||
if (!selected) return
|
||||
const remaining = layout.objects.filter((item) => item.id !== selected.id)
|
||||
const next = { ...layout, objects: remaining }
|
||||
worldRef.current = next
|
||||
setLayout(next)
|
||||
setSelectedId(remaining[0]?.id ?? null)
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const resetLayout = (): void => {
|
||||
const next: WorldLayout = { version: 1, objects: [] }
|
||||
worldRef.current = next
|
||||
setLayout(next)
|
||||
setSelectedId(null)
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
|
||||
setLayout(saved)
|
||||
onSaved?.()
|
||||
onClose()
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
|
||||
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
|
||||
<div>
|
||||
<h2 className="text-[13px] font-bold text-white">맵 설정</h2>
|
||||
<p className="mt-0.5 text-[10px] font-medium text-white/42">주차 워크스페이스에 월드 배치를 저장합니다.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label="닫기"><X size={16} /></button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
|
||||
<div className="border-b border-white/10 px-3 py-3">
|
||||
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
|
||||
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{layout.objects.length ? layout.objects.map((item) => {
|
||||
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
|
||||
const selected = item.id === selectedId
|
||||
return (
|
||||
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition ${selected ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'text-white/60 hover:bg-white/[0.06] hover:text-white'}`}>
|
||||
<Icon size={13} className={selected ? 'text-[#75a4ff]' : 'text-white/38'} />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">{objectLabels[item.type]}</span>
|
||||
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
|
||||
</button>
|
||||
)
|
||||
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 추가해<br />맵을 구성하세요.</div>}
|
||||
</div>
|
||||
<div className="border-t border-white/10 p-2">
|
||||
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{([
|
||||
['pillar', Cylinder, '기둥'],
|
||||
['wall', PanelsTopLeft, '벽'],
|
||||
['box', Box, '박스'],
|
||||
] as const).map(([type, Icon, label]) => (
|
||||
<button key={type} type="button" onClick={() => addObject(type)} title={`${label} 추가`} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="relative min-w-0 flex-1 bg-[#0b1220]">
|
||||
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
|
||||
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="/slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
|
||||
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />월드 불러오는 중</span></div>}
|
||||
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
|
||||
{([
|
||||
['translate', Move3D, '이동'],
|
||||
['rotate', Rotate3D, '회전'],
|
||||
['scale', Maximize2, '크기'],
|
||||
] as const).map(([mode, Icon, label]) => (
|
||||
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={label} aria-label={label} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
|
||||
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">선택한 오브젝트</span></div>
|
||||
{selected ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title="삭제"><Trash2 size={14} /></button></div>
|
||||
{(['position', 'rotation', 'size'] as const).map((key) => {
|
||||
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
|
||||
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? '위치' : key === 'rotation' ? '회전' : '크기'}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? '각도 (Z)' : ['X', 'Y', '높이'][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
|
||||
})}
|
||||
</div>
|
||||
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 선택하거나 추가하세요.</div>}
|
||||
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />모두 비우기</button></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
|
||||
<span className="text-[10px] font-medium text-white/38">저장 위치: world-layout.json</span>
|
||||
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md px-3 text-[11px] font-semibold text-white/58 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}저장</button></div>
|
||||
</footer>
|
||||
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{error}</div>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { AlertTriangle, Check, House, LoaderCircle, Map, PencilRuler, Save, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type WorkspaceMapOption = {
|
||||
key: string
|
||||
platformKey: 'NAV2'
|
||||
name: string
|
||||
description: string
|
||||
worldFile: string
|
||||
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
|
||||
custom?: boolean
|
||||
saved?: boolean
|
||||
savedMapId?: string
|
||||
createdAt?: string
|
||||
sourceMapKey?: string
|
||||
yamlPath?: string
|
||||
pgmPath?: string
|
||||
previewPgm?: string
|
||||
layout?: unknown
|
||||
}
|
||||
|
||||
export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
|
||||
{
|
||||
key: 'nav2-turtlebot3-world',
|
||||
platformKey: 'NAV2',
|
||||
name: '기본 월드',
|
||||
description: 'TurtleBot3 기본 장애물 환경',
|
||||
worldFile: 'turtlebot3_world.world',
|
||||
viewerModel: 'turtlebot3_world'
|
||||
},
|
||||
{
|
||||
key: 'nav2-turtlebot3-house',
|
||||
platformKey: 'NAV2',
|
||||
name: '실내 환경',
|
||||
description: '방과 복도로 구성된 TurtleBot3 House',
|
||||
worldFile: 'turtlebot3_house.world',
|
||||
viewerModel: 'turtlebot3_house'
|
||||
},
|
||||
{
|
||||
key: 'nav2-custom',
|
||||
platformKey: 'NAV2',
|
||||
name: '커스텀 맵',
|
||||
description: '장애물을 직접 배치해 학습 환경 구성',
|
||||
worldFile: 'empty_world.world',
|
||||
viewerModel: 'turtlebot3_world',
|
||||
custom: true
|
||||
}
|
||||
]
|
||||
|
||||
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')!
|
||||
|
||||
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element {
|
||||
const Icon = map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.previewPgm || !canvasRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(map.previewPgm), (char) => char.charCodeAt(0))
|
||||
let cursor = 0
|
||||
const nextToken = (): string => {
|
||||
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
|
||||
if (bytes[cursor] === 35) {
|
||||
while (cursor < bytes.length && bytes[cursor] !== 10) cursor += 1
|
||||
return nextToken()
|
||||
}
|
||||
const start = cursor
|
||||
while (cursor < bytes.length && !/\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
|
||||
return String.fromCharCode(...bytes.slice(start, cursor))
|
||||
}
|
||||
if (nextToken() !== 'P5') throw new Error('unsupported_pgm')
|
||||
const width = Number(nextToken())
|
||||
const height = Number(nextToken())
|
||||
const maxValue = Number(nextToken())
|
||||
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || maxValue !== 255 || bytes.length - cursor < width * height) throw new Error('invalid_pgm')
|
||||
|
||||
const targetWidth = 192
|
||||
const targetHeight = 128
|
||||
canvas.width = targetWidth
|
||||
canvas.height = targetHeight
|
||||
context.fillStyle = '#d9dce0'
|
||||
context.fillRect(0, 0, targetWidth, targetHeight)
|
||||
const scale = Math.min(targetWidth / width, targetHeight / height)
|
||||
const renderedWidth = Math.max(1, Math.floor(width * scale))
|
||||
const renderedHeight = Math.max(1, Math.floor(height * scale))
|
||||
const offsetX = Math.floor((targetWidth - renderedWidth) / 2)
|
||||
const offsetY = Math.floor((targetHeight - renderedHeight) / 2)
|
||||
const image = context.createImageData(renderedWidth, renderedHeight)
|
||||
for (let y = 0; y < renderedHeight; y += 1) {
|
||||
const sourceY = Math.min(height - 1, Math.floor((y / renderedHeight) * height))
|
||||
for (let x = 0; x < renderedWidth; x += 1) {
|
||||
const sourceX = Math.min(width - 1, Math.floor((x / renderedWidth) * width))
|
||||
const value = bytes[cursor + sourceY * width + sourceX]
|
||||
const index = (y * renderedWidth + x) * 4
|
||||
image.data[index] = value
|
||||
image.data[index + 1] = value
|
||||
image.data[index + 2] = value
|
||||
image.data[index + 3] = 255
|
||||
}
|
||||
}
|
||||
context.putImageData(image, offsetX, offsetY)
|
||||
} catch {
|
||||
// Keep the fallback map schematic visible when a legacy preview is invalid.
|
||||
}
|
||||
}, [map.previewPgm])
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
|
||||
selected ? 'border-[#6f9cff]' : 'border-white/15'
|
||||
].join(' ')}
|
||||
>
|
||||
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
|
||||
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
|
||||
{!map.previewPgm && (map.custom ? (
|
||||
<>
|
||||
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
|
||||
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
|
||||
<span className="absolute bottom-[17%] left-[15%] size-[10%] rounded-full bg-[#536273]" />
|
||||
</>
|
||||
) : map.viewerModel === 'turtlebot3_house' ? (
|
||||
<>
|
||||
<span className="absolute inset-[12%] border-[3px] border-[#455363]" />
|
||||
<span className="absolute left-[42%] top-[12%] h-[43%] w-[3px] bg-[#455363]" />
|
||||
<span className="absolute bottom-[28%] left-[12%] h-[3px] w-[52%] bg-[#455363]" />
|
||||
<span className="absolute bottom-[12%] right-[25%] h-[30%] w-[3px] bg-[#455363]" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
|
||||
<span className="absolute left-[28%] top-[30%] size-[16%] rounded-full bg-[#455363]" />
|
||||
<span className="absolute right-[22%] top-[22%] h-[16%] w-[24%] bg-[#455363]" />
|
||||
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
|
||||
</>
|
||||
))}
|
||||
{map.saved && <span className="absolute inset-x-0 bottom-0 bg-[#0f172a]/75 py-0.5 text-center text-[8px] font-extrabold tracking-[0.08em] text-white">SLAM</span>}
|
||||
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
|
||||
<Icon className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type WorldMapSelectorModalProps = {
|
||||
platformKey: string
|
||||
weekNumber: number
|
||||
maps?: WorkspaceMapOption[]
|
||||
selectedMapKey: string
|
||||
onSelect: (map: WorkspaceMapOption) => void
|
||||
onCreate: () => void
|
||||
onDelete?: (map: WorkspaceMapOption) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
|
||||
const maps = platformKey.toUpperCase() === 'NAV2'
|
||||
? (configuredMaps?.length
|
||||
? [...configuredMaps, ...(configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [] : [LOCAL_CUSTOM_MAP])]
|
||||
: NAV2_MAP_OPTIONS)
|
||||
: []
|
||||
const environmentMaps = maps.filter((map) => !map.saved)
|
||||
const savedMaps = maps.filter((map) => map.saved)
|
||||
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
|
||||
const [tab, setTab] = useState<'environment' | 'saved'>(() => selectedMap?.saved ? 'saved' : 'environment')
|
||||
const visibleMaps = tab === 'environment' ? environmentMaps : savedMaps
|
||||
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
|
||||
|
||||
useEffect(() => {
|
||||
setTab(selectedMap?.saved ? 'saved' : 'environment')
|
||||
setPendingMap(selectedMap)
|
||||
}, [selectedMapKey])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
|
||||
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
|
||||
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-bold">맵 설정</h2>
|
||||
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p>
|
||||
</div>
|
||||
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
|
||||
<button type="button" onClick={() => setTab('environment')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'environment' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>학습 환경</button>
|
||||
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>저장된 맵</button>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
|
||||
{visibleMaps.map((map) => {
|
||||
const pending = map.key === pendingMap?.key
|
||||
const selected = map.key === selectedMapKey
|
||||
return (
|
||||
<button
|
||||
key={map.key}
|
||||
type="button"
|
||||
onClick={() => setPendingMap(map)}
|
||||
className={[
|
||||
'group relative flex w-full cursor-pointer items-start gap-3 rounded-md border px-3 py-3 text-left transition',
|
||||
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
|
||||
].join(' ')}
|
||||
>
|
||||
<MapPreview map={map} selected={pending} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-bold">{map.name}</span>
|
||||
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
|
||||
</span>
|
||||
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
|
||||
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
|
||||
{map.saved && map.key !== selectedMapKey && onDelete && (
|
||||
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
|
||||
<Trash2 className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>}
|
||||
</div>
|
||||
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
|
||||
{tab === 'environment' && (
|
||||
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
|
||||
<PencilRuler className="size-3.5" />
|
||||
맵 편집
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={!pendingMap || pendingMap.key === selectedMapKey} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
|
||||
확인
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type WorldMapRestartConfirmModalProps = {
|
||||
map: WorkspaceMapOption
|
||||
restarting: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function WorldMapRestartConfirmModal({ map, restarting, onCancel, onConfirm }: WorldMapRestartConfirmModalProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label="맵 변경 확인">
|
||||
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
|
||||
<header className="flex items-center gap-2 border-b border-white/10 px-5 py-4">
|
||||
<AlertTriangle className="size-4 text-amber-300" />
|
||||
<h2 className="text-[15px] font-bold">맵 변경</h2>
|
||||
</header>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-[13px] font-semibold">{map.name}으로 변경하시겠습니까?</p>
|
||||
<p className="mt-2 text-[12px] leading-5 text-white/55">실행 중인 모듈을 전체 재시작해야 새 맵이 적용됩니다.</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button type="button" disabled={restarting} onClick={onCancel} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/65 transition hover:bg-white/8 hover:text-white disabled:cursor-default disabled:opacity-45">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" disabled={restarting} onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-60">
|
||||
{restarting && <LoaderCircle className="size-3.5 animate-spin" />}
|
||||
{restarting ? '재시작 중' : '변경 후 재시작'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { EnvironmentProfile } from '../platforms/types'
|
||||
import type { ModulePrebuiltArtifact } from './types'
|
||||
|
||||
type CurriculumModule = NonNullable<CurriculumWeek['modules']>[number]
|
||||
|
||||
type RuntimeModuleOverride = {
|
||||
id?: string
|
||||
folderName?: string
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
sortOrder?: number
|
||||
defaultParameters?: string[]
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[]
|
||||
dockerImage?: string | null
|
||||
}
|
||||
|
||||
type RuntimeModuleSet = {
|
||||
target?: 'local' | 'platform'
|
||||
includeUnlisted?: boolean
|
||||
modules?: RuntimeModuleOverride[]
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
function readModuleSet(profile: EnvironmentProfile | null, runtimeTarget: 'local' | 'platform'): RuntimeModuleSet | null {
|
||||
const config = asRecord(profile?.config)
|
||||
const raw = asRecord(config?.moduleSet)
|
||||
if (!raw) return null
|
||||
|
||||
const target = raw.target === 'local' || raw.target === 'platform' ? raw.target : undefined
|
||||
if (target && target !== runtimeTarget) return null
|
||||
const modules = Array.isArray(raw.modules)
|
||||
? raw.modules.map(asRecord).filter((item): item is Record<string, unknown> => item !== null).map((item) => ({
|
||||
id: typeof item.id === 'string' ? item.id : undefined,
|
||||
folderName: typeof item.folderName === 'string' ? item.folderName : undefined,
|
||||
name: typeof item.name === 'string' ? item.name : undefined,
|
||||
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
||||
sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : undefined,
|
||||
defaultParameters: Array.isArray(item.defaultParameters)
|
||||
? item.defaultParameters.filter((value): value is string => typeof value === 'string')
|
||||
: undefined,
|
||||
prebuiltArtifacts: Array.isArray(item.prebuiltArtifacts)
|
||||
? item.prebuiltArtifacts.filter((value): value is ModulePrebuiltArtifact => typeof value === 'object' && value !== null)
|
||||
: undefined,
|
||||
dockerImage: typeof item.dockerImage === 'string' ? item.dockerImage : item.dockerImage === null ? null : undefined,
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
target,
|
||||
includeUnlisted: raw.includeUnlisted !== false,
|
||||
modules,
|
||||
}
|
||||
}
|
||||
|
||||
function matchesModule(module: CurriculumModule, override: RuntimeModuleOverride): boolean {
|
||||
return Boolean(
|
||||
(override.id && override.id === module.id)
|
||||
|| (override.folderName && override.folderName === module.folderName)
|
||||
|| (override.name && override.name === module.name)
|
||||
)
|
||||
}
|
||||
|
||||
/** Environment-specific module selection preserves the shared student source repositories. */
|
||||
export function resolveRuntimeModules(
|
||||
modules: CurriculumWeek['modules'],
|
||||
profile: EnvironmentProfile | EnvironmentProfile[] | null,
|
||||
runtimeTarget: 'local' | 'platform',
|
||||
): CurriculumWeek['modules'] {
|
||||
const baseModules = modules ?? []
|
||||
const profiles = Array.isArray(profile) ? profile : profile ? [profile] : []
|
||||
const moduleSets = profiles
|
||||
.map((item) => readModuleSet(item, runtimeTarget))
|
||||
.filter((item): item is RuntimeModuleSet => item !== null)
|
||||
if (!moduleSets.length) return baseModules
|
||||
|
||||
const overrides = moduleSets.flatMap((moduleSet) => moduleSet.modules ?? [])
|
||||
const includeUnlisted = moduleSets.every((moduleSet) => moduleSet.includeUnlisted !== false)
|
||||
return baseModules
|
||||
.filter((module) => {
|
||||
const override = overrides.find((item) => matchesModule(module, item))
|
||||
return includeUnlisted || Boolean(override?.enabled !== false && override)
|
||||
})
|
||||
.filter((module) => overrides.find((item) => matchesModule(module, item))?.enabled !== false)
|
||||
.map((module) => {
|
||||
const override = overrides.find((item) => matchesModule(module, item))
|
||||
if (!override) return module
|
||||
return {
|
||||
...module,
|
||||
sortOrder: override.sortOrder ?? module.sortOrder,
|
||||
defaultParameters: override.defaultParameters ?? module.defaultParameters,
|
||||
prebuiltArtifacts: override.prebuiltArtifacts ?? module.prebuiltArtifacts,
|
||||
dockerImage: override.dockerImage !== undefined ? override.dockerImage : module.dockerImage,
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
}
|
||||
@@ -25,11 +25,7 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
|
||||
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
|
||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
|
||||
const safePlatformKey = platformKey
|
||||
.replace(/^futuredrive[-_]/i, '')
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
||||
.toLowerCase()
|
||||
const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}`
|
||||
const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
|
||||
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
|
||||
return `${normalizedRoot}/${suffix}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig'
|
||||
import type { PlatformOption } from '../platforms/types'
|
||||
|
||||
export type StudioRuntimeSessionState = {
|
||||
localPcLayoutOpen: boolean
|
||||
@@ -8,6 +9,7 @@ export type StudioRuntimeSessionState = {
|
||||
sessionEndedAt: string | null
|
||||
connectedPlatformId: string | null
|
||||
runningPlatformIds: string[]
|
||||
runningPlatforms: PlatformOption[]
|
||||
workspaceWeekNumber: number | null
|
||||
}
|
||||
|
||||
@@ -27,6 +29,15 @@ export function readStudioRuntimeSessionState(): StudioRuntimeSessionState | nul
|
||||
sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null,
|
||||
connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null,
|
||||
runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'),
|
||||
runningPlatforms: Array.isArray(parsed.runningPlatforms)
|
||||
? parsed.runningPlatforms.filter((platform): platform is PlatformOption => (
|
||||
Boolean(platform)
|
||||
&& typeof platform === 'object'
|
||||
&& typeof (platform as PlatformOption).id === 'string'
|
||||
&& typeof (platform as PlatformOption).platformKey === 'string'
|
||||
&& typeof (platform as PlatformOption).title === 'string'
|
||||
))
|
||||
: [],
|
||||
workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ModuleRow = {
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
dockerImage?: string | null
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
|
||||
@@ -62,19 +62,6 @@ export function useRemoteWorkspaceRuntime({
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
}
|
||||
|
||||
const submitResult = await window.localWorkspace.git.submit(
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`Run: ${weekNumber}주차 ${module.name}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
|
||||
throw new Error(`[${module.name}] ${submitResult.message}`)
|
||||
}
|
||||
|
||||
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
|
||||
await window.remoteWorkspace.modules.install(
|
||||
getRemoteWorkspaceTarget(),
|
||||
@@ -87,23 +74,45 @@ export function useRemoteWorkspaceRuntime({
|
||||
runtimeImages: activeRuntimeImages
|
||||
}
|
||||
)
|
||||
|
||||
const submitResult = remoteWorkspace
|
||||
? await window.remoteWorkspace.git.submit(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName,
|
||||
`Run: ${weekNumber}주차 ${module.name}`,
|
||||
)
|
||||
: await window.localWorkspace.git.submit(
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`Run: ${weekNumber}주차 ${module.name}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
|
||||
throw new Error(`[${module.name}] ${submitResult.message}`)
|
||||
}
|
||||
}, [
|
||||
activeRuntimeImages,
|
||||
clonePathPreview,
|
||||
courseKey,
|
||||
getFreshAccessToken,
|
||||
getRemoteWorkspaceTarget,
|
||||
remoteWorkspace,
|
||||
saveDirtyFilesForModule,
|
||||
weekNumber
|
||||
])
|
||||
|
||||
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
|
||||
if (remoteWorkspace) {
|
||||
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>()
|
||||
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null; sortOrder?: number | null }>()
|
||||
for (const module of availableModules) {
|
||||
moduleMap.set(module.name, {
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl,
|
||||
sortOrder: module.sortOrder
|
||||
})
|
||||
}
|
||||
@@ -112,6 +121,7 @@ export function useRemoteWorkspaceRuntime({
|
||||
moduleMap.set(module.name, {
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl,
|
||||
sortOrder: module.sortOrder
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from '.
|
||||
type UseWorkspaceFileActionsOptions = {
|
||||
selectedModule: ModuleRow | null
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
selectedTreePath: string | null
|
||||
setSelectedTreePath: Dispatch<SetStateAction<string | null>>
|
||||
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
|
||||
@@ -75,6 +77,8 @@ type UseWorkspaceFileActionsResult = {
|
||||
export function useWorkspaceFileActions({
|
||||
selectedModule,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
selectedTreePath,
|
||||
setSelectedTreePath,
|
||||
setSelectedExampleTreePath,
|
||||
@@ -115,7 +119,9 @@ export function useWorkspaceFileActions({
|
||||
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
|
||||
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
|
||||
try {
|
||||
const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const nextFiles = result.files
|
||||
const nextFolders = result.folders.length ? result.folders : []
|
||||
|
||||
@@ -168,7 +174,11 @@ export function useWorkspaceFileActions({
|
||||
if (newFileDraft.kind === 'folder') {
|
||||
const nextFolder = `${newFileDraft.parent}/${normalizedName}`
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFolder, 'folder', selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] create folder failed', error)
|
||||
return
|
||||
@@ -183,7 +193,11 @@ export function useWorkspaceFileActions({
|
||||
|
||||
const nextFile = `${newFileDraft.parent}/${normalizedName}`
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFile, 'file', selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] create file failed', error)
|
||||
return
|
||||
@@ -215,7 +229,11 @@ export function useWorkspaceFileActions({
|
||||
|
||||
const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
|
||||
if (!selectedModule || file.startsWith('examples/example-code/')) return
|
||||
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
markEditorFileSaved(file, editorFiles[file] ?? '')
|
||||
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
|
||||
}
|
||||
@@ -293,7 +311,11 @@ export function useWorkspaceFileActions({
|
||||
}
|
||||
const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}`
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.rename(getRemoteWorkspaceTarget(), selectedModule.name, currentPath, nextPath, selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] rename failed', error)
|
||||
return
|
||||
@@ -334,7 +356,11 @@ export function useWorkspaceFileActions({
|
||||
const performDeleteTreeTarget = async (targetPath: string): Promise<void> => {
|
||||
if (!selectedModule) return
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), selectedModule.name, targetPath, selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] delete failed', error)
|
||||
return
|
||||
@@ -364,7 +390,11 @@ export function useWorkspaceFileActions({
|
||||
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
|
||||
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] copy failed', error)
|
||||
return
|
||||
|
||||
@@ -87,11 +87,23 @@ export function useWorkspaceModuleControl({
|
||||
}
|
||||
|
||||
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
|
||||
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, {
|
||||
const installOptions = {
|
||||
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
|
||||
runtimeImages: activeRuntimeImages
|
||||
})
|
||||
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber])
|
||||
}
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.modules.install(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName,
|
||||
templateRepositoryUrl,
|
||||
prepared.repositoryUrlWithToken,
|
||||
installOptions,
|
||||
)
|
||||
return
|
||||
}
|
||||
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, installOptions)
|
||||
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
|
||||
|
||||
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
|
||||
await prepareRemoteRuntimeModule(module)
|
||||
@@ -102,7 +114,8 @@ export function useWorkspaceModuleControl({
|
||||
getModuleRosParams(module),
|
||||
sourceSettings.gpuEnabled,
|
||||
sourceSettings.rosDomainId,
|
||||
activeRuntimeImages
|
||||
activeRuntimeImages,
|
||||
module.dockerImage,
|
||||
)
|
||||
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
||||
|
||||
@@ -206,10 +219,11 @@ export function useWorkspaceModuleControl({
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
for (const module of targets) {
|
||||
const result = await remoteModuleStop(module)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
}
|
||||
const result = await window.remoteWorkspace.modules.stopMany(
|
||||
getRemoteWorkspaceTarget(),
|
||||
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||
)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
} else {
|
||||
const result = await window.localWorkspace.modules.stopMany(
|
||||
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||
@@ -227,7 +241,7 @@ export function useWorkspaceModuleControl({
|
||||
} finally {
|
||||
setInstallingModuleName(null)
|
||||
}
|
||||
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
|
||||
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteWorkspace])
|
||||
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
||||
if (installingModuleName || controllingModuleNames.includes(module.name)) return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { ModuleRow } from './types'
|
||||
@@ -21,6 +21,7 @@ type UseWorkspaceModuleListOptions = {
|
||||
type UseWorkspaceModuleListResult = {
|
||||
availableModules: ModuleRow[]
|
||||
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
|
||||
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
|
||||
modulesLoading: boolean
|
||||
moduleListError: string | null
|
||||
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
|
||||
@@ -49,29 +50,79 @@ export function useWorkspaceModuleList({
|
||||
const [modulesLoading, setModulesLoading] = useState(false)
|
||||
const [moduleListError, setModuleListError] = useState<string | null>(null)
|
||||
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
|
||||
const loadSequenceRef = useRef(0)
|
||||
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
|
||||
|
||||
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
|
||||
const applyPendingModuleStates = useCallback((modules: ModuleRow[]): ModuleRow[] => {
|
||||
const now = Date.now()
|
||||
return modules.map((module) => {
|
||||
const key = moduleStateKey(module)
|
||||
const pending = pendingModuleStatesRef.current.get(key)
|
||||
if (!pending) return module
|
||||
if (module.state === pending.state || pending.expiresAt <= now) {
|
||||
pendingModuleStatesRef.current.delete(key)
|
||||
return module
|
||||
}
|
||||
return {
|
||||
...module,
|
||||
state: pending.state,
|
||||
runtime: pending.state === 'running' ? '시작 중' : '-',
|
||||
cpu: pending.state === 'running' ? module.cpu : null,
|
||||
memory: pending.state === 'running' ? module.memory : null,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const markModuleState = useCallback((module: ModuleRow, state: ModuleRow['state']): void => {
|
||||
const key = moduleStateKey(module)
|
||||
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + 10_000 })
|
||||
setAvailableModules((current) => current.map((item) => {
|
||||
if (moduleStateKey(item) !== key) return item
|
||||
return {
|
||||
...item,
|
||||
state,
|
||||
runtime: state === 'running' ? '시작 중' : '-',
|
||||
cpu: state === 'running' ? item.cpu : null,
|
||||
memory: state === 'running' ? item.memory : null,
|
||||
}
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
|
||||
if (!remoteTarget) return { modules: [] }
|
||||
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
|
||||
const result = await window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
|
||||
return {
|
||||
modules: result.modules.map((module) => {
|
||||
const rawModule = module as ModuleRow & { defaultParameters?: unknown }
|
||||
const parameters = Array.isArray(rawModule.parameters)
|
||||
? rawModule.parameters
|
||||
: Array.isArray(rawModule.defaultParameters)
|
||||
? rawModule.defaultParameters.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
return { ...module, parameters }
|
||||
}),
|
||||
}
|
||||
}, [curriculumModuleKey, remoteTarget])
|
||||
|
||||
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
|
||||
const requestSequence = ++loadSequenceRef.current
|
||||
if (!silent) setModulesLoading(true)
|
||||
try {
|
||||
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
|
||||
if (remoteWorkspace) {
|
||||
const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] }))
|
||||
const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module]))
|
||||
result.modules = result.modules.map((module) => {
|
||||
const remoteModule = remoteStateByName.get(module.name)
|
||||
return remoteModule?.state === 'running'
|
||||
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
|
||||
: module
|
||||
})
|
||||
const result = await loadRemoteModules()
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
setModuleListError(null)
|
||||
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
setModuleListError(null)
|
||||
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority))
|
||||
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
|
||||
} catch (error) {
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
console.error('[modules] list failed', error)
|
||||
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
|
||||
if (!silent) {
|
||||
@@ -79,10 +130,11 @@ export function useWorkspaceModuleList({
|
||||
setAvailableModules(makeUninstalledModuleRows(curriculumModules))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setModulesLoading(false)
|
||||
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
|
||||
}
|
||||
}, [
|
||||
applyModuleParameterOverrides,
|
||||
applyPendingModuleStates,
|
||||
clonePathPreview,
|
||||
curriculumModuleKey,
|
||||
curriculumModules,
|
||||
@@ -107,6 +159,7 @@ export function useWorkspaceModuleList({
|
||||
return {
|
||||
availableModules,
|
||||
setAvailableModules,
|
||||
markModuleState,
|
||||
modulesLoading,
|
||||
moduleListError,
|
||||
loadAvailableModules,
|
||||
|
||||
@@ -14,6 +14,8 @@ type UseWorkspaceTerminalOptions = {
|
||||
moduleName?: string | null
|
||||
moduleFolderName?: string | null
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
dark: boolean
|
||||
terminalActive: boolean
|
||||
resetKey: string
|
||||
@@ -36,6 +38,8 @@ export function useWorkspaceTerminal({
|
||||
moduleName,
|
||||
moduleFolderName,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
dark,
|
||||
terminalActive,
|
||||
resetKey,
|
||||
@@ -47,11 +51,14 @@ export function useWorkspaceTerminal({
|
||||
const terminalRef = useRef<XTerm | null>(null)
|
||||
const terminalFitRef = useRef<FitAddon | null>(null)
|
||||
const terminalSessionIdRef = useRef<string | null>(null)
|
||||
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
|
||||
const terminalSuppressQueueRef = useRef<string[]>([])
|
||||
const terminalOutputBufferRef = useRef<Record<string, string>>({})
|
||||
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
|
||||
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
|
||||
|
||||
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
|
||||
|
||||
const fitTerminal = (): void => {
|
||||
terminalFitRef.current?.fit()
|
||||
}
|
||||
@@ -71,7 +78,12 @@ export function useWorkspaceTerminal({
|
||||
|
||||
void navigator.clipboard.readText()
|
||||
.then((text) => {
|
||||
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
|
||||
if (!text) return
|
||||
if (remoteWorkspace) {
|
||||
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), text, terminalSessionId)
|
||||
return
|
||||
}
|
||||
window.localWorkspace.terminal.write(text, terminalSessionId)
|
||||
})
|
||||
.catch((error) => console.error('[terminal] paste failed', error))
|
||||
.finally(() => {
|
||||
@@ -153,8 +165,18 @@ export function useWorkspaceTerminal({
|
||||
terminalRef.current = term
|
||||
terminalFitRef.current = fitAddon
|
||||
|
||||
const terminalApi = window.localWorkspace.terminal
|
||||
const removeDataListener = terminalApi.onData((data) => {
|
||||
const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
|
||||
const removeDataListener = remoteWorkspace
|
||||
? terminalApi.onData(remoteTargetResolverRef.current(), (data) => {
|
||||
const suppressQueue = terminalSuppressQueueRef.current
|
||||
if (suppressQueue.length && suppressQueue[0] === data) {
|
||||
suppressQueue.shift()
|
||||
return
|
||||
}
|
||||
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${data}`.slice(-100000)
|
||||
term.write(data)
|
||||
}, terminalSessionId)
|
||||
: terminalApi.onData((data) => {
|
||||
const suppressQueue = terminalSuppressQueueRef.current
|
||||
if (suppressQueue.length && suppressQueue[0] === data) {
|
||||
suppressQueue.shift()
|
||||
@@ -168,10 +190,20 @@ export function useWorkspaceTerminal({
|
||||
term.write(nextData)
|
||||
}
|
||||
}, terminalSessionId)
|
||||
const removeSuppressListener = terminalApi.onSuppressData((data) => {
|
||||
terminalSuppressQueueRef.current.push(data)
|
||||
}, terminalSessionId)
|
||||
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
|
||||
const removeSuppressListener = remoteWorkspace
|
||||
? window.remoteWorkspace.terminal.onSuppressData(remoteTargetResolverRef.current(), (data) => {
|
||||
terminalSuppressQueueRef.current.push(data)
|
||||
}, terminalSessionId)
|
||||
: window.localWorkspace.terminal.onSuppressData((data) => {
|
||||
terminalSuppressQueueRef.current.push(data)
|
||||
}, terminalSessionId)
|
||||
const inputDisposable = term.onData((data) => {
|
||||
if (remoteWorkspace) {
|
||||
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), data, terminalSessionId)
|
||||
return
|
||||
}
|
||||
window.localWorkspace.terminal.write(data, terminalSessionId)
|
||||
})
|
||||
|
||||
let fallbackPromptTimer: number | null = null
|
||||
let sizeSyncTimer: number | null = null
|
||||
@@ -183,8 +215,24 @@ export function useWorkspaceTerminal({
|
||||
startNestedFrame = window.requestAnimationFrame(() => {
|
||||
fitAddon.fit()
|
||||
if (disposed) return resolvePromise(false)
|
||||
void window.localWorkspace.terminal
|
||||
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
|
||||
const start = remoteWorkspace
|
||||
? window.remoteWorkspace.terminal.start(
|
||||
remoteTargetResolverRef.current(),
|
||||
moduleName,
|
||||
term.cols,
|
||||
term.rows,
|
||||
terminalSessionId,
|
||||
moduleFolderName ?? undefined,
|
||||
)
|
||||
: window.localWorkspace.terminal.start(
|
||||
moduleName,
|
||||
term.cols,
|
||||
term.rows,
|
||||
terminalSessionId,
|
||||
clonePathPreview,
|
||||
moduleFolderName ?? undefined,
|
||||
)
|
||||
void start
|
||||
.then(resolvePromise)
|
||||
.catch(() => resolvePromise(false))
|
||||
})
|
||||
@@ -195,7 +243,11 @@ export function useWorkspaceTerminal({
|
||||
if (!started) return
|
||||
sizeSyncTimer = window.setTimeout(() => {
|
||||
fitAddon.fit()
|
||||
void terminalApi.resize(term.cols, term.rows, terminalSessionId)
|
||||
if (remoteWorkspace) {
|
||||
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
|
||||
return
|
||||
}
|
||||
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
|
||||
}, 150)
|
||||
fallbackPromptTimer = window.setTimeout(() => {
|
||||
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
|
||||
@@ -228,14 +280,15 @@ export function useWorkspaceTerminal({
|
||||
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
|
||||
delete terminalOutputBufferRef.current[terminalSessionId]
|
||||
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
|
||||
terminalApi.stop(terminalSessionId)
|
||||
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(remoteTargetResolverRef.current(), terminalSessionId)
|
||||
else window.localWorkspace.terminal.stop(terminalSessionId)
|
||||
term.dispose()
|
||||
terminalRef.current = null
|
||||
terminalFitRef.current = null
|
||||
terminalSessionIdRef.current = null
|
||||
terminalSuppressQueueRef.current = []
|
||||
}
|
||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive])
|
||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
|
||||
|
||||
return {
|
||||
terminalContextMenu,
|
||||
|
||||
@@ -3,12 +3,40 @@ import type { EnvironmentProfile } from '../platforms/types'
|
||||
import type { RuntimeImageArtifact } from './types'
|
||||
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
|
||||
|
||||
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -19,6 +47,8 @@ type UseWorkspaceVisualizationResult = {
|
||||
visualizationRunning: boolean
|
||||
visualizationBusy: boolean
|
||||
visualizationViewerUrl: string
|
||||
nav2VisualizationMode: Nav2VisualizationMode
|
||||
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
|
||||
activeEnvironmentProfile: EnvironmentProfile | null
|
||||
activeRuntimeImages: RuntimeImageArtifact[]
|
||||
refreshVisualizationStatus: () => Promise<void>
|
||||
@@ -36,20 +66,25 @@ 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(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace])
|
||||
const visualizationStacks = useMemo(() => (
|
||||
profiles
|
||||
.map((profile) => profile.profileType.toLowerCase())
|
||||
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam')
|
||||
), [profiles])
|
||||
runtimeProfiles
|
||||
.map((profile) => resolveVisualizationStack(profile.profileType))
|
||||
.filter((stack): stack is VisualizationStack => stack !== null)
|
||||
), [runtimeProfiles])
|
||||
|
||||
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null
|
||||
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 => (
|
||||
@@ -78,16 +113,15 @@ export function useWorkspaceVisualization({
|
||||
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
: 'ws://localhost:8765'
|
||||
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
||||
const visualizationViewerUrl = visualizationStack === 'slam'
|
||||
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(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 profileType = profiles[0]?.profileType?.toLowerCase()
|
||||
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') {
|
||||
setVisualizationStack(profileType)
|
||||
}
|
||||
}, [profiles.map((profile) => profile.id).join('|')])
|
||||
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) => ({
|
||||
@@ -155,6 +189,8 @@ export function useWorkspaceVisualization({
|
||||
visualizationRunning,
|
||||
visualizationBusy,
|
||||
visualizationViewerUrl,
|
||||
nav2VisualizationMode,
|
||||
setNav2VisualizationMode,
|
||||
activeEnvironmentProfile,
|
||||
activeRuntimeImages,
|
||||
refreshVisualizationStatus,
|
||||
|
||||
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
|
||||
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
|
||||
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
|
||||
if (file.endsWith('.json')) return 'json'
|
||||
if (file.endsWith('.xml')) return 'xml'
|
||||
if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml'
|
||||
if (file.endsWith('.html')) return 'html'
|
||||
if (file.endsWith('.css')) return 'css'
|
||||
if (file.endsWith('.py')) return 'python'
|
||||
|
||||
Reference in New Issue
Block a user