430 lines
11 KiB
JavaScript
430 lines
11 KiB
JavaScript
|
||
|
||
|
||
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 don’t 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);
|
||
}
|
||
}
|
||
|
||
|