Circular Slider in canvas

Had to brush up on my Vector Algebra to get it, but the below code will accomplish what I believe you are wanting. I was unable to use your project file as I believe you have a newer version than I do. Hopefully the following will help get you where your trying to go.

I added the following custom properties to the Paintable Canvas object:

value - stores the current unscaled angle of the slider button
length - a value from 20 to 359 degrees ( I force the limits in the propertyChange event, anything less
than 20 degrees is really imho unusable)
minEnginneringValue - the minimum engineering value to use in calculating the scaled value
maxEngineeringValue - the maximum engineering value to use in calculating the scaled value
scaledValue - the value scaled to fit in the engineering range

You will probably want some other custom properties for things like the starting angle of the slider, the color of the bar, and/or the color of the button.

The below code is placed in the repaint event of the canvas

from java.awt import Color
from java.awt import RenderingHints
from java.awt import BasicStroke
from java.awt.geom import Rectangle2D
from java.awt.geom import Arc2D

import math
g = event.graphics

#use Antialiasing to insure the curves look smooth
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON)

#make the strokes thicker line can be omitted
g.setStroke(BasicStroke(2))
#stroke color is black by default include the following line to change the color
g.setColor(Color.GRAY)

#total length of slider in degrees
length = event.source.length
# the angle to start the slider at such that the gap is bisected by
# 0 degrees
startAngle = (360 - length) / 2

#diameter of the arc used to draw the slider 
diameter = 90
radius = diameter /2
#current value of the slider
value = event.source.value

#scale graphics to actual component size
dX = (event.width)/100.0
dY = (event.height)/100.0
g.scale(dX,dY)

#move the origin from the upper left corner to the center of paintable area
g.translate(radius + 5,radius + 5)
#rotate the coordinate system so that 0 is at the bottom
#the angle of rotation must be given in radians

##### CAUTION: CHANGING THIS LINE WILL BREAK THE MOUSE DRAG LOGIC ######
g.rotate(math.pi/2)

#draw an arc representing the full span of the slider
arc = Arc2D.Float(-radius,-radius,diameter,diameter,startAngle,length-2,Arc2D.OPEN)
g.draw(arc)

#rotate the coordinate system about the origin
#this will allow us to easily draw a rectangle perpendicular to
#a line drawn from the center point of the arc the length of the radius
#at an angle egaul to the value
#given angle must be in radians
g.rotate(math.radians(startAngle + value))
#set the color to something different to differentiate the value
g.setColor(Color.RED)
#draw a rectangle to indicate the current value of the slider
button = Rectangle2D.Float(radius - 2,0,4,2)
g.fill(button)

I have chosen to draw the slider with 0 degrees at the bottom as opposed to the default right. This allows for the slider to function in a way most users will be familiar with. This of course can be changed but it will require changes to the logic in the mouseDragged event which calculates the slider value

For testing I only placed the following code in the mouseDragged event, however, it would probably be in your best interest to create a custom function to hold this code which could then be called from multiple places such as the mouseClicked event or outside of the component.

This code can be quite confusing. I will do my best to explain what is going on.

I didn’t create a custom property to hold the starting angle, so I calculate it again. I then create a projection of the unit vector from the center point of the canvas to the start point of the bar onto the y-axis

Next I create a vector from the center point to the mouse location. This is easier said than done (at least with what is most likely a hack). That said, here goes my best explanation.

Depending on what quadrant the mouse is in the components of this vector are calculated differently. In reality the origin is at the upper left hand corner of the canvas, so we need to translate it to the center point. The origin is then (canvas.width / 2, canvas.height / 2). So if the x value of the mouse position is less than the canvas.width / 2 then we need the value from the center point to the mouse position. However the mouse position is given from the upper left corner so the actual x component of the vector is equal to the difference of canvas.width/2 and the x value of the mouse position. We’re still not done though because we need to treat the origin as if it is (0,0) that means that in this case the x component needs to be negative.
In the case where the x value of the mouse is greater than canvas.width/2 then we only need the difference so the x component is the x value of the mouse position minus the canvas.width/2.

Similar logic is used to find the y component of this vector.

Next we need the magnitude of both vectors, this is just the Pythagorean Theorem.

Next we take the dot product of the two vectors. This is the sum of the products of the x and y components of the vectors.

Finally we can calculate the angle between the two vectors. However, because you will not get a value out of the arc cos function greater than pi radians, we need to do a little magic to make it happen. If you don’t do this then when mouse crosses the y-axis the button will begin to travel in the opposite direction.

The code in the repaint method is expecting the value to be in degrees so we convert it to degrees, and offset it for the start angle and the width of the button.

We then limit the value to remain inside of the bar. The low limit will always be 0 and the high limit will be the specified length.

Finally we calculate the scaledValue for use or display elsewhere.

Here is the code:

import math

#calculate zero point of arc
sAng = ((360 - event.source.length) / 2)

#calculate vector from center to start angle
zVecX = 0.0
zVecY = -math.cos(sAng)

#calculate vector from center to mouse position
#since we are constructing vectors from the center of the arc
#we need to account from the mouse crossing over the center point

#if position is greater then center point then coordinate is positive
if event.x > event.source.width / 2:
	mVecX = event.x - (event.source.width/2)
else:
	mVecX = -((event.source.width/2) - event.x) 

if event.y < event.source.height / 2:
	mVecY = (event.source.height/2) - event.y
else:
	mVecY = -event.y + (event.source.height / 2)

#calculate zVec magnitude
zMag = math.sqrt((zVecX*zVecX) + (zVecY*zVecY))

#calculate mVec magnitude
mMag = math.sqrt((mVecX*mVecX) + (mVecY*mVecY))

#calculate dot prod of zVec and mVec
dotP = (zVecX * mVecX) + (zVecY * mVecY)


#calculate the angle in radians between zVec and mVec
#when we cross the "y-axis" the radians begin to decrease back
#towards zero, we will take the change in radians and add it to pi
#this allows the button to travel the full length of the slider
if event.x > event.source.width/2: 
	theta = math.pi + (math.pi - math.acos(dotP/(zMag * mMag)))
else:
	theta = math.acos(dotP/(zMag * mMag))
	
	
#convert theta from radians to degrees and offset it by the start angle
#plus the width of the button / 2
value = math.degrees(theta) - sAng - 2
#we must limit the angle between the start angle and the length
if value > event.source.length:
	value = event.source.length
elif value < 0:
	value = 0
event.source.value = value

min = event.source.minEngineeringValue
max = event.source.maxEngineeringValue

event.source.scaledValue = value * (max - min) / event.source.length

I also included the following in the propertyChange event to limit the length property, to help prevent Zero Division Errors. As long as movement is slow I have seen not problems but it would be a good idea to use some exception handling.

if event.propertyName == 'length':
	if event.newValue >= 360:
		event.source.length = 359
	elif event.newVAlue < 20:
		event.source.length = 20

Hopefully ,that is not too confusing.

This has been tested, and the only thing I have found that has me rather confused is a bug when the length is between 75 and 80 degrees. At values in that range the button does not travel the full length of the slider instead moving from 0 to 100% of the range.

For a slider length upwards of 270 degrees this code works rather well I think.

The button can be any shape you desire, or you can give the effect of filling the slider by drawing another arc (though beware this may change some of the math done in the mouseDrag event).

4 Likes