Harsha M V
Harsha M V

Reputation: 54949

Material-UI Slider - Changing Values using Scale

I want to display a range on the slider from 1000 to 1M and add the following markers

const followersMarks = [
    {
      value: 1000,
      label: '1k',
    },
    {
      value: 5000,
      label: '5k',
    },
    {
      value: 10000,
      label: '10k',
    },
    {
      value: 25000,
      label: '25k',
    },
    {
      value: 50000,
      label: '50k',
    },
    {
      value: 100000,
      label: '100k',
    },
    {
      value: 250000,
      label: '250k',
    },
    {
      value: 500000,
      label: '500k',
    },
    {
      value: 1000000,
      label: '1M',
    },
  ];

Am adding it as follows

<Form.Group controlId="formGridState">
              <Form.Label className="mb-15">Followers Range</Form.Label>
              <Slider
                value={followersRange}
                onChange={handleChangeFollowersRange}
                valueLabelDisplay="auto"
                aria-labelledby="range-slider"
                step="1000"
                valueLabelDisplay="on"
                marks={followersMarks}
                min={1000}
                max={1000000}
              />
            </Form.Group>

This is the result:

enter image description here

What do I need?

Since at the start of the range I am displaying more markers the legibility and UX are bad. Is there a way to use scale to show the range in such way that it takes 50-60% of the space to show the first 25% of the values and then space out the rest?

Upvotes: 9

Views: 17064

Answers (3)

Ryan Cogswell
Ryan Cogswell

Reputation: 81036

Below is a working example of one way to do this. The key thing to note is that the values for min, max, step, and value (including value within marks) are linear values. The scale function then translates these to the non-linear values you want to display.

import React from "react";
import Typography from "@material-ui/core/Typography";
import Slider from "@material-ui/core/Slider";

const followersMarks = [
  {
    value: 0,
    scaledValue: 1000,
    label: "1k"
  },
  {
    value: 25,
    scaledValue: 5000,
    label: "5k"
  },
  {
    value: 50,
    scaledValue: 10000,
    label: "10k"
  },
  {
    value: 75,
    scaledValue: 25000,
    label: "25k"
  },
  {
    value: 100,
    scaledValue: 50000,
    label: "50k"
  },
  {
    value: 125,
    scaledValue: 100000,
    label: "100k"
  },
  {
    value: 150,
    scaledValue: 250000,
    label: "250k"
  },
  {
    value: 175,
    scaledValue: 500000,
    label: "500k"
  },
  {
    value: 200,
    scaledValue: 1000000,
    label: "1M"
  }
];

const scale = value => {
  const previousMarkIndex = Math.floor(value / 25);
  const previousMark = followersMarks[previousMarkIndex];
  const remainder = value % 25;
  if (remainder === 0) {
    return previousMark.scaledValue;
  }
  const nextMark = followersMarks[previousMarkIndex + 1];
  const increment = (nextMark.scaledValue - previousMark.scaledValue) / 25;
  return remainder * increment + previousMark.scaledValue;
};

function numFormatter(num) {
  if (num > 999 && num < 1000000) {
    return (num / 1000).toFixed(0) + "K"; // convert to K for number from > 1000 < 1 million
  } else if (num >= 1000000) {
    return (num / 1000000).toFixed(0) + "M"; // convert to M for number from > 1 million
  } else if (num < 900) {
    return num; // if value < 1000, nothing to do
  }
}

export default function NonLinearSlider() {
  const [value, setValue] = React.useState(1);

  const handleChange = (event, newValue) => {
    setValue(newValue);
  };

  return (
    <div>
      <Typography id="non-linear-slider" gutterBottom>
        Followers
      </Typography>
      <Slider
        style={{ maxWidth: 500 }}
        value={value}
        min={0}
        step={1}
        max={200}
        valueLabelFormat={numFormatter}
        marks={followersMarks}
        scale={scale}
        onChange={handleChange}
        valueLabelDisplay="auto"
        aria-labelledby="non-linear-slider"
      />
      <Typography>Value: {scale(value)}</Typography>
    </div>
  );
}

Edit Slider scale


Here is a similar example, but modified for a range slider:

import React from "react";
import Typography from "@material-ui/core/Typography";
import Slider from "@material-ui/core/Slider";

const followersMarks = [
  {
    value: 0,
    scaledValue: 1000,
    label: "1k"
  },
  {
    value: 25,
    scaledValue: 5000,
    label: "5k"
  },
  {
    value: 50,
    scaledValue: 10000,
    label: "10k"
  },
  {
    value: 75,
    scaledValue: 25000,
    label: "25k"
  },
  {
    value: 100,
    scaledValue: 50000,
    label: "50k"
  },
  {
    value: 125,
    scaledValue: 100000,
    label: "100k"
  },
  {
    value: 150,
    scaledValue: 250000,
    label: "250k"
  },
  {
    value: 175,
    scaledValue: 500000,
    label: "500k"
  },
  {
    value: 200,
    scaledValue: 1000000,
    label: "1M"
  }
];

const scaleValues = (valueArray) => {
  return [scale(valueArray[0]), scale(valueArray[1])];
};
const scale = (value) => {
  if (value === undefined) {
    return undefined;
  }
  const previousMarkIndex = Math.floor(value / 25);
  const previousMark = followersMarks[previousMarkIndex];
  const remainder = value % 25;
  if (remainder === 0) {
    return previousMark.scaledValue;
  }
  const nextMark = followersMarks[previousMarkIndex + 1];
  const increment = (nextMark.scaledValue - previousMark.scaledValue) / 25;
  return remainder * increment + previousMark.scaledValue;
};

function numFormatter(num) {
  if (num > 999 && num < 1000000) {
    return (num / 1000).toFixed(0) + "K"; // convert to K for number from > 1000 < 1 million
  } else if (num >= 1000000) {
    return (num / 1000000).toFixed(0) + "M"; // convert to M for number from > 1 million
  } else if (num < 900) {
    return num; // if value < 1000, nothing to do
  }
}

export default function NonLinearSlider() {
  const [value, setValue] = React.useState([1, 25]);

  const handleChange = (event, newValue) => {
    setValue(newValue);
  };

  return (
    <div>
      <Typography id="non-linear-slider" gutterBottom>
        Followers
      </Typography>
      <Slider
        style={{ maxWidth: 500 }}
        value={value}
        min={0}
        step={1}
        max={200}
        valueLabelFormat={numFormatter}
        marks={followersMarks}
        scale={scaleValues}
        onChange={handleChange}
        valueLabelDisplay="auto"
        aria-labelledby="non-linear-slider"
      />
      <Typography>Values: {JSON.stringify(scaleValues(value))}</Typography>
    </div>
  );
}

Edit Slider scale

Upvotes: 14

Got a working example which allows arbitrary ranges for values and scaled values.

  const [min, setMin] = useState(0);
  const [max, setMax] = useState(0);
  const prices = [
    {
      string: "$800K",
      label: "$800K",
      value: 0,
      scaledValue: 800000,
    },
    {
      string: "$1.2M",
      label: "$1.2M",
      value: 30,
      scaledValue: 1200000,
    },
    {
      string: "$2M",
      label: "$2M",
      value: 60,
      scaledValue: 2000000,
    },
    {
      string: "$4M",
      label: "$4M",
      value: 80,
      scaledValue: 4000000,
    },
    {
      string: "$20M",
      label: "$20M",
      value: 100,
      scaledValue: 20000000,
    },
  ];

  function valuetext(value) {
    return `$${value}`;
  };

  function valueLabelFormat(value) {
    return prices.findIndex((prices) => prices.value === value) + 1;
  };

  const descale = (scaledValue) => {
    const priceIndex = prices.findIndex(
      (price) => price.scaledValue >= scaledValue,
    );
    const price = prices[priceIndex];
    if (price.scaledValue === scaledValue) {
      return price.value;
    }
    if (priceIndex === 0) {
      return 0;
    }
    const m =
      (price.scaledValue - prices[priceIndex - 1].scaledValue) /
      (price.value - prices[priceIndex - 1].value || 1);
    const dX = scaledValue - prices[priceIndex - 1].scaledValue;
    return dX / m + prices[priceIndex - 1].value;
  };

  const scale = (value): number => {
    const priceIndex = prices.findIndex((price) => price.value >= value);
    const price = prices[priceIndex];
    if (price.value === value) {
      return price.scaledValue;
    }
    const m =
      (price.scaledValue - prices[priceIndex - 1].scaledValue) /
      (price.value - prices[priceIndex - 1].value || 1);
    const dX = value - prices[priceIndex - 1].value;
    return m * dX + prices[priceIndex - 1].scaledValue;
  };

In your component:

<Slider
  onChange={(e, value) => {
    setMin(scale(value[0]));
    setMax(scale(value[1]));
  }}
  value={[
    descale(min),
    descale(max) ||
      prices.slice(-1)[0].value,
  ]}
  scale={scale}
  marks={prices}
  defaultValue={[
    prices[0].value,
    prices[prices.length - 1].value,
  ]}
  valueLabelFormat={valueLabelFormat}
  getAriaValueText={valuetext}
  min={0}
  max={100}
/>

Result: Result

You can remove max if you want only 1 value.

Upvotes: 1

HMR
HMR

Reputation: 39280

Did not have time yesterday to work on this but Ryan's answer works. I was thinking of making a more general implementation where you can pass ranges and the slide will adjust according to any range you sent it.

For example if you pass:

[
  { value: 0 },
  { value: 5, label: 5 },
  { value: 10, label: 10 },
  { value: 15, label: 15 },
  { value: 20, label: 20 },
  { value: 100, label: 100 }
],

Then the slider space will be broken up into equal parts, each part in the array gets the same space but as you can see they don't get the same amount of values. In the example above the first 80% gets 20% of the values and the last 20% gets 80% of the values.

Full demo can be found here

Here are the relevant code blocks:

const zip = (...arrays) =>
  [...new Array(Math.max(...arrays.map(a => a.length)))]
    .map((_, i) => i)
    .map(i => arrays.map(a => a[i]));

const createRange = ([fromMin, fromMax], [toMin, toMax]) => {
  const fromRange = fromMax - fromMin;
  const toRange = toMax - toMin;
  return fromValue => ((fromValue - fromMin) / fromRange) * toRange + toMin;
};

const createScale = (min, max, marks) => {
  const zippedMarks = zip([undefined].concat(marks), marks);
  const zone = (max - min) / (marks.length - 1);
  const zones = marks.map((_, i) => [i * zone + min, (i + 1) * zone + min]);
  const ranges = zippedMarks
    .filter(([a, b]) => a !== undefined && b !== undefined)
    .map(([a, b], i) => [
      createRange(zones[i], [a.value, b.value]),
      zones[i],
      createRange([a.value, b.value], zones[i]),
      [a.value, b.value]
    ]);
  const sliderValToScaled = sliderVal =>
    ranges.find(([, [low, high]]) => sliderVal >= low && sliderVal <= high)[0](
      sliderVal
    );
  const scaledValToSlider = scaledVal =>
    ranges.find(
      ([, , , [low, high]]) => scaledVal >= low && scaledVal <= high
    )[2](scaledVal);

  return [sliderValToScaled, scaledValToSlider];
};

export default function NonLinearSlider({
  marks = [{ value: 0 }, { value: 1 }],
  steps = 200,
  onChange = x => x,
  value = 0,
  numFormatter
}) {
  const handleChange = (event, newValue) => {
    onChange(scale(newValue));
  };

  const [scale, unscale] = React.useMemo(() => createScale(0, 1, marks), [
    marks
  ]);
  const followersMarks = marks
    .filter(mark => mark.label)
    .map(mark => ({
      ...mark,
      value: unscale(mark.value)
    }));
  return (
    <Slider
      style={{ maxWidth: 500 }}
      value={unscale(value)}
      min={0}
      step={1 / steps}
      max={1}
      valueLabelFormat={numFormatter}
      marks={followersMarks}
      scale={scale}
      onChange={handleChange}
      valueLabelDisplay="auto"
      aria-labelledby="non-linear-slider"
    />
  );
}

Upvotes: 2

Related Questions