Skip to content

Instantly share code, notes, and snippets.

@enrico-atzeni
Last active July 13, 2022 18:56
Show Gist options
  • Save enrico-atzeni/f43d06c00084f1224ba833826afe1948 to your computer and use it in GitHub Desktop.
Save enrico-atzeni/f43d06c00084f1224ba833826afe1948 to your computer and use it in GitHub Desktop.
Javascript circular hour picker
((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'));
@enrico-atzeni
Copy link
Author

How to use

<input type="time" class="hourpicker" min="9" max="18" data-size="300" data-min-valid="11" data-max-valid="16" />
<script src="/js/hourpicker.js"></script>

Options

Options can be passed using attributes directly in the input element.
Available options are:

  • min: min hour to show
  • max: max hour to show
  • data-min-valid: min valid hour
  • data-max-valid: max valid hour
  • data-size: widget size
<input type="time" class="hourpicker" min="9" max="18" data-size="300" data-min-valid="11" data-max-valid="16" />

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment