Thursday, November 1, 2012

Finding Points Along an SVG Path using Raphael

In my last post, I discussed how using paths with Raphael is a good thing and that certain functions in the library only work with SVG elements of type "path". Now I'm going to do something a little more complex with paths which will utilize these functions. I've posted a demo on my sandbox which implements a slider like control that is bound to a SVG path. Typically, you'd be restricted to a slider that basically moves over a straight line - up/down or left/right. With this functionality, all kinds of slider controls can be created. With the tools in Raphael, this feature is pretty easy to implement.

First, we need to create a path that will serve as the constraint for our slider knob. There are two types of paths we can create:


  • A closed path that is semi-circular. The knob will continue around from start to finish and start again.

  • A open path that is linear in nature. There is a start and end which the knob can travel back-and-forth between.



While the path does not need to be completely circular or exactly linear, the closer they are to these basic shapes, the better the effect. This is because the points are being estimated based on these shapes. If you look at the demo and compare the circle to the ellipse, you might notice that the knob drifts slight from the mouse pointer as you move around the ellipse whereas the circle does not. This is due to the error in the estimation. This is more evident in closed paths that use the circle estimation than the linear path approximations.

In the example, I created a list of predefined shapes that can be created as reference paths. The following line of code uses the currently selected shape (based on clicking one of the buttons) and adds it to the DOM using the Raphael path() function. I pass the path definition through Raphael.transformPath() because all the paths need to start at a specific place for the logic to work.



path = paper.path( Raphael.transformPath(pdefs[useDef].path, pdefs[useDef].transform) )
.attr( 'stroke-width', 10 )
.attr( 'stroke', 'rgb(80,80,80)' ),



Next, I create a simple slider knob. I purposely created this as an ellipse to show how the shape follows the angle of the path as well as the position on the path. Additionally, I need something to track the current mouse position so I add a DIV and position it directly over the knob object. This ensure all dragging operations are caught by the DIV. I did this so even if you move outside of the path, the knob will keep moving relative to the dragging DIV.



knob = paper.ellipse( 0, 0, 25, 15 )
.attr( 'fill', 'lime' )
.attr( 'stroke', 'rgba(80,80,80,0.5)' ),

$shim = $('<div>')
.css( {position: 'absolute', width: 50, height: 50 } )
.appendTo( $('.wrapper') ),



Now, I need a few other pieces of information about the reference path. I initialize the total length of the path, find the center of the path based on its bounding box, and set the starting point of the knob to the start of the path:



len = path.getTotalLength(),
bb = path.getBBox(),
mid = {x: bb.x+bb.width/2, y: bb.y+bb.height/2},

pal = path.getPointAtLength(0);



The Raphael getPointAtLength() function is a really useful function to find a specific point on the path based on the length along the path. So if you have a circle with a parameter of 100 pixels, getPointAtLength(10) would return a point on the circle 10px from the start. In my demo, if I want to know where to position my slider knob is suppose to be on the reference path, I need to figure out how far along the reference path the proxy DIV has been dragged.

For a circular shape, we can figure this out by converting the Cartesan coordinates to Polar coordinates. This transformation provides an angle around a circle which when divided by 360 can be used as a percentage around the circle. That percentage can be multiplied by the total length of the path to find the length to pass to getPointAtLength().

I illustrated this concept below by drawing a reference circle over the rectangle. The yellow dot represents where the DIV has been currently dragged. Using this point relative to the center of the rectangle, we can find the angle around the circle. For this calculation, 0 degrees is on the positive side of x-axis. This is important and the reason why I transformed the reference paths - I needed their start point to align with 0 degrees. In this example, the point is at the 45 degree angle on the circle. 45/360 = 0.125 which I can multiple by the total length of the path to find the point indicated in the second drawing.



So how do we find that angle? In turns out Raphael has a function that does this - angle(). Pass it two points and it will return the angle (0-360 degrees) the first point is relative to the second point on the imaginary circle. Divide the result by 360 and we have the percentage to multiply by the length to find where to position the slider knob.

All of this explanation turns into these few lines of code:


$shim.draggable({
drag: function ( e, ui ) {

// Find lines and then angle to determine
// percentage around an imaginary circle.
var t = ( Raphael.angle( ui.position.left+25, ui.position.top+25, mid.x, mid.y ) ) / 360;

// Using t, find a point along the path
pal = path.getPointAtLength( (t * len) % len );

// Move the knob to the new point
knob.transform( 't' + [pal.x, pal.y] + 'r' + pal.alpha );
},
stop: function ( e, ui ) {
$shim.css({ left: pal.x-25, top: pal.y-25 });
}
});


I enabled a jQuery UI Draggable on the DIV and implemented the drag callback to use the current position of the DIV to find the angle, get the point via getPointAtLength(), and then use a transform to position the slider knob. Notice that I also rotate the knob based on the value "alpha" return by getPointAtLength(). This value represents the tangent line at the current point on the path. It be used to angle an object to follow along with the curve of the path:





The final step in the drag operation is to move the proxy shim back over the knob so the next attempt to move the knob will trigger the movement logic.

This example highlighted one of the possible uses of the path functionality available in Raphael. I'm sure it only scratches the surface of some of the possibilities. What's nice about the implementation is that it is fast enough to keep up with the mouse move events triggered by the Draggable. I was a little concerned that the math operations involved might make the movement look choppy.

I did not implement the linear option in this demo. It is a different set of calculations to project the current mouse coordinates onto a line that represents the path and find the correct point based on that calculation. Additionally, a circular open path implementation would be interesting as well as some other variations. Those are all little exercises for another day.