Last active
July 13, 2022 18:56
-
-
Save enrico-atzeni/f43d06c00084f1224ba833826afe1948 to your computer and use it in GitHub Desktop.
Javascript circular hour picker
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
((els) => { | |
function HPicker(el) { | |
var self = this; | |
this.wrapSize = Math.max(parseInt(el.getAttribute('data-size') || 0), 100); | |
this.mainFontSize = this.wrapSize / 10; | |
this.spaceForCirculareText = 1.3; | |
this.canvasSize = this.wrapSize - 2*(this.mainFontSize * this.spaceForCirculareText); | |
el.insertAdjacentHTML("afterEnd", | |
'<div class="hpicker" style="box-sizing:border-box;display:inline-block;padding:'+this.spaceForCirculareText+'em;font-size:'+this.mainFontSize+'px">\ | |
<div style="position:relative;z-index:1;font-size:'+(this.canvasSize/this.wrapSize)+'em" class="hpicker-canvas"></div>\ | |
</div>'); | |
this.ensureRange = function(x, min, max) { | |
return Math.max(min, Math.min(max, x || 0)); | |
}; | |
this.wrap = el.nextElementSibling.querySelector('.hpicker-canvas'); | |
this.center = this.canvasSize / 2; | |
this.margin = this.canvasSize * 0.1; | |
this.ray = Math.ceil(this.canvasSize - this.margin) / 2; | |
this.min = this.ensureRange(parseInt(el.getAttribute('min')), 0, 23); | |
this.max = this.ensureRange(parseInt(el.getAttribute('max')), this.min+2, 24); | |
this.minValid = this.ensureRange(parseInt(el.getAttribute('data-min-valid') || this.min), this.min, this.max); | |
this.maxValid = this.ensureRange(parseInt(el.getAttribute('data-max-valid') || this.max), this.minValid, this.max); | |
this.default = this.ensureRange(parseInt(el.value), this.minValid, this.maxValid); | |
this.currentValue = this.default; | |
this.stepDegree = Math.ceil(270 / (this.max - this.min)); | |
this.stepDegreeTollerance = 15; | |
this.minValidDegree = (this.minValid - this.min) * this.stepDegree; | |
this.maxValidDegree = (this.maxValid - this.min) * this.stepDegree; | |
// states | |
this.draggingIndicator = false; | |
// add svg | |
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); | |
this.svg.setAttribute("width", this.canvasSize); | |
this.svg.setAttribute("height", this.canvasSize); | |
this.wrap.appendChild(this.svg); | |
this.pToA = function(v) { | |
// transforms v from percentile to absolute value based on self.canvasSize | |
return self.canvasSize * v; | |
} | |
this.drawPath = function(d, strokeColor) { | |
strokeColor = strokeColor || self.color; | |
self.svg.insertAdjacentHTML("beforeEnd", | |
`<path | |
d="${d}" | |
stroke-width="${self.strokeWidth}" fill="transparent" stroke="${strokeColor}"/>`); | |
} | |
this.getCoordsByStep = function(step, ray) { | |
ray = ray || self.ray; | |
var currDegree = step * self.stepDegree, | |
angle = toRadians(currDegree); | |
// console.log(x, y, angle, currDegree); | |
return { | |
x: Math.cos(angle) * (ray * -1) + self.center, | |
y: Math.sin(angle) * (ray * -1) + self.center | |
} | |
} | |
this.color = "#000"; | |
this.invalidColor = "#ccc"; | |
this.strokeWidth = this.pToA(0.03333); | |
this.delimiterWidth = 0.08; | |
var minValidCoords = this.getCoordsByStep(this.minValid-this.min); | |
var maxValidCoords = this.getCoordsByStep(this.maxValid-this.min); | |
// draw BLACK arc from minValid to maxValid | |
var flagCircle = this.minValid > (this.min + (this.max - this.min)/2) ? 0 : 1; | |
this.drawPath("M "+minValidCoords.x+" "+minValidCoords.y+ | |
" A "+this.ray+" "+this.ray+" 0 "+flagCircle+" 1 "+maxValidCoords.x+" "+maxValidCoords.y); | |
if (this.minValid > this.min) { | |
// draw GREY arc from min to minValid | |
var minCoords = this.getCoordsByStep(0); | |
this.drawPath("M "+minCoords.x+" "+minCoords.y+ | |
" A "+this.ray+" "+this.ray+" 0 0 1 "+minValidCoords.x+" "+minValidCoords.y, this.invalidColor); | |
// draw left GREY LIMITER | |
var minFromCoords = this.getCoordsByStep(0, this.ray * (1+this.delimiterWidth)); | |
var minToCoords = this.getCoordsByStep(0, this.ray * (1-this.delimiterWidth)); | |
this.drawPath("M "+minFromCoords.x+" "+minFromCoords.y+" L "+minToCoords.x+" "+minToCoords.y, this.invalidColor); | |
} | |
if (this.maxValid < this.max) { | |
// draw GREY arc from maxValid to max | |
var maxCoords = this.getCoordsByStep(this.max-this.min); | |
this.drawPath("M "+maxValidCoords.x+" "+maxValidCoords.y+ | |
" A "+this.ray+" "+this.ray+" 0 0 1 "+maxCoords.x+" "+maxCoords.y, this.invalidColor); | |
// draw bottom GREY LIMITER | |
var maxFromCoords = this.getCoordsByStep(this.max-this.min, this.ray * (1+this.delimiterWidth)); | |
var maxToCoords = this.getCoordsByStep(this.max-this.min, this.ray * (1-this.delimiterWidth)); | |
this.drawPath("M "+maxFromCoords.x+" "+maxFromCoords.y+" L "+maxToCoords.x+" "+maxToCoords.y, this.invalidColor); | |
} | |
// draw left LIMITER | |
var minFromValidCoords = this.getCoordsByStep(this.minValid-this.min, this.ray * (1+this.delimiterWidth)); | |
var minToValidCoords = this.getCoordsByStep(this.minValid-this.min, this.ray * (1-this.delimiterWidth)); | |
this.drawPath("M "+minFromValidCoords.x+" "+minFromValidCoords.y+" L "+minToValidCoords.x+" "+minToValidCoords.y); | |
// draw bottom LIMITER | |
var maxFromValidCoords = this.getCoordsByStep(this.maxValid-this.min, this.ray * (1+this.delimiterWidth)); | |
var maxToValidCoords = this.getCoordsByStep(this.maxValid-this.min, this.ray * (1-this.delimiterWidth)); | |
this.drawPath("M "+maxFromValidCoords.x+" "+maxFromValidCoords.y+" L "+maxToValidCoords.x+" "+maxToValidCoords.y); | |
// draw indicator | |
this.svg.insertAdjacentHTML("beforeEnd", | |
`<circle id="indicator" | |
cx="`+this.pToA(0.05)+`" cy="${this.center}" r="`+this.pToA(0.05)+`" fill="#487DCD" />`); | |
this.indicator = this.svg.querySelector('#indicator'); | |
// draw texts | |
var textBaseStyle = "position:absolute;text-align:center;z-index:-1;line-height:1;user-select: none"; | |
this.wrap.insertAdjacentHTML("beforeEnd", '<span \ | |
style="'+textBaseStyle+';font-size:5em;top:.5em;left:.5em;width:1em"\ | |
class="hpicker-n hpicker-current">'+this.default+'</span>'); | |
this.currentValueElement = this.wrap.querySelector('.hpicker-current'); | |
for (var i=this.min; i <= this.max; i++) { | |
var currDegree = (i-this.min)*this.stepDegree; | |
var isValid = i >= this.minValid && i <= this.maxValid; | |
this.wrap.insertAdjacentHTML("beforeEnd", | |
'<div \ | |
class="hpicker-n hpicker-step '+(currDegree > 180 ? 'below' : 'above')+' '+(isValid ? '' : 'invalid')+'" \ | |
data-i="'+i+'" \ | |
style="'+textBaseStyle+';color:'+(isValid ? this.color : this.invalidColor)+';transform:rotate('+currDegree+'deg);font-size:1.6em;top:2.6em;left:-1em;width:1em;transform-origin: 4.168em .518em"\ | |
><div style="transform:rotate('+(currDegree > 180 ? '' : '-')+'90deg)">'+i+'</div></div>'); | |
} | |
// LISTENERS | |
function indicatorMouseDown(e) { | |
e.stopPropagation(); | |
if (e.buttons == 1) { | |
self.draggingIndicator = true; | |
var currentRect = self.svg.getBoundingClientRect(); | |
self.offsetTop = currentRect.y; | |
self.offsetLeft = currentRect.x; | |
} else { | |
self.draggingIndicator = false; | |
} | |
} | |
function mouseUp(e) { | |
self.draggingIndicator = false; | |
setTimeout(snapIndicator, 50); | |
} | |
function svgMouseDown(e) { | |
e.stopPropagation(); | |
if (e.buttons == 1) { | |
var currentRect = self.svg.getBoundingClientRect(); | |
self.offsetTop = currentRect.y; | |
self.offsetLeft = currentRect.x; | |
} | |
var tollerance = Math.min(self.canvasSize * 0.4, self.center - 40); | |
moveIndicatorFromMouseEvent(e, tollerance, (mouseX, mouseY, x, y) => { | |
var h = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); | |
if (h >= tollerance) { | |
// enable mousemove dragging if mousedown was enough close to circumference | |
self.draggingIndicator = true; | |
} | |
}); | |
} | |
function svgMouseMove(e) { | |
if (!self.draggingIndicator) return; | |
moveIndicatorFromMouseEvent(e); | |
} | |
function getTargetMatchingTouchEvent(e) { | |
if (!e.touches) return null; | |
for (var i=0; i < e.touches.length; i++) { | |
if (e.touches[i].target == e.target) return e.touches[i]; | |
} | |
return null; | |
} | |
function emulateMouseEvent(e) { | |
var touch = getTargetMatchingTouchEvent(e); | |
// emulate mouse event object | |
e.clientX = touch.clientX; | |
e.clientY = touch.clientY; | |
e.screenX = touch.screenX; | |
e.screenY = touch.screenY; | |
e.buttons = 1; // emulate left click | |
return e; | |
} | |
// for touch events see @https://developer.mozilla.org/en-US/docs/Web/API/Touch_events | |
this.indicator.addEventListener("mousedown", indicatorMouseDown); | |
this.indicator.addEventListener("touchstart", (e) => { | |
e = emulateMouseEvent(e); | |
indicatorMouseDown(e); | |
}); | |
document.body.addEventListener("mouseup", mouseUp); | |
document.body.addEventListener("touchend", mouseUp); | |
document.body.addEventListener("touchcancel", mouseUp); | |
this.svg.addEventListener("mousedown", svgMouseDown); | |
this.svg.addEventListener("touchstart", (e) => { | |
e = emulateMouseEvent(e); | |
svgMouseDown(e); | |
}); | |
this.svg.addEventListener("mousemove", svgMouseMove); | |
this.svg.addEventListener("touchmove", (e) => { | |
e = emulateMouseEvent(e); | |
svgMouseMove(e); | |
}); | |
function moveIndicatorFromMouseEvent(e, distanceThreshold, cb) { | |
cb = cb || function(){}; | |
var mouseX = self.ensureRange(e.clientX - self.offsetLeft, self.margin/2, self.canvasSize - (self.margin/2)), | |
mouseY = self.ensureRange(e.clientY - self.offsetTop, self.margin/2, self.canvasSize - (self.margin/2)), | |
x = Math.abs(self.center - mouseX), | |
y = Math.abs(self.center - mouseY); | |
if (mouseX < self.center && mouseY > self.center) { | |
// lock bottom-left quarter | |
return; | |
} | |
if (distanceThreshold) { | |
// do nothing if mouse position is too distance to circumference | |
var h = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); | |
if (h < distanceThreshold) { | |
return; | |
} | |
} | |
var angle = Math.atan(y/x), | |
degree = toDegrees(angle); | |
if (mouseX >= self.center && mouseY < self.center) { | |
// top right quarter | |
degree = 90 + (90-degree); | |
} else if (mouseX >= self.center && mouseY >= self.center) { | |
// top right quarter | |
degree += 180; | |
} | |
// console.log("degree", degree, self.minValidDegree, self.maxValidDegree); | |
// lock invalid ranges | |
if (degree < self.minValidDegree || degree > self.maxValidDegree) { | |
return; | |
} | |
y = Math.sin(angle) * (self.ray * (mouseY > self.center ? 1 : -1)) + self.center; | |
x = Math.cos(angle) * (self.ray * (mouseX > self.center ? 1 : -1)) + self.center; | |
// console.log(mouseX, mouseY, angle, degree, Math.sin(angle), Math.cos(angle), x, y); | |
self.indicator.setAttribute("cx", x); | |
self.indicator.setAttribute("cy", y); | |
// calculate current selected value by step | |
var steps = Math.floor((degree + self.stepDegreeTollerance) / self.stepDegree), | |
value = self.min + steps; | |
selectedValue(value); | |
cb(mouseX, mouseY, x, y, angle, degree); | |
} | |
function toDegrees (angle) { | |
return angle * (180 / Math.PI); | |
} | |
function toRadians (angle) { | |
return angle * (Math.PI / 180); | |
} | |
function snapIndicator() { | |
var coords = self.getCoordsByStep(self.currentValue-self.min); | |
self.indicator.setAttribute("cx", coords.x); | |
self.indicator.setAttribute("cy", coords.y); | |
} | |
function selectedValue(value) { | |
// console.log("value", value); | |
self.currentValue = value; | |
self.currentValueElement.innerText = self.currentValue; | |
el.value = value+":00"; | |
try {self.wrap.querySelector('.hpicker-step.hpicker-selected').classList.remove("hpicker-selected");} catch(e){} | |
self.wrap.querySelector('.hpicker-step[data-i="'+value+'"]').classList.add("hpicker-selected"); | |
} | |
// first call | |
selectedValue(this.default); | |
snapIndicator(); | |
} | |
els.forEach((el) => { | |
new HPicker(el); | |
}); | |
window.HourPicker = HPicker; | |
})(document.querySelectorAll('.hourpicker')); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
How to use
Options
Options can be passed using attributes directly in the input element.
Available options are:
min
: min hour to showmax
: max hour to showdata-min-valid
: min valid hourdata-max-valid
: max valid hourdata-size
: widget size