feat: enable hybrid local and remote module execution
This commit is contained in:
Binary file not shown.
@@ -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,8 +245,19 @@ 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()
|
||||||
message.header.stamp = self.get_clock().now().to_msg()
|
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.frame_id = frame_id
|
message.header.frame_id = frame_id
|
||||||
message.pose.pose.position.x = x
|
message.pose.pose.position.x = x
|
||||||
message.pose.pose.position.y = y
|
message.pose.pose.position.y = y
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+51
-14
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -247,17 +306,19 @@
|
|||||||
background:rgba(255,255,255,.1);
|
background:rgba(255,255,255,.1);
|
||||||
color:#fff;
|
color:#fff;
|
||||||
}
|
}
|
||||||
.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' }, '*');
|
||||||
|
|||||||
+972
-206
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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}>
|
||||||
<button
|
<Tooltip label="도구 보기" disabled={toolsOpen}>
|
||||||
type="button"
|
<button
|
||||||
aria-label="Open Developer Tools"
|
type="button"
|
||||||
onClick={() => {
|
aria-label="도구 보기"
|
||||||
void window.titlebar.openDevTools().then((result) => {
|
aria-expanded={toolsOpen}
|
||||||
if (!result.opened) console.error('[titlebar] open DevTools failed:', result.message)
|
onClick={() => {
|
||||||
}).catch((error) => console.error('[titlebar] open DevTools request failed:', error))
|
setToolsOpen((current) => !current)
|
||||||
}}
|
setNotificationOpen(false)
|
||||||
className={winButton}
|
setAccountOpen(false)
|
||||||
>
|
}}
|
||||||
<Bug size={15} />
|
className={winButton}
|
||||||
</button>
|
>
|
||||||
</Tooltip>
|
<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) => {
|
||||||
|
if (!result.opened) console.error('[titlebar] open DevTools failed:', result.message)
|
||||||
|
}).catch((error) => console.error('[titlebar] open DevTools request failed:', error))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bug size={15} />
|
||||||
|
개발자 도구
|
||||||
|
</button>
|
||||||
|
</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]"
|
||||||
|
|||||||
Vendored
+57
-9
@@ -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']>
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
<>
|
||||||
className={[
|
<span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" />
|
||||||
'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',
|
{position && createPortal(
|
||||||
dark ? 'bg-[#111315] text-white' : 'bg-[#111520] text-white'
|
<span
|
||||||
].join(' ')}
|
role="tooltip"
|
||||||
>
|
style={{ left: position.left, top: position.top }}
|
||||||
{children}
|
className={[
|
||||||
</span>
|
'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'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</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,25 +339,56 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
)
|
)
|
||||||
})()
|
})()
|
||||||
))}
|
))}
|
||||||
</span>
|
{canDeleteUninstalledCustomModule && (
|
||||||
<span className="flex justify-center">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
disabled={installActionDisabled}
|
||||||
disabled={installActionDisabled}
|
onClick={(event) => {
|
||||||
onClick={(event) => {
|
event.stopPropagation()
|
||||||
event.stopPropagation()
|
|
||||||
if (module.state === 'uninstalled') {
|
|
||||||
onInstallModule(module)
|
onInstallModule(module)
|
||||||
return
|
}}
|
||||||
}
|
aria-label={installAction.label}
|
||||||
onDeleteModule(module)
|
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
|
||||||
}}
|
>
|
||||||
aria-label={installAction.label}
|
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
|
||||||
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
|
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
|
||||||
>
|
</button>
|
||||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
|
)}
|
||||||
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
|
</span>
|
||||||
</button>
|
<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
|
||||||
|
type="button"
|
||||||
|
disabled={installActionDisabled}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (module.state === 'uninstalled') {
|
||||||
|
onInstallModule(module)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onDeleteModule(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>
|
||||||
</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,71 +243,87 @@ 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">
|
||||||
{loading ? (
|
<div
|
||||||
<ModuleListEmptyState dark={dark} loading />
|
className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto"
|
||||||
) : filteredModuleRows.length === 0 ? (
|
onDragOver={(event) => {
|
||||||
<ModuleListEmptyState dark={dark} />
|
if (!draggedModule) return
|
||||||
) : filteredModuleRows.map((row) => (
|
event.preventDefault()
|
||||||
<ModuleListRow
|
setDropTargetModuleName(null)
|
||||||
key={row.name}
|
setDropZone('active')
|
||||||
dark={dark}
|
}}
|
||||||
module={row}
|
onDrop={(event) => {
|
||||||
draggable={canReorder}
|
if (!draggedModule) return
|
||||||
dragging={draggedModule?.name === row.name}
|
event.preventDefault()
|
||||||
dropTarget={dropTargetModuleName === row.name && draggedModule?.name !== row.name}
|
onRestoreParkedModule(draggedModule)
|
||||||
onDragStart={() => setDraggedModule(row)}
|
finishDrag()
|
||||||
onDragOver={() => {
|
}}
|
||||||
if (draggedModule && draggedModule.name !== row.name) setDropTargetModuleName(row.name)
|
>
|
||||||
}}
|
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-full flex-col'].join(' ')}>
|
||||||
onDrop={() => {
|
{loading ? (
|
||||||
if (draggedModule && draggedModule.name !== row.name) onReorderModules(draggedModule, row)
|
<ModuleListEmptyState dark={dark} loading />
|
||||||
setDraggedModule(null)
|
) : filteredModuleRows.length === 0 ? (
|
||||||
setDropTargetModuleName(null)
|
<ModuleListEmptyState dark={dark} />
|
||||||
}}
|
) : filteredModuleRows.map((row) => renderModuleRow(row, false))}
|
||||||
onDragEnd={() => {
|
{!loading && allowCustomModuleCreation && (
|
||||||
setDraggedModule(null)
|
<div className={['flex justify-end px-3 pb-6 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
|
||||||
setDropTargetModuleName(null)
|
<button
|
||||||
}}
|
type="button"
|
||||||
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
|
onClick={() => setModuleAddDialogOpen(true)}
|
||||||
controlling={controllingModuleNames.includes(row.name)}
|
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(' ')}
|
||||||
bulkControlling={bulkControlling}
|
>
|
||||||
onAddModuleTab={onAddModuleTab}
|
+ 모듈 추가
|
||||||
onOpenSource={onOpenSource}
|
</button>
|
||||||
onOpenParameters={(module) => {
|
</div>
|
||||||
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
|
|
||||||
type="button"
|
|
||||||
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(' ')}
|
|
||||||
>
|
|
||||||
<CirclePlus size={14} aria-hidden="true" />
|
|
||||||
모듈 추가
|
|
||||||
</button>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -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">
|
||||||
<StudioSettingsField
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
label="사용자 워크스페이스 루트"
|
<StudioSettingsField
|
||||||
value={configuredDraftWorkspaceRoot}
|
label="사용자 워크스페이스 루트"
|
||||||
onChange={(value) => onUpdateSourceSetting('workspaceRoot', value)}
|
description="모듈 소스와 빌드 결과가 저장되는 기본 경로입니다."
|
||||||
dark={dark}
|
value={configuredDraftWorkspaceRoot}
|
||||||
/>
|
onChange={(value) => onUpdateSourceSetting('workspaceRoot', value)}
|
||||||
<StudioSettingsField
|
dark={dark}
|
||||||
label="ROS Domain ID"
|
/>
|
||||||
value={sourceSettingsDraft.rosDomainId}
|
<StudioSettingsField
|
||||||
onChange={(value) => onUpdateSourceSetting('rosDomainId', value.replace(/[^\d]/g, '').slice(0, 3))}
|
label="ROS Domain ID"
|
||||||
dark={dark}
|
description="같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다."
|
||||||
/>
|
value={sourceSettingsDraft.rosDomainId}
|
||||||
|
onChange={(value) => onUpdateSourceSetting('rosDomainId', value.replace(/[^\d]/g, '').slice(0, 3))}
|
||||||
|
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
|
||||||
</button>
|
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>
|
||||||
|
{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
|
||||||
</div>
|
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 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={() => 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('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('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>
|
||||||
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>저장된 맵</button>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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,17 +114,21 @@ 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) => {
|
||||||
if (moduleStateKey(item) !== key) return item
|
const next = current.map((item) => {
|
||||||
return {
|
if (moduleStateKey(item) !== key) return item
|
||||||
...item,
|
return {
|
||||||
state,
|
...item,
|
||||||
runtime: state === 'running' ? '시작 중' : '-',
|
state,
|
||||||
cpu: state === 'running' ? item.cpu : null,
|
runtime: state === 'running' ? '시작 중' : '-',
|
||||||
memory: state === 'running' ? item.memory : null,
|
cpu: state === 'running' ? item.cpu : 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user