UNPKG

spin-wheel

Version:

An easy to use, themeable component for randomising choices and prizes.

1,464 lines (1,173 loc) 42.2 kB
import * as util from './util.js'; import * as Constants from './constants.js'; import {Defaults} from './constants.js'; import * as events from './events.js'; import {Item} from './item.js'; export class Wheel { /** * Create the wheel inside a container Element and initialise it with props. * `container` must be an Element. * `props` must be an Object or null. */ constructor(container, props = {}) { // Validate params. if (!(container instanceof Element)) throw new Error('container must be an instance of Element'); if (!util.isObject(props) && props !== null) throw new Error('props must be an Object or null'); // Init some things: this._frameRequestId = null; this._rotationSpeed = 0; this._rotationDirection = 1; this._spinToTimeEnd = null; // Used to animate the wheel for spinTo() this._lastSpinFrameTime = null; // Used to animate the wheel for spin() this._isCursorOverWheel = false; this.add(container); // Assign default values. // This avoids null exceptions when we initalise each property one-by-one in `init()`. for (const i of Object.keys(Defaults.wheel)) { this['_' + i] = Defaults.wheel[i]; } if (props) { this.init(props); } else { this.init(Defaults.wheel); } } /** * Initialise all properties. */ init(props = {}) { this._isInitialising = true; this.borderColor = props.borderColor; this.borderWidth = props.borderWidth; this.debug = props.debug; this.image = props.image; this.isInteractive = props.isInteractive; this.itemBackgroundColors = props.itemBackgroundColors; this.itemLabelAlign = props.itemLabelAlign; this.itemLabelBaselineOffset = props.itemLabelBaselineOffset; this.itemLabelColors = props.itemLabelColors; this.itemLabelFont = props.itemLabelFont; this.itemLabelFontSizeMax = props.itemLabelFontSizeMax; this.itemLabelRadius = props.itemLabelRadius; this.itemLabelRadiusMax = props.itemLabelRadiusMax; this.itemLabelRotation = props.itemLabelRotation; this.itemLabelStrokeColor = props.itemLabelStrokeColor; this.itemLabelStrokeWidth = props.itemLabelStrokeWidth; this.items = props.items; this.lineColor = props.lineColor; this.lineWidth = props.lineWidth; this.pixelRatio = props.pixelRatio; this.rotationSpeedMax = props.rotationSpeedMax; this.radius = props.radius; this.rotation = props.rotation; this.rotationResistance = props.rotationResistance; this.offset = props.offset; this.onCurrentIndexChange = props.onCurrentIndexChange; this.onRest = props.onRest; this.onSpin = props.onSpin; this.overlayImage = props.overlayImage; this.pointerAngle = props.pointerAngle; } /** * Add the wheel to the DOM and register event handlers. */ add(container) { this._canvasContainer = container; this.canvas = document.createElement('canvas'); // Prevent infinte resize loop. // The canvas element has a default display of 'inline'. // This forms whitespace inside the container, making it slightly larger than it should be, // which creates a positive feedback loop when the wheel trys to resize itself. this.canvas.style.display = 'block'; this._context = this.canvas.getContext('2d'); this._canvasContainer.append(this.canvas); events.register(this); if (this._isInitialising === false) this.resize(); // Initalise the canvas's dimensions (but not when called from the constructor). } /** * Remove the wheel from the DOM and unregister event handlers. */ remove() { if (this.canvas === null) return; if (this._frameRequestId !== null) window.cancelAnimationFrame(this._frameRequestId); events.unregister(this); this._canvasContainer.removeChild(this.canvas); this._canvasContainer = null; this.canvas = null; this._context = null; } /** * Resize the wheel to fit inside it's container. * Call this after changing any property of the wheel that relates to it's size or position. */ resize() { if (this.canvas === null) return; // Set the dimensions of the canvas element to be the same as its container: this.canvas.style.width = this._canvasContainer.clientWidth + 'px'; this.canvas.style.height = this._canvasContainer.clientHeight + 'px'; // Calc the actual pixel dimensions that will be drawn: // See https://www.khronos.org/webgl/wiki/HandlingHighDPI const [w, h] = [ this._canvasContainer.clientWidth * this.getActualPixelRatio(), this._canvasContainer.clientHeight * this.getActualPixelRatio(), ]; this.canvas.width = w; this.canvas.height = h; // Calc the size that the wheel needs to be to fit in it's container: const min = Math.min(w, h); const wheelSize = { w: min - (min * this._offset.x), h: min - (min * this._offset.y), }; const scale = Math.min(w / wheelSize.w, h / wheelSize.h); this._size = Math.max(wheelSize.w * scale, wheelSize.h * scale); // Calculate the center of the wheel: this._center = { x: w / 2 + (w * this._offset.x), y: h / 2 + (h * this._offset.y), }; // Calculate the wheel radius: this._actualRadius = (this._size / 2) * this.radius; // Adjust the font size of labels so they all fit inside the wheel's radius: this._itemLabelFontSize = this.itemLabelFontSizeMax * (this._size / Constants.baseCanvasSize); this._labelMaxWidth = this._actualRadius * (this.itemLabelRadius - this.itemLabelRadiusMax); if (this.itemLabelAlign === 'center') { this._labelMaxWidth *= 2; } for (const item of this._items) { this._itemLabelFontSize = Math.min(this._itemLabelFontSize, util.getFontSizeToFit(item.label, this.itemLabelFont, this._labelMaxWidth, this._context)); } this.refresh(); } /** * Main animation loop. */ draw(now = 0) { this._frameRequestId = null; if (this._context === null || this.canvas === null) return; const ctx = this._context; // Clear canvas. ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.animateRotation(now); const angles = this.getItemAngles(this._rotation); const actualBorderWidth = this.getScaledNumber(this._borderWidth); // Set font: ctx.textBaseline = 'middle'; ctx.textAlign = this.itemLabelAlign; ctx.font = this._itemLabelFontSize + 'px ' + this.itemLabelFont; // Build paths for each item: for (const [i, a] of angles.entries()) { const item = this._items[i]; const path = new Path2D(); path.moveTo(this._center.x, this._center.y); path.arc( this._center.x, this._center.y, this._actualRadius - (actualBorderWidth / 2), util.degRad(a.start + Constants.arcAdjust), util.degRad(a.end + Constants.arcAdjust) ); item.path = path; } this.drawItemBackgrounds(ctx, angles); this.drawItemImages(ctx, angles); this.drawItemLines(ctx, angles); this.drawItemLabels(ctx, angles); this.drawBorder(ctx); this.drawImage(ctx, this._image, false); this.drawImage(ctx, this._overlayImage, true); this.drawDebugPointerLine(ctx); //this.drawDebugDragPoints(ctx); this._isInitialising = false; } drawItemBackgrounds(ctx, angles = []) { for (const [i, a] of angles.entries()) { const item = this._items[i]; ctx.fillStyle = item.backgroundColor ?? ( // Fall back to a value from the repeating set: this._itemBackgroundColors[i % this._itemBackgroundColors.length] ); ctx.fill(item.path); } } drawItemImages(ctx, angles = []) { for (const [i, a] of angles.entries()) { const item = this._items[i]; if (item.image === null) continue; ctx.save(); ctx.clip(item.path); const angle = a.start + ((a.end - a.start) / 2); ctx.translate( this._center.x + Math.cos(util.degRad(angle + Constants.arcAdjust)) * (this._actualRadius * item.imageRadius), this._center.y + Math.sin(util.degRad(angle + Constants.arcAdjust)) * (this._actualRadius * item.imageRadius) ); ctx.rotate(util.degRad(angle + item.imageRotation)); ctx.globalAlpha = item.imageOpacity; const width = (this._size / 500) * item.image.width * item.imageScale; const height = (this._size / 500) * item.image.height * item.imageScale; const widthHalf = -width / 2; const heightHalf = -height / 2; ctx.drawImage( item.image, widthHalf, heightHalf, width, height ); ctx.restore(); } } drawImage(ctx, image, isOverlay = false) { if (image === null) return; ctx.translate( this._center.x, this._center.y ); if (!isOverlay) ctx.rotate(util.degRad(this._rotation)); // Draw the image centered and scaled to fit the wheel's container: // For convenience, scale the 'normal' image to the size of the wheel radius // (so a change in the wheel radius won't require the image to also be updated). const size = isOverlay ? this._size : this._size * this.radius; const sizeHalf = -(size / 2); ctx.drawImage( image, sizeHalf, sizeHalf, size, size ); ctx.resetTransform(); } drawDebugPointerLine(ctx) { if (!this.debug) return; ctx.translate( this._center.x, this._center.y ); ctx.rotate(util.degRad(this._pointerAngle + Constants.arcAdjust)); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(this._actualRadius * 2, 0); ctx.strokeStyle = Constants.Debugging.pointerLineColor; ctx.lineWidth = this.getScaledNumber(2); ctx.stroke(); ctx.resetTransform(); } drawBorder(ctx) { if (this._borderWidth <= 0) return; const actualBorderWidth = this.getScaledNumber(this._borderWidth); const actualBorderColor = this._borderColor || 'transparent'; ctx.beginPath(); ctx.strokeStyle = actualBorderColor; ctx.lineWidth = actualBorderWidth; ctx.arc(this._center.x, this._center.y, this._actualRadius - (actualBorderWidth / 2), 0, 2 * Math.PI); ctx.stroke(); if (this.debug) { const actualDebugLineWidth = this.getScaledNumber(1); ctx.beginPath(); ctx.strokeStyle = ctx.strokeStyle = Constants.Debugging.labelRadiusColor; ctx.lineWidth = actualDebugLineWidth; ctx.arc(this._center.x, this._center.y, this._actualRadius * this.itemLabelRadius, 0, 2 * Math.PI); ctx.stroke(); ctx.beginPath(); ctx.strokeStyle = ctx.strokeStyle = Constants.Debugging.labelRadiusColor; ctx.lineWidth = actualDebugLineWidth; ctx.arc(this._center.x, this._center.y, this._actualRadius * this.itemLabelRadiusMax, 0, 2 * Math.PI); ctx.stroke(); } } drawItemLines(ctx, angles = []) { if (this._lineWidth <= 0) return; const actualLineWidth = this.getScaledNumber(this._lineWidth); const actualBorderWidth = this.getScaledNumber(this._borderWidth); ctx.translate( this._center.x, this._center.y ); for (const angle of angles) { ctx.rotate(util.degRad(angle.start + Constants.arcAdjust)); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(this._actualRadius - actualBorderWidth, 0); ctx.strokeStyle = this.lineColor; ctx.lineWidth = actualLineWidth; ctx.stroke(); ctx.rotate(-util.degRad(angle.start + Constants.arcAdjust)); } ctx.resetTransform(); } drawItemLabels(ctx, angles = []) { const actualItemLabelBaselineOffset = this._itemLabelFontSize * -this.itemLabelBaselineOffset; const actualDebugLineWidth = this.getScaledNumber(1); const actualLabelStrokeWidth = this.getScaledNumber(this._itemLabelStrokeWidth * 2); for (const [i, a] of angles.entries()) { const item = this._items[i]; const actualLabelColor = item.labelColor || (this._itemLabelColors[i % this._itemLabelColors.length] // Fall back to a value from the repeating set. || 'transparent'); // Handle empty string/undefined. if (item.label.trim() === '' || actualLabelColor === 'transparent') continue; ctx.save(); ctx.clip(item.path); const angle = a.start + ((a.end - a.start) / 2); ctx.translate( this._center.x + Math.cos(util.degRad(angle + Constants.arcAdjust)) * (this._actualRadius * this.itemLabelRadius), this._center.y + Math.sin(util.degRad(angle + Constants.arcAdjust)) * (this._actualRadius * this.itemLabelRadius) ); ctx.rotate(util.degRad(angle + Constants.arcAdjust)); ctx.rotate(util.degRad(this.itemLabelRotation)); if (this.debug) { ctx.save(); let alignAdjust = 0; if (this.itemLabelAlign === 'left') { alignAdjust = this._labelMaxWidth; } else if (this.itemLabelAlign === 'center') { alignAdjust = this._labelMaxWidth / 2; } // Draw label baseline ctx.beginPath(); ctx.moveTo(alignAdjust, 0); ctx.lineTo(-this._labelMaxWidth + alignAdjust, 0); ctx.strokeStyle = Constants.Debugging.labelBoundingBoxColor; ctx.lineWidth = actualDebugLineWidth; ctx.stroke(); // Draw label bounding box ctx.strokeRect(alignAdjust, -this._itemLabelFontSize / 2, -this._labelMaxWidth, this._itemLabelFontSize); ctx.restore(); } if (this._itemLabelStrokeWidth > 0) { ctx.lineWidth = actualLabelStrokeWidth; ctx.strokeStyle = this._itemLabelStrokeColor; ctx.lineJoin = 'round'; ctx.strokeText(item.label, 0, actualItemLabelBaselineOffset); } ctx.fillStyle = actualLabelColor; ctx.fillText(item.label, 0, actualItemLabelBaselineOffset); if (this.debug) { // Draw label anchor point const circleDiameter = this.getScaledNumber(2); ctx.beginPath(); ctx.arc(0, 0, circleDiameter, 0, 2 * Math.PI); ctx.fillStyle = Constants.Debugging.labelRadiusColor; ctx.fill(); } ctx.restore(); } } drawDebugDragPoints(ctx) { if (!this.debug || !this._dragEvents?.length) return; const dragEventsReversed = [...this._dragEvents].reverse(); const lineWidth = this.getScaledNumber(0.5); const circleDiameter = this.getScaledNumber(4); for (const [i, event] of dragEventsReversed.entries()) { const lightnessPercent = (i / this._dragEvents.length) * 100; ctx.beginPath(); ctx.arc(event.x, event.y, circleDiameter, 0, 2 * Math.PI); ctx.fillStyle = `hsl(${Constants.Debugging.dragPointHue},100%,${lightnessPercent}%)`; ctx.strokeStyle = '#000'; ctx.lineWidth = lineWidth; ctx.fill(); ctx.stroke(); } } animateRotation(now = 0) { // Spin method = spinTo() if (this._spinToTimeEnd !== null) { // Check if we should end the animation: if (now >= this._spinToTimeEnd) { this.rotation = this._spinToEndRotation; this._spinToTimeEnd = null; this.raiseEvent_onRest(); return; } const duration = this._spinToTimeEnd - this._spinToTimeStart; let delta = (now - this._spinToTimeStart) / duration; delta = (delta < 0) ? 0 : delta; // Frame time may be before the start time. const distance = this._spinToEndRotation - this._spinToStartRotation; this.rotation = this._spinToStartRotation + distance * this._spinToEasingFunction(delta); this.refresh(); return; } // Spin method = spin() if (this._lastSpinFrameTime !== null) { const delta = now - this._lastSpinFrameTime; if (delta > 0) { this.rotation += ((delta / 1000) * this._rotationSpeed) % 360; // TODO: very small rounding errors can accumulate here. this._rotationSpeed = this.getRotationSpeedPlusDrag(delta); // Check if we should end the animation: if (this._rotationSpeed === 0) { this.raiseEvent_onRest(); this._lastSpinFrameTime = null; } else { this._lastSpinFrameTime = now; } } this.refresh(); return; } } getRotationSpeedPlusDrag(delta = 0) { // Simulate drag: const newRotationSpeed = this._rotationSpeed + ((this.rotationResistance * (delta / 1000)) * this._rotationDirection); // Stop rotation once speed reaches 0. // Otherwise the wheel could rotate in the opposite direction next frame. if ((this._rotationDirection === 1 && newRotationSpeed < 0) || (this._rotationDirection === -1 && newRotationSpeed >= 0)) { return 0; } return newRotationSpeed; } /** * Spin the wheel by setting `rotationSpeed`. * The wheel will immediately start spinning, and slow down over time depending on the value of `rotationResistance`. * A positive number will spin clockwise, a negative number will spin anti-clockwise. */ spin(rotationSpeed = 0) { if (!util.isNumber(rotationSpeed)) throw new Error('rotationSpeed must be a number'); this._dragEvents = []; this.beginSpin(rotationSpeed, 'spin'); } /** * Spin the wheel to a particular rotation. * The animation will occur over the provided `duration` (milliseconds). * The animation can be adjusted by providing an optional `easingFunction` which accepts a single parameter n, where n is between 0 and 1 inclusive. * If no easing function is provided, the default easeSinOut will be used. * For example easing functions see [easing-utils](https://github.com/AndrewRayCode/easing-utils). */ spinTo(rotation = 0, duration = 0, easingFunction = null) { if (!util.isNumber(rotation)) throw new Error('Error: rotation must be a number'); if (!util.isNumber(duration)) throw new Error('Error: duration must be a number'); this.stop(); this._dragEvents = []; this.animate(rotation, duration, easingFunction); this.raiseEvent_onSpin({method: 'spinto', targetRotation: rotation, duration}); } /** * Spin the wheel to a particular item. * The animation will occur over the provided `duration` (milliseconds). * If `spinToCenter` is true, the wheel will spin to the center of the item, otherwise the wheel will spin to a random angle inside the item. * `numberOfRevolutions` controls how many times the wheel will rotate a full 360 degrees before resting on the item. * `direction` can be `1` (clockwise) or `-1` (anti-clockwise). * The animation can be adjusted by providing an optional `easingFunction` which accepts a single parameter n, where n is between 0 and 1 inclusive. * If no easing function is provided, the default easeSinOut will be used. * For example easing functions see [easing-utils](https://github.com/AndrewRayCode/easing-utils). */ spinToItem(itemIndex = 0, duration = 0, spinToCenter = true, numberOfRevolutions = 1, direction = 1, easingFunction = null) { this.stop(); this._dragEvents = []; const itemAngle = spinToCenter ? this.items[itemIndex].getCenterAngle() : this.items[itemIndex].getRandomAngle(); let newRotation = util.calcWheelRotationForTargetAngle(this.rotation, itemAngle - this._pointerAngle, direction); newRotation += ((numberOfRevolutions * 360) * direction); this.animate(newRotation, duration, easingFunction); this.raiseEvent_onSpin({method: 'spintoitem', targetItemIndex: itemIndex, targetRotation: newRotation, duration}); } animate(newRotation, duration, easingFunction) { this._spinToStartRotation = this.rotation; this._spinToEndRotation = newRotation; this._spinToTimeStart = performance.now(); this._spinToTimeEnd = this._spinToTimeStart + duration; this._spinToEasingFunction = easingFunction || util.easeSinOut; this.refresh(); } /** * Immediately stop the wheel from spinning, regardless of which method was used to spin it. */ stop() { // Stop the wheel if it was spun via `spinTo()`. this._spinToTimeEnd = null; // Stop the wheel if it was spun via `spin()`. this._rotationSpeed = 0; this._lastSpinFrameTime = null; } /** * Return n scaled to the size of the canvas. */ getScaledNumber(n) { return (n / Constants.baseCanvasSize) * this._size; } getActualPixelRatio() { return (this._pixelRatio !== 0) ? this._pixelRatio : window.devicePixelRatio; } /** * Return true if the given point is inside the wheel. */ wheelHitTest(point = {x: 0, y: 0}) { if (this.canvas === null) return false; const p = util.translateXYToElement(point, this.canvas, this.getActualPixelRatio()); return util.isPointInCircle(p, this._center.x, this._center.y, this._actualRadius); } /** * Refresh the cursor state. * Call this after the pointer moves. */ refreshCursor() { if (this.canvas === null) return; if (this.isInteractive) { if (this.isDragging) { this.canvas.style.cursor = 'grabbing'; return; } if (this._isCursorOverWheel) { this.canvas.style.cursor = 'grab'; return; } } this.canvas.style.cursor = ''; } /** * Get the angle (in degrees) of the given point from the center of the wheel. * 0 is north. */ getAngleFromCenter(point = {x: 0, y: 0}) { return (util.getAngle(this._center.x, this._center.y, point.x, point.y) + 90) % 360; } /** * Get the index of the item that the Pointer is pointing at. * An item is considered "current" if `pointerAngle` is between it's start angle (inclusive) * and it's end angle (exclusive). */ getCurrentIndex() { return this._currentIndex; } /** * Calculate and set `currentIndex` */ refreshCurrentIndex(angles = []) { if (this._items.length === 0) this._currentIndex = -1; for (const [i, a] of angles.entries()) { if (!util.isAngleBetween(this._pointerAngle, a.start % 360, a.end % 360)) continue; if (this._currentIndex === i) break; this._currentIndex = i; if (!this._isInitialising) this.raiseEvent_onCurrentIndexChange(); break; } } /** * Return an array of objects containing the start angle (inclusive) and end angle (inclusive) of each item. */ getItemAngles(initialRotation = 0) { let weightSum = 0; for (const i of this.items) { weightSum += i.weight; } const weightedItemAngle = 360 / weightSum; let itemAngle; let lastItemAngle = initialRotation; const angles = []; for (const item of this._items) { itemAngle = item.weight * weightedItemAngle; angles.push({ start: lastItemAngle, end: lastItemAngle + itemAngle, }); lastItemAngle += itemAngle; } // Ensure the difference between last angle.end and first angle.start is exactly 360 degrees. // Sometimes floating point arithmetic pushes the end value past 360 degrees by // a very small amount, which causes issues when calculating `currentIndex`. if (this._items.length > 1) { angles[angles.length - 1].end = angles[0].start + 360; } return angles; } /** * Schedule a redraw of the wheel on the canvas. * Call this after changing any property of the wheel that relates to it's appearance. */ refresh() { if (this._frameRequestId === null) { this._frameRequestId = window.requestAnimationFrame(t => this.draw(t)); } } limitSpeed(speed = 0, max = 0) { // Max is always a positive number, but speed may be positive or negative. const newSpeed = Math.min(speed, max); return Math.max(newSpeed, -max); } beginSpin(speed = 0, spinMethod = '') { this.stop(); this._rotationSpeed = this.limitSpeed(speed, this._rotationSpeedMax); this._lastSpinFrameTime = performance.now(); this._rotationDirection = (this._rotationSpeed >= 0) ? 1 : -1; // 1 for clockwise or stationary, -1 for anti-clockwise. if (this._rotationSpeed !== 0) { this.raiseEvent_onSpin({ method: spinMethod, rotationSpeed: this._rotationSpeed, rotationResistance: this._rotationResistance, }); } this.refresh(); } refreshAriaLabel() { if (this.canvas === null) return; // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/img_role this.canvas.setAttribute('role', 'img'); const wheelDescription = (this.items.length >= 2) ? ` The wheel has ${this.items.length} slices.` : ''; this.canvas.setAttribute('aria-label', 'An image of a spinning prize wheel.' + wheelDescription); } /** * The [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) for the line around the circumference of the wheel. */ get borderColor() { return this._borderColor; } set borderColor(val) { this._borderColor = util.setProp({ val, isValid: typeof val === 'string', errorMessage: 'Wheel.borderColor must be a string', defaultValue: Defaults.wheel.borderColor, }); this.refresh(); } /** * The width (in pixels) of the line around the circumference of the wheel. */ get borderWidth() { return this._borderWidth; } set borderWidth(val) { this._borderWidth = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.borderWidth must be a number', defaultValue: Defaults.wheel.borderWidth, }); this.refresh(); } /** * If debugging info will be shown. * This is helpful when positioning labels. */ get debug() { return this._debug; } set debug(val) { this._debug = util.setProp({ val, isValid: typeof val === 'boolean', errorMessage: 'Wheel.debug must be a boolean', defaultValue: Defaults.wheel.debug, }); this.refresh(); } /** * The image (HTMLImageElement) to draw on the wheel and rotate with the wheel. * It will be centered and scaled to fit `Wheel.radius`. */ get image() { return this._image; } set image(val) { this._image = util.setProp({ val, isValid: val instanceof HTMLImageElement || val === null, errorMessage: 'Wheel.image must be a HTMLImageElement or null', defaultValue: Defaults.wheel.image, }); this.refresh(); } /** * If the user will be allowed to spin the wheel using click-drag/touch-flick. * User interaction will only be detected within the bounds of `Wheel.radius`. */ get isInteractive() { return this._isInteractive; } set isInteractive(val) { this._isInteractive = util.setProp({ val, isValid: typeof val === 'boolean', errorMessage: 'Wheel.isInteractive must be a boolean', defaultValue: Defaults.wheel.isInteractive, }); this.refreshCursor(); // Reset the cursor in case the wheel is currently being dragged. } /** * The [CSS colors](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) to use as a repeating pattern for the background colors of all items. * Overridden by `Item.backgroundColor`. * Example: `['#fff','#000']`. */ get itemBackgroundColors() { return this._itemBackgroundColors; } set itemBackgroundColors(val) { this._itemBackgroundColors = util.setProp({ val, isValid: Array.isArray(val), errorMessage: 'Wheel.itemBackgroundColors must be an array', defaultValue: Defaults.wheel.itemBackgroundColors, }); this.refresh(); } /** * The alignment of all item labels. * Possible values: `'left'`,`'center'`,`'right'`. */ get itemLabelAlign() { return this._itemLabelAlign; } set itemLabelAlign(val) { this._itemLabelAlign = util.setProp({ val, isValid: typeof val === 'string' && (val === Constants.AlignText.left || val === Constants.AlignText.right || val === Constants.AlignText.center), errorMessage: 'Wheel.itemLabelAlign must be one of Constants.AlignText', defaultValue: Defaults.wheel.itemLabelAlign, }); this.resize(); // Recalc the max width if the alignment is center } /** * The offset of the baseline (or line height) of all item labels (as a percent of the label's height). */ get itemLabelBaselineOffset() { return this._itemLabelBaselineOffset; } set itemLabelBaselineOffset(val) { this._itemLabelBaselineOffset = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.itemLabelBaselineOffset must be a number', defaultValue: Defaults.wheel.itemLabelBaselineOffset, }); this.resize(); } /** * The [CSS colors](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) to use as a repeating pattern for the colors of all item labels. * Overridden by `Item.labelColor`. * Example: `['#fff','#000']`. */ get itemLabelColors() { return this._itemLabelColors; } set itemLabelColors(val) { this._itemLabelColors = util.setProp({ val, isValid: Array.isArray(val), errorMessage: 'Wheel.itemLabelColors must be an array', defaultValue: Defaults.wheel.itemLabelColors, }); this.refresh(); } /** * The [font familiy](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family) to use for all item labels. * Example: `'Helvetica, sans-serif'`. */ get itemLabelFont() { return this._itemLabelFont; } set itemLabelFont(val) { this._itemLabelFont = util.setProp({ val, isValid: typeof val === 'string', errorMessage: 'Wheel.itemLabelFont must be a string', defaultValue: Defaults.wheel.itemLabelFont, }); this.resize(); } /** * The maximum font size (in pixels) for all item labels. */ get itemLabelFontSizeMax() { return this._itemLabelFontSizeMax; } set itemLabelFontSizeMax(val) { this._itemLabelFontSizeMax = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.itemLabelFontSizeMax must be a number', defaultValue: Defaults.wheel.itemLabelFontSizeMax, }); this.resize(); } /** * The point along the wheel's radius (as a percent, starting from the center) * to start drawing all item labels. */ get itemLabelRadius() { return this._itemLabelRadius; } set itemLabelRadius(val) { this._itemLabelRadius = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.itemLabelRadius must be a number', defaultValue: Defaults.wheel.itemLabelRadius, }); this.resize(); } /** * The point along the wheel's radius (as a percent, starting from the center) * to limit the maximum width of all item labels. */ get itemLabelRadiusMax() { return this._itemLabelRadiusMax; } set itemLabelRadiusMax(val) { this._itemLabelRadiusMax = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.itemLabelRadiusMax must be a number', defaultValue: Defaults.wheel.itemLabelRadiusMax, }); this.resize(); } /** * The rotation of all item labels. * Use this in combination with `itemLabelAlign` to flip the labels `180°`. */ get itemLabelRotation() { return this._itemLabelRotation; } set itemLabelRotation(val) { this._itemLabelRotation = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.itemLabelRotation must be a number', defaultValue: Defaults.wheel.itemLabelRotation, }); this.refresh(); } /** * The [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) of the stroke applied to the outside of the label text. */ get itemLabelStrokeColor() { return this._itemLabelStrokeColor; } set itemLabelStrokeColor(val) { this._itemLabelStrokeColor = util.setProp({ val, isValid: typeof val === 'string', errorMessage: 'Wheel.itemLabelStrokeColor must be a string', defaultValue: Defaults.wheel.itemLabelStrokeColor, }); this.refresh(); } /** * The width of the stroke applied to the outside of the label text. */ get itemLabelStrokeWidth() { return this._itemLabelStrokeWidth; } set itemLabelStrokeWidth(val) { this._itemLabelStrokeWidth = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.itemLabelStrokeWidth must be a number', defaultValue: Defaults.wheel.itemLabelStrokeWidth, }); this.refresh(); } /** * The items (or slices, wedges, segments) shown on the wheel. * Setting this property will re-create all of the items on the wheel based on the objects provided. * Accessing this property lets you change individual items. For example you could change the background color of an item. */ get items() { return this._items; } set items(val) { this._items = util.setProp({ val, isValid: Array.isArray(val), errorMessage: 'Wheel.items must be an array of Items', defaultValue: Defaults.wheel.items, action: () => { const v = []; for (const item of val) { v.push(new Item(this, item)); } return v; }, }); this.refreshAriaLabel(); this.refreshCurrentIndex(this.getItemAngles(this._rotation)); this.resize(); // Refresh item label font size. } /** * The [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) of the lines between the items. */ get lineColor() { return this._lineColor; } set lineColor(val) { this._lineColor = util.setProp({ val, isValid: typeof val === 'string', errorMessage: 'Wheel.lineColor must be a string', defaultValue: Defaults.wheel.lineColor, }); this.refresh(); } /** * The width (in pixels) of the lines between the items. */ get lineWidth() { return this._lineWidth; } set lineWidth(val) { this._lineWidth = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.lineWidth must be a number', defaultValue: Defaults.wheel.lineWidth, }); this.refresh(); } /** * The offset of the wheel from the center of it's container (as a percent of the wheel's diameter). */ get offset() { return this._offset; } set offset(val) { this._offset = util.setProp({ val, isValid: util.isObject(val), errorMessage: 'Wheel.offset must be an object', defaultValue: Defaults.wheel.offset, }); this.resize(); } /** * The callback for the `onCurrentIndexChange` event. */ get onCurrentIndexChange() { return this._onCurrentIndexChange; } set onCurrentIndexChange(val) { this._onCurrentIndexChange = util.setProp({ val, isValid: typeof val === 'function' || val === null, errorMessage: 'Wheel.onCurrentIndexChange must be a function or null', defaultValue: Defaults.wheel.onCurrentIndexChange, }); } /** * The callback for the `onRest` event. */ get onRest() { return this._onRest; } set onRest(val) { this._onRest = util.setProp({ val, isValid: typeof val === 'function' || val === null, errorMessage: 'Wheel.onRest must be a function or null', defaultValue: Defaults.wheel.onRest, }); } /** * The callback for the `onSpin` event. */ get onSpin() { return this._onSpin; } set onSpin(val) { this._onSpin = util.setProp({ val, isValid: typeof val === 'function' || val === null, errorMessage: 'Wheel.onSpin must be a function or null', defaultValue: Defaults.wheel.onSpin, }); } /** * The image (HTMLImageElement) to draw over the top of the wheel. * It will be centered and scaled to fit the container's smallest dimension. * Use this to draw decorations around the wheel, such as a stand or pointer. */ get overlayImage() { return this._overlayImage; } set overlayImage(val) { this._overlayImage = util.setProp({ val, isValid: val instanceof HTMLImageElement || val === null, errorMessage: 'Wheel.overlayImage must be a HTMLImageElement or null', defaultValue: Defaults.wheel.overlayImage, }); this.refresh(); } /** * The pixel ratio (as a percent) used to draw the wheel. * Higher values will produce a sharper image at the cost of performance, but the sharpness depends on the current display device. * A value of `0` will use the pixel ratio of the current display device (see [devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio)). */ get pixelRatio() { return this._pixelRatio; } set pixelRatio(val) { this._pixelRatio = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.pixelRatio must be a number', defaultValue: Defaults.wheel.pixelRatio, }); this._dragEvents = []; this.resize(); } /** * The angle of the Pointer which will be used to determine the `currentIndex` (or the "winning" item). */ get pointerAngle() { return this._pointerAngle; } set pointerAngle(val) { this._pointerAngle = util.setProp({ val, isValid: util.isNumber(val) && val >= 0, errorMessage: 'Wheel.pointerAngle must be a number between 0 and 360', defaultValue: Defaults.wheel.pointerAngle, action: () => val % 360, }); if (this.debug) this.refresh(); } /** * The radius of the wheel (as a percent of the container's smallest dimension). */ get radius() { return this._radius; } set radius(val) { this._radius = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.radius must be a number', defaultValue: Defaults.wheel.radius, }); this.resize(); } /** * The rotation (angle in degrees) of the wheel. * `0` is north. * The first item will be drawn clockwise from this point. */ get rotation() { return this._rotation; } set rotation(val) { this._rotation = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.rotation must be a number', defaultValue: Defaults.wheel.rotation, }); this.refreshCurrentIndex(this.getItemAngles(this._rotation)); this.refresh(); } /** * The amount that `rotationSpeed` will be reduced by every second until the wheel stops spinning. * Set to `0` to spin the wheel infinitely. */ get rotationResistance() { return this._rotationResistance; } set rotationResistance(val) { this._rotationResistance = util.setProp({ val, isValid: util.isNumber(val), errorMessage: 'Wheel.rotationResistance must be a number', defaultValue: Defaults.wheel.rotationResistance, }); } /** * [Readonly] How fast (angle in degrees) the wheel is spinning every 1 second. * A positive number means the wheel is spinning clockwise, a negative number means anti-clockwise, and `0` means the wheel is not spinning. */ get rotationSpeed() { return this._rotationSpeed; } /** * The maximum absolute value for `rotationSpeed`. * The wheel will not spin faster than this value in either direction. */ get rotationSpeedMax() { return this._rotationSpeedMax; } set rotationSpeedMax(val) { this._rotationSpeedMax = util.setProp({ val, isValid: util.isNumber(val) && val >= 0, errorMessage: 'Wheel.rotationSpeedMax must be a number >= 0', defaultValue: Defaults.wheel.rotationSpeedMax, }); } /** * Enter the drag state. */ dragStart(point = {x: 0, y: 0}) { if (this.canvas === null) return; const p = util.translateXYToElement(point, this.canvas, this.getActualPixelRatio()); this.isDragging = true; this.stop(); // Interrupt `spinTo()` this._dragEvents = [{ distance: 0, x: p.x, y: p.y, now: performance.now(), }]; this.refreshCursor(); } dragMove(point = {x: 0, y: 0}) { if (this.canvas === null) return; const p = util.translateXYToElement(point, this.canvas, this.getActualPixelRatio()); const a = this.getAngleFromCenter(p); const lastDragPoint = this._dragEvents[0]; const lastAngle = this.getAngleFromCenter(lastDragPoint); const angleSinceLastMove = util.diffAngle(lastAngle, a); this._dragEvents.unshift({ distance: angleSinceLastMove, x: p.x, y: p.y, now: performance.now(), }); // Retain max 40 drag events. if (this.debug && this._dragEvents.length >= 40) this._dragEvents.pop(); // Snap the wheel to the new rotation. this.rotation += angleSinceLastMove; // TODO: can we apply easing here so it looks nicer? } /** * Exit the drag state. * Set the rotation speed so the wheel continues to spin in the same direction. */ dragEnd() { this.isDragging = false; // Calc the drag distance: let dragDistance = 0; const now = performance.now(); for (const [i, event] of this._dragEvents.entries()) { if (!this.isDragEventTooOld(now, event)) { dragDistance += event.distance; continue; } // Exclude old events: this._dragEvents.length = i; if (this.debug) this.refresh(); // Redraw drag events after trimming the array. break; } this.refreshCursor(); if (dragDistance === 0) return; this.beginSpin(dragDistance * (1000 / Constants.dragCapturePeriod), 'interact'); } isDragEventTooOld(now = 0, event = {}) { return (now - event.now) > Constants.dragCapturePeriod; } raiseEvent_onCurrentIndexChange(data = {}) { this.onCurrentIndexChange?.({ type: 'currentIndexChange', currentIndex: this._currentIndex, ...data, }); } raiseEvent_onRest(data = {}) { this.onRest?.({ type: 'rest', currentIndex: this._currentIndex, rotation: this._rotation, ...data, }); } raiseEvent_onSpin(data = {}) { this.onSpin?.({ type: 'spin', ...data, }); } }