Circular Slider in canvas

OK, after 4 days I have enough… :disappointed_relieved:
It seems that (at least for me) this seems to be more difficult than I thought.
Making a circular slider is way more different/difficult than making a circular gauge.
I’m pretty sure that my lack of knowledge is what’s causing me problems (I also think that the overall approach to this is wrong), but that’s it.
Here is what I came up with: CircularSlider_2019-07-03_0941.proj (322.7 KB)

Feel free to use, experiment with and in case someone makes it usable, please share back the file. With explanation.

2 Likes

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

First of all… Vau… :open_mouth:
Thank you very much for your effort and excellent explanation. But now you are toasted. :smiling_imp:
I’ll not give you peace until we made this… ‘perfect’…

You can open my project even if you have lower version: just edit the .proj file with the Notepad++ and change the version to your Ignition and save. Then you can import/upload…

I’ve tried your code and it’s working… almost… :slight_smile:
The thing that confuses you … it really depends on where on the canvas you press and drag the mouse. If you press the mouse at the bottom then it works…

Please, look at my project.

What we need is three basic things in this slider:

  1. click on the spot/line of the slider can initiate the dragging/movement of the slider, not clicking anywhere on the slider
  2. If the slider is a full circle (also when it’s not) it must not pass the 360 and jump to the 0 or vice-versa.
  3. The scaled value is normally bound to some tag in the PLC. But when that value is changed in the PLC for whatever reason, it must reflect back on the slider.

I think we can make this work and the community will praise us… :laughing:

1 Like

It is OK and safe to do this in general for any project or its only specific to your this project as it deals with only scripts?

Well, I don’t know if it is OK and safe in general…
I’ve done it a few times and until now I haven’t had any problems.

It’s only safe if every component in the export has no new properties added between those versions. And the existing properties work the same. It’s not random chance, but it crashes enough that you certainly can’t count on it.

Generally, maintain resources intended for multiple versions in the oldest version they are supposed to work in. Maintain additional newer copies if the newer version has features or syntax changes that affect that resource. This is where a large collection of VMs is valuable.

You have done it again.
You hijacked somebody else’s thread for the topic that is not related to OP.
Please stop doing that and create your own topic.

1 Like

OK sorry, it digressed.

Okay,

I finally had some time to look at this again. I have made some modifications to the mouse dragged event to correct the bug that was in the previous version. It now looks like this (there are other changes in here as well, I will explain those):

import math

#before we do any drag logic first check to see if
#user clicked on the button
if event.source.clickedButton:
	value = event.source.value
	length = event.source.length
	theta = event.source.theta
	mX = event.x
	mY = event.y
	lastX = event.source.lastX
	lastY = event.source.lastY
	width = event.source.width
	height = event.source.height
	
	#calculate zero point of arc
	sAng = ((360 - length) / 2)
	
	#calculate vector from center to start angle
	zVec = {}
	zVec['x'] = 0.0
	zVec['y'] = -math.cos(sAng)
	zVec['z'] = 0.0
	
	#calculate vector from center to mouse position
	mVec = {'z':0.0}
	mVec['x'] = mX - (width/2)
	mVec['y'] = -mY + (height / 2)
	
	#calculate zVec magnitude
	zMag = math.sqrt(event.source.dotProduct(zVec,zVec))
	
	#calculate mVec magnitude
	mMag = math.sqrt(event.source.dotProduct(mVec,mVec))
	
	#calculate dot prod of zVec and mVec
	dotP = event.source.dotProduct(zVec,mVec)
	
	#determine if this is clockwise motion
	lVec = {'x':lastX,'y':lastY,'z':0.0}
	event.source.lastX = mVec['x']
	event.source.lastY = mVec['y']
	
	crossP = event.source.crossProduct(lVec,mVec)
	
	isClockwise = False
	
	if event.source.dotProduct(crossP,{'x':0,'y':0,'z':1.0}) < 0:
		isClockwise = True
	
	#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 mX >= width/2:
		theta = math.pi + (math.pi - math.acos(dotP/(zMag * mMag)))
	#this logic tries to prevent clockwise motion from increasing the value
	#past 359 degrees, however, at some point the angle will move
	#locking it out completly would require not allowing a drag operation
	elif (theta < math.pi and isClockwise) or not isClockwise:
		theta = math.acos(dotP/(zMag * mMag))
	
	
	#prevent anti clockwise mouse movement from redusing value less than 0
	if value == 0.0 and not isClockwise:
		theta = 0.0
		
	#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 > length:
		value = length
	elif value < 0:
		value = 0
	event.source.value = value
	
	min = event.source.minEngineeringValue
	max = event.source.maxEngineeringValue
	
	event.source.scaledValue = value * (max - min) / length
	event.source.theta = theta

The first think you will notice is that I have added a check to test if the mouse was clicked on the button. I added a custom property called clickedButton and in the mouse pressed event set the result of a custom method called hitTest, it contains the following script.

	import math
	
	#determine where click is relevent to slider origin
	mX = x - self.width/2
	mY = -y + self.height/2
	
	#scale mouse co-ordinates
	dx = 100.0 / self.width
	dy = 100.0 / self.height
	
	mX = mX * dx
	mY = mY * dy
	
	#calculate actual angle of value
	#this translates the angle so that it is referenced from the correct 0 radians point
	val = math.radians(self.value + 20) + math.pi /2

	#components of value vector
	vX = 45 * math.cos(val)
	vY = -45 * math.sin(val)
	
	ret = False
	
	#translate components of the mouse click so that the origin is at center of button
	mX -= vX
	mY -= vY
	
	#check if click is withing hit circle (used a circle to simplify calculations, error should not be too noticable)
	#this can be done with a rectangle as well, however, it would require rotating the rectangle about it's center point
	#and then manually checking if the click was within the perimiter of the box
	hitCirRadius = 2
	mMag = math.hypot(mX,mY)
	
	return mMag <= hitCirRadius

I chose to use a circular region for this check, there is some small error where the user could click outside of the button and this function will return true, but in my opinion this is negligible as most users won’t notice this and those who do won’t really care. It can be done with any polygon shape but that’s a lot of effort for not a whole lot of payoff.

Second, you will notice that I have utilized dictionaries to represent vectors, I did this to make it simpler to pass the values to the custom methods I added for the dot and cross products of vectors.

def dotProduct(self,vec1,vec2):
	return (vec1['x']*vec2['x']) + (vec1['y']*vec2['y']) + (vec1['z']*vec2['z'])
def crossProduct(self,vec1,vec2):
	return {'x':(vec1['y']*vec2['z']-vec1['z']*vec2['y']),'y':-(vec1['x']*vec2['z']-vec1['z']*vec2['x']),'z':(vec1['x']*vec2['y']-vec1['y']*vec2['x'])}

By taking the cross product of the current mouse vector and the previous mouse vector and then taking the dot product of that with a unit vector along the positive z axis, we can determine if the button is being drug in a clockwise or anti-clockwise direction.

I use this to stop the button from crossing over 0 degrees. This works, you can make it appear not to work if you stop rotating around the origin of the slider, because at that point in reference to the origin you are now dragging in an anti clockwise direction, which will cause the value to be recalculated to that new angle.

Also, it is necessary at some point for the value to be recalculated at values that are less than the current value, this keeps the script simple and I see no real advantage to not allowing the button to cross over 0 degrees. You asked for it to do this, so I implemented it as best I could with simple code. If this were my implementation I would just let the value roll over and rely on the operator to be aware of what value they set it to.

The last thing I did was add to the property change event to reflect back changes to the scaled value, that looks like this:

if event.propertyName == 'length':
	if event.newValue >= 360:
		event.source.length = 359
	elif event.newValue < 20:
		event.source.length = 20
		
if event.propertyName == 'scaledValue':
	scaledValue = event.newValue
	max = event.source.maxEngineeringValue
	min = event.source.minEngineeringValue
	
	newValue = scaledValue * (event.source.length /(max - min)) + min
	
	event.source.value = newValue

now if the scaled value is changed externally, the value is updated and the canvas is redrawn.

Finally, one small change to the repaint event. In the last version the button was not centered on the value vector, because I didn’t offset the button on the y-axis. This change corrects that.

#draw a rectangle to indicate the current value of the slider
button = Rectangle2D.Float(radius-2,-1,4,2)
g.fill(button)
1 Like

Thank you again for your effort. :+1:
One thing: when you click on the line/button it does not register it.
hitCirRadius and mMag in hitTest are way off… you must click way to the left side of the line/button…

Interesting, because in my environment it works as expected.

Can you post your code for hitTest or a back up of your project?

The only thing I can think of that may have broken it is, I have the diameters hard coded, if these are made dynamic then scaling must be accounted for.

I will look into a dynamic diameter

Here it is: CircularSlider_2019-07-11_1405.proj (344.4 KB)
There are also my other attempts to create circular slider… maybe you can look at them…

2 Likes

Thanks. Found the problem.

In my testing, I never changed the length, so I missed that I had hard coded the offset value in the hit test.

Change line 22 in hitTest from:

val = math.radians(self.value + 20) + math.pi / 2

to this:

val = math.radians(self.value + ((360 - self.length)/2)) + math.pi /2

That will account for a dynamic offset.

My apologies.

In testing though, I have discovered that there is now/still a bug when the length is less than 180 degrees. I will continue to try and correct that.

2 Likes