// src/engine/ui/Slider.js
import BaseUIElement from './BaseUIElement.js'
import Label from './Label.js' // For displaying the value
/**
* @class Slider
* @extends BaseUIElement
* @description An interactive UI element for selecting a value within a range by dragging a thumb.
*/
class Slider extends BaseUIElement {
/**
* Creates an instance of Slider.
* @param {object} options - Configuration options for the slider.
* @param {number} [options.x=0] - The x-coordinate.
* @param {number} [options.y=0] - The y-coordinate.
* @param {number} [options.width=200] - The length of the slider track for a horizontal slider, or width if vertical.
* @param {number} [options.height=20] - The thickness of the slider track for a horizontal slider, or height if vertical. (This is for the main clickable area)
* @param {number} [options.minValue=0] - The minimum value of the slider.
* @param {number} [options.maxValue=100] - The maximum value of the slider.
* @param {number} [options.currentValue=options.minValue] - The initial value of the slider.
* @param {number} [options.step=1] - The increment step for the slider's value. Use 0 for continuous.
* @param {string} [options.orientation='horizontal'] - 'horizontal' or 'vertical'.
* @param {string} [options.trackColor='#555555'] - Color of the slider track.
* @param {string} [options.thumbColor='dodgerblue'] - Color of the slider thumb.
* @param {string} [options.hoverThumbColor='deepskyblue'] - Color of the thumb when hovered.
* @param {string} [options.pressedThumbColor='royalblue'] - Color of the thumb when pressed/dragged.
* @param {number} [options.thumbWidth=10] - Width of the thumb for a horizontal slider.
* @param {number} [options.thumbHeight=20] - Height of the thumb for a horizontal slider. (Should be >= track height)
* @param {boolean} [options.showValueText=false] - Whether to display the current value as text.
* @param {string} [options.valueTextFont='12px sans-serif'] - Font for the value text.
* @param {string} [options.valueTextColor='white'] - Color for the value text.
* @param {function(number): string} [options.valueTextFormatFunction] - Formats the value text. Receives (currentValue).
* @param {function(number): void} [options.onValueChanged] - Callback when the value changes. Receives (newValue).
* @param {boolean} [options.visible=true]
* @param {boolean} [options.enabled=true]
* @param {string} [options.id]
*/
constructor(options = {}) {
// BaseUIElement's width/height will represent the main track's clickable area
super(options)
this.minValue = options.minValue || 0
this.maxValue = options.maxValue !== undefined ? options.maxValue : 100
this.step = options.step !== undefined ? options.step : 1 // 0 or null for continuous
this.currentValue = options.currentValue !== undefined ? options.currentValue : this.minValue
this.currentValue = this._snapToStep(this._clampValue(this.currentValue))
this.orientation = options.orientation || 'horizontal'
this.trackColor = options.trackColor || '#555555'
this.thumbColor = options.thumbColor || 'dodgerblue'
this.hoverThumbColor = options.hoverThumbColor || 'deepskyblue'
this.pressedThumbColor = options.pressedThumbColor || 'royalblue'
this.disabledColor = options.disabledColor || '#333333'
this.disabledThumbColor = options.disabledThumbColor || '#444444'
// Thumb dimensions
// For horizontal: width is its thickness across track, height is its visual height
// For vertical: width is its visual width, height is its thickness across track
if (this.orientation === 'horizontal') {
this.thumbWidth = options.thumbWidth || 10
this.thumbHeight = options.thumbHeight || this.height // Default to track height
} else {
// vertical
this.thumbWidth = options.thumbWidth || this.width // Default to track width
this.thumbHeight = options.thumbHeight || 10
}
this.showValueText = options.showValueText || false
this.valueTextFont = options.valueTextFont || '12px sans-serif'
this.valueTextColor = options.valueTextColor || 'white'
this.valueTextFormatFunction =
options.valueTextFormatFunction || ((value) => `${Math.round(value)}`)
this.onValueChanged = options.onValueChanged || null
this.isDragging = false
this.isHoveredOnThumb = false
this._thumbRect = { x: 0, y: 0, width: 0, height: 0 } // Internal, for hit detection
this._updateThumbRect()
}
_clampValue(value) {
return Math.max(this.minValue, Math.min(value, this.maxValue))
}
_snapToStep(value) {
if (this.step > 0) {
return Math.round((value - this.minValue) / this.step) * this.step + this.minValue
}
return value
}
/**
* Calculates the thumb's current rectangle based on currentValue.
* @private
*/
_updateThumbRect() {
const valueRatio = (this.currentValue - this.minValue) / (this.maxValue - this.minValue || 1)
if (this.orientation === 'horizontal') {
const trackInnerWidth = this.width - this.thumbWidth // Available travel distance for thumb's center
const thumbCenterX = this.x + this.thumbWidth / 2 + trackInnerWidth * valueRatio
this._thumbRect.x = thumbCenterX - this.thumbWidth / 2
this._thumbRect.y = this.y + this.height / 2 - this.thumbHeight / 2 // Center thumb on track
this._thumbRect.width = this.thumbWidth
this._thumbRect.height = this.thumbHeight
} else {
// Vertical
const trackInnerHeight = this.height - this.thumbHeight // Available travel distance
const thumbCenterY = this.y + this.thumbHeight / 2 + trackInnerHeight * valueRatio
this._thumbRect.x = this.x + this.width / 2 - this.thumbWidth / 2 // Center thumb on track
this._thumbRect.y = thumbCenterY - this.thumbHeight / 2
this._thumbRect.width = this.thumbWidth
this._thumbRect.height = this.thumbHeight
}
}
_pixelToValue(pixelPos) {
let ratio = 0
if (this.orientation === 'horizontal') {
const trackInnerWidth = this.width - this.thumbWidth
// Convert mouseX to position of thumb's center relative to track start for thumb's center
const thumbCenterRelativeX = Math.max(
0,
Math.min(pixelPos.x - (this.x + this.thumbWidth / 2), trackInnerWidth),
)
ratio = trackInnerWidth > 0 ? thumbCenterRelativeX / trackInnerWidth : 0
} else {
// Vertical
const trackInnerHeight = this.height - this.thumbHeight
const thumbCenterRelativeY = Math.max(
0,
Math.min(pixelPos.y - (this.y + this.thumbHeight / 2), trackInnerHeight),
)
ratio = trackInnerHeight > 0 ? thumbCenterRelativeY / trackInnerHeight : 0
}
let value = this.minValue + (this.maxValue - this.minValue) * ratio
return this._snapToStep(this._clampValue(value))
}
setValue(value, triggerCallback = true) {
const clampedValue = this._clampValue(value)
const snappedValue = this._snapToStep(clampedValue)
if (this.currentValue !== snappedValue) {
this.currentValue = snappedValue
this._updateThumbRect()
if (this.onValueChanged && triggerCallback) {
this.onValueChanged(this.currentValue)
}
}
}
update(deltaTime, engine, mousePos) {
super.update(deltaTime, engine, mousePos)
if (!this.visible || !this.enabled) {
this.isDragging = false
this.isHoveredOnThumb = false
return
}
const inputManager = engine.inputManager
const leftButton = inputManager.constructor.MOUSE_BUTTON_LEFT
this.isHoveredOnThumb = this.containsPoint(mousePos.x, mousePos.y, this._thumbRect)
const isHoveredOnTrack = this.containsPoint(mousePos.x, mousePos.y) // Check hover on whole element
if (inputManager.isMouseButtonJustPressed(leftButton)) {
if (this.isHoveredOnThumb) {
this.isDragging = true
} else if (isHoveredOnTrack) {
// Clicked on track, not thumb
const newValue = this._pixelToValue(mousePos)
this.setValue(newValue)
this.isDragging = true // Allow dragging immediately from new position
}
}
if (this.isDragging) {
if (inputManager.isMouseButtonPressed(leftButton)) {
const newValue = this._pixelToValue(mousePos)
this.setValue(newValue) // setValue calls _updateThumbRect and callback
} else {
// Mouse button released
this.isDragging = false
}
}
}
/**
* Overloaded containsPoint to check against a specific rect (for thumb).
* @param {number} px
* @param {number} py
* @param {object} [rect=this] - The rectangle to check against (defaults to the element itself).
* @returns {boolean}
*/
containsPoint(px, py, rect = this) {
return px >= rect.x && px <= rect.x + rect.width && py >= rect.y && py <= rect.y + rect.height
}
_drawSelf(context, engine) {
const currentTrackColor = !this.enabled ? this.disabledColor : this.trackColor
let currentThumbColor = !this.enabled ? this.disabledThumbColor : this.thumbColor
if (this.enabled) {
if (this.isDragging) {
currentThumbColor = this.pressedThumbColor
} else if (this.isHoveredOnThumb && this.hoverThumbColor) {
currentThumbColor = this.hoverThumbColor
}
}
// Draw Track
context.fillStyle = currentTrackColor
if (this.orientation === 'horizontal') {
const trackVisualY = this.y + this.height / 2 - (this.options.trackVisualThickness || 4) / 2
const trackVisualThickness = this.options.trackVisualThickness || Math.min(this.height / 2, 4)
context.fillRect(this.x, trackVisualY, this.width, trackVisualThickness)
} else {
// Vertical
const trackVisualX = this.x + this.width / 2 - (this.options.trackVisualThickness || 4) / 2
const trackVisualThickness = this.options.trackVisualThickness || Math.min(this.width / 2, 4)
context.fillRect(trackVisualX, this.y, trackVisualThickness, this.height)
}
// Draw Thumb
this._updateThumbRect() // Ensure thumb rect is up-to-date before drawing
context.fillStyle = currentThumbColor
context.fillRect(
this._thumbRect.x,
this._thumbRect.y,
this._thumbRect.width,
this._thumbRect.height,
)
// Optional: Draw border around thumb
// context.strokeStyle = 'black';
// context.strokeRect(this._thumbRect.x, this._thumbRect.y, this._thumbRect.width, this._thumbRect.height);
// Draw Value Text
if (this.showValueText) {
context.font = this.valueTextFont
context.fillStyle = !this.enabled ? this.disabledColor : this.valueTextColor
context.textAlign = 'center'
context.textBaseline = 'middle'
const textToDisplay = this.valueTextFormatFunction(this.currentValue)
if (this.orientation === 'horizontal') {
context.fillText(
textToDisplay,
this.x + this.width + 10 + context.measureText(textToDisplay).width / 2,
this.y + this.height / 2,
)
} else {
// Vertical
context.fillText(
textToDisplay,
this.x + this.width / 2,
this.y + this.height + 10 + parseFloat(this.valueTextFont) / 2,
)
}
}
}
}
export default Slider