4

My goal is to design an arc slider which looks something like that

enter image description here

I have the following structure of the template

<svg width="500" height="300">
  <path id="track" stroke="lightgrey" fill="transparent" stroke-width="20" d="
     M 50 50
     A 90 90 0 0 0 300 50
  "/>
  <path id="trackFill" fill="cyan" stroke-width="20" d="
     M 50 50
     A 90 90 0 0 0 [some dynamic value?] [some dynamic value?]
  "/>
  <circle id="knob" fill="lightblue"  cx="[dynamic, initial - 50]" cy="[dynamic, initial - 50]" r="25"/>
</svg>

knob - the control which user is supposed to drag in order to change the value

track - the full arc of the slide

trackFill - the portion of the slider path before the knob

Is it possible to make trackFill cover the portion of the slider before the knob as it is being dragged along the slider curve? If so which APIs or CSS rules will help me to achieve such a result?

Max Liapkalo
  • 121
  • 8

3 Answers3

7

Is it something like this you are after?

let svg = document.getElementById("slider");
let trackFill = document.getElementById("trackFill");
let knob = document.getElementById("knob");
let isDragging = false;
let sliderDragOffset = {dx: 0, dy: 0};
let ARC_CENTRE = {x: 175, y: 50};
let ARC_RADIUS = 125;

let sliderValue = 0;
setSliderValue(sliderValue);


function setSliderValue(value)
{
  // Limit value to (0..sliderMax)
  let sliderMax = track.getTotalLength();
  sliderValue = Math.max(0, Math.min(value, sliderMax));
  // Calculate new position of knob
  let knobRotation = sliderValue * Math.PI / sliderMax;
  let knobX = ARC_CENTRE.x - Math.cos(knobRotation) * ARC_RADIUS;
  let knobY = ARC_CENTRE.y + Math.sin(knobRotation) * ARC_RADIUS;
  // Adjust trackFill dash patter to only draw the portion up to the knob position
  trackFill.setAttribute("stroke-dasharray", sliderValue + " " + sliderMax);
  // Update the knob position
  knob.setAttribute("cx", knobX);
  knob.setAttribute("cy", knobY);
}


knob.addEventListener("mousedown", evt => {
  isDragging = true;
  // Remember where we clicked on knob in order to allow accurate dragging
  sliderDragOffset.dx = evt.offsetX - knob.cx.baseVal.value;
  sliderDragOffset.dy = evt.offsetY - knob.cy.baseVal.value;
  // Attach move event to svg, so that it works if you move outside knob circle
  svg.addEventListener("mousemove", knobMove);
  // Attach move event to window, so that it works if you move outside svg
  window.addEventListener("mouseup", knobRelease);
});


function knobMove(evt)
{
  // Calculate adjusted drag position
  let x = evt.offsetX + sliderDragOffset.dx;
  let y = evt.offsetY + sliderDragOffset.dy;
  // Position relative to centre of slider arc
  x -= ARC_CENTRE.x;
  y -= ARC_CENTRE.y;
  // Get angle of drag position relative to slider centre
  let angle = Math.atan2(y, -x);
  // Positions above arc centre will be negative, so handle them gracefully
  // by clamping angle to the nearest end of the arc
  angle = (angle < -Math.PI / 2) ? Math.PI : (angle < 0) ? 0 : angle;
  // Calculate new slider value from this angle (sliderMaxLength * angle / 180deg)
  setSliderValue(angle * track.getTotalLength() / Math.PI);
}


function knobRelease(evt)
{
  // Cancel event handlers
  svg.removeEventListener("mousemove", knobMove);
  window.removeEventListener("mouseup", knobRelease);
  isDragging = false;
}
<svg id="slider" width="500" height="300">
  <g stroke="lightgrey">
    <path id="track" fill="transparent" stroke-width="20" d="
       M 50 50
       A 125 125 0 0 0 300 50
    "/>
  </g>
  <use id="trackFill" xlink:href="#track" stroke="cyan"/>
  <circle id="knob" fill="lightblue" cx="50" cy="50" r="25"/>
</svg>

I've kept this code simple for clarity, but at the expense of some limitations.

It assumes there is only one slider per page. If you need more than that, you will have to keep the slider-specific values (eg sliderValue and, isDragging) separate. You could use data attributes for that. You would also need to switch from accessing the SVG elements via id attributes to another way (eg. class attributes), because id attributes must be unique on the page.

Paul LeBeau
  • 97,474
  • 9
  • 154
  • 181
  • one more thing, is it possible to make this slider work both on mobile and desktop devices? Simply replacing mouse events with touch events doesn't seem to be the right way of doing it – Max Liapkalo Apr 15 '21 at 18:51
  • 2
    Or wrap it in a W3C standard Web Component to use multiple instances on one page – Danny '365CSI' Engelman Apr 15 '21 at 19:20
  • 1
    @MaxLiapkalo Touches are basically the same. But for `touchstart` and `touchmove` you will need to access the `touches` property. The other complication is that touch events don't have `offsetX/Y` properties, so you will need to calculate those yourself (see https://stackoverflow.com/questions/17130940/retrieve-the-same-offsetx-on-touch-like-mouse-event) – Paul LeBeau Apr 17 '21 at 01:09
2

Here is a simple example:

const radius = 50;
const offsetX = 10;
const offsetY = 10;

// 0 <= pos <= 1
const setSliderPos = (svg, pos) => {
  const angle = Math.PI * pos;
  const x = offsetX + radius - Math.cos(angle) * radius;
  const y = offsetY + Math.sin(angle) * radius;

  svg.select('.knob').attr('cx', x).attr('cy', y);
  svg.select('.first').attr('d', `M ${offsetX},${offsetY} A ${radius},${radius} 0 0 0 ${x},${y}`);
  svg.select('.second').attr('d', `M ${x},${y} A ${radius},${radius} 0 0 0 ${offsetX + radius * 2},${offsetY}`);
}

setSliderPos(d3.select('#svg-1'), 0.3);
setSliderPos(d3.select('#svg-2'), 0.6);
setSliderPos(d3.select('#svg-3'), 1);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg id="svg-1" width="150" height="80">
  <path class="first" stroke-width="5" stroke="lightblue" fill="none"/> 
  <path class="second" stroke-width="5" stroke="cyan" fill="none"/> 
  <circle class="knob" r="10" fill="lightblue"/>
</svg>
   
<svg id="svg-2" width="150" height="80">
  <path class="first" stroke-width="5" stroke="lightblue" fill="none"/> 
  <path class="second" stroke-width="5" stroke="cyan" fill="none"/> 
  <circle class="knob" r="10" fill="lightblue"/>
</svg>

<svg id="svg-3" width="150" height="80">
  <path class="first" stroke-width="5" stroke="lightblue" fill="none"/> 
  <path class="second" stroke-width="5" stroke="cyan" fill="none"/> 
  <circle class="knob" r="10" fill="lightblue"/>
</svg>
Michael Rovinsky
  • 6,807
  • 7
  • 15
  • 30
  • 1
    Hi Michael, could you please explain a bit math behind the `x` and `y` coordinates and why do we need X\Y offset values? – Max Liapkalo Apr 15 '21 at 16:54
  • Sure. It's a basic trigonometry. The knob position relatively to the center of the slider is: x = cos(angle) * radius, y = sin(angle) * radius. The angle is 180 * pos (in degrees) or PI * pos in radians. The center coordinates are x: offsetX + radius; y = offsetY + radius – Michael Rovinsky Apr 15 '21 at 17:13
  • Sorry, center's y = offsetY. All the rest is correct :) – Michael Rovinsky Apr 15 '21 at 17:21
0

To mark the progress you can use stroke-dasharray with a percentage; for example

<g stroke="lightgrey">
  <path id="track" fill="transparent" stroke-width="20"
   stroke-dasharray="40% 60%"
   d="M 50 50 A 125 125 0 0 0 300 50"/>
</g>

This will show 40% of the arc and hide 60% of the arc.

If you need to use two colors, for example the whole arc in grey and the progress in black, you need to use two arcs on top of one another; the one at the bottom would be the one you already have, and the one at the top would have a stroke in black and use stroke-dasharray as shown.

Ken
  • 94
  • 6