add overview..

This commit is contained in:
2026-09-23 10:59:30 +09:00
parent 11ba2bf8b9
commit a7c9cdc16e
5 changed files with 47 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.31", "version": "1.1.32",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
@@ -497,9 +497,6 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
if (REAL_RUNTIME) document.body.classList.add('real-runtime'); if (REAL_RUNTIME) document.body.classList.add('real-runtime');
if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) { if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-yahboom-teleop'); document.body.classList.add('real-yahboom-teleop');
const hint = document.createElement('div');
hint.textContent = '키를 누르는 동안 이동 · 놓으면 정지';
document.getElementById('keyGuide')?.appendChild(hint);
} }
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden'); if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
+1
View File
@@ -3425,6 +3425,7 @@ function LocalPcWorkspaceMockup({
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}> <div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
{cameraOpen && workspaceVisible && <WorkspaceCameraPanel {cameraOpen && workspaceVisible && <WorkspaceCameraPanel
key={`${courseKey}:${weekNumber}`} key={`${courseKey}:${weekNumber}`}
containerRef={visualizationContainerRef}
camera={curriculumWeek.detail.camera} camera={curriculumWeek.detail.camera}
dark={dark} dark={dark}
onClose={() => setCameraOpen(false)} onClose={() => setCameraOpen(false)}
@@ -41,7 +41,7 @@ export type CurriculumWeek = {
duration: string duration: string
github: string github: string
detail: { detail: {
camera?: { url: string; password: string } | null camera?: { url: string } | null
overview: string overview: string
learningSteps?: LearningStep[] learningSteps?: LearningStep[]
topicDetails: string[] topicDetails: string[]
@@ -1,27 +1,28 @@
import { Camera, CirclePlay, Maximize2, Minimize2, Square, X } from 'lucide-react' import { Camera, CirclePlay, Maximize2, Minimize2, Square, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, type RefObject } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus' import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus'
export function cameraViewerUrl(camera: { url: string; password: string }): string { export function cameraViewerUrl(camera: { url: string }): string {
const input = new URL(camera.url) const input = new URL(camera.url)
if (input.protocol !== 'https:' || input.hostname !== 'vdo.ninja' || input.port || input.username || input.password || input.pathname !== '/') throw new Error('카메라 시청 URL이 올바르지 않습니다.') if (input.protocol !== 'https:' || input.hostname !== 'vdo.ninja' || input.port || input.username || input.password || input.pathname !== '/') throw new Error('카메라 시청 URL이 올바르지 않습니다.')
const room = input.searchParams.get('room') const room = input.searchParams.get('room')
const view = input.searchParams.get('view') const view = input.searchParams.get('view')
if (!room?.trim() || !view?.trim()) throw new Error('카메라 시청 URL에 room과 view가 필요합니다.') if (!room?.trim() || (!view?.trim() && !input.searchParams.has('scene'))) throw new Error('카메라 시청 URL에 room과 scene 또는 view가 필요합니다.')
const url = new URL('https://vdo.ninja/') const url = new URL('https://vdo.ninja/')
url.searchParams.set('room', room) url.searchParams.set('room', room)
url.searchParams.set('view', view) if (view?.trim()) url.searchParams.set('view', view)
url.searchParams.set('scene', '') url.searchParams.set('scene', input.searchParams.get('scene') ?? '')
if (camera.password) url.searchParams.set('password', camera.password) if (input.searchParams.has('password')) url.searchParams.set('password', input.searchParams.get('password')!)
return url.toString() return url.toString()
} }
export default function WorkspaceCameraPanel({ camera, dark, onClose }: { export default function WorkspaceCameraPanel({ camera, dark, onClose, containerRef }: {
camera?: { url: string; password: string } | null containerRef: RefObject<HTMLDivElement | null>
camera?: { url: string } | null
dark: boolean dark: boolean
onClose: () => void onClose: () => void
}): React.JSX.Element { }): React.JSX.Element | null {
const [viewerUrl, setViewerUrl] = useState('') const [viewerUrl, setViewerUrl] = useState('')
const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('overview') const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('overview')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -29,12 +30,36 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
const [large, setLarge] = useState(false) const [large, setLarge] = useState(false)
const [position, setPosition] = useState({ x: 32, y: 90 }) const [position, setPosition] = useState({ x: 32, y: 90 })
const panelRef = useRef<HTMLDivElement>(null) const panelRef = useRef<HTMLDivElement>(null)
const [container, setContainer] = useState<HTMLDivElement | null>(null)
const [bounds, setBounds] = useState({ width: 0, height: 0 })
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null) const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
useEffect(() => {
const element = containerRef.current
if (!element) return
setContainer(element)
const update = (): void => {
const width = element.clientWidth
const height = element.clientHeight
setBounds({ width, height })
setPosition((current) => {
const panelWidth = Math.min(panelRef.current?.offsetWidth ?? 660, Math.max(0, width - 24))
const panelHeight = Math.min(panelRef.current?.offsetHeight ?? 420, Math.max(0, height - 60))
return {
x: Math.max(12, Math.min(width - panelWidth - 12, current.x)),
y: Math.max(48, Math.min(height - panelHeight - 12, current.y))
}
})
}
update()
const observer = new ResizeObserver(update)
observer.observe(element)
return () => observer.disconnect()
}, [containerRef])
useEffect(() => { useEffect(() => {
setViewerUrl('') setViewerUrl('')
setLoading(false) setLoading(false)
setError('') setError('')
}, [camera?.url, camera?.password]) }, [camera?.url])
const start = (): void => { const start = (): void => {
try { try {
if (!camera?.url) throw new Error('등록된 주행장 카메라가 없습니다.') if (!camera?.url) throw new Error('등록된 주행장 카메라가 없습니다.')
@@ -46,15 +71,18 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
} }
} }
const buttonClass = 'grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded hover:bg-gray-500/20' const buttonClass = 'grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded hover:bg-gray-500/20'
if (!container) return null
const maxWidth = Math.max(0, bounds.width - position.x - 12)
const maxHeight = Math.max(0, bounds.height - position.y - 12)
return createPortal( return createPortal(
<div ref={panelRef} role="dialog" aria-label="오버뷰" <div ref={panelRef} role="dialog" aria-label="오버뷰"
onPointerDownCapture={bringToFront} onFocusCapture={bringToFront} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}
className={['fixed flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')} className={['absolute flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')}
style={large ? { inset: 24, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: 280, minHeight: 220, maxWidth: `calc(100vw - ${position.x + 12}px)`, maxHeight: `calc(100vh - ${position.y + 12}px)`, resize: 'both' }}> style={large ? { left: 0, right: 0, top: 40, bottom: 0, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: Math.min(280, maxWidth), minHeight: Math.min(220, maxHeight), maxWidth, maxHeight, resize: 'both' }}>
<div className="flex h-10 shrink-0 touch-none items-center gap-2 border-b border-gray-500/25 px-3 select-none" <div className="flex h-10 shrink-0 touch-none items-center gap-2 border-b border-gray-500/25 px-3 select-none"
style={{ cursor: large ? 'default' : 'move' }} style={{ cursor: large ? 'default' : 'move' }}
onPointerDown={(event) => { onPointerDown={(event) => {
if (large || (event.target as HTMLElement).closest('button')) return if (large || event.button !== 0 || (event.target as HTMLElement).closest('button')) return
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y } drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y }
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
}} }}
@@ -62,7 +90,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
if (!drag.current) return if (!drag.current) return
const width = panelRef.current?.offsetWidth ?? 660 const width = panelRef.current?.offsetWidth ?? 660
const height = panelRef.current?.offsetHeight ?? 420 const height = panelRef.current?.offsetHeight ?? 420
setPosition({ x: Math.max(0, Math.min(window.innerWidth - width, drag.current.left + event.clientX - drag.current.x)), y: Math.max(0, Math.min(window.innerHeight - height, drag.current.top + event.clientY - drag.current.y)) }) setPosition({ x: Math.max(12, Math.min(container.clientWidth - width - 12, drag.current.left + event.clientX - drag.current.x)), y: Math.max(48, Math.min(container.clientHeight - height - 12, drag.current.top + event.clientY - drag.current.y)) })
}} }}
onPointerUp={() => { drag.current = null }} onLostPointerCapture={() => { drag.current = null }}> onPointerUp={() => { drag.current = null }} onLostPointerCapture={() => { drag.current = null }}>
<Camera size={15} /><strong className="min-w-0 flex-1 text-[13px]">오버뷰</strong> <Camera size={15} /><strong className="min-w-0 flex-1 text-[13px]">오버뷰</strong>
@@ -79,10 +107,11 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
</> : <div className="absolute inset-0 bg-black/34"> </> : <div className="absolute inset-0 bg-black/34">
<button type="button" disabled={!camera?.url} onClick={start} className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default disabled:opacity-40"><CirclePlay size={40} /><span>실행하기</span></button> <button type="button" disabled={!camera?.url} onClick={start} className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default disabled:opacity-40"><CirclePlay size={40} /><span>실행하기</span></button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center"> <div className="absolute inset-x-0 top-[70%] px-5 text-center">
<p className="text-[13px] font-bold text-white/72">주행장을 외부에서 촬영하는 영상입니다.</p>
{error && <p role="alert" className="text-[13px] font-bold text-red-300">{error}</p>} {error && <p role="alert" className="text-[13px] font-bold text-red-300">{error}</p>}
{!camera?.url && <p className="text-[13px] font-bold text-white/72">등록된 주행장 카메라가 없습니다.</p>} {!camera?.url && <p className="text-[13px] font-bold text-white/72">등록된 주행장 카메라가 없습니다.</p>}
</div> </div>
</div>} </div>}
</div> </div>
</div>, document.body) </div>, container)
} }