Home > Software engineering >  Balancing input sliders gives inconsistent results
Balancing input sliders gives inconsistent results

Time:09-03

I have a couple of range input sliders which are supposed to balance themselves to always sum up to 100¹, regardless of the amount of sliders. For this example I'll show the minimum of two, but the problem occurs with any number of them. I am using Svelte but this probably applies to vanilla JS too.

¹: The full requirements:

  • They must sum up to 100.
  • When a slider is changed, the others must retain their proportions as they are balanced. Namely: it's not sufficient to do 100 - newValue and split the result amongst the remaining sliders.
  • The behaviour must hold for any number of sliders.
<script lang="ts">
  const sliders = [
    { id: 1, percentage: 100 },
    { id: 2, percentage: 100 },
    // { id: 3, percentage: 100 },
    // { id: 4, percentage: 100 },
    // { id: 5, percentage: 100 },
  ];

  const handleInput = (event: InputEvent) => {
    const element = (event.target as HTMLInputElement);
    const newValue = Number(element.value);
    const sliderId = Number(element.dataset["slider"]);
    sliders.find((slider) => slider.id == poolId).percentage = newValue;

    balanceSliders();
  }

  const balanceSliders = () => {
    const total = sliders
      .map((slider) => slider.percentage)
      .reduce((prev, next) => prev   next, 0);
    const normaliser = 100 / total;

    sliders.forEach((slider) => {
      slider.percentage = slider.percentage * normaliser;
    });
  };

  balanceSliders();
</script>

<form>
  {#each sliders as {id, percentage}}
    <div>
      <input type="range" min="0" max="100" step="0.01" data-slider={id} bind:value={percentage} on:input={handleInput} />
      <span>{percentage.toFixed(2)}</span>
    </div>
  {/each}
</form>

This could probably be done better, I'm open to advice. But the main issue is that when I bring a slider close to 100, the sum adds up to more than 100. Moreover, if I move the slider slowly, I get something around:
sliders show 98.18 and 5.88

If I move it fast, the error is much bigger:
sliders show 100 and 25.85

Which is obviously unacceptable for my purposes.

The fact that the speed influences the error suggests to me that the issue lies somewhere in the Svelte/event handling part rather than the formula itself.

I could have the sliders balance themselves only after the user stopped changing them, but for presentation purposes I'd like them to keep balancing themselves as input is being given, too.

Any ideas?

CodePudding user response:

Here is the solution, you can make it dynamic and optimize it according to the requirement.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
  </head>

  <body>
    <div id="app"></div>
    <div >
      <p>Slider 1</p>
      <span >50%</span>
      <input
        
        type="range"
        min="1"
        max="100"
        value="50"
        
        id="myRange"
      />
      <p>Slider 2</p>
      <span >50%</span>
      <input
        
        type="range"
        min="1"
        max="100"
        value="50"
        
        id="myRange"
      />
    </div>
    <script src="src/index.js"></script>
  </body>
</html>

CSS

body {
  font-family: sans-serif;
}

.slidecontainer {
  display: flex;
  flex-direction: column;
}

Javscript

import "./styles.css";

const slider1 = document.querySelector(".slider1");
const slider2 = document.querySelector(".slider2");

const balanceSliders = (e) => {
  if (e.target.classList.contains("slider1")) {
    const slider1Val = document.querySelector(".slider1").value;
    slider2.value = 100 - slider1Val;
    document.querySelector(".slider1Val").innerHTML = slider1Val   "%";
    document.querySelector(".slider2Val").innerHTML = slider2.value   "%";
  } else {
    const slider2Val = document.querySelector(".slider2").value;
    slider1.value = 100 - slider2Val;
    document.querySelector(".slider2Val").innerHTML = slider2Val   "%";
    document.querySelector(".slider1Val").innerHTML = slider1.value   "%";
  }
};
slider1.addEventListener("input", (e) => balanceSliders(e));
slider2.addEventListener("input", (e) => balanceSliders(e));

Demo code sandbox

CodePudding user response:

I would remove the value binding and handle all changes inside handleInput with a slightly different logic.

Assuming the start values are 50-30-20 and the first slider is moved to 60, that's a difference of 10. This difference than has to be subtracted from the other sliders, taking into account their current proportions. I would calculate this proportion in relation to the sum of their percentages.
30 20 = 50
30: 30/50 = 0.630 - 0.6 * 10
20: 20/50 = 0.420 - 0.4 * 10
This works for all cases except when one slider is as 100 and the sum of the others is 0. In that case their share is 1/ the amout of sliders the difference should be split onto.
REPL

<script>
    let sliders = [
        { id: 1, percentage: 100 },
        { id: 2, percentage: 100 },
        { id: 3, percentage: 100 },
//      { id: 4, percentage: 100 },
//      { id: 5, percentage: 100 },
    ];

    sliders = sliders.map(slider => {
        slider.percentage = 100/sliders.length
        return slider
    })

    const handleInput = (event) => {
        const changedSlider = event.target;
        const sliderId = Number(changedSlider.dataset["slider"]);

        const slider = sliders.find((slider) => slider.id == sliderId)
        const currentValue = slider.percentage

        const newValue = Number(changedSlider.value);
        const difference = newValue - currentValue

        slider.percentage = newValue;

        const otherSliders = sliders.filter(slider => slider.id !== sliderId)
        const otherSlidersPercentageSum = otherSliders.reduce((sum, slider) => sum  = slider.percentage, 0)

        otherSliders.forEach(slider => {
            const share = otherSlidersPercentageSum === 0 ? 1 / otherSliders.length : slider.percentage / otherSlidersPercentageSum
            slider.percentage = slider.percentage - difference * share
            return slider
        })

        sliders = sliders       
    }

</script>

<form>
    {#each sliders as {id, percentage}}
    <div>
        <input type="range" min="0" max="100" step="0.01" data-slider={id} value={percentage} on:input={handleInput} />
        <span>{Math.max(percentage.toFixed(2), 0)}</span>
<!--        Math.max to prevent -0 -->
    </div>
    {/each}
</form>
  • Related