Spinning wheel in JS, how to automatically stop the spinning at the center of a randomly chosen slice

僤鯓⒐⒋嵵緔 提交于 2021-02-19 07:22:59

问题


In reference and a continuation of this question: Making an image spin with css and jQuery

I am interested in taking this approach further. A seven part Wheel spins and the correct values from a related block is depicted when the wheel stops its spin. I have a different need that I would like to achieve. My seven part wheel stop at a random area when clicked, but should always stop at the center of the section. How would I achieve this feature? Right now, my wheel stops randomly, sometimes at the edges or at a part of my sections of the wheel that does not have any content. In my opinion, to get the first stop from the zero point, I will need to do some math, such as ((360/7)/2), which yields the center of the first block. how should I incorporate the math in the script to always stop at the center of its targeted block?

Here is what I was able to build so far...

<html>
<style>
  .roulette_center {
      position: absolute;
      top: 255px;
      left: 258px;
      cursor: pointer;
    
  }
  .roulette_center:hover {
    transform: scale(1.01);
    
  }

  .roulette_wheel{
    touch-action:auto;pointer-events:painted
  }
</style>
<body>
<div style="z-index: 0; position: relative">
  <span  style="position: absolute; top: 0px; left: 360px; font-weight:700;font-size:24px;" id="score">&nbsp;</span>
  <img class="roulette_wheel" src="https://www.dropbox.com/s/6kp3fmtp72aj3vy/wellness-wheel.png?raw=1" />
  <img class="roulette_center" src="https://www.dropbox.com/s/52x8iyb1nvkjm0w/wheelCenter.png?raw=1" onclick="roulette_spin(this)" onfocus="transform()">
  <div style="position: absolute; top: 30px; left: 350px; background-color: red; width: 1px; height: 60px;"></div>

</div>

<script>
  var force = 0;
  var angle = 0;
  var rota = 1;
  var inertia = 0.985;
  var minForce = 15;
  var randForce = 15;
  var rouletteElem = document.getElementsByClassName('roulette_wheel')[0];
  var scoreElem = document.getElementById('score');

  var values = [
    "Spititual", "Emotional", "Intellectual", "Physical", "Social", "Environmental", "Financial"
  ].reverse();

  function roulette_spin(btn) {
    // set initial force randomly
    force = Math.floor(Math.random() * randForce) + minForce;
    requestAnimationFrame(doAnimation);
  }

  function doAnimation() {
    // new angle is previous angle + force modulo 360 (so that it stays between 0 and 360)
    angle = (angle + force) % 360;
    // decay force according to inertia parameter
    force *= inertia;
    rouletteElem.style.transform = 'rotate(' + angle + 'deg)';
    // stop animation if force is too low
    if (force < 0.05) {
      // score roughly estimated
      scoreElem.innerHTML = values[Math.floor(((angle / 360) * values.length) - 0.5)];
      return;
    }
    requestAnimationFrame(doAnimation);
  }
</script>
</body>
</html>

回答1:


Here is a solution to handle elastic-like snapping to the center of the stopping slice.

It make use of what I'd call an attractor, to alter the rotation speed according to a target (center of the closest slice). No CSS transitions are involved, only vanilla javascript.

The closer your are to the attractor the stronger the force pulling you to it. It is computed as the guarded inverse of the distance to the attractor. Guarded meaning protected against very big values when distance is close to 0 (and of course when it is exactly 0)

I like this solution because it models what we need using simple artificial physics (wheel inertia and a kind of magnet under the wheel slices), and let the physics only settle things after an initial random push.

You can play with the physical parameters and the stopping condition but be carefull as numerical instabilities arise easily.

EDIT:

  • added controls in the snippet to experiment with and understand the physical settings (min/max of the sliders set to "reasonable" boundaries)
  • fixed a bug where clicking multiple times on the center button spawned multiple animation handlers at the same time (when clicking multiple times without waiting for the wheel to stop),
  • the whole thing is also now in a self called function to avoid polluting the global scope with its vars (removed onclick="roulette_spin(this)" from the html, added document.getElementsByClassName('roulette_center')[0].addEventListener('click', roulette_spin); in the js to bind the now private (scoped) handler (roulette_spin) to the wheel center)

EDIT 2:

  • Added detection of clicked slice

  • If the wheel spins and no slice is clicked, it will stop according to the initial speed and inertia, snapping the end in the center of a (random) slice.

  • If the wheel spins and a slice is clicked, the speed will be altered so that the wheel should stop very close to the target slice, forcing the attractor to the clicked slice will ensure the wheel will actually stop on this slice. @mathspeople here: the proposed formula is far from accurate and I would be pleased to hear about a modified stoppping distance formula to calculate the proper speed correction. But hey... it just works...

  • If the wheel is not spinning, clicking on a slice will animate the wheel to the clicked slice. This is a nice feature with high friction (low inertia) as the wheel will quickly turn until positioned correctly and slows down nicely. It acts as a rotating menu which is what the OP may want in the first place.

(I recommend looking at the snippet in full page mode. Notice: as it stands, multiple click on slice during a spin is not supported and will fail, once a slice is clicked, you need to wait for the wheel to stop. The fix is easy but even if it was fun, I've spent way to much time on this answser, confinment/curfew has its perks :D)

(function() {
var values = [
  "Financial", "Environmental", "Social", "Physical", "Intellectual", "Emotional", "Spititual" 
];
var speed = 0;
// should init angle so that the cursor is at the center of the desired value,
// this is just to test the offset is right
var angle = getAngleFor("Intellectual");
// how much of the speed is kept at each update step (0 < i < 1, the bigger the longer the wheel spins)
var inertia = 0.988;
var minSpeed = 15;
// how much randomness in initial push speed
var randRange = 15;
// strongest absolute force that an attractor can exerce (is squashed to before scaling is applied)
var maxAttractionForce = 0.5;
// scale attraction force
var attractionForceFactor = 0.02;
var rouletteElem = document.getElementsByClassName('roulette_wheel')[0];
var scoreElem = document.getElementById('score');
// offset the display angle so that angle 0 is at the edge of the first slice
var offset = 26.42;
var currentAnimationFrame = null;
var clickedSliceIndex = -1;
var estimatedSliceIndex = -1;
var totalAngle = 0;

function addParamInput(name, value, min, max, step, handler) {
  var id = name + '-input-id';
  var wrapper = document.createElement('div');
  wrapper.classList.add('single-input-wrapper');
  var label = document.createElement('label');
  label.setAttribute('for', id);
  label.innerHTML = name;
  var input = document.createElement('input');
  input.setAttribute('type', 'range');
  input.setAttribute('min', ''+min);
  input.setAttribute('max', ''+max);
  input.setAttribute('step', ''+step);
  input.setAttribute('value', ''+value);
  input.addEventListener('change', handler);
  var meter = document.createElement('span');
  meter.classList.add('input-meter');
  meter.innerHTML = value;
  wrapper.appendChild(label);
  wrapper.appendChild(input);
  wrapper.appendChild(meter);
  document.getElementById('all-input-wrappers').appendChild(wrapper);
}

function updateInputMeter(input) {
  input.parentElement.getElementsByClassName('input-meter')[0].innerHTML = input.value;
}

function roulette_spin(evt) {
  evt.stopPropagation();
  clickedSliceIndex = -1;
  totalAngle = 0;
  // erase score
  scoreElem.innerHTML = '';
  // set initial speed randomly
  speed = Math.floor(Math.random() * randRange) + minSpeed;
  // console.log('initial speed', speed);
  // probably far from accurate but it kind of works, attractor
  // completely ignored because when spinning fast it cost speed as much
  // as it adds, and after it won't matter
  // since we will be forcing the attractor to where we want to stop
  var estimatedSpin = speed / (1 - inertia);
  // console.log('estimated total spin approximation', estimatedSpin);
  estimatedSliceIndex = getSliceIndex((angle + estimatedSpin) % 360);
  // console.log('estimated stopping slice', values[estimatedSliceIndex]);
  if (currentAnimationFrame) {
    cancelAnimationFrame(currentAnimationFrame);
  }
  currentAnimationFrame = requestAnimationFrame(doAnimation);
}

function getSliceIndex(angle) {
    return Math.floor(((angle / 360) * values.length));
}

function getCircularDist(a, b) {
    // assuming both a and b are positive angles 0 <= angle <= 360
    // find shortest oriented distance
    // example: if a is 350 degrees and b is 10 degrees what's the actuals angular dist from a to b
    // naive implementation would be b - a which gives - 340 => wrong, it only takes
    // 20 degrees to rotate from 350 to 10 => b - (a - 360)
    // conversely if a is 300 and b is 250 the shortest distance is
    // the other way around => b - a = -50 degrees
    var d1 = b  - a;
    var d2 = b - (a - 360);
    return Math.abs(d1) >= Math.abs(d2) ? d2 : d1;
}

function setRouletteAngle(angle) {
    rouletteElem.style.transform = 'rotate(' + (offset + angle) + 'deg)';
}

function getAngleFor(value) {
    return getAngleForIndex(values.indexOf(value));
}

function getAngleForIndex(sliceIndex) {
    return (sliceIndex + 0.5) * (360 / values.length);
}

function handleRouletteClick(evt) {
  // coordinate of the center of the wheel
  var centerX = rouletteElem.offsetWidth / 2;
  var centerY = rouletteElem.offsetHeight / 2;
  // console.log('centerX', centerX, 'centerY',centerY);
  // roulette's parent bounding rect
  var rect = evt.target.parentElement.getBoundingClientRect();
  // coordinates of the click in roulette's parent coordinates
  var clickX = evt.clientX - rect.x;
  var clickY = evt.clientY - rect.y;
  // resulting coordinates relative to the center of the wheel
  var x = clickX - centerX;
  var y = clickY - centerY;
  // console.log('x', x, 'y', y);
  var norm = Math.sqrt(x*x+y*y);
  // -pi < rad < pi
  var rad = Math.atan2(x, y);
  var deg = (rad * 180) / Math.PI;
  // 0 <= clickedAngle < 360 starting from the first slice
  var clickedAngle = ((deg < 0 ? deg + 360 : deg) + angle + 180) % 360;
  clickedSliceIndex = Math.floor((clickedAngle / 360) * values.length);
  scoreElem.style.color = 'lightgrey';
  scoreElem.innerHTML = 'clicked ' + values[clickedSliceIndex];
  // outside of an animation started with the center button it will make the wheel spin to target
  if (!currentAnimationFrame) {
    // assume wheel is not mmoving so the estimated slice is the slice at the current angle
    estimatedSliceIndex = getSliceIndex(angle);
    currentAnimationFrame = requestAnimationFrame(doAnimation);
  }
  var estimatedAngle = getAngleForIndex(estimatedSliceIndex);
  var targetFinalAngle = getAngleForIndex(clickedSliceIndex);
  var spinError = getCircularDist(estimatedAngle, targetFinalAngle);
  var speedCorrection = spinError * (1 - inertia);
  speed += speedCorrection;
  
}

function doAnimation() {
  // new angle is previous angle + speed modulo 360 (so that it stays between 0 and 360)
  totalAngle += speed;
  angle = (angle + speed) % 360;
  // decay speed according to inertia parameter
  speed = speed - (1 - inertia) * speed;
  // add attractor force: inverse of circular oriented dist to the closest slice center * some factor
  var sliceIndex = clickedSliceIndex !== -1 ? clickedSliceIndex : getSliceIndex(angle);

  var target = getAngleForIndex(sliceIndex);
  var orientedDist = getCircularDist(angle, target);

  // protect against infinity (division by 0 or close to 0)
  var inverseOrientedDistAmplitude = orientedDist === 0
    ? maxAttractionForce
    : Math.min(1/Math.abs(orientedDist), maxAttractionForce);
  var finalAttractionForce = Math.sign(orientedDist) * inverseOrientedDistAmplitude * attractionForceFactor;
    
  speed = speed + finalAttractionForce;

  setRouletteAngle(angle);
  // stop animation if speed is low and close to attraction point
  if (speed < 0.01  && Math.abs(orientedDist) < 0.05 ) {
    // console.log('total spin', totalAngle);
    scoreElem.style.color = 'black';
    scoreElem.innerHTML = values[getSliceIndex(angle)];
    currentAnimationFrame = null;
    return;
  }
  currentAnimationFrame = requestAnimationFrame(doAnimation);
}

setRouletteAngle(angle);
document.getElementsByClassName('roulette_center')[0].addEventListener('click', roulette_spin);
rouletteElem.parentElement.addEventListener('click', handleRouletteClick);
addParamInput('minSpeed', 15, 0, 60, 0.5, function(evt) {
  var rangeInput = evt.target;
  minSpeed = parseFloat(rangeInput.value);
  updateInputMeter(rangeInput)
});

addParamInput('inertia', 0.988, 0.900, 0.999, 0.001, function(evt) {
  var rangeInput = evt.target;
  inertia = parseFloat(rangeInput.value);
  updateInputMeter(rangeInput)
});

addParamInput('attractionForceFactor', 0.02, 0, 0.1, 0.001, function(evt) {
  var rangeInput = evt.target;
  attractionForceFactor = parseFloat(rangeInput.value);
  updateInputMeter(rangeInput)
});

addParamInput('maxAttractionForce', 0.5, 0, 1.5, 0.01, function(evt) {
  var rangeInput = evt.target;
  maxAttractionForce = parseFloat(rangeInput.value);
  updateInputMeter(rangeInput)
});

})();
.roulette_center {
  position: absolute;
  top: 255px;
  left: 258px;
  cursor: pointer;
}

.roulette_center:hover {
  transform: scale(1.01);        
}

.roulette_wheel{
  touch-action: auto;
  pointer-events:painted
}

.single-input-wrapper {
  display: flex;
  align-items: center;
}

.single-input-wrapper > label {
  width: 150px;
}

.single-input-wrapper > input {
  width: 150px;
}

#score {
  position: absolute;
  top: 0px;
  left: 360px;
  font-weight: 700;
  font-size: 24px;
}

#indicator {
   position: absolute;
   top: 30px;
   left: 350px;
   background-color: red;
   width: 1px;
   height: 60px; 
}
#all-input-wrappers {
  z-index: 10;
  position: absolute;
  top: 0px;
  left: 0px;
  opacity: 0.2;
  background-color: white;
  display: flex;
  flex-direction: column;
  transition: opacity 0.2s;
}

#all-input-wrappers:hover {
  opacity: 0.8;
}
<html>
  <head>
  </head>
  <body>
    <div style="z-index: 0; position: relative">
      <div id="all-input-wrappers"></div>
      <span id="score">&nbsp;</span>
      <img
        class="roulette_wheel"
        src="https://www.dropbox.com/s/6kp3fmtp72aj3vy/wellness-wheel.png?raw=1"
       />
      <img
        class="roulette_center"
        src="https://www.dropbox.com/s/52x8iyb1nvkjm0w/wheelCenter.png?raw=1"
        onfocus="transform()"
       >
      <div id="indicator"></div>
    </div>
  </body>
</html>



回答2:


So I tried something,
I let css handle the rotate transition and set a floored random value as the angle of rotation. Now it stops at the center of the pin, I'll let you figure out where it has stopped and set the score.

var force = 0;
var angle = 0;
var rota = 1;
var inertia = 0.985;
var minForce = 15;
var randForce = 15;
var rouletteElem = document.getElementsByClassName('roulette_wheel')[0];
var scoreElem = document.getElementById('score');

var values = [
  "Spititual", "Emotional", "Intellectual", "Physical", "Social", "Environmental", "Financial"
].reverse();

function roulette_spin(btn) {
  var additionalRotation = Math.floor(Math.random() * values.length) * 360
  angle += additionalRotation;
  rouletteElem.style.transform = 'rotate(' + angle + 'deg)';
}
.roulette_center:hover {
  transform: scale(1.01);
}
.roulette_wheel {
  transition: ease-in-out 3s transform;
}
<div style="z-index: 0; position: relative">
  <span style="position: absolute; top: 0px; left: 360px; font-weight:700;font-size:24px;" id="score">&nbsp;</span>
  <img class="roulette_wheel" src="https://www.dropbox.com/s/6kp3fmtp72aj3vy/wellness-wheel.png?raw=1" />
  <img class="roulette_center" style="position:absolute;left:2px;cursor:pointer;" src="https://www.dropbox.com/s/52x8iyb1nvkjm0w/wheelCenter.png?raw=1" onclick="roulette_spin(this)" onfocus="transform()">
  <div style="position: absolute; top: 30px; left: 350px; background-color: red; width: 1px; height: 60px;"></div>

</div>



回答3:


If you wanted to keep your angle changes inside your current function and still utilize how you're keeping score this is a way you can find the closest 1/7th to which your angle is at any given time (this will allow you to know how far away or close to a 1/7th at any moment). So long as your graphic is designed to start on the top 1/7th, in the middle of the first section at the top, then you can use this angle_difference to adjust your angle variable however you wish. For instance you could at start slowing towards the nearest 1/7th at force < 1.0 by taking the angle_difference and dividing it into so many steps and instead of modifying your new angle by (angle + force) % 360 you could modify it with these tiny steps to slowly inch forward or backward to the nearest 1/7th.

Here is some example code to see what I mean:

if (force < 1.0) {
    closest_seventh = Math.round(7*(angle/360))*(1/7);
    closest_seventh_angle = 360 * closest_seventh;
    angle_difference = abs(angle - closest_seventh);
    angle_step = angle_difference; / 10 #arbitrarily chosen number of steps
    if (angle - closest_seventh < 0) {
        angle = angle - angle_step;
    } else {
        angle = angle + angle_step;
    }
    force = force - (0.5 / 10);
    if (force < 0.05) {
      // score roughly estimated
      scoreElem.innerHTML = values[Math.floor(((angle / 360) * values.length) - 0.5)];
      return;
    }
    requestAnimationFrame(doAnimation);
        

You can adjust the arbitrary values as you need to make the animation look the way you wish, but this processes:

  1. Waits until the force is low but not low enough to score
  2. Upon force dropping below 1.0 finds the closest 1/7th
  3. Decides how far away it is
  4. Calculates the number of degrees per step the image still needs to rotate to get there
  5. Then alters the force and angle by the number of steps (chosen by you) to then rotate the image by the time force gets below 0.5 and a score is obtained.

There would need to be some other minor modifications in regards to when you change your force value when Force > 1.0 but I hope this offers an alternative mindset on how to get to that middle section of your wheel segments.

EDIT

I forgot one line of code where you take the closest 7th and multiply it by 360 to actually find the angle we need to get to to be in the middle of the pie piece, so I have added that to the code.



来源:https://stackoverflow.com/questions/65959865/spinning-wheel-in-js-how-to-automatically-stop-the-spinning-at-the-center-of-a

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!