Compare commits

...

2 Commits

Author SHA1 Message Date
chyoo 755546713c version up! 2026-08-06 16:30:56 +09:00
chyoo 2ab7e279eb feat: enable hybrid local and remote module execution 2026-08-06 16:18:32 +09:00
38 changed files with 3268 additions and 745 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.5", "version": "1.1.6",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+76
View File
@@ -25,10 +25,12 @@ from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
from nav2_msgs.action import NavigateToPose from nav2_msgs.action import NavigateToPose
from pydantic import BaseModel from pydantic import BaseModel
from rclpy.action import ActionClient from rclpy.action import ActionClient
from rclpy.duration import Duration
from rclpy.executors import MultiThreadedExecutor from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node from rclpy.node import Node
from rclpy.parameter import Parameter from rclpy.parameter import Parameter
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import LaserScan, PointCloud2 from sensor_msgs.msg import LaserScan, PointCloud2
from tf2_msgs.msg import TFMessage from tf2_msgs.msg import TFMessage
import uvicorn import uvicorn
@@ -149,6 +151,7 @@ class SlamNode(Node):
self.odom_topic = os.environ.get("ODOM_TOPIC", "/odom") self.odom_topic = os.environ.get("ODOM_TOPIC", "/odom")
self.scan_topic = os.environ.get("SCAN_TOPIC", "/scan") self.scan_topic = os.environ.get("SCAN_TOPIC", "/scan")
self.amcl_pose_topic = os.environ.get("AMCL_POSE_TOPIC", "/amcl_pose")
self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map") self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map")
self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered") self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered")
self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom") self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom")
@@ -181,6 +184,7 @@ class SlamNode(Node):
self._odom: Optional[Odometry] = None self._odom: Optional[Odometry] = None
self._scan: Optional[LaserScan] = None self._scan: Optional[LaserScan] = None
self._amcl_pose: Optional[PoseWithCovarianceStamped] = None
self._point_cloud: Optional[PointCloud2] = None self._point_cloud: Optional[PointCloud2] = None
self._fallback_point_cloud: Optional[PointCloud2] = None self._fallback_point_cloud: Optional[PointCloud2] = None
self._icp_odom: Optional[Odometry] = None self._icp_odom: Optional[Odometry] = None
@@ -189,6 +193,7 @@ class SlamNode(Node):
self._tf = {} self._tf = {}
self._odom_count = 0 self._odom_count = 0
self._scan_count = 0 self._scan_count = 0
self._amcl_pose_count = 0
self._point_cloud_count = 0 self._point_cloud_count = 0
self._fallback_point_cloud_count = 0 self._fallback_point_cloud_count = 0
self._icp_odom_count = 0 self._icp_odom_count = 0
@@ -196,10 +201,15 @@ class SlamNode(Node):
self._tf_count = 0 self._tf_count = 0
self._last_odom_wall_time = None self._last_odom_wall_time = None
self._last_scan_wall_time = None self._last_scan_wall_time = None
self._last_amcl_pose_wall_time = None
self._last_point_cloud_wall_time = None self._last_point_cloud_wall_time = None
self._last_fallback_point_cloud_wall_time = None self._last_fallback_point_cloud_wall_time = None
self._last_icp_odom_wall_time = None self._last_icp_odom_wall_time = None
self._last_map_wall_time = None self._last_map_wall_time = None
self._plan_count = 0
self._last_plan_wall_time = None
self._topic_status_cache = {}
self._topic_status_last_refresh = 0.0
self._navigation = {"status": "idle", "goal_id": None} self._navigation = {"status": "idle", "goal_id": None}
self._active_goal_handle = None self._active_goal_handle = None
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -208,6 +218,8 @@ class SlamNode(Node):
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos) self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data) self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos) self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, best_effort_qos)
self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos) self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
if self.fallback_point_cloud_topic != self.point_cloud_topic: if self.fallback_point_cloud_topic != self.point_cloud_topic:
self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data) self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data)
@@ -233,7 +245,18 @@ class SlamNode(Node):
self._last_odom_wall_time = time.time() self._last_odom_wall_time = time.time()
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> None: def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> None:
# AMCL transforms the initial pose at the message timestamp. On a real
# platform the bridge clock can be a few milliseconds ahead of the
# latest odom/TF sample, which makes AMCL reject the pose as a future
# extrapolation. Anchor it to the latest received odometry instead.
with self._lock:
odom_stamp = self._odom.header.stamp if self._odom is not None else None
message = PoseWithCovarianceStamped() message = PoseWithCovarianceStamped()
if odom_stamp:
# The odom callback can lead the TF buffer by a few milliseconds.
# Keep a small margin so AMCL can always transform map -> odom.
message.header.stamp = (Time.from_msg(odom_stamp) - Duration(seconds=0.1)).to_msg()
else:
message.header.stamp = self.get_clock().now().to_msg() message.header.stamp = self.get_clock().now().to_msg()
message.header.frame_id = frame_id message.header.frame_id = frame_id
message.pose.pose.position.x = x message.pose.pose.position.x = x
@@ -355,6 +378,12 @@ class SlamNode(Node):
self._scan_count += 1 self._scan_count += 1
self._last_scan_wall_time = time.time() self._last_scan_wall_time = time.time()
def _on_amcl_pose(self, msg: PoseWithCovarianceStamped):
with self._lock:
self._amcl_pose = msg
self._amcl_pose_count += 1
self._last_amcl_pose_wall_time = time.time()
def _on_point_cloud(self, msg: PointCloud2): def _on_point_cloud(self, msg: PointCloud2):
with self._lock: with self._lock:
self._point_cloud = msg self._point_cloud = msg
@@ -393,6 +422,51 @@ class SlamNode(Node):
def _on_plan(self, msg: NavPath): def _on_plan(self, msg: NavPath):
with self._lock: with self._lock:
self._plan = msg self._plan = msg
self._plan_count += 1
self._last_plan_wall_time = time.time()
def topic_status(self) -> dict:
now = time.time()
with self._lock:
if now - self._topic_status_last_refresh < 1.0:
return dict(self._topic_status_cache)
scan_last = self._last_scan_wall_time
odom_last = self._last_odom_wall_time
amcl_pose_last = self._last_amcl_pose_wall_time
point_cloud_last = self._last_point_cloud_wall_time
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
map_count = self._map_count
plan_count = self._plan_count
def received_recently(last_received: Optional[float]) -> bool:
return last_received is not None and now - last_received <= 3.0
try:
plan_available = plan_count > 0 or self.count_publishers(self.plan_topic) > 0
cmd_vel_available = self.count_subscribers(self.cmd_vel_topic) > 0
navigation_available = self._navigate_to_pose_client.server_is_ready()
except Exception:
plan_available = plan_count > 0
cmd_vel_available = False
navigation_available = False
status = {
"scan": {"name": self.scan_topic, "available": received_recently(scan_last)},
"odom": {"name": self.odom_topic, "available": received_recently(odom_last)},
"map": {"name": self.map_topic, "available": map_count > 0},
"amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)},
"point_cloud": {
"name": self.point_cloud_topic,
"available": received_recently(point_cloud_last) or received_recently(fallback_point_cloud_last),
},
"plan": {"name": self.plan_topic, "available": plan_available},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
"navigate_to_pose": {"name": self.navigate_to_pose_action, "available": navigation_available},
}
with self._lock:
self._topic_status_cache = status
self._topic_status_last_refresh = now
return dict(status)
@staticmethod @staticmethod
def _has_populated_map(grid: Optional[OccupancyGrid]) -> bool: def _has_populated_map(grid: Optional[OccupancyGrid]) -> bool:
@@ -738,6 +812,7 @@ class SlamNode(Node):
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"), "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
}, },
"navigation": navigation, "navigation": navigation,
"topic_status": self.topic_status(),
"plan": { "plan": {
"frame_id": plan.header.frame_id, "frame_id": plan.header.frame_id,
"poses": [ "poses": [
@@ -904,6 +979,7 @@ def health():
"has_point_cloud": bool(snap.get("point_cloud")), "has_point_cloud": bool(snap.get("point_cloud")),
"has_map": bool(snap.get("map")), "has_map": bool(snap.get("map")),
"topics": snap.get("topics", {}), "topics": snap.get("topics", {}),
"topic_status": snap.get("topic_status", {}),
} }
+661 -41
View File
File diff suppressed because it is too large Load Diff
+51 -14
View File
@@ -21,6 +21,7 @@ type ModulePrebuiltArtifact = {
fileName?: string | null fileName?: string | null
checksumSha256?: string | null checksumSha256?: string | null
extractPath?: string | null extractPath?: string | null
architecture?: string | null
required?: boolean | null required?: boolean | null
} }
@@ -37,6 +38,24 @@ type ModuleInstallOptions = {
runtimeImages?: RuntimeImageArtifact[] | null runtimeImages?: RuntimeImageArtifact[] | null
} }
type RosNetworkConfig = {
enabled?: boolean | null
peer?: RemoteWorkspaceTarget | null
}
type SavedMapArtifactName = 'yaml' | 'pgm' | 'database'
type SavedMapArtifactPath = {
name: SavedMapArtifactName
path: string
}
type SavedMapArtifactDownload = {
name: SavedMapArtifactName
fileName: string
url: string
}
const decodeArgValue = (prefix: string): string | null => { const decodeArgValue = (prefix: string): string | null => {
const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
if (!rawValue) return null if (!rawValue) return null
@@ -94,6 +113,9 @@ contextBridge.exposeInMainWorld('titlebar', {
openDevTools: () => ipcRenderer.invoke('titlebar-open-devtools'), openDevTools: () => ipcRenderer.invoke('titlebar-open-devtools'),
close: () => ipcRenderer.send('titlebar-close') close: () => ipcRenderer.send('titlebar-close')
}) })
contextBridge.exposeInMainWorld('appDiagnostics', {
read: () => ipcRenderer.invoke('app-diagnostics:read')
})
contextBridge.exposeInMainWorld('appLifecycle', { contextBridge.exposeInMainWorld('appLifecycle', {
quit: () => ipcRenderer.send('app-quit') quit: () => ipcRenderer.send('app-quit')
}) })
@@ -113,18 +135,20 @@ contextBridge.exposeInMainWorld('modules', {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('modules:list', workspaceRoot, modules), list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('modules:list', workspaceRoot, modules),
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName),
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages), build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams),
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName), stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName),
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot), stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot),
stopRunning: () => ipcRenderer.invoke('modules:stop-running'), stopRunning: () => ipcRenderer.invoke('modules:stop-running'),
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:logs', moduleName, workspaceRoot, folderName), logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:logs', moduleName, workspaceRoot, folderName),
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
}) })
contextBridge.exposeInMainWorld('git', { contextBridge.exposeInMainWorld('git', {
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('git:status', moduleName, workspaceRoot, folderName, selectedFilePath), status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId), submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail),
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('git:team-pull', moduleName, workspaceRoot, folderName),
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('git:submissions', modules, workspaceRoot), submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('git:submissions', modules, workspaceRoot),
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('git:commit-diff', moduleName, workspaceRoot, folderName, commitHash) commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
}) })
@@ -154,7 +178,7 @@ contextBridge.exposeInMainWorld('terminal', {
}) })
contextBridge.exposeInMainWorld('visualization', { contextBridge.exposeInMainWorld('visualization', {
status: () => ipcRenderer.invoke('visualization:status'), status: () => ipcRenderer.invoke('visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile), start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile) stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
}) })
contextBridge.exposeInMainWorld('localWorkspace', { contextBridge.exposeInMainWorld('localWorkspace', {
@@ -172,12 +196,12 @@ contextBridge.exposeInMainWorld('localWorkspace', {
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input), createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName),
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages), build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams),
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName), stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName),
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot), stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot),
stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'), stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'),
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName), logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName),
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
}, },
@@ -190,14 +214,20 @@ contextBridge.exposeInMainWorld('localWorkspace', {
read: (workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:world-layout:read', workspaceRoot), read: (workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:world-layout:read', workspaceRoot),
write: (workspaceRoot: string | undefined, layout: unknown) => ipcRenderer.invoke('local-workspace:world-layout:write', workspaceRoot, layout) write: (workspaceRoot: string | undefined, layout: unknown) => ipcRenderer.invoke('local-workspace:world-layout:write', workspaceRoot, layout)
}, },
maps: {
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('local-workspace:maps:read-artifacts', workspaceRoot, artifacts),
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('local-workspace:maps:sync', workspaceRoot, artifacts)
},
visualization: { visualization: {
status: () => ipcRenderer.invoke('local-workspace:visualization:status'), status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile), start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile) stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
}, },
git: { git: {
status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('local-workspace:git:status', moduleName, workspaceRoot, folderName, selectedFilePath), status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('local-workspace:git:status', moduleName, workspaceRoot, folderName, selectedFilePath),
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId), submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('local-workspace:git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail),
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:git:team-pull', moduleName, workspaceRoot, folderName),
restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('local-workspace:git:restore-file', moduleName, workspaceRoot, folderName, filePath, status), restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('local-workspace:git:restore-file', moduleName, workspaceRoot, folderName, filePath, status),
submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:git:submissions', modules, workspaceRoot), submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:git:submissions', modules, workspaceRoot),
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash) commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
@@ -234,26 +264,33 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName), rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName) delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
}, },
maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
},
modules: { modules: {
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules), list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options), install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input), createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input),
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName),
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages), build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages),
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName), stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName),
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules), stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules),
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules), resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules),
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName) logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName)
}, },
visualization: { visualization: {
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target), status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile), start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork),
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target) stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
}, },
git: { git: {
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath), status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath),
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId), submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl),
teamPublish: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('remote-workspace:git:team-publish', target, moduleName, folderName, message, authorName, authorEmail),
teamPull: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:git:team-pull', target, moduleName, folderName),
restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('remote-workspace:git:restore-file', target, moduleName, folderName, filePath, status), restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('remote-workspace:git:restore-file', target, moduleName, folderName, filePath, status),
submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:git:submissions', target, modules), submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:git:submissions', target, modules),
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash) commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
+229 -58
View File
@@ -24,7 +24,7 @@
#miniMapToggle { #miniMapToggle {
position:absolute; position:absolute;
right:16px; right:16px;
bottom:236px; bottom:240px;
z-index:11; z-index:11;
min-height:28px; min-height:28px;
padding:0 10px; padding:0 10px;
@@ -76,6 +76,65 @@
backdrop-filter:blur(8px); backdrop-filter:blur(8px);
box-shadow:0 10px 28px rgba(0,0,0,.16); box-shadow:0 10px 28px rgba(0,0,0,.16);
} }
#rosTopicStatusWrap {
position:absolute;
left:16px;
bottom:54px;
z-index:10;
width:178px;
}
#rosTopicStatus {
overflow:hidden;
border:1px solid rgba(255,255,255,.24);
background:rgba(17,24,39,.48);
color:rgba(255,255,255,.88);
font:11px sans-serif;
backdrop-filter:blur(8px);
box-shadow:0 10px 28px rgba(0,0,0,.16);
}
#rosTopicStatusToggle {
position:absolute;
left:0;
top:-28px;
display:grid;
width:30px;
height:28px;
place-items:center;
padding:0;
border:1px solid rgba(255,255,255,.22);
background:rgba(17,24,39,.42);
color:rgba(255,255,255,.9);
font:11px sans-serif;
font-weight:800;
cursor:pointer;
backdrop-filter:blur(8px);
box-shadow:0 10px 28px rgba(0,0,0,.16);
}
#rosTopicStatusToggle:hover { color:#fff; background:rgba(255,255,255,.06); }
#rosTopicStatusWrap.is-collapsed #rosTopicStatus { display:none; }
#rosTopicStatusChevron { font-size:13px; line-height:1; }
#rosTopicStatusWrap.is-collapsed #rosTopicStatusChevron { transform:rotate(180deg); }
#rosTopicStatusTitle {
min-height:30px;
display:flex;
align-items:center;
padding:0 9px;
border-bottom:1px solid rgba(255,255,255,.14);
font-weight:800;
}
#rosTopicStatusList { padding:4px 0; }
.ros-topic-row {
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
min-height:23px;
padding:0 9px;
}
.ros-topic-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ros-topic-state { flex:0 0 auto; font-size:12px; font-weight:900; }
.ros-topic-state.is-ok { color:#5eead4; }
.ros-topic-state.is-error { color:#fca5a5; }
#responseWaitOverlay { #responseWaitOverlay {
position:fixed; position:fixed;
inset:0; inset:0;
@@ -250,14 +309,16 @@
.editor-mode #topRightControls, .editor-mode #topRightControls,
.editor-mode #miniMap, .editor-mode #miniMap,
.editor-mode #miniMapToggle, .editor-mode #miniMapToggle,
.editor-mode #keyGuide { .editor-mode #keyGuide,
.editor-mode #rosTopicStatusWrap {
display:none; display:none;
} }
#splatToggle { display:none; } #splatToggle { display:none; }
.nav2-slam #goalToggle, .nav2-slam #poseToggle, .nav2-slam #sensorToggle { display:none; } .nav2-slam #goalToggle, .nav2-slam #poseToggle, .nav2-slam #sensorToggle, .nav2-slam #initToggle { display:none; }
.nav2-navigation #downloadWrap { display:none; } .nav2-navigation #downloadWrap { display:none; }
.nav2-navigation #topLeftControls { display:block; } .nav2-navigation #topLeftControls { display:block; }
.nav2-navigation #keyGuide { display:none; } .nav2-navigation #keyGuide { display:none; }
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.nav2-navigation.real-runtime #splatToggle { display:inline-flex; } .nav2-navigation.real-runtime #splatToggle { display:inline-flex; }
.real-runtime #topLeftControls { display:none; } .real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; } .real-runtime #keyGuide { display:none; }
@@ -288,6 +349,15 @@
<div id="keyGuide"> <div id="keyGuide">
<div>i 전진 · , 후진 · j/l 회전 · k 정지</div> <div>i 전진 · , 후진 · j/l 회전 · k 정지</div>
</div> </div>
<div id="rosTopicStatusWrap">
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" aria-expanded="true">
<span id="rosTopicStatusChevron" aria-hidden="true">▾</span>
</button>
<aside id="rosTopicStatus" aria-label="ROS 상태">
<div id="rosTopicStatusTitle">ROS 상태</div>
<div id="rosTopicStatusList"></div>
</aside>
</div>
<div id="responseWaitOverlay" role="status" aria-live="polite"> <div id="responseWaitOverlay" role="status" aria-live="polite">
<div class="response-wait-dialog"> <div class="response-wait-dialog">
<span class="response-wait-spinner" aria-hidden="true"></span> <span class="response-wait-spinner" aria-hidden="true"></span>
@@ -315,9 +385,17 @@ import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
const viewerMapKey = new URLSearchParams(window.location.search).get('mapKey'); const viewerMapKey = new URLSearchParams(window.location.search).get('mapKey');
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const REAL_RUNTIME = params.get('runtime') === 'real'; const REAL_RUNTIME = params.get('runtime') === 'real';
const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
// Keep the prior real-platform default when an older LMS URL has no platform.
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1'; const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom'; const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world'; const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world';
const MKMINI_WORLD_SPACING_SCALE = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world' ? 1.6 : 1;
const MKMINI_RENDER_LENGTH = 1.05;
const DEFAULT_SIMULATION_SPAWN = USE_MKMINI_MODEL
? { x: -1.4, y: -0.5 }
: { x: -2.0, y: -0.5 };
const MODEL_ROOT = `./turtlebot3_world_model/${activeWorldModel}`; const MODEL_ROOT = `./turtlebot3_world_model/${activeWorldModel}`;
const MODEL_SDF = `${MODEL_ROOT}/model.sdf`; const MODEL_SDF = `${MODEL_ROOT}/model.sdf`;
const miniMap = document.getElementById('miniMap'); const miniMap = document.getElementById('miniMap');
@@ -333,6 +411,10 @@ const goalToggleButton = document.getElementById('goalToggle');
const obstacleToggleButton = document.getElementById('obstacleToggle'); const obstacleToggleButton = document.getElementById('obstacleToggle');
const initToggleButton = document.getElementById('initToggle'); const initToggleButton = document.getElementById('initToggle');
const cameraPresetSelect = document.getElementById('cameraPresetSelect'); const cameraPresetSelect = document.getElementById('cameraPresetSelect');
const rosTopicStatusWrap = document.getElementById('rosTopicStatusWrap');
const rosTopicStatus = document.getElementById('rosTopicStatus');
const rosTopicStatusToggle = document.getElementById('rosTopicStatusToggle');
const rosTopicStatusList = document.getElementById('rosTopicStatusList');
const EDITOR_MODE = params.get('editor') === '1'; const EDITOR_MODE = params.get('editor') === '1';
const NAV2_VIEW_MODE = params.get('mode') === 'navigation' ? 'navigation' : 'slam'; const NAV2_VIEW_MODE = params.get('mode') === 'navigation' ? 'navigation' : 'slam';
const SHOW_REMOTE_NAVIGATION_ROOM = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation'; const SHOW_REMOTE_NAVIGATION_ROOM = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
@@ -347,6 +429,44 @@ const cameraPresetSelect = document.getElementById('cameraPresetSelect');
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden'); if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
let visualizationResponseReceived = EDITOR_MODE; let visualizationResponseReceived = EDITOR_MODE;
const ROS_TOPIC_STATUS_KEYS = NAV2_VIEW_MODE === 'navigation'
? ['scan', 'odom', 'map', 'amcl_pose', 'plan', 'cmd_vel', 'navigate_to_pose', ...(SHOW_POINT_CLOUD ? ['point_cloud'] : [])]
: ['scan', 'odom', 'map'];
const ROS_TOPIC_STATUS_FALLBACK_NAMES = {
scan: '/scan',
odom: '/odom',
map: '/map',
amcl_pose: '/amcl_pose',
plan: '/plan',
cmd_vel: '/cmd_vel',
navigate_to_pose: '/navigate_to_pose',
point_cloud: '/cloud_map',
};
function updateRosTopicStatus(topicStatus = {}) {
if (!rosTopicStatusList) return;
rosTopicStatusList.replaceChildren(...ROS_TOPIC_STATUS_KEYS.map((key) => {
const status = topicStatus?.[key] ?? {};
const row = document.createElement('div');
row.className = 'ros-topic-row';
const name = document.createElement('span');
name.className = 'ros-topic-name';
name.textContent = status.name || ROS_TOPIC_STATUS_FALLBACK_NAMES[key] || key;
const indicator = document.createElement('span');
const available = status.available === true;
indicator.className = `ros-topic-state ${available ? 'is-ok' : 'is-error'}`;
indicator.textContent = available ? 'O' : 'X';
row.append(name, indicator);
return row;
}));
}
rosTopicStatusToggle?.addEventListener('click', () => {
const collapsed = rosTopicStatusWrap?.classList.toggle('is-collapsed') ?? false;
rosTopicStatusToggle.setAttribute('aria-expanded', String(!collapsed));
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? 'ROS 상태 펼치기' : 'ROS 상태 접기');
});
updateRosTopicStatus();
function markVisualizationResponseReceived() { function markVisualizationResponseReceived() {
if (visualizationResponseReceived) return; if (visualizationResponseReceived) return;
@@ -421,7 +541,7 @@ let remoteNavigationSplatTransform = null;
let remoteNavigationSplatEnabled = false; let remoteNavigationSplatEnabled = false;
let remoteNavigationSplatLoading = null; let remoteNavigationSplatLoading = null;
// 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다. // 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다.
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.7322845855746674, 1.3107954323633903, -0.8021416967574936); const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.6822845855746674, 1.3107954323633903, -0.8021416967574936);
const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469); const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469);
const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587); const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587);
const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null; const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null;
@@ -651,6 +771,13 @@ let obstaclePreviewTemplate = null;
const navigationRaycaster = new THREE.Raycaster(); const navigationRaycaster = new THREE.Raycaster();
const navigationGroundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); const navigationGroundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
// A saved map, the remote room model, and AMCL's pose all use `map`.
// Keep the real-platform navigation scene in that frame. Local Gazebo still
// needs odom coordinates for its pose service, so its conversion remains.
function navigationSceneUsesMapFrame() {
return REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
}
function syncGoalToggleAvailability() { function syncGoalToggleAvailability() {
const disabled = NAV2_VIEW_MODE === 'navigation' && !navigationInitialPoseSet; const disabled = NAV2_VIEW_MODE === 'navigation' && !navigationInitialPoseSet;
goalToggleButton.disabled = disabled; goalToggleButton.disabled = disabled;
@@ -694,6 +821,17 @@ function applySdfPose(obj, pose) {
obj.position.set(pose.x, pose.y, pose.z); obj.position.set(pose.x, pose.y, pose.z);
obj.rotation.set(pose.roll, pose.pitch, pose.yaw, 'XYZ'); obj.rotation.set(pose.roll, pose.pitch, pose.yaw, 'XYZ');
} }
function applyWorldSdfPose(obj, pose) {
applySdfPose(obj, {
...pose,
x: pose.x * MKMINI_WORLD_SPACING_SCALE,
y: pose.y * MKMINI_WORLD_SPACING_SCALE,
});
}
function isMkMiniWorldPillar(visual) {
return MKMINI_WORLD_SPACING_SCALE > 1
&& /^(one|two|three)_/.test(visual.getAttribute('name') || '');
}
function getFirst(el, tag) { return el?.getElementsByTagName(tag)?.[0] || null; } function getFirst(el, tag) { return el?.getElementsByTagName(tag)?.[0] || null; }
function directChild(el, tag) { return Array.from(el.children || []).find(c => c.tagName === tag) || null; } function directChild(el, tag) { return Array.from(el.children || []).find(c => c.tagName === tag) || null; }
function getDirectText(el, tag, fallback = '') { function getDirectText(el, tag, fallback = '') {
@@ -754,6 +892,9 @@ async function addMesh(parent, visual, geometry) {
if (!uri) return; if (!uri) return;
const url = gazeboUriToPath(uri); const url = gazeboUriToPath(uri);
const scale = parseScale(getDirectText(meshEl, 'scale', '1 1 1')); const scale = parseScale(getDirectText(meshEl, 'scale', '1 1 1'));
if (MKMINI_WORLD_SPACING_SCALE > 1 && uri.endsWith('/wall.dae')) {
scale.multiplyScalar(MKMINI_WORLD_SPACING_SCALE);
}
const material = materialFromVisual(visual); const material = materialFromVisual(visual);
if (/\.stl$/i.test(url)) { if (/\.stl$/i.test(url)) {
@@ -801,7 +942,8 @@ function addBox(parent, visual, geometry) {
function addCylinder(parent, visual, geometry) { function addCylinder(parent, visual, geometry) {
const cyl = directChild(geometry, 'cylinder'); const cyl = directChild(geometry, 'cylinder');
const radius = Number(getDirectText(cyl, 'radius', '0.1')) || .1; const radius = Number(getDirectText(cyl, 'radius', '0.1')) || .1;
const length = Number(getDirectText(cyl, 'length', '1')) || 1; const sourceLength = Number(getDirectText(cyl, 'length', '1')) || 1;
const length = isMkMiniWorldPillar(visual) && sourceLength === .5 ? .8 : sourceLength;
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), materialFromVisual(visual)); const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), materialFromVisual(visual));
// Three cylinder 기본 축은 Y, SDF cylinder 기본 축은 Z입니다. // Three cylinder 기본 축은 Y, SDF cylinder 기본 축은 Z입니다.
mesh.rotation.x = Math.PI / 2; mesh.rotation.x = Math.PI / 2;
@@ -827,7 +969,7 @@ async function buildFromSdf() {
const model = doc.querySelector('model'); const model = doc.querySelector('model');
const modelGroup = new THREE.Group(); const modelGroup = new THREE.Group();
modelGroup.name = model?.getAttribute('name') || 'turtlebot3_world'; modelGroup.name = model?.getAttribute('name') || 'turtlebot3_world';
applySdfPose(modelGroup, parsePose(getDirectText(model, 'pose', '0 0 0 0 0 0'))); applyWorldSdfPose(modelGroup, parsePose(getDirectText(model, 'pose', '0 0 0 0 0 0')));
sdfRoot.add(modelGroup); sdfRoot.add(modelGroup);
let loaded = 0; let loaded = 0;
@@ -835,19 +977,20 @@ async function buildFromSdf() {
const nestedModelGroup = new THREE.Group(); const nestedModelGroup = new THREE.Group();
let directGeometryCount = 0; let directGeometryCount = 0;
nestedModelGroup.name = sdfModel.getAttribute('name') || 'model'; nestedModelGroup.name = sdfModel.getAttribute('name') || 'model';
applySdfPose(nestedModelGroup, parsePose(getDirectText(sdfModel, 'pose', '0 0 0 0 0 0'))); applyWorldSdfPose(nestedModelGroup, parsePose(getDirectText(sdfModel, 'pose', '0 0 0 0 0 0')));
parent.add(nestedModelGroup); parent.add(nestedModelGroup);
for (const link of Array.from(sdfModel.querySelectorAll(':scope > link'))) { for (const link of Array.from(sdfModel.querySelectorAll(':scope > link'))) {
const linkGroup = new THREE.Group(); const linkGroup = new THREE.Group();
linkGroup.name = link.getAttribute('name') || 'link'; linkGroup.name = link.getAttribute('name') || 'link';
applySdfPose(linkGroup, parsePose(getDirectText(link, 'pose', '0 0 0 0 0 0'))); applyWorldSdfPose(linkGroup, parsePose(getDirectText(link, 'pose', '0 0 0 0 0 0')));
nestedModelGroup.add(linkGroup); nestedModelGroup.add(linkGroup);
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) { for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
const visualGroup = new THREE.Group(); const visualGroup = new THREE.Group();
visualGroup.name = visual.getAttribute('name') || 'visual'; visualGroup.name = visual.getAttribute('name') || 'visual';
applySdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0'))); applyWorldSdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0')));
if (isMkMiniWorldPillar(visual)) visualGroup.position.z += 0.15;
linkGroup.add(visualGroup); linkGroup.add(visualGroup);
const geometry = directChild(visual, 'geometry'); const geometry = directChild(visual, 'geometry');
@@ -983,7 +1126,8 @@ async function loadMkMiniModel(sdfPose = { x:0, y:0, z:0, roll:0, pitch:0, yaw:0
const initialBounds = new THREE.Box3().setFromObject(model); const initialBounds = new THREE.Box3().setFromObject(model);
const initialSize = initialBounds.getSize(new THREE.Vector3()); const initialSize = initialBounds.getSize(new THREE.Vector3());
const largestDimension = Math.max(initialSize.x, initialSize.y, initialSize.z, 0.001); const largestDimension = Math.max(initialSize.x, initialSize.y, initialSize.z, 0.001);
model.scale.setScalar(0.66 / largestDimension); // Match the Gazebo MKMini collision body's 1.05 m longest dimension.
model.scale.setScalar(MKMINI_RENDER_LENGTH / largestDimension);
model.updateMatrixWorld(true); model.updateMatrixWorld(true);
const normalizedBounds = new THREE.Box3().setFromObject(model); const normalizedBounds = new THREE.Box3().setFromObject(model);
@@ -1083,14 +1227,15 @@ async function setRemoteNavigationSplatVisible(visible) {
} }
const LASER_HOLE_R = 0.14; const LASER_HOLE_R = 0.14;
const LASER_OFFSET_X = REAL_RUNTIME ? 0.1 : -0.032; const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.1 : -0.032;
const LASER_OFFSET_Y = 0; const LASER_OFFSET_Y = 0;
// Calibrated to the MKMini GLB at the current display scale. // Calibrated to the MKMini GLB at the current display scale.
const LASER_Z = REAL_RUNTIME ? 0.5 : 0.181; const LASER_Z = USE_MKMINI_MODEL ? 0.5 : 0.181;
// The MKMini ROS frame reports os_lidar at Z=0.6 m while the bundled GLB // The MKMini ROS frame reports os_lidar at Z=0.6 m while the bundled GLB
// places the visible LiDAR head at approximately Z=0.5 m. Keep ROS/map // places the visible LiDAR head at approximately Z=0.5 m. Keep ROS/map
// coordinates untouched and correct only the rendered scan origin. // coordinates untouched and correct only the rendered scan origin.
const MKMINI_LASER_RENDER_Z_OFFSET = 0.1; const MKMINI_LASER_RENDER_Z_OFFSET = 0.1;
const MKMINI_LASER_RENDER_FORWARD_OFFSET = 0.10;
function setRobotPose(pose) { function setRobotPose(pose) {
if (!pose) return; if (!pose) return;
@@ -1234,7 +1379,7 @@ function setNavigationSensorVisible(visible) {
function drawLaserScan(scan, scanPose = ( function drawLaserScan(scan, scanPose = (
NAV2_VIEW_MODE === 'navigation' NAV2_VIEW_MODE === 'navigation'
? latestSlamState?.scan_pose ? (navigationSceneUsesMapFrame() ? latestSlamState?.scan_pose_map : latestSlamState?.scan_pose)
: latestSlamState?.scan_pose_map : latestSlamState?.scan_pose_map
) || null) { ) || null) {
if (NAV2_VIEW_MODE === 'navigation' && (!navigationInitialPoseSet || !navigationSensorVisible)) { if (NAV2_VIEW_MODE === 'navigation' && (!navigationInitialPoseSet || !navigationSensorVisible)) {
@@ -1245,8 +1390,13 @@ function drawLaserScan(scan, scanPose = (
clearLaserScan(); clearLaserScan();
const sourcePose = scanPose || currentRobotPose; const sourcePose = scanPose || currentRobotPose;
const renderPose = REAL_RUNTIME && scanPose const renderPose = USE_MKMINI_MODEL && scanPose
? { ...sourcePose, z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET } ? {
...sourcePose,
x: sourcePose.x + Math.cos(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET,
y: sourcePose.y + Math.sin(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET,
z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET,
}
: sourcePose; : sourcePose;
const origin = scanPose const origin = scanPose
? new THREE.Vector3(renderPose.x, renderPose.y, renderPose.z || 0) ? new THREE.Vector3(renderPose.x, renderPose.y, renderPose.z || 0)
@@ -1268,31 +1418,24 @@ function drawLaserScan(scan, scanPose = (
const start = origin.clone().addScaledVector(dir, startD); const start = origin.clone().addScaledVector(dir, startD);
const point = origin.clone().addScaledVector(dir, d); const point = origin.clone().addScaledVector(dir, d);
endpoints.push({ start, point, hit: valid }); endpoints.push({ start, point, hit: valid });
linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); // A range-max value represents open space, not an actual reflected beam.
if (valid) linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z);
} }
const fillPoints = []; const fillPoints = [];
const openFillPoints = [];
for (let i = 0; i < endpoints.length; i++) { for (let i = 0; i < endpoints.length; i++) {
const curr = endpoints[i]; const curr = endpoints[i];
const next = endpoints[(i + 1) % endpoints.length]; const next = endpoints[(i + 1) % endpoints.length];
const targetPoints = curr.hit && next.hit ? fillPoints : openFillPoints; if (!curr.hit || !next.hit) continue;
targetPoints.push(curr.start.x, curr.start.y, curr.start.z); fillPoints.push(curr.start.x, curr.start.y, curr.start.z);
targetPoints.push(curr.point.x, curr.point.y, curr.point.z); fillPoints.push(curr.point.x, curr.point.y, curr.point.z);
targetPoints.push(next.point.x, next.point.y, next.point.z); fillPoints.push(next.point.x, next.point.y, next.point.z);
targetPoints.push(curr.start.x, curr.start.y, curr.start.z); fillPoints.push(curr.start.x, curr.start.y, curr.start.z);
targetPoints.push(next.point.x, next.point.y, next.point.z); fillPoints.push(next.point.x, next.point.y, next.point.z);
targetPoints.push(next.start.x, next.start.y, next.start.z); fillPoints.push(next.start.x, next.start.y, next.start.z);
} }
const grp = new THREE.Group(); const grp = new THREE.Group();
const openFillMat = new THREE.MeshBasicMaterial({
color: 0x3390ff,
transparent: true,
opacity: 0.18,
side: THREE.DoubleSide,
depthWrite: false,
});
const fillMat = new THREE.MeshBasicMaterial({ const fillMat = new THREE.MeshBasicMaterial({
color: 0x005cff, color: 0x005cff,
transparent: true, transparent: true,
@@ -1302,11 +1445,6 @@ function drawLaserScan(scan, scanPose = (
}); });
const lineMat = new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 }); const lineMat = new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 });
if (openFillPoints.length) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(openFillPoints, 3));
grp.add(new THREE.Mesh(geo, openFillMat));
}
if (fillPoints.length) { if (fillPoints.length) {
const geo = new THREE.BufferGeometry(); const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(fillPoints, 3)); geo.setAttribute('position', new THREE.Float32BufferAttribute(fillPoints, 3));
@@ -1318,7 +1456,7 @@ function drawLaserScan(scan, scanPose = (
const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc))); const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc)));
const front = endpoints[frontIndex]; const front = endpoints[frontIndex];
if (front) { if (front?.hit) {
const delta = new THREE.Vector3( const delta = new THREE.Vector3(
front.point.x - front.start.x, front.point.x - front.start.x,
front.point.y - front.start.y, front.point.y - front.start.y,
@@ -1549,6 +1687,12 @@ function updateMiniMapMetadata(map) {
function updateNavigationMapTransform() { function updateNavigationMapTransform() {
if (NAV2_VIEW_MODE !== 'navigation') return; if (NAV2_VIEW_MODE !== 'navigation') return;
if (navigationSceneUsesMapFrame()) {
navigationMapRoot.position.set(0, 0, 0);
navigationMapRoot.rotation.set(0, 0, 0);
updateRemoteNavigationRoomTransform();
return;
}
const transform = navigationMapToOdom || { x:0, y:0, yaw:0 }; const transform = navigationMapToOdom || { x:0, y:0, yaw:0 };
const yaw = transform.yaw || 0; const yaw = transform.yaw || 0;
const c = Math.cos(yaw); const c = Math.cos(yaw);
@@ -1567,8 +1711,19 @@ function updateNavigationMapTransform() {
function updateRemoteNavigationRoomTransform() { function updateRemoteNavigationRoomTransform() {
if (!remoteNavigationSplatEnabled || !remoteNavigationSplatScene || NAV2_VIEW_MODE !== 'navigation') return; if (!remoteNavigationSplatEnabled || !remoteNavigationSplatScene || NAV2_VIEW_MODE !== 'navigation') return;
if (navigationSceneUsesMapFrame()) {
if (remoteNavigationSplatTransform?.frame === 'map') return;
remoteNavigationSplatScene.position.copy(remoteNavigationSplatBasePosition);
remoteNavigationSplatScene.quaternion.copy(remoteNavigationSplatBaseQuaternion);
remoteNavigationSplatScene.scale.copy(remoteNavigationSplatBaseScale);
remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = { frame: 'map' };
return;
}
const transform = navigationMapToOdom || { x:0, y:0, yaw:0 }; const transform = navigationMapToOdom || { x:0, y:0, yaw:0 };
if (remoteNavigationSplatTransform if (remoteNavigationSplatTransform
&& remoteNavigationSplatTransform.frame === 'odom'
&& Math.abs(remoteNavigationSplatTransform.x - (transform.x || 0)) < 0.03 && Math.abs(remoteNavigationSplatTransform.x - (transform.x || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.y - (transform.y || 0)) < 0.03 && Math.abs(remoteNavigationSplatTransform.y - (transform.y || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.yaw - (transform.yaw || 0)) < 0.01) { && Math.abs(remoteNavigationSplatTransform.yaw - (transform.yaw || 0)) < 0.01) {
@@ -1600,6 +1755,7 @@ function updateRemoteNavigationRoomTransform() {
); );
remoteNavigationSplatViewer.splatMesh.updateTransforms(); remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = { remoteNavigationSplatTransform = {
frame: 'odom',
x: transform.x || 0, x: transform.x || 0,
y: transform.y || 0, y: transform.y || 0,
yaw, yaw,
@@ -1656,8 +1812,10 @@ function renderNavigationOccupancyMap() {
updateNavigationMapTransform(); updateNavigationMapTransform();
if (!navigationMapFocused) { if (!navigationMapFocused) {
const odomCenter = mapToOdom({ x:centerX, y:centerY, yaw:originYaw }); const sceneCenter = navigationSceneUsesMapFrame()
const center = sdfPointToWorld(odomCenter.x, odomCenter.y, 0); ? { x:centerX, y:centerY }
: mapToOdom({ x:centerX, y:centerY, yaw:originYaw });
const center = sdfPointToWorld(sceneCenter.x, sceneCenter.y, 0);
const distance = Math.max(width, height) * 1.1; const distance = Math.max(width, height) * 1.1;
controls.target.copy(center); controls.target.copy(center);
camera.position.set(center.x + distance * .62, center.y + distance * .86, center.z + distance * .62); camera.position.set(center.x + distance * .62, center.y + distance * .86, center.z + distance * .62);
@@ -2040,7 +2198,7 @@ function placeNavigationGoalMarker(x, y, yaw = 0) {
function updateNavigationGoalMarker() { function updateNavigationGoalMarker() {
if (!navigationGoalMarker || !navigationGoalPose) return; if (!navigationGoalMarker || !navigationGoalPose) return;
const markerPose = NAV2_VIEW_MODE === 'navigation' const markerPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame()
? mapToOdom(navigationGoalPose) ? mapToOdom(navigationGoalPose)
: navigationGoalPose; : navigationGoalPose;
navigationGoalMarker.position.set(markerPose.x, markerPose.y, 0.05); navigationGoalMarker.position.set(markerPose.x, markerPose.y, 0.05);
@@ -2055,9 +2213,9 @@ function setNavigationGoalPreview(pose) {
); );
sdfRoot.add(navigationGoalPreview); sdfRoot.add(navigationGoalPreview);
} }
const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
navigationGoalPreview.position.set(odomPose.x, odomPose.y, 0.06); navigationGoalPreview.position.set(scenePose.x, scenePose.y, 0.06);
navigationGoalPreview.rotation.z = odomPose.yaw || 0; navigationGoalPreview.rotation.z = scenePose.yaw || 0;
navigationGoalPreview.visible = true; navigationGoalPreview.visible = true;
} }
@@ -2098,8 +2256,8 @@ function setObstaclePreview(pose) {
); );
sdfRoot.add(obstaclePreview); sdfRoot.add(obstaclePreview);
} }
const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
obstaclePreview.position.set(odomPose.x, odomPose.y, obstaclePreviewTemplate.height / 2); obstaclePreview.position.set(scenePose.x, scenePose.y, obstaclePreviewTemplate.height / 2);
obstaclePreview.rotation.z = obstaclePreviewTemplate.yaw; obstaclePreview.rotation.z = obstaclePreviewTemplate.yaw;
obstaclePreview.visible = true; obstaclePreview.visible = true;
} }
@@ -2122,8 +2280,8 @@ function updateNavigationPlan(plan) {
} }
if (poses.length < 2) return; if (poses.length < 2) return;
const points = poses.map((pose) => { const points = poses.map((pose) => {
const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
return new THREE.Vector3(odomPose.x, odomPose.y, 0.07); return new THREE.Vector3(scenePose.x, scenePose.y, 0.07);
}); });
navigationPlanLine = new THREE.Line( navigationPlanLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points), new THREE.BufferGeometry().setFromPoints(points),
@@ -2150,19 +2308,29 @@ function pickNavigationGoal(event) {
const worldPoint = new THREE.Vector3(); const worldPoint = new THREE.Vector3();
if (!navigationRaycaster.ray.intersectPlane(navigationGroundPlane, worldPoint)) return null; if (!navigationRaycaster.ray.intersectPlane(navigationGroundPlane, worldPoint)) return null;
const sdfPoint = worldPointToSdf(worldPoint); const sdfPoint = worldPointToSdf(worldPoint);
const mapPoint = NAV2_VIEW_MODE === 'navigation' const mapPoint = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame()
? odomToMap({ x: sdfPoint.x, y: sdfPoint.y, yaw: currentRobotPose.yaw || 0 }) ? odomToMap({ x: sdfPoint.x, y: sdfPoint.y, yaw: currentRobotPose.yaw || 0 })
: sdfPoint; : sdfPoint;
const robotMapPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame()
? odomToMap(currentRobotPose)
: currentRobotPose;
const headingToGoal = Math.atan2(
mapPoint.y - (robotMapPose?.y || 0),
mapPoint.x - (robotMapPose?.x || 0),
);
return { return {
x: mapPoint.x, x: mapPoint.x,
y: mapPoint.y, y: mapPoint.y,
yaw: mapPoint.yaw || 0, // A Dubins-based Nav2 planner must know which way the vehicle should face
// at the destination. The clicked ground point has no yaw, so using zero
// forced every goal to face east and made otherwise valid paths fail.
yaw: Number.isFinite(headingToGoal) ? headingToGoal : (mapPoint.yaw || 0),
}; };
} }
function showInitialPosePreview(pose) { function showInitialPosePreview(pose) {
if (!posePreviewModel) return; if (!posePreviewModel) return;
const scenePose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
posePreviewModel.position.set(scenePose.x, scenePose.y, 0.01); posePreviewModel.position.set(scenePose.x, scenePose.y, 0.01);
posePreviewModel.rotation.set(0, 0, scenePose.yaw || 0); posePreviewModel.rotation.set(0, 0, scenePose.yaw || 0);
posePreviewModel.visible = true; posePreviewModel.visible = true;
@@ -2235,7 +2403,7 @@ async function sendInitialPose(pose) {
hideInitialPosePreview(); hideInitialPosePreview();
return; return;
} }
const gazeboPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; const gazeboPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose;
const requestId = `initial-pose-${Date.now()}-${++initialPoseRequestSequence}`; const requestId = `initial-pose-${Date.now()}-${++initialPoseRequestSequence}`;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -2397,11 +2565,11 @@ renderer.domElement.addEventListener('click', async (event) => {
if (!goal) return; if (!goal) return;
try { try {
if (obstaclePlacementMode) { if (obstaclePlacementMode) {
const sdfPoint = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(goal) : goal; const scenePoint = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(goal) : goal;
const obstacle = { const obstacle = {
id: `runtime-wall-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `runtime-wall-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
x: sdfPoint.x, x: scenePoint.x,
y: sdfPoint.y, y: scenePoint.y,
...(obstaclePreviewTemplate ?? createObstaclePreviewTemplate()), ...(obstaclePreviewTemplate ?? createObstaclePreviewTemplate()),
}; };
window.parent.postMessage({ source:'autonomy-slam-viewer', type:'runtime-obstacle-add', obstacle }, '*'); window.parent.postMessage({ source:'autonomy-slam-viewer', type:'runtime-obstacle-add', obstacle }, '*');
@@ -2422,6 +2590,7 @@ renderer.domElement.addEventListener('click', async (event) => {
function applySlamState(state) { function applySlamState(state) {
latestSlamState = state; latestSlamState = state;
updateRosTopicStatus(state?.topic_status);
if ( if (
navigationGoalMarker navigationGoalMarker
&& navigationGoalId && navigationGoalId
@@ -2437,11 +2606,13 @@ function applySlamState(state) {
updateNavigationMapTransform(); updateNavigationMapTransform();
} }
updateNavigationPlan(state?.plan); updateNavigationPlan(state?.plan);
// Navigation is rendered in odom so the map, robot, path and goal share // Remote navigation is rendered in the map frame, matching the saved map,
// one scene frame. SLAM keeps its map-frame pose for the live map view. // room model, AMCL pose, route and goal. Local Gazebo remains odom-based.
const visualizationPose = SHOW_POINT_CLOUD const visualizationPose = SHOW_POINT_CLOUD
? (state?.pose_3d || state?.pose) ? (state?.pose_3d || state?.pose)
: (NAV2_VIEW_MODE === 'navigation' ? state?.pose : (state?.pose_map || state?.pose)); : (NAV2_VIEW_MODE === 'navigation'
? (navigationSceneUsesMapFrame() ? (state?.pose_map || state?.pose) : state?.pose)
: (state?.pose_map || state?.pose));
if (visualizationPose) setRobotPose(visualizationPose); if (visualizationPose) setRobotPose(visualizationPose);
if (state?.scan) { if (state?.scan) {
latestScanMetadata = state.scan; latestScanMetadata = state.scan;
@@ -2658,7 +2829,7 @@ const worldSceneTasks = CUSTOM_WORLD || REAL_RUNTIME
const remoteNavigationSceneTasks = []; const remoteNavigationSceneTasks = [];
Promise.all(EDITOR_MODE Promise.all(EDITOR_MODE
? worldSceneTasks ? worldSceneTasks
: [...worldSceneTasks, ...remoteNavigationSceneTasks, (REAL_RUNTIME ? loadMkMiniModel : loadBurgerModel)({ x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 })] : [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })]
).then(() => { ).then(() => {
if (EDITOR_MODE) { if (EDITOR_MODE) {
window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*'); window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*');
+954 -188
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -233,12 +233,14 @@ export const QaInputBox = memo(function QaInputBox({
dark, dark,
busy, busy,
disabled, disabled,
accent = 'blue',
onStop, onStop,
onSubmit onSubmit
}: { }: {
dark: boolean dark: boolean
busy: boolean busy: boolean
disabled: boolean disabled: boolean
accent?: 'blue' | 'cyan'
onStop: () => void onStop: () => void
onSubmit: (content: string) => void onSubmit: (content: string) => void
}): React.JSX.Element { }): React.JSX.Element {
@@ -279,7 +281,12 @@ export const QaInputBox = memo(function QaInputBox({
type="button" type="button"
disabled={!canSubmit} disabled={!canSubmit}
onClick={busy ? onStop : submit} onClick={busy ? onStop : submit}
className="grid w-10 shrink-0 cursor-pointer place-items-center bg-[#2563eb] text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-45 disabled:hover:bg-[#2563eb]" className={[
'grid w-10 shrink-0 cursor-pointer place-items-center text-white transition disabled:cursor-default disabled:opacity-45',
accent === 'cyan'
? 'bg-[#0e7490] hover:bg-[#0891b2] disabled:hover:bg-[#0e7490]'
: 'bg-[#2563eb] hover:bg-[#1d4ed8] disabled:hover:bg-[#2563eb]'
].join(' ')}
> >
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />} {busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
</button> </button>
+19 -7
View File
@@ -30,7 +30,7 @@ export default function Sidebar({
sessionStartedAt?: string | null sessionStartedAt?: string | null
sessionEndedAt?: string | null sessionEndedAt?: string | null
sessionEndsAt?: string | null sessionEndsAt?: string | null
runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }> runningSessions?: Array<{ id: string; title: string; runningModuleCount?: number; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
canReleaseControl?: boolean canReleaseControl?: boolean
onOpenSession?: (sessionId?: string) => void onOpenSession?: (sessionId?: string) => void
onDisconnectSession?: (sessionId?: string) => void onDisconnectSession?: (sessionId?: string) => void
@@ -138,7 +138,7 @@ export default function Sidebar({
<button <button
type="button" type="button"
onClick={() => onOpenSession?.(session.id)} onClick={() => onOpenSession?.(session.id)}
className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 rounded-lg border border-white/10 bg-white/[0.05] py-3 pl-3 pr-11 text-left transition hover:border-white/20 hover:bg-white/[0.08]" className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 rounded-lg border border-white/10 bg-white/[0.05] py-3 pl-3 pr-11 text-left transition hover:border-white/20 hover:bg-white/[0.08]"
> >
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
@@ -146,10 +146,16 @@ export default function Sidebar({
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span> <span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
</span> </span>
{(session.startedAt || session.endsAt) && ( {(session.startedAt || session.endsAt) && (
<span className="mt-0.5 block truncate text-[11px] text-white/38"> <span className="mt-1 block truncate text-[11px] text-white/38">
{[session.startedAt ? `시작 ${session.startedAt}` : null, session.endsAt ? `종료 ${session.endsAt}` : null].filter(Boolean).join(' · ')} {[
session.startedAt ? `시작 ${session.startedAt}` : null,
session.endsAt ? `종료 ${session.endsAt}` : null
].filter(Boolean).join(' · ')}
</span> </span>
)} )}
{session.runningModuleCount !== undefined && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
)}
</span> </span>
</button> </button>
{sessionStatus === 'running' && ( {sessionStatus === 'running' && (
@@ -191,7 +197,7 @@ export default function Sidebar({
setSessionPopoverOpen(false) setSessionPopoverOpen(false)
onOpenSession?.(session.id) onOpenSession?.(session.id)
}} }}
className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]" className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]"
> >
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
@@ -199,10 +205,16 @@ export default function Sidebar({
<span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span> <span className="block min-w-0 truncate text-[13px] font-bold text-white/85">{session.title}</span>
</span> </span>
{(session.startedAt || session.endsAt) && ( {(session.startedAt || session.endsAt) && (
<span className="mt-0.5 block truncate text-[11px] text-white/38"> <span className="mt-1 block truncate text-[11px] text-white/38">
{[session.startedAt ? `시작 ${session.startedAt}` : null, session.endsAt ? `종료 ${session.endsAt}` : null].filter(Boolean).join(' · ')} {[
session.startedAt ? `시작 ${session.startedAt}` : null,
session.endsAt ? `종료 ${session.endsAt}` : null
].filter(Boolean).join(' · ')}
</span> </span>
)} )}
{session.runningModuleCount !== undefined && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
)}
</span> </span>
</button> </button>
{sessionStatus === 'running' && ( {sessionStatus === 'running' && (
+140 -8
View File
@@ -1,5 +1,5 @@
import icon from '/assets/icon.png' import icon from '/assets/icon.png'
import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, HelpCircle, LogOut, Minus, Search, Square, X } from 'lucide-react' import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, Clipboard, HelpCircle, LogOut, Minus, RotateCw, ScrollText, Search, Square, Wrench, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
@@ -97,7 +97,13 @@ const normalizeNotification = (notification: NotificationMessage): NotificationM
export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCourseSelection, transientNotice = null }: TitleBarProps): React.JSX.Element { export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCourseSelection, transientNotice = null }: TitleBarProps): React.JSX.Element {
const [notificationOpen, setNotificationOpen] = useState(false) const [notificationOpen, setNotificationOpen] = useState(false)
const [accountOpen, setAccountOpen] = useState(false) const [accountOpen, setAccountOpen] = useState(false)
const [toolsOpen, setToolsOpen] = useState(false)
const [showCloseConfirm, setShowCloseConfirm] = useState(false) const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const [appLogsOpen, setAppLogsOpen] = useState(false)
const [appLogsLoading, setAppLogsLoading] = useState(false)
const [appLogContent, setAppLogContent] = useState('')
const [appLogPath, setAppLogPath] = useState('')
const [appLogsCopied, setAppLogsCopied] = useState(false)
const [notifications, setNotifications] = useState<NotificationMessage[]>([]) const [notifications, setNotifications] = useState<NotificationMessage[]>([])
const [localNotifications, setLocalNotifications] = useState<NotificationMessage[]>([]) const [localNotifications, setLocalNotifications] = useState<NotificationMessage[]>([])
const [notificationModalOpen, setNotificationModalOpen] = useState(false) const [notificationModalOpen, setNotificationModalOpen] = useState(false)
@@ -111,6 +117,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
const [transientNoticeVisible, setTransientNoticeVisible] = useState(false) const [transientNoticeVisible, setTransientNoticeVisible] = useState(false)
const notificationRef = useRef<HTMLDivElement | null>(null) const notificationRef = useRef<HTMLDivElement | null>(null)
const accountRef = useRef<HTMLDivElement | null>(null) const accountRef = useRef<HTMLDivElement | null>(null)
const toolsRef = useRef<HTMLDivElement | null>(null)
const enqueuedTransientNoticeIdsRef = useRef<Set<number>>(new Set()) const enqueuedTransientNoticeIdsRef = useRef<Set<number>>(new Set())
const knownNotificationIdsRef = useRef<Set<string>>(new Set()) const knownNotificationIdsRef = useRef<Set<string>>(new Set())
const notificationHydratedRef = useRef(false) const notificationHydratedRef = useRef(false)
@@ -148,6 +155,32 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
const affiliationLabel = formatAffiliation(user?.primaryAffiliation) const affiliationLabel = formatAffiliation(user?.primaryAffiliation)
const winButton = const winButton =
'grid h-7 w-7 cursor-pointer place-items-center rounded text-white/50 transition hover:bg-white/[0.06] hover:text-white' 'grid h-7 w-7 cursor-pointer place-items-center rounded text-white/50 transition hover:bg-white/[0.06] hover:text-white'
const loadAppLogs = useCallback(async (): Promise<void> => {
if (!window.appDiagnostics) {
setAppLogContent('현재 앱에서는 로그 조회 기능을 사용할 수 없습니다. LMS 앱을 최신 버전으로 다시 실행하세요.')
return
}
setAppLogsLoading(true)
try {
const result = await window.appDiagnostics.read()
setAppLogContent(result.content)
setAppLogPath(result.path)
} catch (error) {
setAppLogContent(`로그를 읽지 못했습니다.\n${error instanceof Error ? error.message : String(error)}`)
} finally {
setAppLogsLoading(false)
}
}, [])
const handleCopyAppLogs = useCallback((): void => {
if (!appLogContent) return
void navigator.clipboard.writeText(appLogContent)
.then(() => {
setAppLogsCopied(true)
window.setTimeout(() => setAppLogsCopied(false), 1600)
})
.catch((error) => console.error('[app-logs] copy failed', error))
}, [appLogContent])
const enqueueTransientNotice = useCallback((notice: { id: number; title: string; tone?: 'info' | 'warning' | 'error' }): void => { const enqueueTransientNotice = useCallback((notice: { id: number; title: string; tone?: 'info' | 'warning' | 'error' }): void => {
const title = notice.title.trim() const title = notice.title.trim()
if (!title) return if (!title) return
@@ -325,6 +358,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
setNotificationOpen(false) setNotificationOpen(false)
} }
if (!accountRef.current?.contains(target)) setAccountOpen(false) if (!accountRef.current?.contains(target)) setAccountOpen(false)
if (!toolsRef.current?.contains(target)) setToolsOpen(false)
} }
window.addEventListener('pointerdown', handlePointerDown) window.addEventListener('pointerdown', handlePointerDown)
@@ -344,16 +378,17 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
setNotificationModalOpen(false) setNotificationModalOpen(false)
return return
} }
if (notificationOpen || accountOpen) { if (notificationOpen || accountOpen || toolsOpen) {
event.preventDefault() event.preventDefault()
setNotificationOpen(false) setNotificationOpen(false)
setAccountOpen(false) setAccountOpen(false)
setToolsOpen(false)
} }
} }
window.addEventListener('keydown', handleEscape) window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape)
}, [accountOpen, notificationModalOpen, notificationOpen, showCloseConfirm]) }, [accountOpen, notificationModalOpen, notificationOpen, showCloseConfirm, toolsOpen])
const handleNotificationClick = (): void => { const handleNotificationClick = (): void => {
setNotificationOpen((current) => !current) setNotificationOpen((current) => !current)
@@ -495,20 +530,60 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<HelpCircle size={16} /> <HelpCircle size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="개발자 도구 열기"> <div className="relative" ref={toolsRef}>
<Tooltip label="도구 보기" disabled={toolsOpen}>
<button <button
type="button" type="button"
aria-label="Open Developer Tools" aria-label="도구 보기"
aria-expanded={toolsOpen}
onClick={() => { onClick={() => {
setToolsOpen((current) => !current)
setNotificationOpen(false)
setAccountOpen(false)
}}
className={winButton}
>
<Wrench size={15} />
</button>
</Tooltip>
{toolsOpen ? (
<div className="absolute right-0 top-[34px] z-50 w-[164px] overflow-hidden rounded-lg border border-white/10 bg-[rgba(18,23,35,0.97)] p-1 shadow-[0_18px_42px_rgba(0,0,0,0.42)] backdrop-blur-xl">
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
onClick={() => window.location.reload()}
>
<RotateCw size={15} />
새로고침
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
onClick={() => {
setToolsOpen(false)
setAppLogsOpen(true)
void loadAppLogs()
}}
>
<ScrollText size={15} />
앱 로그
</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
onClick={() => {
setToolsOpen(false)
void window.titlebar.openDevTools().then((result) => { void window.titlebar.openDevTools().then((result) => {
if (!result.opened) console.error('[titlebar] open DevTools failed:', result.message) if (!result.opened) console.error('[titlebar] open DevTools failed:', result.message)
}).catch((error) => console.error('[titlebar] open DevTools request failed:', error)) }).catch((error) => console.error('[titlebar] open DevTools request failed:', error))
}} }}
className={winButton}
> >
<Bug size={15} /> <Bug size={15} />
개발자 도구
</button> </button>
</Tooltip> </div>
) : null}
</div>
<div className="relative" ref={notificationRef}> <div className="relative" ref={notificationRef}>
<Tooltip label="알림" disabled={notificationOpen}> <Tooltip label="알림" disabled={notificationOpen}>
<button <button
@@ -742,7 +817,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<header className="flex h-[70px] shrink-0 items-center justify-between px-5"> <header className="flex h-[70px] shrink-0 items-center justify-between px-5">
<div> <div>
<h2 className="text-[16px] font-bold text-white/90">전체 알림</h2> <h2 className="text-[16px] font-bold text-white/90">전체 알림</h2>
<p className="mt-1 text-[11px] font-medium text-white/42">알림은 최대 하루동안만 저장됩니다. 조회기간: 현재부터 24시간 전까지</p> <p className="mt-1 text-[11px] font-medium text-white/42">알림은 최대 하루동안만 저장됩니다.</p>
</div> </div>
<button <button
type="button" type="button"
@@ -870,6 +945,63 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
</div> </div>
) : null} ) : null}
{appLogsOpen ? (
<div
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<section className="flex h-[min(680px,calc(100vh-100px))] w-[min(920px,calc(100vw-80px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#171a21] shadow-[0_28px_90px_rgba(0,0,0,0.58)]">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-white/10 px-5">
<div className="min-w-0">
<h2 className="text-[14px] font-bold text-white/92">앱 로그</h2>
<p className="mt-0.5 truncate text-[11px] font-medium text-white/42">{appLogPath || '로그 파일을 불러오는 중입니다.'}</p>
</div>
<button
type="button"
onClick={() => setAppLogsOpen(false)}
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
aria-label="앱 로그 닫기"
>
<X size={18} />
</button>
</header>
<div className="min-h-0 flex-1 bg-[#101217] p-4">
{appLogsLoading ? (
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">로그를 불러오는 중입니다.</div>
) : appLogContent ? (
<pre className="h-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-white/[0.07] bg-black/20 p-3 font-mono text-[11px] leading-5 text-white/70">
{appLogContent}
</pre>
) : (
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">
아직 기록된 앱 로그가 없습니다.
</div>
)}
</div>
<footer className="flex h-[58px] shrink-0 items-center justify-end gap-2 border-t border-white/10 px-5">
<button
type="button"
onClick={handleCopyAppLogs}
disabled={!appLogContent}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
>
<Clipboard size={14} />
{appLogsCopied ? '복사됨' : '복사'}
</button>
<button
type="button"
onClick={() => void loadAppLogs()}
disabled={appLogsLoading}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
>
<RotateCw size={14} className={appLogsLoading ? 'animate-spin' : ''} />
새로고침
</button>
</footer>
</section>
</div>
) : null}
{showCloseConfirm ? ( {showCloseConfirm ? (
<div <div
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]" className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
+57 -9
View File
@@ -15,6 +15,7 @@ type ModulePrebuiltArtifact = {
fileName?: string | null fileName?: string | null
checksumSha256?: string | null checksumSha256?: string | null
extractPath?: string | null extractPath?: string | null
architecture?: string | null
required?: boolean | null required?: boolean | null
} }
@@ -31,6 +32,24 @@ type ModuleInstallOptions = {
runtimeImages?: RuntimeImageArtifact[] | null runtimeImages?: RuntimeImageArtifact[] | null
} }
type RosNetworkConfig = {
enabled?: boolean | null
peer?: RemoteWorkspaceTarget | null
}
type SavedMapArtifactName = 'yaml' | 'pgm' | 'database'
type SavedMapArtifactPath = {
name: SavedMapArtifactName
path: string
}
type SavedMapArtifactDownload = {
name: SavedMapArtifactName
fileName: string
url: string
}
interface Window { interface Window {
launcherAuth?: { launcherAuth?: {
appUrl: string | null appUrl: string | null
@@ -47,6 +66,9 @@ interface Window {
openDevTools: () => Promise<{ opened: boolean; message?: string }> openDevTools: () => Promise<{ opened: boolean; message?: string }>
close: () => void close: () => void
} }
appDiagnostics?: {
read: () => Promise<{ path: string; content: string }>
}
appLifecycle: { appLifecycle: {
quit: () => void quit: () => void
} }
@@ -105,12 +127,12 @@ interface Window {
}> }>
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }> createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => Promise<{ ok: boolean; message: string }>
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }> stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
stopRunning: () => Promise<{ ok: boolean; message: string }> stopRunning: () => Promise<{ ok: boolean; message: string }>
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }> updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }>
} }
@@ -137,7 +159,7 @@ interface Window {
}> }>
message?: string message?: string
}> }>
submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => Promise<{ submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => Promise<{
ok: boolean ok: boolean
message: string message: string
commit?: { commit?: {
@@ -148,6 +170,21 @@ interface Window {
message: string message: string
} }
}> }>
teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => Promise<{
ok: boolean
message: string
commit?: {
hash: string
shortHash: string
author: string
date: string
message: string
}
}>
teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{
ok: boolean
message: string
}>
restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => Promise<{ restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => Promise<{
ok: boolean ok: boolean
message: string message: string
@@ -198,7 +235,7 @@ interface Window {
stack: 'autoware' | 'nav2' | 'slam' | null stack: 'autoware' | 'nav2' | 'slam' | null
message?: string message?: string
}> }>
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => Promise<{ start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{
ok: boolean ok: boolean
running: boolean running: boolean
stack: 'autoware' | 'nav2' | 'slam' | null stack: 'autoware' | 'nav2' | 'slam' | null
@@ -223,6 +260,10 @@ interface Window {
read: (workspaceRoot?: string) => Promise<WorldLayout> read: (workspaceRoot?: string) => Promise<WorldLayout>
write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise<WorldLayout> write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise<WorldLayout>
} }
maps: {
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
}
visualization: Window['visualization'] visualization: Window['visualization']
git: Window['git'] git: Window['git']
terminal: Window['terminal'] terminal: Window['terminal']
@@ -237,6 +278,10 @@ interface Window {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']> rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']>
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']> delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
} }
maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
}
modules: { modules: {
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => ReturnType<Window['modules']['list']> list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => ReturnType<Window['modules']['list']>
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{ install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
@@ -257,22 +302,25 @@ interface Window {
} }
}> }>
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }> createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => Promise<{ ok: boolean; workspacePath: string }>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }> resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }>
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
} }
visualization: { visualization: {
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
} }
git: { git: {
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']> status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']>
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ReturnType<Window['git']['submit']> submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ReturnType<Window['git']['submit']>
teamPublish: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ReturnType<Window['git']['teamPublish']>
teamPull: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['git']['teamPull']>
restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => ReturnType<Window['git']['restoreFile']> restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => ReturnType<Window['git']['restoreFile']>
submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ReturnType<Window['git']['submissions']> submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ReturnType<Window['git']['submissions']>
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType<Window['git']['commitDiff']> commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType<Window['git']['commitDiff']>
+11 -11
View File
@@ -80,38 +80,38 @@ export default function AiGuideView(props: Props): React.JSX.Element {
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button> <button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button>
</header> </header>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]"> <aside data-help="ai-guide-context" className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3"> <div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="rounded-lg border border-violet-300/20 bg-violet-400/[0.08] px-3 pb-3 pt-2"> <div className="rounded-lg border border-cyan-300/18 bg-cyan-400/[0.07] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-violet-200">현재 학습</span> <span className="text-[10px] font-bold tracking-[0.08em] text-cyan-200">현재 학습</span>
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong> <strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p> <p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
</div> </div>
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-emerald-300" /><span className="min-w-0 flex-1">새 채팅</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button> <button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">새 채팅</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
<div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1"> <div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1">
{quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)} {quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)}
</div></div> </div></div>
<div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 채팅</span><div className="mt-2 grid gap-1"> <div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 채팅</span><div className="mt-2 grid gap-1">
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span></button> : null} {activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span></button> : null}
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="채팅 삭제" aria-label="채팅 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)} {history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="채팅 삭제" aria-label="채팅 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
</div></div> </div></div>
</div> </div>
</aside> </aside>
<main className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]"> <main data-help="ai-guide-conversation" className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]">
<div ref={conversationScrollRef} onScroll={(event) => { <div ref={conversationScrollRef} onScroll={(event) => {
const target = event.currentTarget const target = event.currentTarget
followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96 followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96
}} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7"> }} className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7">
{loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5"> {loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>} {messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>}
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)} {messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)}
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>} {busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
</div>} </div>}
</div> </div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div> <div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact accent="cyan" busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span></div>} {switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span></div>}
</main> </main>
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside> <aside data-help="ai-guide-questions" className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
</div> </div>
{deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">채팅 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 채팅 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 채팅'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 채팅과 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>} {deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">채팅 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 채팅 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 채팅'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 채팅과 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>}
</section> </section>
@@ -19,7 +19,6 @@ type CurriculumHomeWeek = {
description: string description: string
}> }>
deliverable: string deliverable: string
requiresSourceSubmission?: boolean
sourceSubmitted?: boolean sourceSubmitted?: boolean
sourceSubmittedAt?: string | null sourceSubmittedAt?: string | null
} }
@@ -1,4 +1,4 @@
import { BookOpen, Car, Clock3, FileText, Play, Upload } from 'lucide-react' import { BookOpen, Car, Clock3, FileText, Play } from 'lucide-react'
import type React from 'react' import type React from 'react'
type LearningInfoWeek = { type LearningInfoWeek = {
@@ -7,10 +7,6 @@ type LearningInfoWeek = {
tags: string[] tags: string[]
progress: number progress: number
duration: string duration: string
detail: {
requiresSourceSubmission?: boolean
sourceSubmitted?: boolean
}
} }
type LearningInfoCardProps = { type LearningInfoCardProps = {
@@ -59,24 +55,16 @@ export default function LearningInfoCard({
</div> </div>
</div> </div>
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] pb-2 pt-2"> <div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] py-1">
{[ {[
{ label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car }, { label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car },
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 }, { label: '예상 학습 시간', value: week.duration, Icon: Clock3 }
...(week.detail.requiresSourceSubmission
? [{
label: '제출 여부',
value: week.detail.sourceSubmitted ? '제출 완료' : '미제출',
Icon: Upload,
valueClassName: week.detail.sourceSubmitted ? 'text-[#178c78]' : 'text-[#e11d48]'
}]
: [])
].map(({ label, value, Icon, valueClassName }) => ( ].map(({ label, value, Icon, valueClassName }) => (
<div <div
key={label} key={label}
className={[ className={[
'flex items-center justify-between gap-3 border-b border-[#edf0f5] px-3.5 last:border-b-0', 'flex items-center justify-between gap-3 border-b border-[#edf0f5] px-3.5 last:border-b-0',
label === '제출 여부' ? 'min-h-[42px] pb-1 pt-0' : 'min-h-[30px] py-0' 'min-h-[34px] py-0'
].join(' ')} ].join(' ')}
> >
<span className="flex min-w-0 items-center gap-2 text-[12px] font-medium text-[#4f596b]"> <span className="flex min-w-0 items-center gap-2 text-[12px] font-medium text-[#4f596b]">
@@ -52,7 +52,6 @@ export type CurriculumWeek = {
references?: DownloadResource[] references?: DownloadResource[]
exampleCodes?: DownloadResource[] exampleCodes?: DownloadResource[]
deliverable: string deliverable: string
requiresSourceSubmission: boolean
sourceSubmitted?: boolean sourceSubmitted?: boolean
sourceSubmittedAt?: string | null sourceSubmittedAt?: string | null
} }
@@ -69,6 +68,7 @@ export type CurriculumWeek = {
dockerImage?: string | null dockerImage?: string | null
packageName?: string | null packageName?: string | null
launchFile?: string | null launchFile?: string | null
runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null
}> }>
environmentProfiles?: EnvironmentProfile[] environmentProfiles?: EnvironmentProfile[]
maps?: Array<{ maps?: Array<{
@@ -81,6 +81,7 @@ export type CurriculumWeek = {
custom?: boolean custom?: boolean
layout?: unknown layout?: unknown
}> }>
isTeamProject?: boolean
} }
export type CurriculumResponse = { export type CurriculumResponse = {
@@ -102,6 +103,10 @@ export type CurriculumResponse = {
activeWeek?: number | null activeWeek?: number | null
activeLearningStep?: string | null activeLearningStep?: string | null
learningStats?: LearningStat[] learningStats?: LearningStat[]
studentGroup?: {
id: string
name: string
} | null
platforms?: PlatformOption[] platforms?: PlatformOption[]
} }
+40 -1
View File
@@ -2,10 +2,12 @@ import type { DriveStep } from 'driver.js'
export function getContextHelpSteps({ export function getContextHelpSteps({
platformModalOpen, platformModalOpen,
localPcLayoutOpen localPcLayoutOpen,
aiGuideOpen
}: { }: {
platformModalOpen: boolean platformModalOpen: boolean
localPcLayoutOpen: boolean localPcLayoutOpen: boolean
aiGuideOpen: boolean
}): DriveStep[] { }): DriveStep[] {
if (platformModalOpen) { if (platformModalOpen) {
return [ return [
@@ -20,6 +22,35 @@ export function getContextHelpSteps({
] ]
} }
if (aiGuideOpen) {
return [
{
element: '[data-help="ai-guide-context"]',
popover: {
title: '학습 맥락과 빠른 질문',
description: '현재 강좌와 주차 정보를 확인하고, 자주 묻는 질문을 바로 선택할 수 있습니다. 이전 대화도 이곳에서 다시 열 수 있습니다.',
side: 'right'
}
},
{
element: '[data-help="ai-guide-conversation"]',
popover: {
title: 'AI 가이드 대화',
description: '학습 중 궁금한 내용을 입력하면 현재 강좌와 주차 맥락을 바탕으로 답변합니다.',
side: 'left'
}
},
{
element: '[data-help="ai-guide-questions"]',
popover: {
title: '질문 목록',
description: '이전에 입력한 질문 목록입니다. 항목을 누르면 해당 질문과 답변 위치로 바로 이동합니다.',
side: 'left'
}
}
]
}
if (localPcLayoutOpen) { if (localPcLayoutOpen) {
return [ return [
{ {
@@ -46,6 +77,14 @@ export function getContextHelpSteps({
side: 'left' side: 'left'
} }
}, },
{
element: '[data-help="module-parking"]',
popover: {
title: '모듈 보관',
description: '사용하지 않는 모듈을 이 영역으로 드래그하면 전체 설치·실행·중지 대상에서 제외됩니다. 보관한 모듈은 개별적으로 제어할 수 있습니다.',
side: 'left'
}
},
{ {
element: '[data-help="workspace-chat"]', element: '[data-help="workspace-chat"]',
popover: { popover: {
@@ -83,7 +83,8 @@ export default function PlatformSelectionPanel({
const { id, status, action, state, iconKey } = platform const { id, status, action, state, iconKey } = platform
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
const locked = state === 'locked' const locked = state === 'locked'
const statusPending = platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(id) const statusPending = platform.platformKey !== 'futuredrive-local-pc'
&& !checkedRemotePlatformStatusIds.has(id)
const unavailable = !statusPending && isPlatformUnavailable(platform) const unavailable = !statusPending && isPlatformUnavailable(platform)
const actionDisabled = statusPending || locked || unavailable const actionDisabled = statusPending || locked || unavailable
const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id) const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id)
@@ -3,7 +3,13 @@ export type StudioSourceSettings = {
giteaBaseUrl: string giteaBaseUrl: string
giteaOwner: string giteaOwner: string
gpuEnabled: boolean gpuEnabled: boolean
editorWordWrap: boolean
editorFontSize: number
editorTabSize: number
editorMinimap: boolean
rosDomainId: string rosDomainId: string
rosLinkedPlatformId?: string | null
visualizationBridgeLocation?: 'local' | 'platform'
agentUrl?: string | null agentUrl?: string | null
sshUser?: string | null sshUser?: string | null
sshPort?: string | null sshPort?: string | null
@@ -18,6 +24,14 @@ export type EnvironmentProfile = {
config: Record<string, unknown> config: Record<string, unknown>
} }
export type VehicleRuntimeProfile = {
profileKey: string
driveType: 'ackermann' | 'differential'
wheelbaseMeters: number
maxSpeedMetersPerSecond: number
maxSteeringAngleRadians: number
}
export type PlatformOption = { export type PlatformOption = {
id: string id: string
platformKey: string platformKey: string
@@ -30,6 +44,7 @@ export type PlatformOption = {
state: 'ready' | 'request' | 'reserved' | 'locked' state: 'ready' | 'request' | 'reserved' | 'locked'
sourceSettings?: Partial<StudioSourceSettings> | null sourceSettings?: Partial<StudioSourceSettings> | null
environmentProfiles?: EnvironmentProfile[] environmentProfiles?: EnvironmentProfile[]
vehicleRuntimeProfile?: VehicleRuntimeProfile | null
environmentConfigOverride?: Record<string, unknown> | null environmentConfigOverride?: Record<string, unknown> | null
connection?: { connection?: {
registered: boolean registered: boolean
@@ -4,6 +4,7 @@ import type React from 'react'
type DeletableModule = { type DeletableModule = {
name: string name: string
state?: 'running' | 'stopped' | 'uninstalled'
} }
type ModuleDeleteConfirmProps<Module extends DeletableModule> = { type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
@@ -40,9 +41,11 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
{module.name} 삭제할까요? {module.name} 삭제할까요?
</p> </p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}> <p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
워크스페이스에 설치된 모듈 폴더가 삭제됩니다. {module.state === 'uninstalled'
? '등록된 커스텀 모듈 정보를 삭제합니다.'
: '워크스페이스에 설치된 모듈 폴더가 삭제됩니다.'}
</p> </p>
{allowRepositoryDeletion && <label {allowRepositoryDeletion && module.state !== 'uninstalled' && <label
className={[ className={[
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3', '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]' dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
@@ -1,6 +1,8 @@
import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react' import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react' import type React from 'react'
import { createPortal } from 'react-dom'
export type ModuleListRowItem = { export type ModuleListRowItem = {
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
@@ -72,15 +74,73 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean)
} }
function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element { function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
const anchorRef = useRef<HTMLSpanElement>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
useEffect(() => {
const anchor = anchorRef.current?.parentElement
if (!(anchor instanceof HTMLElement)) return
const showTooltip = (): void => {
const bounds = anchor.getBoundingClientRect()
setPosition({
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
top: bounds.top - 8
})
}
const hideTooltip = (): void => setPosition(null)
anchor.addEventListener('mouseenter', showTooltip)
anchor.addEventListener('mouseleave', hideTooltip)
anchor.addEventListener('focus', showTooltip)
anchor.addEventListener('blur', hideTooltip)
return () => {
anchor.removeEventListener('mouseenter', showTooltip)
anchor.removeEventListener('mouseleave', hideTooltip)
anchor.removeEventListener('focus', showTooltip)
anchor.removeEventListener('blur', hideTooltip)
}
}, [])
useEffect(() => {
if (!position) return
const updateTooltipPosition = (): void => {
const anchor = anchorRef.current?.parentElement
if (!(anchor instanceof HTMLElement)) return
const bounds = anchor.getBoundingClientRect()
setPosition({
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
top: bounds.top - 8
})
}
window.addEventListener('resize', updateTooltipPosition)
window.addEventListener('scroll', updateTooltipPosition, true)
return () => {
window.removeEventListener('resize', updateTooltipPosition)
window.removeEventListener('scroll', updateTooltipPosition, true)
}
}, [position])
return ( return (
<>
<span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" />
{position && createPortal(
<span <span
role="tooltip"
style={{ left: position.left, top: position.top }}
className={[ className={[
'pointer-events-none absolute -top-8 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded px-2 py-1 text-[11px] font-bold opacity-0 shadow-[0_8px_18px_rgba(0,0,0,0.22)] transition group-hover/action:opacity-100', 'pointer-events-none fixed z-[10030] -translate-x-1/2 -translate-y-full whitespace-nowrap rounded px-2 py-1 text-[11px] font-bold shadow-[0_8px_18px_rgba(0,0,0,0.22)]',
dark ? 'bg-[#111315] text-white' : 'bg-[#111520] text-white' dark ? 'bg-[#111315] text-white' : 'bg-[#111520] text-white'
].join(' ')} ].join(' ')}
> >
{children} {children}
</span> </span>,
document.body
)}
</>
) )
} }
@@ -139,6 +199,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
? { label: module.sourceType === 'CUSTOM' ? '소스 생성' : '설치', Icon: Download, tone: 'install' as ModuleActionTone } ? { label: module.sourceType === 'CUSTOM' ? '소스 생성' : '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone } : { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped') const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
const canDeleteUninstalledCustomModule = module.sourceType === 'CUSTOM' && module.state === 'uninstalled'
return ( return (
<div <div
@@ -185,7 +246,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span> <span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
{module.sourceType === 'CUSTOM' && ( {module.sourceType === 'CUSTOM' && (
<span className={['shrink-0 rounded border px-1.5 py-[1px] text-[9px] font-bold', dark ? 'border-[#a78bfa]/35 bg-[#a78bfa]/12 text-[#c4b5fd]' : 'border-[#8b5cf6]/22 bg-[#f3efff] text-[#7350c8]'].join(' ')}> <span className={['shrink-0 rounded border px-1.5 py-[1px] text-[9px] font-bold', dark ? 'border-[#a78bfa]/35 bg-[#a78bfa]/12 text-[#c4b5fd]' : 'border-[#8b5cf6]/22 bg-[#f3efff] text-[#7350c8]'].join(' ')}>
커스텀 사용자모듈
</span> </span>
)} )}
</span> </span>
@@ -278,8 +339,38 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
) )
})() })()
))} ))}
{canDeleteUninstalledCustomModule && (
<button
type="button"
disabled={installActionDisabled}
onClick={(event) => {
event.stopPropagation()
onInstallModule(module)
}}
aria-label={installAction.label}
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
</button>
)}
</span> </span>
<span className="flex justify-center"> <span className="flex justify-center gap-1.5">
{canDeleteUninstalledCustomModule ? (
<button
type="button"
disabled={busy}
onClick={(event) => {
event.stopPropagation()
onDeleteModule(module)
}}
aria-label="삭제"
className={actionButtonClass(dark, 'delete', busy)}
>
<Trash2 size={14} />
<TooltipLabel dark={dark}>삭제</TooltipLabel>
</button>
) : (
<button <button
type="button" type="button"
disabled={installActionDisabled} disabled={installActionDisabled}
@@ -297,6 +388,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />} {busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel> <TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
</button> </button>
)}
</span> </span>
</div> </div>
) )
@@ -1,4 +1,4 @@
import { CirclePlus, X } from 'lucide-react' import { X } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type React from 'react' import type React from 'react'
import ModuleDeleteConfirm from './ModuleDeleteConfirm' import ModuleDeleteConfirm from './ModuleDeleteConfirm'
@@ -9,20 +9,27 @@ import ModuleListToolbar, { type ModuleFilter } from './ModuleListToolbar'
import ModuleParameterDialog from './ModuleParameterDialog' import ModuleParameterDialog from './ModuleParameterDialog'
import { createParameterId, parseModuleParameters } from './moduleParameters' import { createParameterId, parseModuleParameters } from './moduleParameters'
import type { ModuleParameterPair, ModuleRow } from './types' import type { ModuleParameterPair, ModuleRow } from './types'
import { WorkspaceMapPreview, type WorkspaceMapOption } from './WorldMapSelectorModal'
type ModuleListViewProps = { type ModuleListViewProps = {
dark: boolean dark: boolean
modules: ModuleRow[] modules: ModuleRow[]
parkedModules: ModuleRow[]
loading: boolean loading: boolean
installingModuleName: string | null installingModuleName: string | null
controllingModuleNames: string[] controllingModuleNames: string[]
listError: string | null listError: string | null
installError: string | null installError: string | null
onDismissError: () => void
selectedMap: WorkspaceMapOption
showSelectedMapPreview?: boolean
allowRepositoryDeletion?: boolean allowRepositoryDeletion?: boolean
allowCustomModuleCreation?: boolean
onAddModuleTab: (module: ModuleRow) => void onAddModuleTab: (module: ModuleRow) => void
onOpenSource: (module: ModuleRow) => void onOpenSource: (module: ModuleRow) => void
onInstallModule: (module: ModuleRow) => void onInstallModule: (module: ModuleRow) => void
onDeleteModule: (module: ModuleRow, options: { deleteRepository: boolean }) => void onDeleteModule: (module: ModuleRow, options: { deleteRepository: boolean }) => void
getStoredParameters?: (module: ModuleRow) => ModuleParameterPair[] | undefined
onUpdateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => void onUpdateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => void
onStartModule: (module: ModuleRow) => void onStartModule: (module: ModuleRow) => void
onBuildModule: (module: ModuleRow) => void onBuildModule: (module: ModuleRow) => void
@@ -32,6 +39,9 @@ type ModuleListViewProps = {
onStartAll: () => void onStartAll: () => void
onStopAll: () => void onStopAll: () => void
onReorderModules: (source: ModuleRow, target: ModuleRow) => void onReorderModules: (source: ModuleRow, target: ModuleRow) => void
onParkModule: (module: ModuleRow) => void
onRestoreParkedModule: (module: ModuleRow) => void
parkingHeightStorageKey?: string
onOpenWorldEditor: () => void onOpenWorldEditor: () => void
onCreateCustomModule: (input: CustomModuleInput) => Promise<void> onCreateCustomModule: (input: CustomModuleInput) => Promise<void>
} }
@@ -39,16 +49,22 @@ type ModuleListViewProps = {
export default function ModuleListView({ export default function ModuleListView({
dark, dark,
modules, modules,
parkedModules,
loading, loading,
installingModuleName, installingModuleName,
controllingModuleNames, controllingModuleNames,
listError, listError,
installError, installError,
onDismissError,
selectedMap,
showSelectedMapPreview = true,
allowRepositoryDeletion = true, allowRepositoryDeletion = true,
allowCustomModuleCreation = true,
onAddModuleTab, onAddModuleTab,
onOpenSource, onOpenSource,
onInstallModule, onInstallModule,
onDeleteModule, onDeleteModule,
getStoredParameters,
onUpdateModuleParameters, onUpdateModuleParameters,
onStartModule, onStartModule,
onBuildModule, onBuildModule,
@@ -58,6 +74,9 @@ export default function ModuleListView({
onStartAll, onStartAll,
onStopAll, onStopAll,
onReorderModules, onReorderModules,
onParkModule,
onRestoreParkedModule,
parkingHeightStorageKey,
onOpenWorldEditor, onOpenWorldEditor,
onCreateCustomModule onCreateCustomModule
}: ModuleListViewProps): React.JSX.Element { }: ModuleListViewProps): React.JSX.Element {
@@ -67,7 +86,10 @@ export default function ModuleListView({
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false) const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null) const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null) const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
const [dropZone, setDropZone] = useState<'active' | 'parking' | null>(null)
const [parkingHeight, setParkingHeight] = useState(232)
const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter) const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter)
const filteredParkedRows = moduleFilter === 'all' ? parkedModules : parkedModules.filter((row) => row.state === moduleFilter)
const canReorder = moduleFilter === 'all' && !loading const canReorder = moduleFilter === 'all' && !loading
const uninstalledCount = modules.filter((row) => row.state === 'uninstalled').length const uninstalledCount = modules.filter((row) => row.state === 'uninstalled').length
const runningCount = modules.filter((row) => row.state === 'running').length const runningCount = modules.filter((row) => row.state === 'running').length
@@ -75,6 +97,93 @@ export default function ModuleListView({
const installingAll = installingModuleName === '__all__' const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__' const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => {
if (!parkingHeightStorageKey) {
setParkingHeight(232)
return
}
const storedValue = window.localStorage.getItem(parkingHeightStorageKey)
const storedHeight = storedValue === null ? Number.NaN : Number(storedValue)
setParkingHeight(Number.isFinite(storedHeight) ? Math.min(520, Math.max(84, storedHeight)) : 232)
}, [parkingHeightStorageKey])
const updateParkingHeight = (nextHeight: number): void => {
const height = Math.min(520, Math.max(84, nextHeight))
setParkingHeight(height)
if (!parkingHeightStorageKey) return
try {
window.localStorage.setItem(parkingHeightStorageKey, String(height))
} catch {
// Keep the resized height for the current workspace session.
}
}
const finishDrag = (): void => {
setDraggedModule(null)
setDropTargetModuleName(null)
setDropZone(null)
}
const beginParkingResize = (event: React.PointerEvent<HTMLDivElement>): void => {
event.preventDefault()
const startY = event.clientY
const startHeight = parkingHeight
const onPointerMove = (moveEvent: PointerEvent): void => {
updateParkingHeight(startHeight - (moveEvent.clientY - startY))
}
const onPointerUp = (): void => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
const renderModuleRow = (row: ModuleRow, parked: boolean): React.JSX.Element => (
<ModuleListRow
key={row.folderName ?? row.name}
dark={dark}
module={row}
draggable={canReorder}
dragging={draggedModule?.name === row.name}
dropTarget={dropTargetModuleName === row.name && draggedModule?.name !== row.name && dropZone === (parked ? 'parking' : 'active')}
onDragStart={() => setDraggedModule(row)}
onDragOver={() => {
if (!draggedModule || draggedModule.name === row.name) return
setDropTargetModuleName(row.name)
setDropZone(parked ? 'parking' : 'active')
}}
onDrop={() => {
if (draggedModule) {
if (parked) onParkModule(draggedModule)
else onRestoreParkedModule(draggedModule)
if (draggedModule.name !== row.name) onReorderModules(draggedModule, row)
}
finishDrag()
}}
onDragEnd={finishDrag}
installing={installingModuleName === row.name || (!parked && installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling && !parked}
onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource}
onOpenParameters={(module) => {
const storedParameters = getStoredParameters?.(module)
const parsedParameters = storedParameters ?? parseModuleParameters(module.parameters)
setParameterDialog({
module,
parameters: parsedParameters.length
? parsedParameters
: [{ id: createParameterId(), name: '', value: '', enabled: true }]
})
}}
onInstallModule={onInstallModule}
onDeleteModule={setDeleteConfirmModule}
onStartModule={onStartModule}
onBuildModule={onBuildModule}
onStopModule={onStopModule}
onRestartModule={onRestartModule}
/>
)
useEffect(() => { useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => { const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return if (event.key !== 'Escape') return
@@ -100,15 +209,24 @@ export default function ModuleListView({
return ( return (
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}> <section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className={['flex h-[69px] items-center justify-between p-3', dark ? 'bg-[#252525]' : 'bg-[#eef1f6]'].join(' ')}> <div className={['flex h-[69px] items-center justify-between gap-4 p-3', dark ? 'bg-[#252525]' : 'bg-[#eef1f6]'].join(' ')}>
<div> <div>
<strong className={['block text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>모듈 리스트</strong> <strong className={['block text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>모듈 리스트</strong>
<span className={['mt-1 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>자율주행 알고리즘 모듈 관리</span> <span className={['mt-1 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>자율주행 알고리즘 모듈 관리</span>
</div> </div>
{showSelectedMapPreview && (
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 text-right">
<span className={['block text-[10px] font-bold', dark ? 'text-white/40' : 'text-[#788396]'].join(' ')}>현재 맵</span>
<strong className={['block max-w-[176px] truncate text-[12px] font-bold', dark ? 'text-white/85' : 'text-[#263247]'].join(' ')}>{selectedMap.name}</strong>
</div>
<WorkspaceMapPreview map={selectedMap} selected={false} compact borderless />
</div>
)}
</div> </div>
<div className="-mt-2 flex min-h-0 flex-1 overflow-auto"> <div className="-mt-2 flex min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className={['flex min-h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}> <div className={['flex h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<ModuleListToolbar <ModuleListToolbar
dark={dark} dark={dark}
filter={moduleFilter} filter={moduleFilter}
@@ -125,73 +243,89 @@ export default function ModuleListView({
/> />
<ModuleListHeader dark={dark} /> <ModuleListHeader dark={dark} />
{(listError || installError) && ( {(listError || installError) && (
<div className={['border-b px-3 py-2 text-[12px] font-semibold', dark ? 'border-white/10 bg-[#3a2525] text-[#ffb4b4]' : 'border-[#ffd4d4] bg-[#fff4f4] text-[#b42323]'].join(' ')}> <div className={['flex items-start justify-between gap-3 border-b px-3 py-2 text-[12px] font-semibold', dark ? 'border-white/10 bg-[#3a2525] text-[#ffb4b4]' : 'border-[#ffd4d4] bg-[#fff4f4] text-[#b42323]'].join(' ')}>
{listError || installError} <p className="min-w-0 break-words">{listError || installError}</p>
<button
type="button"
aria-label="오류 닫기"
onClick={onDismissError}
className={['mt-0.5 shrink-0 cursor-pointer transition hover:opacity-100', dark ? 'text-[#ffb4b4]/60' : 'text-[#b42323]/60'].join(' ')}
>
<X size={15} />
</button>
</div> </div>
)} )}
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-0 flex-1 flex-col'].join(' ')}> <div className="flex min-h-0 flex-1 flex-col">
<div
className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto"
onDragOver={(event) => {
if (!draggedModule) return
event.preventDefault()
setDropTargetModuleName(null)
setDropZone('active')
}}
onDrop={(event) => {
if (!draggedModule) return
event.preventDefault()
onRestoreParkedModule(draggedModule)
finishDrag()
}}
>
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-full flex-col'].join(' ')}>
{loading ? ( {loading ? (
<ModuleListEmptyState dark={dark} loading /> <ModuleListEmptyState dark={dark} loading />
) : filteredModuleRows.length === 0 ? ( ) : filteredModuleRows.length === 0 ? (
<ModuleListEmptyState dark={dark} /> <ModuleListEmptyState dark={dark} />
) : filteredModuleRows.map((row) => ( ) : filteredModuleRows.map((row) => renderModuleRow(row, false))}
<ModuleListRow {!loading && allowCustomModuleCreation && (
key={row.name} <div className={['flex justify-end px-3 pb-6 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
dark={dark}
module={row}
draggable={canReorder}
dragging={draggedModule?.name === row.name}
dropTarget={dropTargetModuleName === row.name && draggedModule?.name !== row.name}
onDragStart={() => setDraggedModule(row)}
onDragOver={() => {
if (draggedModule && draggedModule.name !== row.name) setDropTargetModuleName(row.name)
}}
onDrop={() => {
if (draggedModule && draggedModule.name !== row.name) onReorderModules(draggedModule, row)
setDraggedModule(null)
setDropTargetModuleName(null)
}}
onDragEnd={() => {
setDraggedModule(null)
setDropTargetModuleName(null)
}}
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling}
onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource}
onOpenParameters={(module) => {
const parsedParameters = parseModuleParameters(module.parameters)
setParameterDialog({
module,
parameters: parsedParameters.length
? parsedParameters
: [{ id: createParameterId(), name: '', value: '' }]
})
}}
onInstallModule={onInstallModule}
onDeleteModule={setDeleteConfirmModule}
onStartModule={onStartModule}
onBuildModule={onBuildModule}
onStopModule={onStopModule}
onRestartModule={onRestartModule}
/>
))}
{!loading && (
<div className={['flex justify-center pb-4 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
<button <button
type="button" type="button"
onClick={() => setModuleAddDialogOpen(true)} onClick={() => setModuleAddDialogOpen(true)}
className={['inline-flex cursor-pointer items-center gap-1.5 text-[12px] font-bold underline-offset-4 transition hover:underline', dark ? 'text-[#8ab8ff] hover:text-white' : 'text-[#2563eb] hover:text-[#1d4ed8]'].join(' ')} className={['inline-flex cursor-pointer items-center text-[12px] font-bold underline underline-offset-4 transition', dark ? 'text-[#6ea8ff] hover:text-white' : 'text-[#1d4ed8] hover:text-[#1e40af]'].join(' ')}
> >
<CirclePlus size={14} aria-hidden="true" /> + 모듈 추가
모듈 추가
</button> </button>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div
data-help="module-parking"
className={['relative shrink-0 border-t-[3px]', dark ? 'border-white/20 bg-[#202020]' : 'border-[#c5cedc] bg-[#fbfcff]'].join(' ')}
style={{ height: parkingHeight }}
onDragOver={(event) => {
if (!draggedModule) return
event.preventDefault()
setDropTargetModuleName(null)
setDropZone('parking')
}}
onDrop={(event) => {
if (!draggedModule) return
event.preventDefault()
onParkModule(draggedModule)
finishDrag()
}}
>
<div
role="separator"
aria-orientation="horizontal"
aria-label="보관 영역 크기 조절"
onPointerDown={beginParkingResize}
className={['absolute -top-2 left-0 right-0 z-10 h-5 cursor-row-resize', dropZone === 'parking' ? (dark ? 'bg-[#3b82f6]/16' : 'bg-[#3b82f6]/10') : ''].join(' ')}
/>
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'thin-y-scrollbar h-full overflow-y-auto'].join(' ')}>
{filteredParkedRows.length > 0 ? filteredParkedRows.map((row) => renderModuleRow(row, true)) : (
<div className={['grid h-full place-items-center px-4 text-center text-[12px] font-medium', dark ? 'text-white/28' : 'text-[#7b8799]/55'].join(' ')}>
모듈을 이곳으로 드래그하면 전체 제어 대상에서 제외됩니다
</div>
)}
</div>
</div>
</div>
</div>
</div> </div>
{deleteConfirmModule && ( {deleteConfirmModule && (
<ModuleDeleteConfirm <ModuleDeleteConfirm
@@ -218,7 +352,7 @@ export default function ModuleListView({
}} }}
/> />
)} )}
{moduleAddDialogOpen && ( {allowCustomModuleCreation && moduleAddDialogOpen && (
<ModuleAddDialog <ModuleAddDialog
dark={dark} dark={dark}
onClose={() => setModuleAddDialogOpen(false)} onClose={() => setModuleAddDialogOpen(false)}
@@ -30,7 +30,7 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36"> <div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<section <section
className={[ className={[
'w-[460px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', 'w-[500px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]' dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
].join(' ')} ].join(' ')}
> >
@@ -42,13 +42,14 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
</div> </div>
<div className="p-4"> <div className="p-4">
<div className="grid gap-2"> <div className="grid gap-2">
<div className={['grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px] gap-2 text-[11px] font-bold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}> <div className={['grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_38px_32px] gap-2 text-[11px] font-bold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>
<span>파라미터명</span> <span>파라미터명</span>
<span>값</span> <span>값</span>
<span className="text-center">사용</span>
<span /> <span />
</div> </div>
{parameters.map((parameter) => ( {parameters.map((parameter) => (
<div key={parameter.id} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px] gap-2"> <div key={parameter.id} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_38px_32px] gap-2">
<input <input
value={parameter.name} value={parameter.name}
onChange={(event) => setParameters((items) => items.map((item) => item.id === parameter.id ? { ...item, name: event.target.value } : item))} onChange={(event) => setParameters((items) => items.map((item) => item.id === parameter.id ? { ...item, name: event.target.value } : item))}
@@ -67,6 +68,14 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
].join(' ')} ].join(' ')}
placeholder="" placeholder=""
/> />
<label className="flex h-8 cursor-pointer items-center justify-center" title="실행에 포함">
<input
type="checkbox"
checked={parameter.enabled !== false}
onChange={(event) => setParameters((items) => items.map((item) => item.id === parameter.id ? { ...item, enabled: event.target.checked } : item))}
className="h-3.5 w-3.5 cursor-pointer accent-[#2563eb]"
/>
</label>
<button <button
type="button" type="button"
onClick={() => setParameters((items) => items.filter((item) => item.id !== parameter.id))} onClick={() => setParameters((items) => items.filter((item) => item.id !== parameter.id))}
@@ -81,7 +90,7 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
))} ))}
<button <button
type="button" type="button"
onClick={() => setParameters((items) => [...items, { id: createParameterId(), name: '', value: '' }])} onClick={() => setParameters((items) => [...items, { id: createParameterId(), name: '', value: '', enabled: true }])}
className={[ className={[
'mt-1 flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md border text-[12px] font-bold transition', 'mt-1 flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md border text-[12px] font-bold transition',
dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]' dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
@@ -10,6 +10,10 @@ type Props = {
activeImagePreviewSource?: string activeImagePreviewSource?: string
editorLanguage: string editorLanguage: string
editorValue: string | undefined editorValue: string | undefined
wordWrap: boolean
fontSize: number
tabSize: number
minimapEnabled: boolean
exampleActive: boolean exampleActive: boolean
beforeMount: BeforeMount beforeMount: BeforeMount
onMount: OnMount onMount: OnMount
@@ -25,6 +29,10 @@ function WorkspaceEditorPane({
activeImagePreviewSource, activeImagePreviewSource,
editorLanguage, editorLanguage,
editorValue, editorValue,
wordWrap,
fontSize,
tabSize,
minimapEnabled,
exampleActive, exampleActive,
beforeMount, beforeMount,
onMount, onMount,
@@ -77,13 +85,13 @@ function WorkspaceEditorPane({
options={{ options={{
automaticLayout: true, automaticLayout: true,
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace', fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
fontSize: 13, fontSize,
minimap: { enabled: true }, minimap: { enabled: minimapEnabled },
renderLineHighlight: 'all', renderLineHighlight: 'all',
roundedSelection: false, roundedSelection: false,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
tabSize: 4, tabSize,
wordWrap: 'on', wordWrap: wordWrap ? 'on' : 'off',
readOnly: exampleActive readOnly: exampleActive
}} }}
/> />
@@ -249,7 +249,6 @@ function ModalButtonRow({
} }
type SubmitConfirmModalProps = WorkspaceModalTheme & { type SubmitConfirmModalProps = WorkspaceModalTheme & {
requiresSourceSubmission: boolean
submitting: boolean submitting: boolean
onCancel: () => void onCancel: () => void
onConfirm: () => void onConfirm: () => void
@@ -260,7 +259,6 @@ export function SubmitConfirmModal({
borderSoft, borderSoft,
textStrong, textStrong,
textDim, textDim,
requiresSourceSubmission,
submitting, submitting,
onCancel, onCancel,
onConfirm onConfirm
@@ -275,14 +273,14 @@ export function SubmitConfirmModal({
> >
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}> <div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
<Upload size={15} className="text-[#7aa7ff]" /> <Upload size={15} className="text-[#7aa7ff]" />
<strong className="text-[13px] font-bold">{requiresSourceSubmission ? '제출' : '학습 완료'}</strong> <strong className="text-[13px] font-bold">학습 완료</strong>
</div> </div>
<div className="p-4"> <div className="p-4">
<p className={['text-[13px] font-semibold', textStrong].join(' ')}> <p className={['text-[13px] font-semibold', textStrong].join(' ')}>
{requiresSourceSubmission ? '변경사항을 제출할까요?' : '학습을 완료하시겠습니까?'} 현재 주차의 학습을 완료할까요?
</p> </p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}> <p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{requiresSourceSubmission ? '변경된 모듈만 원격 저장소에 제출합니다.' : '현재 주차의 학습 완료 상태로 기록합니다.'} 완료 후에는 이 주차의 학습을 다시 완료 처리할 수 없습니다.
</p> </p>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button <button
@@ -301,7 +299,7 @@ export function SubmitConfirmModal({
onClick={onConfirm} onClick={onConfirm}
className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-60" className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-60"
> >
{requiresSourceSubmission ? '제출' : '완료'} 학습 완료
</button> </button>
</div> </div>
</div> </div>
@@ -9,6 +9,7 @@ type WorkspaceNavProps = {
activeActivity: WorkspaceActivity activeActivity: WorkspaceActivity
selectedModuleExists: boolean selectedModuleExists: boolean
installedGitModuleCount: number installedGitModuleCount: number
teamProject: boolean
moduleTabCount: number moduleTabCount: number
gitChangedFileCount: number gitChangedFileCount: number
workspaceNavFlash: boolean workspaceNavFlash: boolean
@@ -35,6 +36,7 @@ export default function WorkspaceNav({
activeActivity, activeActivity,
selectedModuleExists, selectedModuleExists,
installedGitModuleCount, installedGitModuleCount,
teamProject,
moduleTabCount, moduleTabCount,
gitChangedFileCount, gitChangedFileCount,
workspaceNavFlash, workspaceNavFlash,
@@ -57,7 +59,7 @@ export default function WorkspaceNav({
id === 'workspace' || id === 'workspace' ||
id === 'examples' || id === 'examples' ||
id === 'settings' || id === 'settings' ||
(id === 'git' && installedGitModuleCount > 0) || (id === 'git' && (teamProject || installedGitModuleCount > 0)) ||
((id === 'search' || id === 'logs') && selectedModuleExists) ((id === 'search' || id === 'logs') && selectedModuleExists)
return ( return (
@@ -188,7 +188,7 @@ export default function WorkspaceQaPanel({
})} })}
</div> </div>
</div> </div>
<div ref={workspaceChatScrollRef} className="min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}> <div ref={workspaceChatScrollRef} className="thin-y-scrollbar min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}>
{qaMessagesLoading ? ( {qaMessagesLoading ? (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}> <div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<div> <div>
@@ -1,4 +1,5 @@
import type { StudioSourceSettings } from '../platforms/types' import type { StudioSourceSettings } from '../platforms/types'
import type { PlatformOption } from '../platforms/types'
import { StudioSettingsField, StudioSettingsSwitch } from './StudioSettingsControls' import { StudioSettingsField, StudioSettingsSwitch } from './StudioSettingsControls'
type Props = { type Props = {
@@ -9,9 +10,8 @@ type Props = {
platformName: string platformName: string
configuredDraftWorkspaceRoot: string configuredDraftWorkspaceRoot: string
sourceSettingsDraft: StudioSourceSettings sourceSettingsDraft: StudioSourceSettings
platformSourceSettingsDefaults: StudioSourceSettings remotePlatforms: PlatformOption[]
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
onResetSourceSettings: (settings: StudioSourceSettings) => void
onSaveSourceSettings: () => void onSaveSourceSettings: () => void
} }
@@ -23,9 +23,8 @@ function WorkspaceSettingsView({
platformName, platformName,
configuredDraftWorkspaceRoot, configuredDraftWorkspaceRoot,
sourceSettingsDraft, sourceSettingsDraft,
platformSourceSettingsDefaults, remotePlatforms,
onUpdateSourceSetting, onUpdateSourceSetting,
onResetSourceSettings,
onSaveSourceSettings onSaveSourceSettings
}: Props): React.JSX.Element { }: Props): React.JSX.Element {
return ( return (
@@ -60,18 +59,22 @@ function WorkspaceSettingsView({
) : ( ) : (
<> <>
<div className="mt-5 grid gap-4"> <div className="mt-5 grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<StudioSettingsField <StudioSettingsField
label="사용자 워크스페이스 루트" label="사용자 워크스페이스 루트"
description="모듈 소스와 빌드 결과가 저장되는 기본 경로입니다."
value={configuredDraftWorkspaceRoot} value={configuredDraftWorkspaceRoot}
onChange={(value) => onUpdateSourceSetting('workspaceRoot', value)} onChange={(value) => onUpdateSourceSetting('workspaceRoot', value)}
dark={dark} dark={dark}
/> />
<StudioSettingsField <StudioSettingsField
label="ROS Domain ID" label="ROS Domain ID"
description="같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다."
value={sourceSettingsDraft.rosDomainId} value={sourceSettingsDraft.rosDomainId}
onChange={(value) => onUpdateSourceSetting('rosDomainId', value.replace(/[^\d]/g, '').slice(0, 3))} onChange={(value) => onUpdateSourceSetting('rosDomainId', value.replace(/[^\d]/g, '').slice(0, 3))}
dark={dark} dark={dark}
/> />
</div>
<StudioSettingsSwitch <StudioSettingsSwitch
label="GPU 가속" label="GPU 가속"
description="ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다." description="ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다."
@@ -81,14 +84,93 @@ function WorkspaceSettingsView({
/> />
</div> </div>
<div className="mt-5 flex justify-end gap-2"> <div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
<button <h3 className={['text-[16px] font-black', textStrong].join(' ')}>플랫폼</h3>
type="button" <div className="mt-5 grid gap-4 sm:grid-cols-2">
onClick={() => onResetSourceSettings(platformSourceSettingsDefaults)} <label className="block">
className={['h-9 cursor-pointer rounded-md border px-4 text-[12px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/7 hover:text-white' : 'border-[#d7dde8] bg-white text-[#4f596b] hover:bg-[#f8f9fc] hover:text-[#111520]'].join(' ')} <span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>연동 대상 플랫폼</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.</span>
<select
value={sourceSettingsDraft.rosLinkedPlatformId ?? ''}
onChange={(event) => onUpdateSourceSetting('rosLinkedPlatformId', event.target.value || null)}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
> >
기본값 <option value="" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>연동하지 않음</option>
</button> {remotePlatforms.map((platform) => (
<option key={platform.id} value={platform.id} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{platform.title}</option>
))}
</select>
</label>
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>시각화 브리지 실행 위치</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.</span>
<select
value={sourceSettingsDraft.visualizationBridgeLocation ?? 'local'}
onChange={(event) => onUpdateSourceSetting('visualizationBridgeLocation', event.target.value === 'platform' ? 'platform' : 'local')}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
<option value="local" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>실행 PC</option>
<option value="platform" disabled={!sourceSettingsDraft.rosLinkedPlatformId} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>연동 플랫폼</option>
</select>
</label>
</div>
</div>
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>에디터</h3>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>글꼴 크기</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>코드 편집기에 표시할 글자 크기입니다.</span>
<select
value={sourceSettingsDraft.editorFontSize}
onChange={(event) => onUpdateSourceSetting('editorFontSize', Number(event.target.value))}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
{[12, 13, 14, 15, 16].map((size) => (
<option key={size} value={size} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{size}px</option>
))}
</select>
</label>
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>탭 크기</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>탭 입력 시 사용할 공백 칸 수입니다.</span>
<select
value={sourceSettingsDraft.editorTabSize}
onChange={(event) => onUpdateSourceSetting('editorTabSize', Number(event.target.value))}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
{[2, 4, 8].map((size) => (
<option key={size} value={size} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{size}칸</option>
))}
</select>
</label>
</div>
<div className="mt-4">
<StudioSettingsSwitch
label="줄바꿈"
description="긴 줄을 편집기 너비에 맞춰 다음 줄에 표시합니다."
checked={sourceSettingsDraft.editorWordWrap}
onChange={(checked) => onUpdateSourceSetting('editorWordWrap', checked)}
dark={dark}
/>
<div className="mt-4">
<StudioSettingsSwitch
label="미니맵 표시"
description="편집기 오른쪽에 코드 축소 지도를 표시합니다."
checked={sourceSettingsDraft.editorMinimap}
onChange={(checked) => onUpdateSourceSetting('editorMinimap', checked)}
dark={dark}
/>
</div>
</div>
</div>
<div className="mt-8 flex justify-end">
<button <button
type="button" type="button"
onClick={onSaveSourceSettings} onClick={onSaveSourceSettings}
@@ -1,4 +1,4 @@
import { RefreshCw } from 'lucide-react' import { Download, RefreshCw, Upload } from 'lucide-react'
import type { ModuleRow } from './types' import type { ModuleRow } from './types'
type GitChangedFile = { type GitChangedFile = {
@@ -60,6 +60,11 @@ type Props = {
textStrong: string textStrong: string
textMid: string textMid: string
textDim: string textDim: string
teamProject: boolean
teamSyncing: boolean
gitSubmitting: boolean
learningSubmitted: boolean
hasInstalledGitModules: boolean
gitPanelTab: 'source' | 'history' gitPanelTab: 'source' | 'history'
gitChangedFileCount: number gitChangedFileCount: number
gitChangedModules: ModuleRow[] gitChangedModules: ModuleRow[]
@@ -77,6 +82,9 @@ type Props = {
selectedGitSubmissionDiff: string selectedGitSubmissionDiff: string
onGitPanelTabChange: (tab: 'source' | 'history') => void onGitPanelTabChange: (tab: 'source' | 'history') => void
onRefresh: () => void onRefresh: () => void
onTeamPublish: () => void
onTeamPull: () => void
onCommitPush: () => void
onSelectGitFile: (moduleName: string, filePath: string) => void onSelectGitFile: (moduleName: string, filePath: string) => void
onOpenGitChangedFile: (moduleName: string, filePath: string) => void onOpenGitChangedFile: (moduleName: string, filePath: string) => void
onOpenGitFileContextMenu: (module: ModuleRow, file: GitChangedFile, x: number, y: number) => void onOpenGitFileContextMenu: (module: ModuleRow, file: GitChangedFile, x: number, y: number) => void
@@ -94,6 +102,11 @@ function WorkspaceSourceControlView({
textStrong, textStrong,
textMid, textMid,
textDim, textDim,
teamProject,
teamSyncing,
gitSubmitting,
learningSubmitted,
hasInstalledGitModules,
gitPanelTab, gitPanelTab,
gitChangedFileCount, gitChangedFileCount,
gitChangedModules, gitChangedModules,
@@ -111,6 +124,9 @@ function WorkspaceSourceControlView({
selectedGitSubmissionDiff, selectedGitSubmissionDiff,
onGitPanelTabChange, onGitPanelTabChange,
onRefresh, onRefresh,
onTeamPublish,
onTeamPull,
onCommitPush,
onSelectGitFile, onSelectGitFile,
onOpenGitChangedFile, onOpenGitChangedFile,
onOpenGitFileContextMenu, onOpenGitFileContextMenu,
@@ -121,7 +137,8 @@ function WorkspaceSourceControlView({
getGitStatusTone, getGitStatusTone,
formatGitCommitDate formatGitCommitDate
}: Props): React.JSX.Element { }: Props): React.JSX.Element {
const refreshing = gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading const refreshing = !teamProject && gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading
const historyLoading = teamProject ? gitLoading : gitSubmissionsLoading
return ( return (
<section <section
@@ -133,7 +150,7 @@ function WorkspaceSourceControlView({
<div className="flex items-end"> <div className="flex items-end">
{[ {[
{ key: 'source' as const, label: '소스 제어' }, { key: 'source' as const, label: '소스 제어' },
{ key: 'history' as const, label: '제출 내역' } { key: 'history' as const, label: teamProject ? '팀 변경 내역' : '제출 내역' }
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.key} key={tab.key}
@@ -155,6 +172,57 @@ function WorkspaceSourceControlView({
))} ))}
</div> </div>
<div className="flex items-center gap-2 pb-2"> <div className="flex items-center gap-2 pb-2">
{!teamProject && (
<button
type="button"
onClick={onCommitPush}
disabled={learningSubmitted || gitSubmitting || !hasInstalledGitModules || gitChangedFileCount === 0}
className={[
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
learningSubmitted || gitSubmitting || !hasInstalledGitModules || gitChangedFileCount === 0 ? 'cursor-default opacity-45' : 'cursor-pointer',
dark
? 'border-[#60a5fa]/45 bg-[#2563eb]/15 text-[#bfdbfe] hover:bg-[#2563eb]/26 hover:text-white'
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
].join(' ')}
>
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}
제출
</button>
)}
{teamProject && (
<>
<button
type="button"
onClick={onTeamPull}
disabled={teamSyncing || !hasInstalledGitModules}
className={[
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
teamSyncing || !hasInstalledGitModules ? 'cursor-default opacity-45' : 'cursor-pointer',
dark
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
<Download size={14} />
팀 변경 사항 받기
</button>
<button
type="button"
onClick={onTeamPublish}
disabled={learningSubmitted || teamSyncing || !hasInstalledGitModules || gitChangedFileCount === 0}
className={[
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
learningSubmitted || teamSyncing || !hasInstalledGitModules || gitChangedFileCount === 0 ? 'cursor-default opacity-45' : 'cursor-pointer',
dark
? 'border-[#60a5fa]/45 bg-[#2563eb]/15 text-[#bfdbfe] hover:bg-[#2563eb]/26 hover:text-white'
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
].join(' ')}
>
{teamSyncing ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}
팀에 반영
</button>
</>
)}
<button <button
type="button" type="button"
onClick={onRefresh} onClick={onRefresh}
@@ -178,7 +246,7 @@ function WorkspaceSourceControlView({
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}> <div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}> <aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}> <div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>변경 파일</span> <span className={['truncate text-[12px] font-bold', textMid].join(' ')}>{teamProject ? '내 변경 파일' : '변경 파일'}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}개</span> <span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}개</span>
</div> </div>
@@ -189,7 +257,11 @@ function WorkspaceSourceControlView({
</div> </div>
)} )}
{gitLoading && !Object.keys(gitModuleStatuses).length ? ( {!hasInstalledGitModules ? (
<div className={['grid min-h-full place-items-center px-6 text-center text-[12px] font-semibold leading-relaxed', textDim].join(' ')}>
공동 저장소는 모듈을 설치하면 생성됩니다.
</div>
) : gitLoading && !Object.keys(gitModuleStatuses).length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}> <div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
Git 상태 불러오는 중 Git 상태 불러오는 중
</div> </div>
@@ -289,7 +361,7 @@ function WorkspaceSourceControlView({
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}> <div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}> <aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}> <div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['text-[12px] font-bold', textMid].join(' ')}>제출 목록</span> <span className={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? '팀 변경 내역' : '제출 목록'}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}개</span> <span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}개</span>
</div> </div>
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3"> <div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
@@ -298,22 +370,22 @@ function WorkspaceSourceControlView({
{gitError} {gitError}
</div> </div>
)} )}
{gitSubmissionsLoading && !gitSubmissions.length ? ( {historyLoading && !gitSubmissions.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}> <div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
제출 내역 불러오는 중 {teamProject ? '팀 변경 내역 불러오는 중' : '제출 내역 불러오는 중'}
</div> </div>
) : !gitSubmissionGroups.length ? ( ) : !gitSubmissionGroups.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}> <div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
제출 내역 없음 {teamProject ? '팀 변경 내역 없음' : '제출 내역 없음'}
</div> </div>
) : ( ) : (
<div className="grid gap-3"> <div className="grid gap-3">
{gitSubmissionGroups.map((group, groupIndex) => ( {gitSubmissionGroups.map((group, groupIndex) => (
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}> <div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
<div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}> <div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
<p className={['text-[12px] font-black', textStrong].join(' ')}>제출 {gitSubmissionGroups.length - groupIndex}</p> <p className={['text-[12px] font-black', textStrong].join(' ')}>{teamProject ? '팀 반영' : `제출 ${gitSubmissionGroups.length - groupIndex}`}</p>
<p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}> <p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}>
{formatGitCommitDate(group.submittedAt)} · {group.submissions.length}개 모듈 {formatGitCommitDate(group.submittedAt)} · {teamProject ? group.submissions[0]?.commit.author : `${group.submissions.length}개 모듈`}
</p> </p>
</div> </div>
<div className="grid gap-1 p-1.5"> <div className="grid gap-1 p-1.5">
@@ -118,10 +118,10 @@ export default function WorkspaceTopBar({
)} )}
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} /> <span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
{submitVisible && ( {submitVisible && (
<Tooltip label="제출" side="bottom"> <Tooltip label="학습 완료" side="bottom">
<button <button
type="button" type="button"
aria-label="제출" aria-label="학습 완료"
disabled={submitDisabled} disabled={submitDisabled}
onClick={onSubmit} onClick={onSubmit}
className={[ className={[
@@ -1,30 +1,28 @@
import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react' import { CirclePlay, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useEffect, useState } from 'react'
export type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d' export type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
export type Nav2VisualizationMode = 'slam' | 'navigation'
type WorkspaceVisualizationPanelProps = { type WorkspaceVisualizationPanelProps = {
dark: boolean dark: boolean
textStrong: string textStrong: string
textDim: string textDim: string
iconButtonClass: string iconButtonClass: string
minimized: boolean
large: boolean large: boolean
position: { x: number; y: number } position: { x: number; y: number }
size: { width: number; height: number } size: { width: number; height: number }
stacks: VisualizationStack[] available: boolean
activeStack: VisualizationStack
running: boolean running: boolean
busy: boolean busy: boolean
viewerUrl: string viewerUrl: string
nav2Mode: Nav2VisualizationMode
navigationMapSelected: boolean
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onToggleLarge: () => void onToggleLarge: () => void
onMinimize: () => void
onClose: () => void onClose: () => void
onStackChange: (stack: VisualizationStack) => void
onStart: () => void onStart: () => void
onStop: () => void onTerminate: () => Promise<void> | void
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
} }
@@ -33,34 +31,32 @@ export default function WorkspaceVisualizationPanel({
textStrong, textStrong,
textDim, textDim,
iconButtonClass, iconButtonClass,
minimized,
large, large,
position, position,
size, size,
stacks, available,
activeStack,
running, running,
busy, busy,
viewerUrl, viewerUrl,
nav2Mode,
navigationMapSelected,
onDragStart, onDragStart,
onToggleLarge, onToggleLarge,
onMinimize,
onClose, onClose,
onStackChange,
onStart, onStart,
onStop, onTerminate,
onResizeStart onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element { }: WorkspaceVisualizationPanelProps): React.JSX.Element {
const selectableStacks = navigationMapSelected const [terminateConfirmOpen, setTerminateConfirmOpen] = useState(false)
? ['nav2'] as VisualizationStack[] const [activeViewerUrl, setActiveViewerUrl] = useState<string | null>(() => (running ? viewerUrl : null))
: stacks.filter((stack) => stack !== 'nav2')
const stackLabel = (stack: VisualizationStack): string => { useEffect(() => {
if (stack === 'slam') return '2D SLAM' if (!running || !viewerUrl) return
if (stack === 'slam3d') return '3D SLAM' setActiveViewerUrl((current) => current === viewerUrl ? current : viewerUrl)
if (stack === 'nav2') return '주행' }, [running, viewerUrl])
return 'Autoware'
}
return ( return (
<>
<section <section
className={[ className={[
'absolute z-40 overflow-hidden rounded-lg border shadow-[0_18px_48px_rgba(0,0,0,0.32)]', 'absolute z-40 overflow-hidden rounded-lg border shadow-[0_18px_48px_rgba(0,0,0,0.32)]',
@@ -71,12 +67,14 @@ export default function WorkspaceVisualizationPanel({
left: 0, left: 0,
right: 0, right: 0,
top: 40, top: 40,
bottom: 0 bottom: 0,
display: minimized ? 'none' : undefined
} }
: { : {
left: position.x, left: position.x,
top: position.y, top: position.y,
width: size.width width: size.width,
display: minimized ? 'none' : undefined
}} }}
> >
<div <div
@@ -94,24 +92,42 @@ export default function WorkspaceVisualizationPanel({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
aria-label="크게보기" aria-label="최소화"
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={onToggleLarge} onClick={onMinimize}
className={[iconButtonClass, 'group relative'].join(' ')} className={[iconButtonClass, 'group relative'].join(' ')}
> >
{large ? <Minimize2 size={14} /> : <Maximize2 size={14} />} <Minus size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}> <span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{large ? '작게보기' : '크게보기'} 최소화
</span> </span>
</button> </button>
<button <button
type="button" type="button"
aria-label="닫기" aria-label={large ? '원래 크기' : '최대화'}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={onClose} onClick={onToggleLarge}
className={iconButtonClass} className={[iconButtonClass, 'group relative'].join(' ')}
>
<Square size={13} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{large ? '원래 크기' : '최대화'}
</span>
</button>
<button
type="button"
aria-label={running ? '시각화 종료' : '닫기'}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => {
if (running) setTerminateConfirmOpen(true)
else onClose()
}}
className={[iconButtonClass, 'group relative'].join(' ')}
> >
<X size={15} /> <X size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{running ? '종료' : '닫기'}
</span>
</button> </button>
</div> </div>
</div> </div>
@@ -119,72 +135,39 @@ export default function WorkspaceVisualizationPanel({
className={['flex flex-col bg-black', large ? 'h-[calc(100%-36px)]' : ''].join(' ')} className={['flex flex-col bg-black', large ? 'h-[calc(100%-36px)]' : ''].join(' ')}
style={large ? undefined : { height: size.height }} style={large ? undefined : { height: size.height }}
> >
<div className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#101624] px-3">
{navigationMapSelected ? (
<span className="text-[11px] font-bold text-white/72">주행</span>
) : selectableStacks.length > 1 ? (
<div className="flex items-center gap-1">
{selectableStacks.map((stack) => (
<button
key={stack}
type="button"
disabled={busy}
onClick={() => onStackChange(stack)}
className={[
'h-7 rounded-md px-2.5 text-[11px] font-bold transition',
stack === activeStack
? 'bg-white/14 text-white'
: 'text-white/52 hover:bg-white/8 hover:text-white/80',
busy ? 'cursor-default opacity-55' : 'cursor-pointer'
].join(' ')}
>
{stackLabel(stack)}
</button>
))}
</div>
) : <span />}
<div className="flex items-center gap-2">
{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>
) : (
<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"> <div className="relative min-h-0 flex-1">
<iframe <iframe
key={`${activeStack}-${nav2Mode}-${running ? 'running' : 'offline'}`}
title="Autonomy Studio Visualization" title="Autonomy Studio Visualization"
src={running ? viewerUrl : 'about:blank'} src={activeViewerUrl ?? 'about:blank'}
className="h-full w-full border-0" className="h-full w-full border-0"
allow="fullscreen" allow="fullscreen"
onError={() => console.error('[visualization] viewer frame failed to load', activeViewerUrl)}
/> />
{!running && ( {!running && (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34"> <div className="absolute inset-0 bg-black/34">
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]"> {available ? (
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '해당 주차는 시각화를 지원하지 않습니다'}</p> <>
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p> <button
type="button"
disabled={busy}
onClick={onStart}
className="absolute left-1/2 top-1/2 inline-flex h-28 w-40 -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 text-[15px] font-bold text-white/56 transition hover:text-white disabled:cursor-default disabled:opacity-45 disabled:hover:text-white/56"
>
<CirclePlay size={40} />
{busy ? '처리 중' : '실행하기'}
</button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
<p className="text-[13px] font-bold text-white/72">지도와 차량 상태가 표시됩니다.</p>
</div> </div>
</>
) : (
<div className="grid h-full place-items-center px-5 text-center">
<div>
<p className="text-[13px] font-bold text-white/72">해당 주차는 시각화를 지원하지 않습니다</p>
<p className="mt-2 text-[11px] font-medium text-white/52">강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.</p>
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -202,5 +185,24 @@ export default function WorkspaceVisualizationPanel({
</> </>
)} )}
</section> </section>
{terminateConfirmOpen && (
<div className="fixed inset-0 z-[70] grid place-items-center bg-black/42">
<section className={['w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#dfe3ed] bg-white text-[#111520]'].join(' ')}>
<div className={['flex items-center gap-2 border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#e5e8ef]'].join(' ')}>
<Power size={15} className="text-[#e87979]" />
<strong className="text-[13px] font-bold">시각화 종료</strong>
</div>
<div className="p-4">
<p className="text-[13px] font-semibold">시각화를 종료할까요?</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>종료 후에는 실행 버튼으로 다시 시작할 수 있습니다.</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={busy} onClick={() => setTerminateConfirmOpen(false)} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-default disabled:opacity-45', dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>취소</button>
<button type="button" disabled={busy} onClick={() => { setTerminateConfirmOpen(false); void onTerminate() }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838] disabled:cursor-default disabled:opacity-45">종료</button>
</div>
</div>
</section>
</div>
)}
</>
) )
} }
@@ -1,6 +1,8 @@
import { AlertTriangle, Check, House, Map, PencilRuler, Radio, Save, Trash2, X } from 'lucide-react' import { AlertTriangle, Check, House, Map, PencilRuler, Radio, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
export type SavedMapRuntimeKind = 'SIMULATION' | 'PLATFORM_2D' | 'PLATFORM_3D'
export type WorkspaceMapOption = { export type WorkspaceMapOption = {
key: string key: string
platformKey: 'NAV2' platformKey: 'NAV2'
@@ -15,8 +17,12 @@ export type WorkspaceMapOption = {
sourceMapKey?: string sourceMapKey?: string
yamlPath?: string yamlPath?: string
pgmPath?: string pgmPath?: string
yamlUrl?: string
pgmUrl?: string
databasePath?: string databasePath?: string
databaseUrl?: string
previewPgm?: string previewPgm?: string
runtimeKind?: SavedMapRuntimeKind
layout?: unknown layout?: unknown
remoteSlam?: boolean remoteSlam?: boolean
} }
@@ -49,9 +55,17 @@ export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
} }
] ]
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')! export function WorkspaceMapPreview({
map,
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element { selected,
compact = false,
borderless = false
}: {
map: WorkspaceMapOption
selected: boolean
compact?: boolean
borderless?: boolean
}): React.JSX.Element {
const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const saved = Boolean(map.saved || map.remoteSlam) const saved = Boolean(map.saved || map.remoteSlam)
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -116,8 +130,9 @@ function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: bool
<span <span
aria-hidden="true" aria-hidden="true"
className={[ className={[
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner', 'relative block shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
selected ? 'border-[#6f9cff]' : 'border-white/15' compact ? 'h-11 w-[66px]' : 'h-16 w-24',
borderless ? 'border-0' : selected ? 'border-[#6f9cff]' : 'border-white/15'
].join(' ')} ].join(' ')}
> >
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />} {map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
@@ -143,7 +158,7 @@ function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: bool
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" /> <span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</> </>
))} ))}
{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>} {saved && <span className={['absolute inset-x-0 bottom-0 bg-[#0f172a]/75 text-center font-extrabold tracking-[0.08em] text-white', compact ? 'py-px text-[6px]' : 'py-0.5 text-[8px]'].join(' ')}>{map.runtimeKind === 'PLATFORM_3D' ? '3D SLAM' : '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(' ')}> <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" /> <Icon className="size-3" />
</span> </span>
@@ -152,10 +167,10 @@ function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: bool
} }
type WorldMapSelectorModalProps = { type WorldMapSelectorModalProps = {
platformKey: string
weekNumber: number weekNumber: number
maps?: WorkspaceMapOption[] initialScope?: 'simulation' | 'platform'
showEnvironment?: boolean simulationMaps: WorkspaceMapOption[]
platformMaps: WorkspaceMapOption[]
selectedMapKey: string selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void onSelect: (map: WorkspaceMapOption) => void
onCreate?: () => void onCreate?: () => void
@@ -163,22 +178,19 @@ type WorldMapSelectorModalProps = {
onClose: () => void onClose: () => void
} }
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, showEnvironment = true, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element { export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const maps = configuredMaps?.length const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope)
? [...configuredMaps, ...(showEnvironment && !configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [LOCAL_CUSTOM_MAP] : [])] const maps = scope === 'simulation' ? simulationMaps : platformMaps
: (showEnvironment && platformKey.toUpperCase() === 'NAV2' ? NAV2_MAP_OPTIONS : [])
const environmentMaps = maps.filter((map) => !map.saved && !map.remoteSlam)
const savedMaps = maps.filter((map) => map.saved)
const remoteSlamMaps = maps.filter((map) => map.remoteSlam)
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [tab, setTab] = useState<'environment' | 'saved'>(() => (!showEnvironment || selectedMap?.saved ? 'saved' : 'environment'))
const visibleMaps = tab === 'environment' ? environmentMaps : [...remoteSlamMaps, ...savedMaps]
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap) const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => { useEffect(() => {
setTab(!showEnvironment || selectedMap?.saved ? 'saved' : 'environment') setPendingMap(maps.find((map) => map.key === selectedMapKey) ?? null)
setPendingMap(selectedMap) }, [maps, selectedMapKey])
}, [selectedMapKey, showEnvironment])
useEffect(() => {
setScope(initialScope)
}, [initialScope])
return ( 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="맵 선택"> <div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
@@ -186,20 +198,18 @@ export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: c
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4"> <header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div> <div>
<h2 className="text-[16px] font-bold">맵 설정</h2> <h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p> <p className="mt-1 text-[12px] text-white/50">{weekNumber}주차 학습 세션 맵</p>
</div> </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"> <button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" /> <X className="size-4" />
</button> </button>
</header> </header>
{showEnvironment && (
<div className="flex gap-1 border-b border-white/10 px-5 pt-3"> <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={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? '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> <button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
</div> </div>
)}
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5"> <div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
{visibleMaps.map((map) => { {maps.map((map) => {
const pending = map.key === pendingMap?.key const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey const selected = map.key === selectedMapKey
return ( return (
@@ -212,7 +222,7 @@ export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: c
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]' pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
].join(' ')} ].join(' ')}
> >
<MapPreview map={map} selected={pending} /> <WorkspaceMapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{map.name}</span> <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 className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
@@ -227,10 +237,10 @@ export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: c
</button> </button>
) )
})} })}
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>} {!maps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">{scope === 'platform' ? '저장된 플랫폼 SLAM 맵이 없습니다.' : '사용 가능한 시뮬레이션 맵이 없습니다.'}</div>}
</div> </div>
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3"> <footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
{tab === 'environment' && onCreate && ( {scope === 'simulation' && onCreate && (
<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"> <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" /> <PencilRuler className="size-3.5" />
맵 편집 맵 편집
@@ -13,7 +13,8 @@ export function parseModuleParameters(tokens: string[]): ModuleParameterPair[] {
return { return {
id: createParameterId(), id: createParameterId(),
name: separatorIndex >= 0 ? token.slice(0, separatorIndex) : token, name: separatorIndex >= 0 ? token.slice(0, separatorIndex) : token,
value: separatorIndex >= 0 ? token.slice(separatorIndex + 2) : '' value: separatorIndex >= 0 ? token.slice(separatorIndex + 2) : '',
enabled: true
} }
}) })
} }
@@ -21,6 +22,7 @@ export function parseModuleParameters(tokens: string[]): ModuleParameterPair[] {
export function serializeModuleParameters(parameters: ModuleParameterPair[]): string { export function serializeModuleParameters(parameters: ModuleParameterPair[]): string {
return parameters return parameters
.map((parameter) => { .map((parameter) => {
if (parameter.enabled === false) return ''
const name = parameter.name.trim() const name = parameter.name.trim()
if (!name) return '' if (!name) return ''
return `${name}:=${parameter.value.trim()}` return `${name}:=${parameter.value.trim()}`
@@ -6,13 +6,23 @@ export const defaultStudioSourceSettings: StudioSourceSettings = {
giteaBaseUrl: 'http://localhost:3000', giteaBaseUrl: 'http://localhost:3000',
giteaOwner: 'student', giteaOwner: 'student',
gpuEnabled: false, gpuEnabled: false,
rosDomainId: '77' editorWordWrap: false,
editorFontSize: 13,
editorTabSize: 4,
editorMinimap: true,
rosDomainId: '77',
rosLinkedPlatformId: null,
visualizationBridgeLocation: 'local',
} }
function normalizeCoursePathSegment(courseKey: string): string { function normalizeCoursePathSegment(courseKey: string): string {
return courseKey.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'default-course' return courseKey.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'default-course'
} }
function normalizeTeamPathSegment(groupId: string): string {
return groupId.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'team'
}
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number): string { export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '') const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
const suffix = `${studentSlug}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}` const suffix = `${studentSlug}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
@@ -22,8 +32,16 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
return `${normalizedRoot}/${suffix}` return `${normalizedRoot}/${suffix}`
} }
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string { export function makeTeamWeekWorkspaceRoot(workspaceRoot: string, groupId: string, courseKey: string, weekNumber: number): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '') const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
const suffix = `teams/${normalizeTeamPathSegment(groupId)}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
return `${normalizedRoot}/${suffix}`
}
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string, teamGroupId?: string | null): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
if (teamGroupId) return makeTeamWeekWorkspaceRoot(normalizedRoot, teamGroupId, courseKey, weekNumber)
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber) if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}` const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
@@ -32,12 +50,23 @@ export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: st
export function normalizeStudioSourceSettings(settings?: Partial<StudioSourceSettings> | null): StudioSourceSettings { export function normalizeStudioSourceSettings(settings?: Partial<StudioSourceSettings> | null): StudioSourceSettings {
const giteaOwner = settings?.giteaOwner?.trim() || defaultStudioSourceSettings.giteaOwner const giteaOwner = settings?.giteaOwner?.trim() || defaultStudioSourceSettings.giteaOwner
const requestedEditorFontSize = Number(settings?.editorFontSize)
return { return {
workspaceRoot: settings?.workspaceRoot || defaultStudioSourceSettings.workspaceRoot, workspaceRoot: settings?.workspaceRoot || defaultStudioSourceSettings.workspaceRoot,
giteaBaseUrl: (settings?.giteaBaseUrl || defaultStudioSourceSettings.giteaBaseUrl).replace(/\/+$/, ''), giteaBaseUrl: (settings?.giteaBaseUrl || defaultStudioSourceSettings.giteaBaseUrl).replace(/\/+$/, ''),
giteaOwner: giteaOwner === 'students' ? defaultStudioSourceSettings.giteaOwner : giteaOwner, giteaOwner: giteaOwner === 'students' ? defaultStudioSourceSettings.giteaOwner : giteaOwner,
gpuEnabled: Boolean(settings?.gpuEnabled), gpuEnabled: Boolean(settings?.gpuEnabled),
editorWordWrap: settings?.editorWordWrap ?? defaultStudioSourceSettings.editorWordWrap,
editorFontSize: Number.isFinite(requestedEditorFontSize)
? Math.min(16, Math.max(12, Math.round(requestedEditorFontSize)))
: defaultStudioSourceSettings.editorFontSize,
editorTabSize: [2, 4, 8].includes(Number(settings?.editorTabSize))
? Number(settings?.editorTabSize)
: defaultStudioSourceSettings.editorTabSize,
editorMinimap: settings?.editorMinimap ?? defaultStudioSourceSettings.editorMinimap,
rosDomainId: String(settings?.rosDomainId ?? defaultStudioSourceSettings.rosDomainId).trim() || defaultStudioSourceSettings.rosDomainId, rosDomainId: String(settings?.rosDomainId ?? defaultStudioSourceSettings.rosDomainId).trim() || defaultStudioSourceSettings.rosDomainId,
rosLinkedPlatformId: settings?.rosLinkedPlatformId?.trim() || null,
visualizationBridgeLocation: settings?.visualizationBridgeLocation === 'platform' ? 'platform' : 'local',
agentUrl: settings?.agentUrl ?? null, agentUrl: settings?.agentUrl ?? null,
sshUser: settings?.sshUser ?? null, sshUser: settings?.sshUser ?? null,
sshPort: settings?.sshPort ?? null sshPort: settings?.sshPort ?? null
@@ -2,6 +2,7 @@ export type ModuleParameterPair = {
id: string id: string
name: string name: string
value: string value: string
enabled?: boolean
} }
export type ModuleRow = { export type ModuleRow = {
@@ -19,6 +20,7 @@ export type ModuleRow = {
dockerImage?: string | null dockerImage?: string | null
packageName?: string | null packageName?: string | null
launchFile?: string | null launchFile?: string | null
runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
@@ -31,6 +33,7 @@ export type ModulePrebuiltArtifact = {
fileName?: string | null fileName?: string | null
checksumSha256?: string | null checksumSha256?: string | null
extractPath?: string | null extractPath?: string | null
architecture?: string | null
required?: boolean | null required?: boolean | null
} }
@@ -41,3 +44,8 @@ export type RuntimeImageArtifact = {
checksumSha256?: string | null checksumSha256?: string | null
required?: boolean | null required?: boolean | null
} }
export type RosNetworkConfig = {
enabled?: boolean
peer?: RemoteWorkspaceTarget | null
}
@@ -1,8 +1,8 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { LMS_API_BASE_URL } from '../../config/appConfig' import { LMS_API_BASE_URL } from '../../config/appConfig'
import type { StudioSourceSettings } from '../platforms/types' import type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types'
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters' import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
import type { ModuleParameterPair, ModulePrebuiltArtifact, ModuleRow, RuntimeImageArtifact } from './types' import type { ModuleParameterPair, ModulePrebuiltArtifact, ModuleRow, RosNetworkConfig, RuntimeImageArtifact } from './types'
import { compareModulePriority, compareModuleShutdownPriority } from './workspaceModules' import { compareModulePriority, compareModuleShutdownPriority } from './workspaceModules'
type NoticeTone = 'info' | 'warning' | 'error' type NoticeTone = 'info' | 'warning' | 'error'
@@ -12,6 +12,11 @@ type ModuleActionResult = {
message?: string message?: string
} }
type SavedMapPathOverrides = {
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
}
const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system'] const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system']
function compareModuleStartupPriority(a: ModuleRow, b: ModuleRow): number { function compareModuleStartupPriority(a: ModuleRow, b: ModuleRow): number {
@@ -36,10 +41,14 @@ type UseWorkspaceModuleControlOptions = {
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean remoteWorkspace: boolean
sourceSettings: StudioSourceSettings sourceSettings: StudioSourceSettings
rosNetwork?: RosNetworkConfig | null
simulatorWorldFile?: string | null simulatorWorldFile?: string | null
savedMapYamlPath?: string | null savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null savedMapDatabasePath?: string | null
prepareSavedMap?: () => Promise<SavedMapPathOverrides>
bulkExcludedModuleKeys?: string[]
activeRuntimeImages: RuntimeImageArtifact[] activeRuntimeImages: RuntimeImageArtifact[]
vehicleRuntimeProfiles?: VehicleRuntimeProfile[]
getFreshAccessToken: () => Promise<string> getFreshAccessToken: () => Promise<string>
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void> prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void>
@@ -72,10 +81,14 @@ export function useWorkspaceModuleControl({
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
sourceSettings, sourceSettings,
rosNetwork,
simulatorWorldFile, simulatorWorldFile,
savedMapYamlPath, savedMapYamlPath,
savedMapDatabasePath, savedMapDatabasePath,
prepareSavedMap,
bulkExcludedModuleKeys = [],
activeRuntimeImages, activeRuntimeImages,
vehicleRuntimeProfiles = [],
getFreshAccessToken, getFreshAccessToken,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
prepareRemoteRuntimeModule, prepareRemoteRuntimeModule,
@@ -87,18 +100,33 @@ export function useWorkspaceModuleControl({
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null) const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve()) const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve())
const queuedModuleNamesRef = useRef(new Set<string>()) const queuedModuleNamesRef = useRef(new Set<string>())
const isExcludedFromBulkControl = useCallback((module: ModuleRow): boolean => (
bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
), [bulkExcludedModuleKeys])
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null; savedMapDatabasePath?: string | null }): string => { const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null; savedMapDatabasePath?: string | null }): string => {
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
const parameters = module.parameters.filter((parameter) => ( const parameters = module.parameters.filter((parameter) => (
!parameter.startsWith('world:=') !parameter.startsWith('world:=')
&& !parameter.startsWith('world_layout_enabled:=') && !parameter.startsWith('world_layout_enabled:=')
&& !parameter.startsWith('map:=') && !parameter.startsWith('map:=')
&& !parameter.startsWith('database_path:=') && !parameter.startsWith('database_path:=')
&& !parameter.startsWith('localization:=') && !parameter.startsWith('localization:=')
&& (!isMkMiniModule || (
!parameter.startsWith('runtime_wheelbase:=')
&& !parameter.startsWith('runtime_max_speed:=')
&& !parameter.startsWith('runtime_max_steering_angle:=')
)) ))
))
if (isMkMiniModule && mkMiniProfile) {
parameters.push(`runtime_wheelbase:=${mkMiniProfile.wheelbaseMeters}`)
parameters.push(`runtime_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`)
parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`)
}
if (module.category.toLowerCase() === 'simulator' && worldFile) { if (module.category.toLowerCase() === 'simulator' && worldFile) {
parameters.push(`world:=${worldFile}`) parameters.push(`world:=${worldFile}`)
} }
@@ -111,7 +139,7 @@ export function useWorkspaceModuleControl({
parameters.push('localization:=true') parameters.push('localization:=true')
} }
return parameters.join(' ') return parameters.join(' ')
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile]) }, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => { const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
if (!module.folderName || !module.packageName || !module.launchFile) { if (!module.folderName || !module.packageName || !module.launchFile) {
@@ -126,6 +154,15 @@ export function useWorkspaceModuleControl({
launchFile: module.launchFile, launchFile: module.launchFile,
} }
if (remoteWorkspace) { if (remoteWorkspace) {
if (module.runtimeTarget === 'SHARED') {
await window.remoteWorkspace.modules.transfer(
getRemoteWorkspaceTarget(),
clonePathPreview,
module.name,
module.folderName,
)
return
}
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), input) await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), input)
return return
} }
@@ -185,26 +222,27 @@ export function useWorkspaceModuleControl({
) )
}, [activeRuntimeImages, clonePathPreview, courseKey, createCustomModuleSource, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber]) }, [activeRuntimeImages, clonePathPreview, courseKey, createCustomModuleSource, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => { const remoteModuleStart = useCallback(async (module: ModuleRow, overrides?: SavedMapPathOverrides): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module) await prepareRemoteRuntimeModule(module)
return window.remoteWorkspace.modules.start( return window.remoteWorkspace.modules.start(
getRemoteWorkspaceTarget(), getRemoteWorkspaceTarget(),
module.name, module.name,
module.folderName, module.folderName,
getModuleRosParams(module), getModuleRosParams(module, overrides),
sourceSettings.gpuEnabled, sourceSettings.gpuEnabled,
sourceSettings.rosDomainId, sourceSettings.rosDomainId,
activeRuntimeImages activeRuntimeImages,
rosNetwork,
) )
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId]) }, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const remoteModuleStop = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => ( const remoteModuleStop = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => (
window.remoteWorkspace.modules.stop(getRemoteWorkspaceTarget(), module.name, module.folderName) window.remoteWorkspace.modules.stop(getRemoteWorkspaceTarget(), module.name, module.folderName)
), [getRemoteWorkspaceTarget]) ), [getRemoteWorkspaceTarget])
const remoteModuleRestart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => { const remoteModuleRestart = useCallback(async (module: ModuleRow, overrides?: SavedMapPathOverrides): Promise<ModuleActionResult> => {
await remoteModuleStop(module).catch(() => ({ ok: false, message: '모듈 중지 실패' })) await remoteModuleStop(module).catch(() => ({ ok: false, message: '모듈 중지 실패' }))
return remoteModuleStart(module) return remoteModuleStart(module, overrides)
}, [remoteModuleStart, remoteModuleStop]) }, [remoteModuleStart, remoteModuleStop])
const installModule = useCallback(async (module: ModuleRow): Promise<void> => { const installModule = useCallback(async (module: ModuleRow): Promise<void> => {
@@ -225,7 +263,7 @@ export function useWorkspaceModuleControl({
const installAllModules = useCallback(async (): Promise<void> => { const installAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return if (installingModuleName) return
const targets = availableModules.filter((module) => module.state === 'uninstalled').sort(compareModulePriority) const targets = availableModules.filter((module) => !isExcludedFromBulkControl(module) && module.state === 'uninstalled').sort(compareModulePriority)
if (!targets.length) return if (!targets.length) return
setInstallingModuleName('__all__') setInstallingModuleName('__all__')
@@ -243,11 +281,11 @@ export function useWorkspaceModuleControl({
} finally { } finally {
setInstallingModuleName(null) setInstallingModuleName(null)
} }
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules]) }, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, loadAvailableModules])
const startAllModules = useCallback(async (): Promise<void> => { const startAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return if (installingModuleName) return
const targets = availableModules.filter((module) => module.state === 'stopped').sort(compareModulePriority) const targets = availableModules.filter((module) => !isExcludedFromBulkControl(module) && module.state === 'stopped').sort(compareModulePriority)
if (!targets.length) { if (!targets.length) {
onTitlebarNotice('시작할 모듈이 없습니다', 'warning') onTitlebarNotice('시작할 모듈이 없습니다', 'warning')
return return
@@ -256,10 +294,11 @@ export function useWorkspaceModuleControl({
setInstallingModuleName('__start_all__') setInstallingModuleName('__start_all__')
setModuleInstallError(null) setModuleInstallError(null)
try { try {
const savedMapOverrides = await prepareSavedMap?.()
for (const module of [...targets].sort(compareModuleStartupPriority)) { for (const module of [...targets].sort(compareModuleStartupPriority)) {
const result = remoteWorkspace const result = remoteWorkspace
? await remoteModuleStart(module) ? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
markModuleState(module, 'running') markModuleState(module, 'running')
if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup() if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup()
@@ -280,20 +319,24 @@ export function useWorkspaceModuleControl({
clonePathPreview, clonePathPreview,
getModuleRosParams, getModuleRosParams,
installingModuleName, installingModuleName,
isExcludedFromBulkControl,
loadAvailableModules, loadAvailableModules,
markModuleState, markModuleState,
onTitlebarNotice, onTitlebarNotice,
prepareSavedMap,
remoteModuleStart, remoteModuleStart,
remoteWorkspace, remoteWorkspace,
sourceSettings.gpuEnabled, sourceSettings.gpuEnabled,
sourceSettings.rosDomainId sourceSettings.rosDomainId,
rosNetwork
]) ])
const stopAllModules = useCallback(async (): Promise<void> => { const stopAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return if (installingModuleName) return
const targets = availableModules.filter((module) => module.state === 'running').sort(compareModuleShutdownPriority) const targets = availableModules.filter((module) => !isExcludedFromBulkControl(module) && module.state === 'running').sort(compareModuleShutdownPriority)
const hasExcludedRunningModule = availableModules.some((module) => isExcludedFromBulkControl(module) && module.state === 'running')
const stopLocalVisualization = async (): Promise<void> => { const stopLocalVisualization = async (): Promise<void> => {
if (remoteWorkspace) return if (remoteWorkspace || hasExcludedRunningModule) return
await onStopVisualization() await onStopVisualization()
} }
@@ -331,7 +374,7 @@ export function useWorkspaceModuleControl({
} finally { } finally {
setInstallingModuleName(null) setInstallingModuleName(null)
} }
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onStopVisualization, onTitlebarNotice, remoteWorkspace]) }, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, isExcludedFromBulkControl, loadAvailableModules, onStopVisualization, onTitlebarNotice, remoteWorkspace])
const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => { const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
if (installingModuleName) return false if (installingModuleName) return false
@@ -341,6 +384,7 @@ export function useWorkspaceModuleControl({
setInstallingModuleName('__restart_all__') setInstallingModuleName('__restart_all__')
setModuleInstallError(null) setModuleInstallError(null)
try { try {
const savedMapOverrides = { ...overrides, ...(await prepareSavedMap?.()) }
if (remoteWorkspace) { if (remoteWorkspace) {
const result = await window.remoteWorkspace.modules.stopMany( const result = await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(), getRemoteWorkspaceTarget(),
@@ -362,8 +406,8 @@ export function useWorkspaceModuleControl({
for (const module of [...targets].sort(compareModuleStartupPriority)) { for (const module of [...targets].sort(compareModuleStartupPriority)) {
const result = remoteWorkspace const result = remoteWorkspace
? await remoteModuleStart(module) ? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, overrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
markModuleState(module, 'running') markModuleState(module, 'running')
if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup() if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup()
@@ -380,16 +424,28 @@ export function useWorkspaceModuleControl({
} finally { } finally {
setInstallingModuleName(null) setInstallingModuleName(null)
} }
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, remoteModuleStart, remoteWorkspace, sourceSettings.gpuEnabled, sourceSettings.rosDomainId]) }, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => { const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (installingModuleName || queuedModuleNamesRef.current.has(module.name)) return // The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = (
installingModuleName === '__stop_all__'
&& isExcludedFromBulkControl(module)
)
if (
(!canControlExcludedModuleDuringBulkStop && installingModuleName)
|| queuedModuleNamesRef.current.has(module.name)
) return
queuedModuleNamesRef.current.add(module.name) queuedModuleNamesRef.current.add(module.name)
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name])) setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null) setModuleInstallError(null)
const execute = async (): Promise<void> => { const execute = async (): Promise<void> => {
try { try {
const savedMapOverrides = action === 'start' || action === 'restart'
? await prepareSavedMap?.()
: undefined
const result = action === 'stop' const result = action === 'stop'
? remoteWorkspace ? remoteWorkspace
? await remoteModuleStop(module) ? await remoteModuleStop(module)
@@ -397,14 +453,14 @@ export function useWorkspaceModuleControl({
: action === 'build' : action === 'build'
? remoteWorkspace ? remoteWorkspace
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) ? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, getModuleRosParams(module))
: action === 'restart' : action === 'restart'
? remoteWorkspace ? remoteWorkspace
? await remoteModuleRestart(module) ? await remoteModuleRestart(module, savedMapOverrides)
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
: remoteWorkspace : remoteWorkspace
? await remoteModuleStart(module) ? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
if (action === 'start' || action === 'restart') { if (action === 'start' || action === 'restart') {
@@ -420,7 +476,7 @@ export function useWorkspaceModuleControl({
if (vehicle) { if (vehicle) {
const vehicleResult = remoteWorkspace const vehicleResult = remoteWorkspace
? await remoteModuleRestart(vehicle) ? await remoteModuleRestart(vehicle)
: await window.localWorkspace.modules.restart(vehicle.name, clonePathPreview, vehicle.folderName, getModuleRosParams(vehicle), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.restart(vehicle.name, clonePathPreview, vehicle.folderName, getModuleRosParams(vehicle), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패') if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
} }
} }
@@ -465,9 +521,11 @@ export function useWorkspaceModuleControl({
clonePathPreview, clonePathPreview,
getModuleRosParams, getModuleRosParams,
installingModuleName, installingModuleName,
isExcludedFromBulkControl,
loadAvailableModules, loadAvailableModules,
markModuleState, markModuleState,
onTitlebarNotice, onTitlebarNotice,
prepareSavedMap,
remoteModuleRestart, remoteModuleRestart,
remoteModuleStart, remoteModuleStart,
remoteModuleStop, remoteModuleStop,
@@ -15,6 +15,7 @@ type UseWorkspaceModuleListOptions = {
remoteWorkspace: boolean remoteWorkspace: boolean
remoteWorkspaceTargetKey: string remoteWorkspaceTargetKey: string
pollingActive: boolean pollingActive: boolean
skipInitialModuleLoad?: boolean
applyModuleParameterOverrides: (modules: ModuleRow[]) => ModuleRow[] applyModuleParameterOverrides: (modules: ModuleRow[]) => ModuleRow[]
} }
@@ -24,10 +25,30 @@ type UseWorkspaceModuleListResult = {
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
modulesLoading: boolean modulesLoading: boolean
moduleListError: string | null moduleListError: string | null
clearModuleListError: () => void
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void> loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
loadRemoteModules: () => Promise<{ modules: ModuleRow[] }> loadRemoteModules: () => Promise<{ modules: ModuleRow[] }>
} }
function readRemoteModuleCache(key: string): ModuleRow[] | null {
try {
const raw = window.sessionStorage.getItem(key)
if (!raw) return null
const modules = JSON.parse(raw) as unknown
return Array.isArray(modules) ? modules as ModuleRow[] : null
} catch {
return null
}
}
function storeRemoteModuleCache(key: string, modules: ModuleRow[]): void {
try {
window.sessionStorage.setItem(key, JSON.stringify(modules))
} catch {
// The next background probe can still populate the visible module state.
}
}
function parseRemoteWorkspaceTarget(targetKey: string): RemoteWorkspaceTarget | null { function parseRemoteWorkspaceTarget(targetKey: string): RemoteWorkspaceTarget | null {
try { try {
const parsed = JSON.parse(targetKey) as RemoteWorkspaceTarget | null const parsed = JSON.parse(targetKey) as RemoteWorkspaceTarget | null
@@ -44,12 +65,22 @@ export function useWorkspaceModuleList({
remoteWorkspace, remoteWorkspace,
remoteWorkspaceTargetKey, remoteWorkspaceTargetKey,
pollingActive, pollingActive,
skipInitialModuleLoad = false,
applyModuleParameterOverrides applyModuleParameterOverrides
}: UseWorkspaceModuleListOptions): UseWorkspaceModuleListResult { }: UseWorkspaceModuleListOptions): UseWorkspaceModuleListResult {
const [availableModules, setAvailableModules] = useState<ModuleRow[]>([]) const [availableModules, setAvailableModules] = useState<ModuleRow[]>([])
const [modulesLoading, setModulesLoading] = useState(false) const [modulesLoading, setModulesLoading] = useState(false)
const [moduleListError, setModuleListError] = useState<string | null>(null) const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey]) const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const remoteModuleCacheKey = useMemo(
() => `lms:remote-module-list:${remoteWorkspaceTargetKey}:${curriculumModuleKey}`,
[curriculumModuleKey, remoteWorkspaceTargetKey],
)
const localModuleCacheKey = useMemo(
() => `lms:local-module-list:${clonePathPreview}:${curriculumModuleKey}`,
[clonePathPreview, curriculumModuleKey],
)
const moduleCacheKey = remoteWorkspace ? remoteModuleCacheKey : localModuleCacheKey
const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides }) const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides })
const loadSequenceRef = useRef(0) const loadSequenceRef = useRef(0)
const moduleLoadInFlightRef = useRef(false) const moduleLoadInFlightRef = useRef(false)
@@ -83,7 +114,8 @@ export function useWorkspaceModuleList({
// runtime confirms it, rather than briefly switching the row back. // runtime confirms it, rather than briefly switching the row back.
const pendingDuration = state === 'running' ? 120_000 : 30_000 const pendingDuration = state === 'running' ? 120_000 : 30_000
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + pendingDuration }) pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + pendingDuration })
setAvailableModules((current) => current.map((item) => { setAvailableModules((current) => {
const next = current.map((item) => {
if (moduleStateKey(item) !== key) return item if (moduleStateKey(item) !== key) return item
return { return {
...item, ...item,
@@ -92,8 +124,11 @@ export function useWorkspaceModuleList({
cpu: state === 'running' ? item.cpu : null, cpu: state === 'running' ? item.cpu : null,
memory: state === 'running' ? item.memory : null, memory: state === 'running' ? item.memory : null,
} }
})) })
}, []) storeRemoteModuleCache(moduleCacheKey, next)
return next
})
}, [moduleCacheKey])
useEffect(() => { useEffect(() => {
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides } moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
@@ -117,14 +152,18 @@ export function useWorkspaceModuleList({
const result = await loadRemoteModules() const result = await loadRemoteModules()
if (requestSequence !== loadSequenceRef.current) return if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null) setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))) const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
setAvailableModules(modules)
storeRemoteModuleCache(moduleCacheKey, modules)
return return
} }
const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? []) const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? [])
if (requestSequence !== loadSequenceRef.current) return if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null) setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))) const modules = applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority))
setAvailableModules(modules)
storeRemoteModuleCache(moduleCacheKey, modules)
} catch (error) { } catch (error) {
if (requestSequence !== loadSequenceRef.current) return if (requestSequence !== loadSequenceRef.current) return
console.error('[modules] list failed', error) console.error('[modules] list failed', error)
@@ -142,12 +181,29 @@ export function useWorkspaceModuleList({
clonePathPreview, clonePathPreview,
curriculumModuleKey, curriculumModuleKey,
loadRemoteModules, loadRemoteModules,
moduleCacheKey,
remoteWorkspace remoteWorkspace
]) ])
useEffect(() => { useEffect(() => {
const cachedModules = readRemoteModuleCache(moduleCacheKey)
if (cachedModules) {
const modules = applyPendingModuleStates(
moduleInputsRef.current.applyModuleParameterOverrides(cachedModules).sort(compareModulePriority),
)
setAvailableModules(modules)
setModuleListError(null)
setModulesLoading(false)
if (skipInitialModuleLoad) return
}
if (!cachedModules) {
setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules))
setModuleListError(null)
}
void loadAvailableModules() void loadAvailableModules()
}, [loadAvailableModules, remoteWorkspaceTargetKey]) }, [applyPendingModuleStates, loadAvailableModules, moduleCacheKey, remoteWorkspaceTargetKey, skipInitialModuleLoad])
useEffect(() => { useEffect(() => {
if (!pollingActive) return if (!pollingActive) return
@@ -165,6 +221,7 @@ export function useWorkspaceModuleList({
markModuleState, markModuleState,
modulesLoading, modulesLoading,
moduleListError, moduleListError,
clearModuleListError: () => setModuleListError(null),
loadAvailableModules, loadAvailableModules,
loadRemoteModules loadRemoteModules
} }
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import type { EnvironmentProfile } from '../platforms/types' import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types' import type { RosNetworkConfig, RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel' import type { VisualizationStack } from './WorkspaceVisualizationPanel'
export type Nav2VisualizationMode = 'slam' | 'navigation' export type Nav2VisualizationMode = 'slam' | 'navigation'
function resolveVisualizationStack(profileType: string): VisualizationStack | null { function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase() const type = profileType.toLowerCase()
if (type === 'local' || type === 'platform') return 'nav2'
if (type === 'real_slam') return 'slam' if (type === 'real_slam') return 'slam'
if (type === 'real_slam_3d') return 'slam3d' if (type === 'real_slam_3d') return 'slam3d'
if (type === 'real_navigation') return 'nav2' if (type === 'real_navigation') return 'nav2'
@@ -20,7 +21,14 @@ function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationM
} }
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean { function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
return ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile?.profileType.toLowerCase() ?? '') if (['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile?.profileType.toLowerCase() ?? '')) return true
const config = profile?.config
return Boolean(
config
&& typeof config === 'object'
&& !Array.isArray(config)
&& ((config as Record<string, unknown>).target === 'remote-jetson' || (config as Record<string, unknown>).target === 'platform'),
)
} }
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] { function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
@@ -39,7 +47,13 @@ type UseWorkspaceVisualizationOptions = {
fallbackStack: VisualizationStack fallbackStack: VisualizationStack
remoteWorkspace: boolean remoteWorkspace: boolean
remoteTarget: RemoteWorkspaceTarget | null remoteTarget: RemoteWorkspaceTarget | null
bridgeLocation?: 'local' | 'platform'
bridgeWorkspaceRoot?: string | null
sessionKey?: string | null
rosNetwork?: RosNetworkConfig | null
mapKey?: string | null mapKey?: string | null
platform?: string | null
visualizationRuntime?: 'real' | 'simulation'
worldLayoutVersion?: number worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
} }
@@ -74,7 +88,13 @@ export function useWorkspaceVisualization({
fallbackStack, fallbackStack,
remoteWorkspace, remoteWorkspace,
remoteTarget, remoteTarget,
bridgeLocation = 'local',
bridgeWorkspaceRoot,
sessionKey,
rosNetwork,
mapKey, mapKey,
platform,
visualizationRuntime,
worldLayoutVersion, worldLayoutVersion,
onTitlebarNotice onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult { }: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
@@ -84,18 +104,26 @@ export function useWorkspaceVisualization({
const [, setVisualizationStatusMessage] = useState('대기') const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false) const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace]) const runtimeProfiles = useMemo(() => {
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
if (fallbackStack === 'autoware') return candidates
// A NAV2 curriculum can retain legacy Autoware profiles. Do not let one
// become the active viewer merely because it appears first in the list.
return candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
}, [fallbackStack, profiles, remoteWorkspace])
const visualizationStacks = useMemo(() => ( const visualizationStacks = useMemo(() => (
runtimeProfiles runtimeProfiles
.map((profile) => resolveVisualizationStack(profile.profileType)) .map((profile) => resolveVisualizationStack(profile.profileType))
.filter((stack): stack is VisualizationStack => stack !== null) .filter((stack): stack is VisualizationStack => stack !== null)
), [runtimeProfiles]) ), [runtimeProfiles])
const visualizationStackStorageKey = useMemo(() => { const visualizationStackStorageKey = useMemo(() => {
if (sessionKey) return `lms:visualization-stack:${sessionKey}`
const workspaceKey = remoteWorkspace const workspaceKey = remoteWorkspace
? `${remoteTarget?.host ?? ''}:${remoteTarget?.workspaceRoot ?? ''}` ? `${remoteTarget?.host ?? ''}:${remoteTarget?.workspaceRoot ?? ''}`
: clonePathPreview : clonePathPreview
return workspaceKey ? `lms:visualization-stack:${workspaceKey}` : null return workspaceKey ? `lms:visualization-stack:${workspaceKey}` : null
}, [clonePathPreview, remoteTarget?.host, remoteTarget?.workspaceRoot, remoteWorkspace]) }, [clonePathPreview, remoteTarget?.host, remoteTarget?.workspaceRoot, remoteWorkspace, sessionKey])
const selectVisualizationStack = useCallback((stack: VisualizationStack) => { const selectVisualizationStack = useCallback((stack: VisualizationStack) => {
setVisualizationStack(stack) setVisualizationStack(stack)
if (visualizationStackStorageKey) window.localStorage.setItem(visualizationStackStorageKey, stack) if (visualizationStackStorageKey) window.localStorage.setItem(visualizationStackStorageKey, stack)
@@ -108,33 +136,34 @@ export function useWorkspaceVisualization({
typeof image === 'object' && image !== null && typeof (image as RuntimeImageArtifact).image === 'string' typeof image === 'object' && image !== null && typeof (image as RuntimeImageArtifact).image === 'string'
)) ))
: [] : []
const activeEnvironmentAgent = typeof activeEnvironmentConfig.agent === 'object' && activeEnvironmentConfig.agent !== null const bridgeTarget = remoteTarget ?? rosNetwork?.peer ?? null
? activeEnvironmentConfig.agent as Record<string, unknown> const bridgeRunsOnPlatform = bridgeLocation === 'platform' && Boolean(bridgeTarget?.host)
: null const visualizationBridgeHttpUrl = bridgeRunsOnPlatform
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string' ? `http://${bridgeTarget!.host}:8765`
? activeEnvironmentAgent.url.replace(/\/+$/, '') : 'http://localhost:8765'
: '' const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && Boolean(activeEnvironmentAgentUrl || remoteTarget?.host)
const visualizationRemoteBridgeHttpUrl = (() => {
if (!visualizationRemote) return ''
if (!activeEnvironmentAgentUrl && remoteTarget?.host) return `http://${remoteTarget.host}:8765`
try {
const url = new URL(activeEnvironmentAgentUrl)
url.port = '8765'
return url.toString().replace(/\/+$/, '')
} catch {
return ''
}
})()
const visualizationBridgeHttpUrl = visualizationRemoteBridgeHttpUrl || 'http://localhost:8765'
const visualizationBridgeUrl = visualizationRemote
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
: 'ws://localhost:8765'
const visualizationBackendUrl = visualizationBridgeHttpUrl const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack
const pointCloudQuery = visualizationStack === 'slam3d' ? '&pointCloud=1' : '' const pointCloudQuery = visualizationStack === 'slam3d' ? '&pointCloud=1' : ''
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation')
const localBridgeProfile = useMemo(() => {
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
? activeEnvironmentConfig.bridge as Record<string, unknown>
: {}
return {
...activeEnvironmentConfig,
type: visualizationRuntimeStack,
target: 'local',
agent: undefined,
runtime: undefined,
runtimeImages: undefined,
bridge: visualizationRuntimeStack === 'autoware'
? configuredBridge
: { ...configuredBridge, image: 'slam-ros-bridge:latest' },
}
}, [activeEnvironmentConfig, visualizationRuntimeStack])
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2' const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2'
? `./slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}` ? `./slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
: `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}` : `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => { useEffect(() => {
@@ -149,10 +178,10 @@ export function useWorkspaceVisualization({
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null)) setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks]) }, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks])
const refreshVisualizationStatus = async (): Promise<void> => { const refreshVisualizationStatus = useCallback(async (): Promise<void> => {
const status = remoteWorkspace const status = bridgeRunsOnPlatform
? remoteTarget?.host ? bridgeTarget?.host
? await window.remoteWorkspace.visualization.status(remoteTarget).catch((error) => ({ ? await window.remoteWorkspace.visualization.status(bridgeTarget).catch((error) => ({
ok: false, ok: false,
running: false, running: false,
stack: null, stack: null,
@@ -176,7 +205,17 @@ export function useWorkspaceVisualization({
: status.message : status.message
? status.message ? status.message
: '대기') : '대기')
} }, [bridgeRunsOnPlatform, bridgeTarget, visualizationStack, visualizationStacks])
useEffect(() => {
if (!visualizationRunning) return
void refreshVisualizationStatus()
const timer = window.setInterval(() => {
void refreshVisualizationStatus()
}, 3000)
return () => window.clearInterval(timer)
}, [refreshVisualizationStatus, visualizationRunning])
const startVisualizationBridge = async (mode = nav2VisualizationMode): Promise<void> => { const startVisualizationBridge = async (mode = nav2VisualizationMode): Promise<void> => {
if (visualizationBusy) return if (visualizationBusy) return
@@ -189,11 +228,11 @@ export function useWorkspaceVisualization({
setVisualizationBusy(true) setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중') setVisualizationStatusMessage('시작 중')
try { try {
const status = remoteWorkspace const status = bridgeRunsOnPlatform
? remoteTarget?.host ? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(remoteTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config) ? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' } : { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, clonePathPreview, rosDomainId, activeEnvironmentProfile.config) : await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork)
if (!status.ok) throw new Error(status.message ?? '시작 실패') if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running)) setVisualizationRunning(Boolean(status.running))
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d' const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'
@@ -203,6 +242,7 @@ export function useWorkspaceVisualization({
setVisualizationStatusMessage(formatVisualizationStackLabel(statusStack)) setVisualizationStatusMessage(formatVisualizationStackLabel(statusStack))
onTitlebarNotice('화면이 시작되었습니다', 'info') onTitlebarNotice('화면이 시작되었습니다', 'info')
} catch (error) { } catch (error) {
console.error('[visualization] start failed', error)
setVisualizationRunning(false) setVisualizationRunning(false)
setVisualizationStatusMessage(error instanceof Error ? error.message : '시작 실패') setVisualizationStatusMessage(error instanceof Error ? error.message : '시작 실패')
onTitlebarNotice('화면 시작에 실패했습니다', 'error') onTitlebarNotice('화면 시작에 실패했습니다', 'error')
@@ -216,11 +256,11 @@ export function useWorkspaceVisualization({
setVisualizationBusy(true) setVisualizationBusy(true)
setVisualizationStatusMessage('종료 중') setVisualizationStatusMessage('종료 중')
try { try {
const status = remoteWorkspace const status = bridgeRunsOnPlatform
? remoteTarget?.host ? bridgeTarget?.host
? await window.remoteWorkspace.visualization.stop(remoteTarget) ? await window.remoteWorkspace.visualization.stop(bridgeTarget)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' } : { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config) : await window.localWorkspace.visualization.stop(localBridgeProfile)
if (!status.ok) throw new Error(status.message ?? '종료 실패') if (!status.ok) throw new Error(status.message ?? '종료 실패')
setVisualizationRunning(false) setVisualizationRunning(false)
setVisualizationStatusMessage('대기') setVisualizationStatusMessage('대기')
@@ -22,6 +22,7 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
dockerImage: module.dockerImage ?? null, dockerImage: module.dockerImage ?? null,
packageName: module.packageName ?? null, packageName: module.packageName ?? null,
launchFile: module.launchFile ?? null, launchFile: module.launchFile ?? null,
runtimeTarget: module.runtimeTarget ?? null,
parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [], parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
cpu: null, cpu: null,
memory: null, memory: null,