sophia_controller/ros_robot_visualiser/ui/canvasui.js

430 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

class UIElement {
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) {
return px >= this.x && px <= this.x + this.w &&
py >= this.y && py <= this.y + this.h;
}
handlePointerEvent(event, px, py) {
switch (event.type) {
case 'pointermove':
this.onPointerMove(px, py);
break;
case 'pointerdown':
this.onPointerDown(px, py);
break;
case 'pointerup':
this.onPointerUp(px, py);
break;
case 'click':
// optional: treat click as pointerup for convenience
if (this.contains(px, py)) {
this.onClick?.(); // if subclass defines onClick
}
break;
}
}
onPointerMove(px, py) {
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;
return true;
}
return false;
}
onPointerUp(px, py) {
if (this.active && this.contains(px, py)) {
this.active = false;
return true;
}
this.active = false;
return false;
}
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 dont need pointer/key handling, so we leave defaults
}
export class Panel extends UIElement {
constructor(x, y, w, h, title = '', tooltipText = null) {
super(x, y, w, h, tooltipText);
this.title = title;
this.elements = [];
}
addElement(el) { this.elements.push(el); }
draw(ctx) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = '#fff';
ctx.strokeRect(this.x, this.y, this.w, this.h);
if (this.title) {
ctx.fillStyle = '#fff';
ctx.font = '16px monospace';
ctx.fillText(this.title, this.x + 10, this.y + 20);
}
this.elements.forEach(el => el.draw(ctx));
}
handlePointerEvent(event, px, py) {
super.handlePointerEvent(event, px, py); // panel itself
this.elements.forEach(el => el.handlePointerEvent(event, px, py));
}
handleKeyEvent(event) {
this.elements.forEach(el => {
if (el.onKeyDown) el.onKeyDown(event);
});
}
}
export class Button extends UIElement {
constructor(x, y, w, h, label, onClick, tooltipText = null) {
super(x, y, w, h, tooltipText);
this.label = label;
this.onClick = onClick;
}
draw(ctx) {
// ctx.fillStyle = this.active ? '#225433' :
// this.hovered ? '#666' : '#333';
// ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.fillStyle = '#fff';
if (this.hovered) {
ctx.fillStyle = '#00a808ff';
}
ctx.font = '14px monospace';
ctx.fillText(this.label, this.x + 10, this.y + this.h - 8);
super.draw(ctx);
}
// onPointerUp(px, py) {
// const clicked = super.onPointerUp(px, py);
// if (clicked && this.onClick) this.onClick();
// return clicked;
// }
}
export class Textbox extends UIElement {
constructor(x, y, w, h, initialValue = '', tooltipText = null) {
super(x, y, w, h, tooltipText);
this.value = initialValue;
this.focused = false;
}
draw(ctx) {
ctx.fillStyle = '#000';
ctx.fillRect(this.x, this.y, this.w, this.h);
ctx.strokeStyle = this.focused ? '#0f0' : '#aaa';
ctx.strokeRect(this.x, this.y, this.w, this.h);
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.fillText(this.value, this.x + 5, this.y + this.h - 8);
}
onPointerUp(px, py) {
const clicked = super.onPointerUp(px, py);
this.focused = clicked;
return clicked;
}
onKeyDown(event) {
if (!this.focused) return;
if (event.key >= '0' && event.key <= '9') {
this.value += event.key;
} else if (event.key === 'Backspace') {
this.value = this.value.slice(0, -1);
}
}
}
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);
}
}
export class Slider extends UIElement {
constructor(x, y, w, h, min = 0, max = 100, initialValue = 0, onChange = null) {
super(x, y, w, h);
this.min = min;
this.max = max;
this.value = initialValue;
this.onChange = onChange;
this.dragging = false;
}
draw(ctx) {
// track
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(this.x, this.y + this.h / 2);
ctx.lineTo(this.x + this.w, this.y + this.h / 2);
ctx.stroke();
// knob position
const ratio = (this.value - this.min) / (this.max - this.min);
const knobX = this.x + ratio * this.w;
const knobY = this.y + this.h / 2;
// knob
ctx.fillStyle = this.hovered || this.dragging ? '#0f0' : '#fff';
ctx.beginPath();
ctx.arc(knobX, knobY, this.h / 2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#333';
ctx.stroke();
// optional value text
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.fillText(this.value.toFixed(0), this.x + this.w + 10, this.y + this.h / 2 + 4);
}
onPointerDown(px, py) {
if (this.contains(px, py)) {
this.dragging = true;
this.updateValue(px);
return true;
}
return false;
}
onPointerMove(px, py) {
super.onPointerMove(px, py);
if (this.dragging) {
this.updateValue(px);
}
}
onPointerUp(px, py) {
if (this.dragging) {
this.dragging = false;
return true;
}
return false;
}
updateValue(px) {
const ratio = Math.min(Math.max((px - this.x) / this.w, 0), 1);
this.value = this.min + ratio * (this.max - this.min);
if (this.onChange) this.onChange(this.value);
}
}