Как реализовать такой квадратный селектор на Javascript
Есть задача, реализовать такой селектор:
Схема работы:
- Зажимаем мышку на жёлтом кругу
- Тянем в нужную сторону
- Получаем данные в зависимости от положения жёлтого круга
Подскажите, пожалуйста, подход к реализации этого селектора или ваши идеи, или даже пример.
Буду рад любой помощи, спасибо!
Ответы (2 шт):
В общем-то всё это тяжело объяснить, но лучше готовый пример, чем отсутствие примера.
const canvas = document.getElementById('canvas')
const context = canvas.getContext('2d')
const curve = {}, control = {}, knob = {}
let frame_id = null, width = 0, height = 0
resize(); drawAll()
window.addEventListener('resize', () => {
resize()
drawAll()
})
canvas.addEventListener('mousedown', startDragging)
canvas.addEventListener('touchstart', startDragging)
function startDragging(e) {
const { pageX, pageY } = e.type === 'mousedown' ? e : e.changedTouches[0]
const mouse = { x: pageX, y: pageY }
const _knob = { x: knob.x[0], y: knob.y[0],r: knob.r }
if (!pointCircleCollision(mouse, _knob)) return
update()
const x_start = pageX - knob.x[0]
const y_start = pageY - knob.y[0]
if (e.type === 'mousedown') {
canvas.addEventListener('mousemove', continueDragging)
canvas.addEventListener('mouseup', stopDragging)
}
if (e.type === 'touchstart') {
canvas.addEventListener('touchmove', continueDragging)
canvas.addEventListener('touchend', stopDragging)
}
function continueDragging(e) {
let { pageX, pageY } = e.type === 'mousemove' ? e : e.changedTouches[0]
const x = pageX - x_start
const y = pageY - y_start
knob.x[1] = minmax(x, knob.x_edges[0], knob.x_edges[1])
knob.y[1] = minmax(y, knob.y_edges[0], knob.y_edges[1])
}
function stopDragging(e) {
knob.x[1] = control.x + control.w / 2
knob.y[1] = control.y + control.h / 2
setTimeout(() => cancelAnimationFrame(frame_id), 500)
if (e.type === 'mouseup') {
canvas.removeEventListener('mousemove', continueDragging)
canvas.removeEventListener('mouseup', stopDragging)
}
if (e.type === 'touchend') {
canvas.removeEventListener('touchmove', continueDragging)
canvas.removeEventListener('touchend', stopDragging)
}
}
}
function pointCircleCollision(point, circle) {
const dx = point.x - circle.x
const dy = point.y - circle.y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < circle.r
}
function minmax(v, min, max) {
return Math.max(min, Math.min(v, max))
}
function drawControl() {
context.fillStyle = 'lightgreen'
context.fillRect(control.x, control.y, control.w, control.h)
}
function drawKnob() {
knob.x[0] += (knob.x[1] - knob.x[0]) * 0.23
knob.y[0] += (knob.y[1] - knob.y[0]) * 0.23
context.fillStyle = knob.c
context.beginPath()
context.arc(knob.x[0], knob.y[0], knob.r, 0, Math.PI * 2)
context.fill()
}
function drawCurve() {
const x_step = 1 / (control.w - knob.r * 2)
const y_step = 1 / (control.h - knob.r * 2)
const dx = knob.x[0] - (control.x + control.w / 2)
const dy = knob.y[0] - (control.y + control.h / 2)
const x_percentage = +(x_step * 2 * dx).toFixed(5)
const y_percentage = +(y_step * 2 * dy).toFixed(5)
const curve_point_x = ((curve.x + curve.w) / 2) + curve.w * x_percentage
const curve_point_y = curve.y + innerHeight * 0.3 * y_percentage
context.strokeStyle = 'brown'
context.lineWidth = (width + height) * 0.004
context.lineCap = 'round'
context.beginPath()
context.moveTo(curve.x, curve.y)
context.quadraticCurveTo(curve_point_x, curve_point_y, curve.w, curve.y)
context.stroke()
}
function drawAll() {
context.clearRect(0, 0, width, height)
drawCurve()
drawControl()
drawKnob()
}
function update() {
drawAll()
frame_id = requestAnimationFrame(update)
}
function resize() {
width = canvas.width = innerWidth
height = canvas.height = innerHeight
curve.x = width * 0.3
curve.y = height * 0.3
curve.w = width * 0.7
control.x = width * 0.3
control.y = height * 0.6
control.w = width * 0.4
control.h = height * 0.3
const knob_x = control.x + control.w / 2
const knob_y = control.y + control.h / 2
knob.x = [knob_x, knob_x]
knob.y = [knob_y, knob_y]
knob.r = (width + height) * 0.02
knob.c = 'tomato'
knob.x_edges = [control.x + knob.r, control.x + control.w - knob.r]
knob.y_edges = [control.y + knob.r, control.y + control.h - knob.r]
}
body {
margin: 0;
}
canvas {
display: block;
}
<canvas id="canvas"></canvas>
knob.x[0] и knob.y[0] - плавно меняющиеся значения
knob.x[1] и knob.y[1] - финальные позиции ручки
Главный механизм тут, естественно drag'n'drop. Вот типичный пример, как это сделать для canvas элемента:
canvas.addEventListener('mousedown', startDragging)
canvas.addEventListener('touchstart', startDragging)
function startDragging(e) {
// координаты нажатия мыши или пальца
const { pageX, pageY } = e.type === 'mousedown' ? e : e.changedTouches[0]
// проверка на то, произошло ли нажатие внутри объекта
if (!collision(mouse, object)) return
// координаты нажатия внутри объекта
const x_start = pageX - object.x
const y_start = pageY - object.y
if (e.type === 'mousedown') {
canvas.addEventListener('mousemove', continueDragging)
canvas.addEventListener('mouseup', stopDragging)
}
if (e.type === 'touchstart') {
canvas.addEventListener('touchmove', continueDragging)
canvas.addEventListener('touchend', stopDragging)
}
function continueDragging(e) {
// координаты движения мыши или пальца
const { pageX, pageY } = e.type === 'mousemove' ? e : e.changedTouches[0]
// перемещение объекта
const x = pageX - x_start
const y = pageY - y_start
object.x = x
object.y = y
}
function stopDragging(e) {
if (e.type === 'mouseup') {
canvas.removeEventListener('mousemove', continueDragging)
canvas.removeEventListener('mouseup', stopDragging)
}
if (e.type === 'touchend') {
canvas.removeEventListener('touchmove', continueDragging)
canvas.removeEventListener('touchend', stopDragging)
}
}
}
Функция для проверки на то, произошло ли нажатие внутри круглого объекта:
function pointCircleCollision(point, circle) {
const dx = point.x - circle.x
const dy = point.y - circle.y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < circle.r
}
Если ручку делать dom-элементом, то код будет много отличаться, но разницы, в принципе, никакой нету.
Впервые написал "Drag'n'Drop", думаю что криво, буду рад если укажут на ошибку по нему..
По поводу механики.. Я решил выводить значения следующим образом: У нас есть "коорданатная плоскость", думаю как она работает объяснять не надо.. Думаю визуально понятно.
Получить их можно как при едином нажатии, так и при движение, при помощи переменной moveParam.move.value.
let moveParam = {
isMove: false,
areaEl: $('.-area'), moveEl: $('.-move'),
//
pointerInherit: $('.-move').css('pointer-events')
};
moveParam.areaEl.on({
'mousedown': function(e) {
if(moveParam.isMove === false) {
moveParam.isMove = true;
$('body').addClass('--no-select');
//
moveParam.move = {
x: e.pageX - moveParam.areaEl.offset().left,
y: e.pageY - moveParam.areaEl.offset().top,
w: moveParam.moveEl.width(),
h: moveParam.moveEl.height()
};
moveParam.area = {
w: moveParam.areaEl.width(),
h: moveParam.areaEl.height(),
size: {
w: moveParam.areaEl.width() - (moveParam.move.w / 2),
h: moveParam.areaEl.height() - (moveParam.move.h / 2)
},
start: {
x: moveParam.move.w / 2,
y: moveParam.move.h / 2
},
end: {
x: moveParam.areaEl.width() - (moveParam.move.w / 2),
y: moveParam.areaEl.height() - (moveParam.move.h / 2)
}
};
//
moveParam.move.value = {
x: ((moveParam.area.w/2) - (moveParam.area.w - moveParam.move.x)) / ((moveParam.area.w - moveParam.move.w)/2),
y: ((moveParam.area.h - moveParam.move.y) - (moveParam.area.h/2)) / ((moveParam.area.h - moveParam.move.h)/2),
};
//
moveParam.moveEl.css({'left': moveParam.move.x, 'top': moveParam.move.y});
}
}
});
$(document).on({
'mousemove': function(e) {
if(moveParam.isMove === true) {
moveParam.move.x = e.pageX - moveParam.areaEl.offset().left;
moveParam.move.y = e.pageY - moveParam.areaEl.offset().top;
//
if(moveParam.move.x >= moveParam.area.end.x) moveParam.move.x = moveParam.area.end.x;
if(moveParam.move.y >= moveParam.area.end.y) moveParam.move.y = moveParam.area.end.y;
if(moveParam.move.x <= moveParam.area.start.x) moveParam.move.x = moveParam.area.start.x;
if(moveParam.move.y <= moveParam.area.start.y) moveParam.move.y = moveParam.area.start.y;
//
moveParam.move.value = {
x: ((moveParam.area.w/2) - (moveParam.area.w - moveParam.move.x)) / ((moveParam.area.w - moveParam.move.w)/2),
y: ((moveParam.area.h - moveParam.move.y) - (moveParam.area.h/2)) / ((moveParam.area.h - moveParam.move.h)/2)
};
//
console.clear();
console.log(moveParam.move.value);
//
moveParam.moveEl.css({'left': moveParam.move.x, 'top': moveParam.move.y});
}
},
'mouseup': function(){
if(moveParam.isMove === true) {
moveParam.isMove = false;
$('body').removeClass('--no-select');
moveParam.moveEl.css('pointer-events', moveParam.pointerInherit);
}
}
});
function MoveInput(x, y) {
x = Number(x); y = Number(y);
if((x >= -1 && x <= 1) && (y >= -1 && y <= 1)) {
x = moveParam.areaEl.width()/2 - (moveParam.areaEl.width()/2 * x) * -1;
y = moveParam.areaEl.height()/2 - (moveParam.areaEl.height()/2 * y);
moveParam.moveEl.css({'left': x, 'top': y});
}
} MoveInput(.5, -.5); // Входные данные
body {
background: #0a0b0b;
font-size: 13px;
font-family: -apple-system,BlinkMacSystemFont,'Roboto','Helvetica Neue',Geneva,"Noto Sans Armenian","Noto Sans Bengali","Noto Sans Cherokee","Noto Sans Devanagari","Noto Sans Ethiopic","Noto Sans Georgian","Noto Sans Hebrew","Noto Sans Kannada","Noto Sans Khmer","Noto Sans Lao","Noto Sans Osmanya","Noto Sans Tamil","Noto Sans Telugu","Noto Sans Thai",sans-serif,arial,Tahoma,verdana;
/* for snippet */
padding-bottom: 20px;
}
.change-area {
display: flex;
flex-direction: column;
width: 250px;
color: #8e8e8e;
}
.change-area h1 {
margin: 0 0 20px;
padding: 0;
font: inherit;
}
.change-area .select {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
}
.change-area .select .option {
display: block;
border-radius: 10px;
width: calc(50% - 4px);
background: rgba(255,255,255,.05);
padding: 14px 6px;
text-align: center;
box-sizing: border-box;
cursor: pointer;
}
.change-area .select .option:nth-child(odd) {margin-right: 8px;}
.change-area .select .option:nth-last-child(n+3) {margin-bottom: 8px;}
.change-area .select .option:hover {background: rgba(255,255,255,.1);}
.change-area .select .option.--selected {
background: rgba(255,255,255,1);
color: #222;
}
.change-area .range {
display: block;
width: 100%;
border-radius: 10px;
background: rgba(255,255,255,.05);
padding: 8px 14px;
margin: 12px 0;
box-sizing: border-box;
--area-col: 6;
--area-row: 4;
--area-grid-color: rgba(255,255,255,.1);
}
.change-area .range .-area {
display: block;
width: 100%;
height: 100%;
position: relative;
background:
linear-gradient(to right, var(--area-grid-color), transparent 1.25px) repeat-x left top / calc((100% / var(--area-col)) - (1.25px / var(--area-col))) 100%,
linear-gradient(to bottom, var(--area-grid-color), transparent 1.25px) repeat-y left top / 100% calc((100% / var(--area-row)) - (1.25px / var(--area-row)));
cursor: move;
}
.change-area .range .-area::before {
content: '';
display: block;
height: 0;
padding-top: calc(100% / var(--area-col) * var(--area-row));
z-index: -1;
}
.change-area .range .label {
display: inline-block;
opacity: .75;
z-index: 2;
position: absolute;
}
.change-area .range .label.-up,
.change-area .range .label.-down {
left: 50%;
transform: translateX(-50%);
}
.change-area .range .label.-up {top: 5px;}
.change-area .range .label.-down {bottom: 5px;}
.change-area .range .label.-left,
.change-area .range .label.-right {
top: 50%;
transform: translateY(-50%);
}
.change-area .range .label.-left {left: 5px;}
.change-area .range .label.-right {right: 5px;}
.change-area .range .-move {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffbe3f;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
.change-area .submit {
display: block;
width: 100%;
border-radius: 10px;
background: rgba(255,255,255,.05);
padding: 14px 8px;
text-align: center;
box-sizing: border-box;
cursor: pointer;
}
.change-area .submit:hover {background: rgba(255,255,255,.1);}
.--no-select {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="change-area">
<h1>Характеристики</h1>
<div class="select">
<div class="option --selected">Нос</div>
<div class="option">Брови</div>
<div class="option">Глаза</div>
<div class="option">Губы</div>
<div class="option">Лоб</div>
<div class="option">Скулы</div>
<div class="option">Челюсть</div>
<div class="option">Подбородок</div>
</div>
<div class="range">
<div class="-area">
<div class="label -up">Кверху</div>
<div class="label -right">Широкий</div>
<div class="label -down">Низкий</div>
<div class="label -left">Узкий</div>
<div class="-move"></div>
</div>
</div>
<div class="submit">Готово</div>
</div>
Вариант написан на jQuery, потому что вот - коммент
UPD: Добавил "входные данные".

