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); } }