<template> <div :class="containerClass"> <svg viewBox="0 0 100 100" role="slider" :width="size" :height="size" :tabindex="readonly || disabled ? -1 : tabindex" :aria-valuemin="min" :aria-valuemax="max" :aria-valuenow="modelValue" :aria-labelledby="ariaLabelledby" :aria-label="ariaLabel" @click="onClick" @keydown="onKeyDown" @mousedown="onMouseDown" @mouseup="onMouseUp" @touchstart="onTouchStart" @touchend="onTouchEnd" > <path :d="rangePath" :stroke-width="strokeWidth" :stroke="rangeColor" class="p-knob-range"></path> <path :d="valuePath" :stroke-width="strokeWidth" :stroke="valueColor" class="p-knob-value"></path> <text v-if="showValue" :x="50" :y="57" text-anchor="middle" :fill="textColor" class="p-knob-text">{{ valueToDisplay }}</text> </svg> </div> </template> <script> export default { name: 'Knob', emits: ['update:modelValue', 'change'], props: { modelValue: { type: Number, default: null }, size: { type: Number, default: 100 }, disabled: { type: Boolean, default: false }, readonly: { type: Boolean, default: false }, step: { type: Number, default: 1 }, min: { type: Number, default: 0 }, max: { type: Number, default: 100 }, valueColor: { type: String, default: 'var(--primary-color, Black)' }, rangeColor: { type: String, default: 'var(--surface-border, LightGray)' }, textColor: { type: String, default: 'var(--text-color-secondary, Black)' }, strokeWidth: { type: Number, default: 14 }, showValue: { type: Boolean, default: true }, valueTemplate: { type: String, default: '{value}' }, tabindex: { type: Number, default: 0 }, 'aria-labelledby': { type: String, default: null }, 'aria-label': { type: String, default: null } }, data() { return { radius: 40, midX: 50, midY: 50, minRadians: (4 * Math.PI) / 3, maxRadians: -Math.PI / 3 }; }, methods: { updateValue(offsetX, offsetY) { let dx = offsetX - this.size / 2; let dy = this.size / 2 - offsetY; let angle = Math.atan2(dy, dx); let start = -Math.PI / 2 - Math.PI / 6; this.updateModel(angle, start); }, updateModel(angle, start) { let mappedValue; if (angle > this.maxRadians) mappedValue = this.mapRange(angle, this.minRadians, this.maxRadians, this.min, this.max); else if (angle < start) mappedValue = this.mapRange(angle + 2 * Math.PI, this.minRadians, this.maxRadians, this.min, this.max); else return; let newValue = Math.round((mappedValue - this.min) / this.step) * this.step + this.min; this.$emit('update:modelValue', newValue); this.$emit('change', newValue); }, updateModelValue(newValue) { if (newValue > this.max) this.$emit('update:modelValue', this.max); else if (newValue < this.min) this.$emit('update:modelValue', this.min); else this.$emit('update:modelValue', newValue); }, mapRange(x, inMin, inMax, outMin, outMax) { return ((x - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; }, onClick(event) { if (!this.disabled && !this.readonly) { this.updateValue(event.offsetX, event.offsetY); } }, onMouseDown(event) { if (!this.disabled && !this.readonly) { window.addEventListener('mousemove', this.onMouseMove); window.addEventListener('mouseup', this.onMouseUp); event.preventDefault(); } }, onMouseUp(event) { if (!this.disabled && !this.readonly) { window.removeEventListener('mousemove', this.onMouseMove); window.removeEventListener('mouseup', this.onMouseUp); event.preventDefault(); } }, onTouchStart(event) { if (!this.disabled && !this.readonly) { window.addEventListener('touchmove', this.onTouchMove); window.addEventListener('touchend', this.onTouchEnd); event.preventDefault(); } }, onTouchEnd(event) { if (!this.disabled && !this.readonly) { window.removeEventListener('touchmove', this.onTouchMove); window.removeEventListener('touchend', this.onTouchEnd); event.preventDefault(); } }, onMouseMove(event) { if (!this.disabled && !this.readonly) { this.updateValue(event.offsetX, event.offsetY); event.preventDefault(); } }, onTouchMove(event) { if (!this.disabled && !this.readonly && event.touches.length == 1) { const rect = this.$el.getBoundingClientRect(); const touch = event.targetTouches.item(0); const offsetX = touch.clientX - rect.left; const offsetY = touch.clientY - rect.top; this.updateValue(offsetX, offsetY); } }, onKeyDown(event) { if (!this.disabled && !this.readonly) { switch (event.code) { case 'ArrowRight': case 'ArrowUp': { event.preventDefault(); this.updateModelValue(this.modelValue + 1); break; } case 'ArrowLeft': case 'ArrowDown': { event.preventDefault(); this.updateModelValue(this.modelValue - 1); break; } case 'Home': { event.preventDefault(); this.$emit('update:modelValue', this.min); break; } case 'End': { event.preventDefault(); this.$emit('update:modelValue', this.max); break; } case 'PageUp': { event.preventDefault(); this.updateModelValue(this.modelValue + 10); break; } case 'PageDown': { event.preventDefault(); this.updateModelValue(this.modelValue - 10); break; } } } } }, computed: { containerClass() { return [ 'p-knob p-component', { 'p-disabled': this.disabled } ]; }, rangePath() { return `M ${this.minX} ${this.minY} A ${this.radius} ${this.radius} 0 1 1 ${this.maxX} ${this.maxY}`; }, valuePath() { return `M ${this.zeroX} ${this.zeroY} A ${this.radius} ${this.radius} 0 ${this.largeArc} ${this.sweep} ${this.valueX} ${this.valueY}`; }, zeroRadians() { if (this.min > 0 && this.max > 0) return this.mapRange(this.min, this.min, this.max, this.minRadians, this.maxRadians); else return this.mapRange(0, this.min, this.max, this.minRadians, this.maxRadians); }, valueRadians() { return this.mapRange(this.modelValue, this.min, this.max, this.minRadians, this.maxRadians); }, minX() { return this.midX + Math.cos(this.minRadians) * this.radius; }, minY() { return this.midY - Math.sin(this.minRadians) * this.radius; }, maxX() { return this.midX + Math.cos(this.maxRadians) * this.radius; }, maxY() { return this.midY - Math.sin(this.maxRadians) * this.radius; }, zeroX() { return this.midX + Math.cos(this.zeroRadians) * this.radius; }, zeroY() { return this.midY - Math.sin(this.zeroRadians) * this.radius; }, valueX() { return this.midX + Math.cos(this.valueRadians) * this.radius; }, valueY() { return this.midY - Math.sin(this.valueRadians) * this.radius; }, largeArc() { return Math.abs(this.zeroRadians - this.valueRadians) < Math.PI ? 0 : 1; }, sweep() { return this.valueRadians > this.zeroRadians ? 0 : 1; }, valueToDisplay() { return this.valueTemplate.replace(/{value}/g, this.modelValue); } } }; //Derived and forked from https://github.com/kramer99/vue-knob-control </script> <style> @keyframes dash-frame { 100% { stroke-dashoffset: 0; } } .p-knob-range { fill: none; transition: stroke 0.1s ease-in; } .p-knob-value { animation-name: dash-frame; animation-fill-mode: forwards; fill: none; } .p-knob-text { font-size: 1.3rem; text-align: center; } </style>