简体   繁体   中英

Balancing input sliders gives inconsistent results

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:
滑块显示 98.18 和 5.88

If I move it fast, the error is much bigger:
滑块显示 100 和 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?

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 class="slidecontainer">
      <p>Slider 1</p>
      <span class="slider1Val">50%</span>
      <input
        class="slider1"
        type="range"
        min="1"
        max="100"
        value="50"
        class="slider"
        id="myRange"
      />
      <p>Slider 2</p>
      <span class="slider2Val">50%</span>
      <input
        class="slider2"
        type="range"
        min="1"
        max="100"
        value="50"
        class="slider"
        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

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.
start 50 - 30 - 20
30 + 20 = 50
30: 30/50 = 0.630 - 0.6 * 10
20: 20/50 = 0.420 - 0.4 * 10
end 60 - 24 - 16

This works for all cases except when one slider is at 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>

Comparing this to your way

start 50 - 30 - 20
change 60 - 30 - 20
new sum 110
normaliser 100 / 110 (~0.9)
end 54,54 - 27,27 - 18,18

While this sums up to 100, the value which was intentionally changed is directly modified again. After removing the bind:value from your example and adding sliders=sliders at the end of balanceSliders so that Svelte knows of the changes, this can be noticed when dragging one slider against 100 REPL

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM