From f2e8764f6b563d5bc4c9ae2290e1be3cf6ae8a0f Mon Sep 17 00:00:00 2001 From: Jake Wilkinson Date: Sun, 23 Nov 2025 12:14:21 +0800 Subject: [PATCH] page styled to be a single page, no scrolling. tooltips added. sync option radioboxes implemented --- index.html | 6 +- ros_robot_visualiser/ViewerOverlay.js | 59 +++++-- ros_robot_visualiser/ui/canvasui.js | 222 ++++++++++++++++++++++++-- script.js | 2 +- style.css | 15 +- 5 files changed, 265 insertions(+), 39 deletions(-) diff --git a/index.html b/index.html index d3dbe8e..b00d8aa 100644 --- a/index.html +++ b/index.html @@ -13,13 +13,13 @@ - +
- +

Little Sophia Control Panel

@@ -174,7 +174,7 @@
- + diff --git a/ros_robot_visualiser/ViewerOverlay.js b/ros_robot_visualiser/ViewerOverlay.js index 476325e..ced0d03 100644 --- a/ros_robot_visualiser/ViewerOverlay.js +++ b/ros_robot_visualiser/ViewerOverlay.js @@ -1,4 +1,4 @@ -import { Button, Panel, Textbox } from './ui/canvasui.js'; +import { Button, Panel, Textbox, Label, Checkbox, RadioButton, RadioGroup } from './ui/canvasui.js'; export class ViewerOverlay { constructor(renderer, robot, jointAngles, findObjectByName, saveURDF, loadURDF) { @@ -49,6 +49,7 @@ export class ViewerOverlay { window.addEventListener('resize', resizeOverlay); resizeOverlay(); + this.panels.push(this.createAnimationControlPanel()); this.panels.push(this.createSystemPanel()); const handlePointerEvent = (event) => { const rect = this.overlayCanvas.getBoundingClientRect(); @@ -109,23 +110,58 @@ export class ViewerOverlay { //console.log(this.panels); } + createAnimationControlPanel() { + const w = this.overlayCanvas.width; + const h = this.overlayCanvas.height / 8; + const x = 0; + const y = this.overlayCanvas.height - this.overlayCanvas.height / 8; + const panel = new Panel(x, y, w, h, "Animation Controls"); + + const group = new RadioGroup(); + + const rb1 = new RadioButton(panel.x + 20, panel.y + 40, 16, "None", group, true, () => { + this.setSyncMode("none"); + }, ""); + const rb2 = new RadioButton(panel.x + 20, panel.y + 70, 16, "Sim -> Real", group, false, () => { + this.setSyncMode("sim_to_real"); + }, "Real robot will mirror simulated movements"); + const rb3 = new RadioButton(panel.x + 20, panel.y + 100, 16, "Real -> Sim", group, false, () => { + this.setSyncMode("real_to_sim"); + }, "Deactivate all motor torque and simulated robot will mirror real robot motion"); + + group.addButton(rb1); + group.addButton(rb2); + group.addButton(rb3); + + panel.addElement(rb1); + panel.addElement(rb2); + panel.addElement(rb3); + + + return panel; + } + + setSyncMode(mode) { + + console.log(mode); + } + createSystemPanel() { - const w = 160; - const h = 80; - const x = this.overlayCanvas.width - w - 10; // bottom right - const y = this.overlayCanvas.height - h - 10; - console.log(this.overlayCanvas.width, this.overlayCanvas.height); + const w = this.overlayCanvas.width * 0.1; + const h = this.overlayCanvas.height * 0.05; + const x = this.overlayCanvas.width * 0.9; // bottom right + const y = this.overlayCanvas.height * 0; console.log(this.overlayCanvas.width, this.overlayCanvas.height); const panel = new Panel(x, y, w, h, "System"); // Save button - panel.addElement(new Button(x + 20, y + 20, 120, 24, "Save", () => { + panel.addElement(new Button(x, h / 2, 50, 24, "Save", () => { // delegate to editor’s save this.saveURDF(this.robot); })); // Load button - panel.addElement(new Button(x + 20, y + 50, 120, 24, "Load", () => { + panel.addElement(new Button(x + w / 2, h / 2, 50, 24, "Load", () => { // delegate to editor’s load this.loadURDF(); })); @@ -137,7 +173,7 @@ export class ViewerOverlay { createMotorListPanel() { if (!this.robot?.joints) return null; this.panels = this.panels.filter(p => !p.title?.startsWith("Config:")); - const panel = new Panel(0, 0, 300, this.overlayCanvas.height, "Motors"); + const panel = new Panel(0, 0, 300, this.overlayCanvas.height / 8 * 7, "Motors"); let y = 10; for (const jointName in this.robot.joints) { @@ -214,7 +250,10 @@ export class ViewerOverlay { const panel = new Panel(400, 50, 400, 250, `Config: ${motorName}`); - const motorIdBox = new Textbox(panel.x + 100, panel.y + 45, 80, 24, motor.transmission.motorID || ''); + const motorIDLabel = new Label(panel.x + 20, panel.y + 50, 'Motor ID:'); + panel.addElement(motorIDLabel); + + const motorIdBox = new Textbox(panel.x + 100, panel.y + 35, 80, 24, motor.transmission.motorID || ''); panel.addElement(motorIdBox); panel.addElement(new Button(panel.x + 20, panel.y + 150, 80, 24, 'Save', () => { diff --git a/ros_robot_visualiser/ui/canvasui.js b/ros_robot_visualiser/ui/canvasui.js index f112a11..07fea4e 100644 --- a/ros_robot_visualiser/ui/canvasui.js +++ b/ros_robot_visualiser/ui/canvasui.js @@ -2,12 +2,17 @@ class UIElement { - constructor(x, y, w, h) { + constructor(x, y, w, h, tooltipText = null) { this.x = x; this.y = y; this.w = w; this.h = h; this.hovered = false; this.active = false; + + this.tooltipDelay = 1000; // ms before showing + this.hoverStart = null; // timestamp when hover began + this.tooltip = tooltipText ? new Tooltip(tooltipText) : null; + } contains(px, py) { @@ -33,15 +38,27 @@ class UIElement { } break; } - + } onPointerMove(px, py) { - this.hovered = this.contains(px, py); - //console.log(this); + const inside = this.contains(px, py); + + // detect entering hover + if (inside && !this.hovered) { + this.hoverStart = performance.now(); + } + + // detect leaving hover + if (!inside && this.hovered) { + this.hoverStart = null; + } + + this.hovered = inside; } + onPointerDown(px, py) { if (this.contains(px, py)) { this.active = true; @@ -59,12 +76,41 @@ class UIElement { return false; } - draw(ctx) { /* override in subclasses */ } + draw(ctx) { + if (this.tooltip && this.hovered && this.hoverStart) { + const elapsed = performance.now() - this.hoverStart; + if (elapsed >= this.tooltipDelay) { + this.tooltip.show(this.x + 100, this.y + this.h*2); + this.tooltip.draw(ctx); + } else { + this.tooltip.hide(); + } + } + } } +export class Label extends UIElement { + constructor(x, y, text, font = '14px monospace', color = '#fff', tooltipText = null) { + // width/height are optional for labels, but we can set them to 0 + super(x, y, 0, 0, tooltipText); + this.text = text; + this.font = font; + this.color = color; + } + + draw(ctx) { + ctx.fillStyle = this.color; + ctx.font = this.font; + ctx.fillText(this.text, this.x, this.y); + } + + // labels don’t need pointer/key handling, so we leave defaults +} + + export class Panel extends UIElement { - constructor(x, y, w, h, title = '') { - super(x, y, w, h); + constructor(x, y, w, h, title = '', tooltipText = null) { + super(x, y, w, h, tooltipText); this.title = title; this.elements = []; } @@ -102,8 +148,8 @@ export class Panel extends UIElement { export class Button extends UIElement { - constructor(x, y, w, h, label, onClick) { - super(x, y, w, h); + constructor(x, y, w, h, label, onClick, tooltipText = null) { + super(x, y, w, h, tooltipText); this.label = label; this.onClick = onClick; } @@ -115,17 +161,14 @@ export class Button extends UIElement { ctx.fillStyle = '#fff'; if (this.hovered) { - ctx.fillStyle = '#666'; + ctx.fillStyle = '#00a808ff'; } ctx.font = '14px monospace'; ctx.fillText(this.label, this.x + 10, this.y + this.h - 8); + super.draw(ctx); } - onPointerMove(px, py) { - this.hovered = this.contains(px, py); - //console.log(this); - } onPointerUp(px, py) { const clicked = super.onPointerUp(px, py); @@ -135,8 +178,8 @@ export class Button extends UIElement { } export class Textbox extends UIElement { - constructor(x, y, w, h, initialValue = '') { - super(x, y, w, h); + constructor(x, y, w, h, initialValue = '', tooltipText = null) { + super(x, y, w, h, tooltipText); this.value = initialValue; this.focused = false; } @@ -169,3 +212,150 @@ export class Textbox extends UIElement { } } +export class Checkbox extends UIElement { + constructor(x, y, size = 16, label = '', initialChecked = false, onChange = null, tooltipText = null) { + super(x, y, size, size, tooltipText); + this.checked = initialChecked; + this.label = label; + this.onChange = onChange; + } + + draw(ctx) { + // box + ctx.fillStyle = '#000'; + ctx.fillRect(this.x, this.y, this.w, this.h); + + ctx.strokeStyle = this.hovered ? '#0f0' : '#aaa'; + ctx.strokeRect(this.x, this.y, this.w, this.h); + + // check mark + if (this.checked) { + ctx.fillStyle = '#0f0'; + ctx.fillRect(this.x + 3, this.y + 3, this.w - 6, this.h - 6); + } + + // label text + if (this.label) { + ctx.fillStyle = '#fff'; + ctx.font = '14px monospace'; + ctx.fillText(this.label, this.x + this.w + 8, this.y + this.h - 4); + } + } + + onPointerUp(px, py) { + const clicked = super.onPointerUp(px, py); + if (clicked) { + this.checked = !this.checked; + if (this.onChange) this.onChange(this.checked); + } + return clicked; + } +} + +export class RadioButton extends UIElement { + constructor(x, y, size = 16, label = '', group = null, initialSelected = false, onChange = null, tooltipText) { + super(x, y, size, size, tooltipText); + this.selected = initialSelected; + this.label = label; + this.group = group; // reference to RadioGroup + this.onChange = onChange; + } + + draw(ctx) { + // outer circle + ctx.strokeStyle = this.hovered ? '#0f0' : '#aaa'; + ctx.beginPath(); + ctx.arc(this.x + this.w / 2, this.y + this.h / 2, this.w / 2, 0, Math.PI * 2); + ctx.stroke(); + + // inner dot if selected + if (this.selected) { + ctx.fillStyle = '#0f0'; + ctx.beginPath(); + ctx.arc(this.x + this.w / 2, this.y + this.h / 2, this.w / 2 - 4, 0, Math.PI * 2); + ctx.fill(); + } + + // label text + if (this.label) { + ctx.fillStyle = '#fff'; + ctx.font = '14px monospace'; + ctx.fillText(this.label, this.x + this.w + 8, this.y + this.h - 4); + } + super.draw(ctx); + } + + onPointerUp(px, py) { + const clicked = super.onPointerUp(px, py); + if (clicked) { + if (this.group) { + this.group.select(this); // delegate exclusivity + } else { + this.selected = !this.selected; + } + if (this.onChange) this.onChange(this.selected); + } + return clicked; + } +} + +export class RadioGroup { + constructor() { + this.buttons = []; + } + + addButton(btn) { + btn.group = this; + this.buttons.push(btn); + } + + select(selectedBtn) { + this.buttons.forEach(btn => btn.selected = (btn === selectedBtn)); + } + + getSelected() { + return this.buttons.find(btn => btn.selected); + } +} + +export class Tooltip { + constructor(text) { + this.text = text; + this.visible = false; + this.x = 0; + this.y = 0; + } + + show(x, y) { + this.visible = true; + this.x = x; + this.y = y; + } + + hide() { + this.visible = false; + } + + draw(ctx) { + if (!this.visible) return; + + ctx.font = '12px monospace'; + const padding = 6; + const metrics = ctx.measureText(this.text); + const w = metrics.width + padding * 2; + const h = 20; + + ctx.fillStyle = 'rgba(0,0,0,0.8)'; + ctx.fillRect(this.x, this.y - h, w, h); + + ctx.strokeStyle = '#fff'; + ctx.strokeRect(this.x, this.y - h, w, h); + + ctx.fillStyle = '#fff'; + ctx.fillText(this.text, this.x + padding, this.y - 6); + } +} + + + + diff --git a/script.js b/script.js index 563000a..b83ec62 100644 --- a/script.js +++ b/script.js @@ -12,7 +12,7 @@ import { URDFEditor } from './ros_robot_visualiser/URDFEditor.js'; window.onload = () => { const urdfCanvas = document.getElementById('urdfCanvas'); - const editor = new URDFEditor(urdfCanvas); + const visualEditor = new URDFEditor(urdfCanvas); const serial = new SerialManager(); const servoMotors = [[], []]; // index 0 = channel 0, index 1 = channel 1 diff --git a/style.css b/style.css index f765f54..bd65202 100644 --- a/style.css +++ b/style.css @@ -1,8 +1,7 @@ -body { - margin: 0; +html, body { + margin: 0; padding: 0; - width: 100%; - height: 100%; + overflow: hidden; /* optional: prevents scrollbars entirely */ } .dial-container { @@ -25,8 +24,6 @@ body { } - - #fileListWrapper { width: 400px; max-height: 200px; @@ -91,11 +88,11 @@ body { #curveCanvas, #nodeeditor { + width: 100vw; + height: 25vh; background-color: #f0f0f0; border: 1px solid #ccc; display: block; - margin: 0 auto; - margin-bottom: 5px; } @@ -119,7 +116,7 @@ textarea { #urdf-container { position: relative; width: 100vw; - height: 100vh; + height: 75vh; overflow: hidden; }