diff --git a/demo/foxglove-layout.json b/demo/foxglove-layout.json index b61b0fc..56d8bec 100644 --- a/demo/foxglove-layout.json +++ b/demo/foxglove-layout.json @@ -7,7 +7,7 @@ "foxglovePanelTitle": "E-stop", "buttonText": "STOP", "serviceName": "panther/hardware/e-stop", - "buttonColor": "#ff0000" + "buttonColor": "#bb0000" }, "TriggerButton!194be9": { "requestPayload": "{}", @@ -15,19 +15,19 @@ "advancedView": false, "foxglovePanelTitle": "E-stop", "buttonText": "Enable", - "buttonColor": "#00ff00", + "buttonColor": "#009900", "serviceName": "/hardware" }, "Tab!1l9vxsv": { - "activeTabIdx": 0, + "activeTabIdx": 1, "tabs": [ { "title": "Slow mode", - "layout": "Teleop!1qlq2qt" + "layout": "Joy!3fmstz6" }, { "title": "Fast mode", - "layout": "Teleop!1mtrw1e" + "layout": "Joy!7eprst" } ] }, @@ -47,57 +47,68 @@ "Indicator!2wjfnhj": { "path": "/hardware/e_stop.data", "style": "background", - "fallbackColor": "#ff0000", + "fallbackColor": "#bb0000", "fallbackLabel": "Stopped", "rules": [ { "operator": "=", "rawValue": "true", - "color": "#68e24a", + "color": "#00aa00", "label": "Working" } ], "foxglovePanelTitle": "E-stop Status" }, - "Teleop!1qlq2qt": { + "Joy!3fmstz6": { "topic": "/cmd_vel", - "publishRate": 1, + "publishRate": 5, "upButton": { "field": "linear-x", - "value": 0.5 + "value": 1 }, "downButton": { "field": "linear-x", - "value": -0.5 + "value": -1 }, "leftButton": { "field": "angular-z", - "value": 0.7 + "value": 1 }, "rightButton": { "field": "angular-z", - "value": -0.7 + "value": 0 }, - "foxglovePanelTitle": "" - }, - "Teleop!1mtrw1e": { - "topic": "/cmd_vel", - "publishRate": 1, - "upButton": { + "xValue": { "field": "linear-x", - "value": 1.5 + "value": 1 }, - "downButton": { + "yValue": { + "field": "angular-z", + "value": -1 + }, + "xAxis": { "field": "linear-x", - "value": -1.5 + "maxSpeed": 0.5, + "minSpeed": 0.5 }, - "leftButton": { + "yAxis": { "field": "angular-z", - "value": 3 + "maxSpeed": 0.75, + "minSpeed": -0.75 + } + }, + "Joy!7eprst": { + "topic": "/cmd_vel", + "publishRate": 5, + "xAxis": { + "field": "linear-x", + "maxSpeed": 2, + "minSpeed": -2 }, - "rightButton": { + "yAxis": { "field": "angular-z", - "value": -3 + "maxSpeed": 2.5, + "minSpeed": -2.5 } }, "3D!2a7yeqc": { @@ -272,7 +283,7 @@ }, "second": "Tab!1l9vxsv", "direction": "column", - "splitPercentage": 21.976401179941004 + "splitPercentage": 21.242937853107343 }, "second": { "first": "Battery!wppv5y", @@ -281,7 +292,7 @@ "splitPercentage": 21.976401179941004 }, "direction": "row", - "splitPercentage": 74.1822429906542 + "splitPercentage": 76.81272643821838 } }, { @@ -300,7 +311,9 @@ ] } }, - "globalVariables": {}, + "globalVariables": { + "": "\"\"" + }, "userNodes": {}, "layout": "Tab!62jad4" } diff --git a/packages/studio-base/src/i18n/en/panels.ts b/packages/studio-base/src/i18n/en/panels.ts index 920b616..bb3e39b 100644 --- a/packages/studio-base/src/i18n/en/panels.ts +++ b/packages/studio-base/src/i18n/en/panels.ts @@ -17,6 +17,8 @@ export const panels = { imageDescription: "Display annotated images.", indicator: "Indicator", indicatorDescription: "Display a colored and/or textual indicator based on a threshold value.", + joy: "Joy", + joyDescription: "Teleoperate a robot over a live connection.", log: "Log", logDescription: "Display logs by node and severity level.", map: "Map", diff --git a/packages/studio-base/src/panels/Battery/Battery.tsx b/packages/studio-base/src/panels/Battery/Battery.tsx index 38dea44..71eb48a 100644 --- a/packages/studio-base/src/panels/Battery/Battery.tsx +++ b/packages/studio-base/src/panels/Battery/Battery.tsx @@ -14,7 +14,7 @@ import { simpleGetMessagePathDataItems } from "@foxglove/studio-base/components/ import { settingsActionReducer, useSettingsTree } from "./settings"; import type { Config } from "./types"; -import "./styles.css"; // Assuming you have the CSS styles in a separate file +import "./styles.css"; type Props = { context: PanelExtensionContext; diff --git a/packages/studio-base/src/panels/Joy/DirectionalPad.stories.tsx b/packages/studio-base/src/panels/Joy/DirectionalPad.stories.tsx new file mode 100644 index 0000000..f787925 --- /dev/null +++ b/packages/studio-base/src/panels/Joy/DirectionalPad.stories.tsx @@ -0,0 +1,25 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { action } from "@storybook/addon-actions"; +import { StoryObj } from "@storybook/react"; + +import DirectionalPad from "./DirectionalPad"; + +export default { + title: "panels/Teleop/DirectionalPad", + component: DirectionalPad, +}; + +export const Basic: StoryObj = { + render: () => { + return ; + }, +}; + +export const Disabled: StoryObj = { + render: () => { + return ; + }, +}; diff --git a/packages/studio-base/src/panels/Joy/DirectionalPad.tsx b/packages/studio-base/src/panels/Joy/DirectionalPad.tsx new file mode 100644 index 0000000..e0889a6 --- /dev/null +++ b/packages/studio-base/src/panels/Joy/DirectionalPad.tsx @@ -0,0 +1,111 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import React, { useCallback, useState, useRef } from "react"; + +import Stack from "@foxglove/studio-base/components/Stack"; +import "./styles.css"; + +// Type for the Joystick Props +type DirectionalPadProps = { + disabled?: boolean; + onSpeedChange?: (pos: { x: number; y: number }) => void; +}; + +// Component for the DirectionalPad +function DirectionalPad(props: DirectionalPadProps): JSX.Element { + const { onSpeedChange, disabled = false } = props; + const [speed, setSpeed] = useState<{ x: number; y: number } | undefined>(); + const [startPos, setStartPos] = useState<{ x: number; y: number } | undefined>(); + const [isDragging, setIsDragging] = useState(false); + const joystickHeadRef = useRef(null); // Ref for the joystick head + + // Mouse down event to initiate dragging + const handleMouseDownOnJoystick = useCallback((event: React.MouseEvent) => { + setIsDragging(true); + setStartPos({ x: event.clientX, y: event.clientY }); + if (joystickHeadRef.current) { + joystickHeadRef.current.style.cursor = 'grabbing'; + joystickHeadRef.current.style.animation = 'none'; + } + }, []); + + + const handleMouseMove = useCallback((event: MouseEvent) => { + if (isDragging && startPos && joystickHeadRef.current) { + let dx = event.clientX - startPos.x; + let dy = event.clientY - startPos.y; + + // Calculate the distance from the center to the new position + const distance = Math.sqrt(dx * dx + dy * dy); + const maxDistance = 130; // Assuming the joystick can move 75px in any direction from the center + + // If the distance is more than allowed, clamp it to the circular boundary + if (distance > maxDistance) { + dx *= maxDistance / distance; + dy *= maxDistance / distance; + } + + const v_x = Math.round(-dy)/maxDistance; + const v_y = Math.round(-dx)/maxDistance; + + setSpeed({x: v_x, y: v_y}); + if(!disabled) + { + onSpeedChange?.({x: v_x, y: v_y}); + } + + joystickHeadRef.current.style.transform = `translate(${dx}px, ${dy}px)`; + } + }, [isDragging, startPos, onSpeedChange, disabled]); + + // Mouse up event to end dragging + const handleMouseUp = useCallback(() => { + if (speed != undefined || isDragging) { + setIsDragging(false); + setSpeed(undefined); + props.onSpeedChange?.({x: 0, y: 0}); + if (joystickHeadRef.current) { + joystickHeadRef.current.style.cursor = ''; + joystickHeadRef.current.style.transform = ''; + // joystickHeadRef.current.style.animation = 'glow'; + } + } + }, [isDragging, speed, props]); + + // UseEffect hook to add and remove the global event listeners + React.useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + } + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( + +
+
+
+
+
+
+
+
+
+ {/* Note below joystick for action feedback */} +
+ X: {speed?.x.toFixed(2) ?? '0.00'} Y: {speed?.y.toFixed(2) ?? '0.00'} +
+
+
+
+ ); +} + +export default DirectionalPad; diff --git a/packages/studio-base/src/panels/Joy/Joy.tsx b/packages/studio-base/src/panels/Joy/Joy.tsx new file mode 100644 index 0000000..9c895eb --- /dev/null +++ b/packages/studio-base/src/panels/Joy/Joy.tsx @@ -0,0 +1,259 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import * as _ from "lodash-es"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { DeepPartial } from "ts-essentials"; + +import { ros1 } from "@foxglove/rosmsg-msgs-common"; +import { + PanelExtensionContext, + SettingsTreeAction, + SettingsTreeNode, + SettingsTreeNodes, + Topic, +} from "@foxglove/studio"; +import EmptyState from "@foxglove/studio-base/components/EmptyState"; +import Stack from "@foxglove/studio-base/components/Stack"; +import ThemeProvider from "@foxglove/studio-base/theme/ThemeProvider"; + +import DirectionalPad from "./DirectionalPad"; + +type JoyProps = { + context: PanelExtensionContext; +}; + +const geometryMsgOptions = [ + { label: "linear-x", value: "linear-x" }, + { label: "linear-y", value: "linear-y" }, + { label: "linear-z", value: "linear-z" }, + { label: "angular-x", value: "angular-x" }, + { label: "angular-y", value: "angular-y" }, + { label: "angular-z", value: "angular-z" }, +]; + +type Axis = { field: string; maxSpeed: number, minSpeed: number } + +type Config = { + topic: undefined | string; + publishRate: number; + xAxis: Axis; + yAxis: Axis; +}; + +function buildSettingsTree(config: Config, topics: readonly Topic[]): SettingsTreeNodes { + const general: SettingsTreeNode = { + label: "General", + fields: { + publishRate: { label: "Publish rate", input: "number", value: config.publishRate }, + topic: { + label: "Topic", + input: "autocomplete", + value: config.topic, + items: topics.map((t) => t.name), + }, + }, + children: { + xAxis: { + label: "X Axis", + fields: { + field: { + label: "Field", + input: "select", + value: config.xAxis.field, + options: geometryMsgOptions, + }, + maxSpeed: { label: "Max Speed", input: "number", value: config.xAxis.maxSpeed, step: 0.25, min: 0, max: 10 }, + minSpeed: { label: "Min Speed", input: "number", value: config.xAxis.minSpeed, step: 0.25, min: -10, max: 0 }, + }, + }, + yAxis: { + label: "Y Axis", + fields: { + field: { + label: "Field", + input: "select", + value: config.yAxis.field, + options: geometryMsgOptions, + }, + maxSpeed: { label: "Max Speed", input: "number", value: config.yAxis.maxSpeed, step: 0.25, min: 0, max: 10 }, + minSpeed: { label: "Min Speed", input: "number", value: config.yAxis.minSpeed, step: 0.25, min: -10, max: 0 }, + }, + }, + }, + }; + + return { general }; +} + +function Joy(props: JoyProps): JSX.Element { + const { context } = props; + const { saveState } = context; + + const [speed, setVelocity] = useState<{ x: number; y: number } | undefined>(); + const [topics, setTopics] = useState([]); + + // resolve an initial config which may have some missing fields into a full config + const [config, setConfig] = useState(() => { + const partialConfig = context.initialState as DeepPartial; + + const { + topic, + publishRate = 5, + xAxis: { field: xAxisField = "linear-x", maxSpeed: xMax = 1, minSpeed: xMin = -1 } = {}, + yAxis: { field: yAxisField = "angular-z", maxSpeed: yMax = 1, minSpeed: yMin = -1 } = {}, + } = partialConfig; + + return { + topic, + publishRate, + xAxis: { field: xAxisField, maxSpeed: xMax, minSpeed: xMin }, + yAxis: { field: yAxisField, maxSpeed: yMax, minSpeed: yMin }, + }; + }); + + const settingsActionHandler = useCallback((action: SettingsTreeAction) => { + if (action.action !== "update") { + return; + } + + setConfig((previous) => { + const newConfig = { ...previous }; + _.set(newConfig, action.payload.path.slice(1), action.payload.value); + return newConfig; + }); + }, []); + + // setup context render handler and render done handling + const [renderDone, setRenderDone] = useState<() => void>(() => () => {}); + const [colorScheme, setColorScheme] = useState<"dark" | "light">("light"); + useLayoutEffect(() => { + context.watch("topics"); + context.watch("colorScheme"); + + context.onRender = (renderState, done) => { + setTopics(renderState.topics ?? []); + setRenderDone(() => done); + if (renderState.colorScheme) { + setColorScheme(renderState.colorScheme); + } + }; + }, [context]); + + useEffect(() => { + const tree = buildSettingsTree(config, topics); + context.updatePanelSettingsEditor({ + actionHandler: settingsActionHandler, + nodes: tree, + }); + saveState(config); + }, [config, context, saveState, settingsActionHandler, topics]); + + // advertise topic + const { topic: currentTopic } = config; + useLayoutEffect(() => { + if (!currentTopic) { + return; + } + + context.advertise?.(currentTopic, "geometry_msgs/Twist", { + datatypes: new Map([ + ["geometry_msgs/Vector3", ros1["geometry_msgs/Vector3"]], + ["geometry_msgs/Twist", ros1["geometry_msgs/Twist"]], + ]), + }); + + return () => { + context.unadvertise?.(currentTopic); + }; + }, [context, currentTopic]); + + useLayoutEffect(() => { + if ((speed == undefined) || !currentTopic) { + return; + } + + const message = { + linear: {x: 0, y: 0, z: 0}, + angular: {x: 0, y: 0, z: 0}, + }; + + const scaleJoyValue = (axis: Axis, value: number): number => + value > 0 ? value * axis.maxSpeed : value * -axis.minSpeed; + + function setTwistValue(axis: Axis, value: number) { + switch (axis.field) { + case "linear-x": + message.linear.x = scaleJoyValue(axis, value); + break; + case "linear-y": + message.linear.y = scaleJoyValue(axis, value); + break; + case "linear-z": + message.linear.z = scaleJoyValue(axis, value); + break; + case "angular-x": + message.angular.x = scaleJoyValue(axis, value); + break; + case "angular-y": + message.angular.y = scaleJoyValue(axis, value); + break; + case "angular-z": + message.angular.z = scaleJoyValue(axis, value); + break; + } + } + + setTwistValue(config.xAxis, speed.x); + setTwistValue(config.yAxis, speed.y); + + // don't publish if rate is 0 or negative - this is a config error on user's part + if (config.publishRate <= 0) { + return; + } + + const intervalMs = (1000 * 1) / config.publishRate; + context.publish?.(currentTopic, message); + const intervalHandle = setInterval(() => { + context.publish?.(currentTopic, message); + }, intervalMs); + + return () => { + clearInterval(intervalHandle); + }; + }, [context, config, currentTopic, speed]); + + useLayoutEffect(() => { + renderDone(); + }, [renderDone]); + + const canPublish = context.publish != undefined && config.publishRate > 0; + const hasTopic = Boolean(currentTopic); + const enabled = canPublish && hasTopic; + + return ( + + + {!canPublish && Connect to a data source that supports publishing} + {canPublish && !hasTopic && ( + Select a publish topic in the panel settings + )} + {enabled && { + setVelocity(value); + }} + />} + + + + ); +} + +export default Joy; diff --git a/packages/studio-base/src/panels/Joy/index.stories.tsx b/packages/studio-base/src/panels/Joy/index.stories.tsx new file mode 100644 index 0000000..3eaf2d6 --- /dev/null +++ b/packages/studio-base/src/panels/Joy/index.stories.tsx @@ -0,0 +1,51 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { action } from "@storybook/addon-actions"; +import { StoryFn, StoryContext, StoryObj } from "@storybook/react"; + +import { PlayerCapabilities } from "@foxglove/studio-base/players/types"; +import PanelSetup from "@foxglove/studio-base/stories/PanelSetup"; + +import Joy from "./index"; + +export default { + title: "panels/Teleop", + component: Joy, + decorators: [ + (StoryComponent: StoryFn, context: StoryContext): JSX.Element => { + return ( + + + + ); + }, + ], +}; + +export const Unconfigured: StoryObj = { + render: () => { + return ; + }, +}; + +export const WithSettings: StoryObj = { + render: function Story() { + return ; + }, + + parameters: { + colorScheme: "light", + includeSettings: true, + }, +}; + +export const WithTopic: StoryObj = { + render: () => { + return ; + }, +}; diff --git a/packages/studio-base/src/panels/Joy/index.tsx b/packages/studio-base/src/panels/Joy/index.tsx new file mode 100644 index 0000000..5aa1293 --- /dev/null +++ b/packages/studio-base/src/panels/Joy/index.tsx @@ -0,0 +1,55 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { StrictMode, useMemo } from "react"; +import ReactDOM from "react-dom"; + +import { useCrash } from "@foxglove/hooks"; +import { PanelExtensionContext } from "@foxglove/studio"; +import { CaptureErrorBoundary } from "@foxglove/studio-base/components/CaptureErrorBoundary"; +import Panel from "@foxglove/studio-base/components/Panel"; +import { PanelExtensionAdapter } from "@foxglove/studio-base/components/PanelExtensionAdapter"; +import { SaveConfig } from "@foxglove/studio-base/types/panels"; + +import Joy from "./Joy"; + +function initPanel(crash: ReturnType, context: PanelExtensionContext) { + // eslint-disable-next-line react/no-deprecated + ReactDOM.render( + + + + + , + context.panelElement, + ); + return () => { + // eslint-disable-next-line react/no-deprecated + ReactDOM.unmountComponentAtNode(context.panelElement); + }; +} + +type Props = { + config: unknown; + saveConfig: SaveConfig; +}; + +function JoyAdapter(props: Props) { + const crash = useCrash(); + const boundInitPanel = useMemo(() => initPanel.bind(undefined, crash), [crash]); + + return ( + + ); +} + +JoyAdapter.panelType = "Teleop"; +JoyAdapter.defaultConfig = {}; + +export default Panel(JoyAdapter); diff --git a/packages/studio-base/src/panels/Joy/styles.css b/packages/studio-base/src/panels/Joy/styles.css new file mode 100644 index 0000000..46e9460 --- /dev/null +++ b/packages/studio-base/src/panels/Joy/styles.css @@ -0,0 +1,116 @@ +body { + /* https://coolors.co/f06449-ede6e3-7d82b8-36382e-613f75 */ + --background-color: #ede6e3; + --joystick-color: #888; + --joystick-head-color: #f64; + --text-color: #210124; + + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--background-color); +} + +#center { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +#game { + display: grid; + grid-template-rows: auto 1fr; + gap: 50px; +} + +#joystick { + position: relative; + background-color: var(--joystick-color); + border-radius: 50%; + width: 300px; + height: 300px; + display: flex; + align-items: center; + justify-content: center; + margin: 10px 50px; + grid-row: 2; +} + +#joystick-head { + position: relative; + background-color: var(--joystick-head-color); + border-radius: 50%; + width: 130px; + height: 130px; + cursor: grab; + + animation-name: glow; + animation-duration: 0.6s; + animation-iteration-count: infinite; + animation-direction: alternate; + animation-timing-function: ease-in-out; + animation-delay: 4s; +} + +@keyframes glow { + 0% { + transform: scale(1); + } + 100% { + transform: scale(1.2); + } +} + +.joystick-arrow:nth-of-type(1) { + position: absolute; + bottom: 335px; + + width: 0; + height: 0; + border-left: 40px solid transparent; + border-right: 40px solid transparent; + + border-bottom: 40px solid var(--joystick-color); +} + +.joystick-arrow:nth-of-type(2) { + position: absolute; + top: 335px; + + width: 0; + height: 0; + border-left: 40px solid transparent; + border-right: 40px solid transparent; + + border-top: 40px solid var(--joystick-color); +} + +.joystick-arrow:nth-of-type(3) { + position: absolute; + left: 335px; + + width: 0; + height: 0; + border-top: 40px solid transparent; + border-bottom: 40px solid transparent; + + border-left: 40px solid var(--joystick-color); +} + +.joystick-arrow:nth-of-type(4) { + position: absolute; + right: 335px; + + width: 0; + height: 0; + border-top: 40px solid transparent; + border-bottom: 40px solid transparent; + + border-right: 40px solid var(--joystick-color); +} + +#note { + grid-row: 3; + text-align: center; + font-size: 2em; + border-top: 40px solid transparent; +} diff --git a/packages/studio-base/src/panels/Joy/thumbnail.png b/packages/studio-base/src/panels/Joy/thumbnail.png new file mode 100644 index 0000000..d33b58a --- /dev/null +++ b/packages/studio-base/src/panels/Joy/thumbnail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3954122410a10ce36ca7920d0ad991846d9cb6a06929fa3899ec2b819a8b17b +size 4259 diff --git a/packages/studio-base/src/panels/TriggerButton/TriggerButton.tsx b/packages/studio-base/src/panels/TriggerButton/TriggerButton.tsx index b26f7cf..3ab037e 100644 --- a/packages/studio-base/src/panels/TriggerButton/TriggerButton.tsx +++ b/packages/studio-base/src/panels/TriggerButton/TriggerButton.tsx @@ -14,6 +14,9 @@ import ThemeProvider from "@foxglove/studio-base/theme/ThemeProvider"; import { defaultConfig, settingsActionReducer, useSettingsTree } from "./settings"; +import "./styles.css"; + + const log = Log.getLogger(__dirname); type Props = { @@ -244,38 +247,43 @@ function TriggerButtonContent( )} {!config.advancedView && ( - - {statusMessage && ( - - {statusMessage} - - )} - - - - - + + +
+ + {statusMessage && ( + + {statusMessage} + + )} + + + + + + +
)}
diff --git a/packages/studio-base/src/panels/TriggerButton/styles.css b/packages/studio-base/src/panels/TriggerButton/styles.css new file mode 100644 index 0000000..ea318ec --- /dev/null +++ b/packages/studio-base/src/panels/TriggerButton/styles.css @@ -0,0 +1,6 @@ +#center { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} diff --git a/packages/studio-base/src/panels/index.ts b/packages/studio-base/src/panels/index.ts index 0aef24e..b5c0369 100644 --- a/packages/studio-base/src/panels/index.ts +++ b/packages/studio-base/src/panels/index.ts @@ -6,17 +6,17 @@ import { TFunction } from "i18next"; import { PanelInfo } from "@foxglove/studio-base/context/PanelCatalogContext"; import { TAB_PANEL_TYPE } from "@foxglove/studio-base/util/globalConstants"; +import batteryThumbnail from "./Battery/thumbnail.png"; import dataSourceInfoThumbnail from "./DataSourceInfo/thumbnail.png"; import gaugeThumbnail from "./Gauge/thumbnail.png"; -import batteryThumbnail from "./Battery/thumbnail.png"; import imageThumbnail from "./Image/thumbnail.png"; import indicatorThumbnail from "./Indicator/thumbnail.png"; +import joyThumbnail from "./Joy/thumbnail.png"; import logThumbnail from "./Log/thumbnail.png"; import mapThumbnail from "./Map/thumbnail.png"; import parametersThumbnail from "./Parameters/thumbnail.png"; import plotThumbnail from "./Plot/thumbnail.png"; import publishThumbnail from "./Publish/thumbnail.png"; -import triggerButtonThumbnail from "./TriggerButton/thumbnail.png"; import rawMessagesThumbnail from "./RawMessages/thumbnail.png"; import stateTransitionsThumbnail from "./StateTransitions/thumbnail.png"; import tabThumbnail from "./Tab/thumbnail.png"; @@ -24,6 +24,7 @@ import tableThumbnail from "./Table/thumbnail.png"; import teleopThumbnail from "./Teleop/thumbnail.png"; import threeDeeRenderThumbnail from "./ThreeDeeRender/thumbnail.png"; import topicGraphThumbnail from "./TopicGraph/thumbnail.png"; +import triggerButtonThumbnail from "./TriggerButton/thumbnail.png"; import variableSliderThumbnail from "./VariableSlider/thumbnail.png"; import diagnosticStatusThumbnail from "./diagnostics/thumbnails/diagnostic-status.png"; import diagnosticSummaryThumbnail from "./diagnostics/thumbnails/diagnostic-summary.png"; @@ -80,6 +81,13 @@ export const getBuiltin: (t: TFunction<"panels">) => PanelInfo[] = (t) => [ thumbnail: gaugeThumbnail, module: async () => await import("./Gauge"), }, + { + title: t("joy"), + type: "Joy", + description: t("joyDescription"), + thumbnail: joyThumbnail, + module: async () => await import("./Joy"), + }, { title: t("teleop"), type: "Teleop",