skip to Main Content

I’d like a clock-like object that points to the cursor position from the centre of the clock and reports/sets the position of the ‘hand’ in terms of its percentage around the circle.

I’ve gotten as far as the following, which has these problems:

  1. calculating the angle of the ‘hand’ is relative to the whole svg, not the circle. The circle might be in any position within the svg viewbox, so calculating the angle would be relative to the centre of that circle.
  2. the ‘0’ percentage is at the 3 o’clock position rather than 12 o’clock.
// Get SVG and clock hand elements
const svg = document.getElementById('clock-svg');
const clockHand = document.getElementById('clock-hand');
const pc = document.getElementById('pc');
const handLength = 40; // clock hand length, matches the r of the circle
const circleX = 100;
const circleY = 100;

// listen to percentage spin control input event
pc.addEventListener('input', function(event) {
  changePercentage(event.target.value);
});

// Add mousemove event listener
svg.addEventListener('mousemove', function(event) {

// Calculate mouse position relative to the SVG
const svgRect = svg.getBoundingClientRect(); // TODO make it relative to the position of the circle inside the SVG
const mouseX = event.clientX - svgRect.left; // TODO do I need to convert the DOM event coordinates to SVG coordinates?
const mouseY = event.clientY - svgRect.top;

// Calculate angle of the mouse position relative to the center of the clock
let angle = Math.atan2(mouseY - 100, mouseX - 100);  // TODO what happens when the document is scrolled? clientX and clientY are relative to the viewport, not the document?

updateHand(angle);

// Calculate percentage of the circle completed
let percentage = (angle / (2 * Math.PI)) * 100;

// angle is negative when mouse is on the left side of the clock, lets adjust for that
if (percentage < 0) percentage += 100;

// set the percentage input control
document.getElementById('pc').value = parseInt(percentage,10);
});

function changePercentage(percentage) {

  // Calculate angle based on percentage
  const angle = (percentage / 100) * 2 * Math.PI;
  updateHand(angle);
  
}

// Update clock hand position for the angle
function updateHand(angle) {
  const handX = circleX + handLength * Math.cos(angle);
  const handY = circleY + handLength * Math.sin(angle);
  
  clockHand.setAttribute('x2', handX);
  clockHand.setAttribute('y2', handY);
}
<input type="number" id="pc" min="0" max="100" value="0">
<br>
<svg id="clock-svg" width="250" height="300" viewBox="50 50 250 150" style="border:1px solid black">
    <rect x="0" y="0" width="100%" height="100%" fill="lightgrey" />
    <circle cx="100" cy="100" r="40" fill="none" stroke="black" stroke-width="2" />
    <line id="clock-hand" x1="100" y1="100" x2="120" y2="120" stroke="black" stroke-width="2" />
</svg>

My rusty math has only gotten me this far, i need to remove a half radian somewhere yes? How do I make the angle relative to the centre of the circle not the size of the svg object?

2

Answers


  1. The assumption 12 o’clock should be 0°/0% is incorrect and only based on the convention this is the "starting point" on the dial of a watch.

    In fact 3 o’ clock describes a flat line so 0 degree, whereas 12 o’ clock describes an angle of -90/270°.

    You need to add a 90 degree offset to set 12 o’clock as 0/100%.

    I also recommend to translate cursor screen coordinates to svg user units.
    This way you dont’t have to bother about scroll offsets or scaled placement of your svg.

    // Get SVG and clock hand elements
    const svg = document.getElementById("clock-svg");
    const clockHand = document.getElementById("clock-hand");
    const pc = document.getElementById("pc");
    const handLength = 40; // clock hand length, matches the r of the circle
    const circleX = 100;
    const circleY = 100;
    let ptCenter = { x: circleX, y: circleY };
    let angleOffset = 90
    
    // listen to percentage spin control input event
    
    pc.addEventListener('input', e=>{
      let value= +e.currentTarget.value
      let angle = 360/100 * value - angleOffset
      updateHand(angle);
    })
    
    
    // Add mousemove event listener
    svg.addEventListener("mousemove", function (event) {
      let ptCursor = { x: event.clientX, y: event.clientY };
      let pt = screenToSVG(svg, ptCursor);
      let angle = getAngle(ptCenter, pt) 
      let percent = 100/360 * (angle) +25
      percent = percent<100 ? percent : percent-100
      pc.value = percent
      updateHand(angle);
    });
    
    
    // Update clock hand position for the angle
    function updateHand(angle) {
      let ptHand = getPointOnCircle(handLength, circleX, circleY, angle)
      clockHand.setAttribute("x2", ptHand.x);
      clockHand.setAttribute("y2", ptHand.y);
    }
    
    
    // get angle between 2 points
    function getAngle(p1, p2) {
      let angle = (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
      return angle > 0 ? angle : 360 + angle;
    }
    
    function getPointOnCircle(r, cx, cy, deg) {
      let { cos, sin, PI } = Math;
      let rad = (deg * PI) / 180;
    
      return {
        x: cx + r * cos(rad),
        y: cy + r * sin(rad)
      };
    }
    
    /** Based on @Paul LeBeau's answer
     * https://stackoverflow.com/questions/48343436/how-to-convert-svg-element-coordinates-to-screen-coordinates#48354404
     */
    
    function screenToSVG(svg, pt) {
      let p = new DOMPoint(pt.x, pt.y);
      return p.matrixTransform(svg.getScreenCTM().inverse());
    }
    
    /*
    function SVGToScreen(svg, pt) {
      let p = new DOMPoint(pt.x, pt.y);
      p = p.matrixTransform(svg.getScreenCTM());
      return p;
    }
    */
    svg{
      width:2400px
    }
    
    
    #pc{
      position:fixed;
      top:50%;
      left:50%;
      z-index:9;
      width:10em;
    }
    <input  type="number" id="pc" min="0" max="100" value="0">
    <br>
    <svg id="clock-svg"   viewBox="50 50 250 150" style="border:1px solid black">
        <rect x="0" y="0" width="100%" height="100%" fill="lightgrey" />
        <circle cx="100" cy="100" r="40" fill="none" stroke="black" stroke-width="2" />
        <line id="clock-hand" x1="100" y1="100" x2="100" y2="60" stroke="black" stroke-width="2" />
    </svg>

    So we need these helpers:

    • getAngle(p1, p2) – calculate angle between cursor x/y and clock center
    • screenToSVG() – translate screen to svg coordinates
    • getPointOnCircle() – calculate end point for the hand <line> x2 and y2 attribute values
    Login or Signup to reply.
  2. You can skip a calcullation or two by using a <marker> on the line (clock-hand). The line is not showing (stroke = none) and the marker is "styled as" the line.

    Thanks to @herrstrietzel for providing the function for calculating the angle.

    let clockHand = document.querySelector("#clock-hand");
    let svg = document.querySelector("#clock-svg");
    let pc = document.querySelector("#pc");
    
    const toSVGPoint = (svg, x, y) => {
      let p = new DOMPoint(x, y);
      return p.matrixTransform(svg.getScreenCTM().inverse());
    };
    
    // get angle between 2 points
    // thanks to @herrstrietzel https://stackoverflow.com/a/78003502/322084
    function getAngle(p1, p2) {
      let angle = (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
      return angle > 0 ? angle : 360 + angle;
    }
    
    document.addEventListener('mousemove', e => {
      let p = toSVGPoint(svg, e.clientX, e.clientY);
      clockHand.setAttribute('x2', p.x);
      clockHand.setAttribute('y2', p.y);
    
      let angle = getAngle({x: 50,y: 50}, p);
      let percent = 100 / 360 * (angle) + 25;
      percent = percent < 100 ? percent : percent - 100;
      pc.value = percent;
    });
    <input type="number" id="pc" min="0" max="100" value="0">
    <br>
    <svg id="clock-svg" viewBox="0 0 300 100" style="border:1px solid black">
      <defs>
        <marker id="pupil" viewBox="0 0 40 2" refX="40" refY="1" markerWidth="40"
          markerHeight="2" orient="auto-start-reverse">
          <path d="M 0 1 H 40" stroke="black" stroke-width="2" />
        </marker>
      </defs>
      <rect width="100%" height="100%" fill="lightgrey" />
      <circle cx="50" cy="50" r="40" fill="none" stroke="black" stroke-width="2" />
      <line id="clock-hand" marker-start="url(#pupil)" x1="50" y1="50" x2="100" stroke="none" />
    </svg>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search