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:
If I move it fast, the error is much bigger:
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));
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.6
→ 30 - 0.6 * 10
20: 20/50 = 0.4
→ 20 - 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>