Ellipse at angle buggy

If I render a simple ellipse at an angle I get a weird behavior when zooming in and out.

from bokeh.plotting import figure, show
fig = figure()
fig.ellipse(0, 0, 1, 0.1, angle=1)
show(fig)

depending on the view, the ellipse is either tilted left or right. When zooming in, the height of the ellipse also changes. I made a video of the two effects, but as a new user I’m not allowed to upload attachments :frowning:
I uploaded it to youtube: bokeh ellipse bug - YouTube

My bokeh version is 2.4.3. Happy to hear if there is any easy fix, or if I’m stupid and this is a feature, not a bug :smiley:

It’s not a bug, or a feature, just something unavoidable that has to be handled explicitly. Circles and ellipses offer options to specify their radii [1] in “data-space” units, but fundamentally, the HTML Canvas APIs only work with pixel units. For the conversion to have any mathematical sense, the data-space aspect ratio has to match the pixel aspect ratio of the plot frame. We can’t enforce this as the default, however, because it’s not a concern at all for most plots. You can force the aspect ratios to be the same by setting the match_aspect property of the plot.


  1. the width and height of ellipse are really the major and minor radii ↩︎

Thanks for your super fast response!
I am still trying to wrap my mind around the units, so I only halfway get your explanation :D.
I changed to

fig = figure(match_aspect=True)

but this didn’t change the odd behavior.
All I need is an ellipse with a constant size, also after zooming. Would creating a polygon with sufficient points be a working workaround?

The zoom tool also has a match_aspect you will need to set. In the video it is clearly visible that the zoom regions being defined have a wildly different aspect ratio than the plot frame, which puts things in the situation described above (data vs pixel aspect ratio mismatch).

Would creating a polygon with sufficient points be a working workaround?

It’s unclear what you want. If you want the ellipse to “stretch and deform” when a zoom region is much wider than tall (or vice versa), then yes. (Assuming you don’t set match_aspect on the zoom tool to prevent that)

I set the following (btw. is there a better way to do that?):

fig.tools[2].match_aspect = True

It works fine now for the box zoom, but scroll zoom still has the same problem, I couldn’t find match_aspect for that one.

What I want:
I have an image (with pixels not quadratic, and axes have different scales), and I want to consistently overlay an ellipse to highlight a certain area of this image. This covered area should always stay the same. Keeping the aspect ratio would be fine, but is not necessary.

Rotated ellipses are not mathematically well-defined in this case. What scale do you use to transform the data-space radius, which is at some angle in-between horizontal and vertical, in to fixed pixel distances? The horizontal scale? The vertical scale? Some average? There is no right answer. Under these requirements, you should use a polygon with explicit coordinates (not as a workaround, but as the only workable solution).

1 Like

I totally “get” this explanation now and good lord that nuance is tricky to explain! Would it be possible to leverage a CustomJSTransform to transform x-y and the radius args into corresponding xs and ys arrays (say 100 coordinate pairs worth for each location) to drive a patch renderer that plots ellipses in actual data coordinate space?

This is interesting…

I think that might actually be possible, but I also have never tried to do it :smiley:

Alright I farted around with this for way too long but here’s a rough sketch/example. The short answer is that yes, it does work… but it leads to another question/consideration way outside the scope of this question (however this question and me subsequently exploring a solution through CustomJSTransform has sparked it).

For the core math I leaned heavily on geometry - What is the general equation of the ellipse that is not in the origin and rotated by an angle? - Mathematics Stack Exchange and math - Rotate point about another point in degrees python - Stack Overflow .

# -*- coding: utf-8 -*-
"""
Created on Thu Jul 28 13:28:58 2022

@author: Gaelen
"""
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, Slider, CustomJSTransform, Patches
from bokeh.layouts import column
import numpy as np
#one datasource since the way you outlined it in your post, I should be able to assume the x values are the same for all lines?
#starting data
data = {'x':[10,20,30],'y':[10,20,30],'rad_x':[4,2,4],'rad_y':[2,3,5],'angle':[np.pi,np.pi/4,0],'color':['red','blue','green']}
#CDS of this data
src = ColumnDataSource(data)

f = figure()


#plot the "sum" line driving off a CustomJSTransform
xs_tf = CustomJSTransform(args=dict(src=src)
                       ,v_func='''
                       const n = 100.0 //number of vertices for each ellipse
                       //function to make an array of thetas to evaluate based on those 100 points
                       function makeArr(startValue, stopValue, cardinality) {
                        	var arr = [];
                        	var step = (stopValue - startValue) / (cardinality - 1);
                        	for (var i = 0; i < cardinality; i++) {
                        	  arr.push(startValue + (step * i));
                        	  }
                        	return arr;
                        	}
                        
                       const theta_array = makeArr(0,2*Math.PI,101).slice(0,-1)
                       //retrieve x,y coords along the edge of an unrotated ellipse
                       function eval_ellipse_edge(xc,yc,xrad,yrad,theta){
                    	    return {'x':xc+xrad*Math.cos(theta),'y':yc+yrad*Math.sin(theta)}
                    	  }
                       //retrieve x,y coords after rotating an edge coordinate about the ellipse's centroid
                       function rotate_point(xc,yc,x,y,rot_theta){
                           return {'x':(x-xc)*Math.cos(rot_theta)-(y-yc)*Math.sin(rot_theta)+xc
                      		       ,'y':(y-yc)*Math.cos(rot_theta)+(x-xc)*Math.sin(rot_theta)+yc}
                        		}
                       
                       var new_xs = [] //array to be populated with new xs vals
                       for (var i = 0;i<src.data['x'].length;i++){
                               //retrieve the values
                               var xc = src.data['x'][i]
                               var yc = src.data['y'][i]
                               var xrad = src.data['rad_x'][i]
                               var yrad = src.data['rad_y'][i]
                               var rot_theta = src.data['angle'][i]
                               // get the unrotated coords at each theta location
                               var unrotated = theta_array.map(t=>eval_ellipse_edge(xc,yc,xrad,yrad,t))
                               // rotate that result
                               var rotated = unrotated.map(p=>rotate_point(xc,yc,p.x,p.y,rot_theta))
                               new_xs.push(rotated.map(p=>p.x))
                               }
                       return new_xs
                       ''')
                       
ys_tf = CustomJSTransform(args=dict(src=src)
                       ,v_func='''
                       const n = 100.0 //number of vertices for each ellipse
                       //function to make an array of thetas to evaluate based on those 100 points
                       function makeArr(startValue, stopValue, cardinality) {
                        	var arr = [];
                        	var step = (stopValue - startValue) / (cardinality - 1);
                        	for (var i = 0; i < cardinality; i++) {
                        	  arr.push(startValue + (step * i));
                        	  }
                        	return arr;
                        	}
                        
                       const theta_array = makeArr(0,2*Math.PI,101).slice(0,-1)
                       //retrieve x,y coords along the edge of an unrotated ellipse
                       function eval_ellipse_edge(xc,yc,xrad,yrad,theta){
                    	    return {'x':xc+xrad*Math.cos(theta),'y':yc+yrad*Math.sin(theta)}
                    	  }
                       //retrieve x,y coords after rotating an edge coordinate about the ellipse's centroid
                       function rotate_point(xc,yc,x,y,rot_theta){
                           return {'x':(x-xc)*Math.cos(rot_theta)-(y-yc)*Math.sin(rot_theta)+xc
                      		       ,'y':(y-yc)*Math.cos(rot_theta)+(x-xc)*Math.sin(rot_theta)+yc}
                        		}
                       
                       var new_ys = [] //array to be populated with new xs vals
                       for (var i = 0;i<src.data['x'].length;i++){
                               //retrieve the values
                               var xc = src.data['x'][i]
                               var yc = src.data['y'][i]
                               var xrad = src.data['rad_x'][i]
                               var yrad = src.data['rad_y'][i]
                               var rot_theta = src.data['angle'][i]
                               // get the unrotated coords at each theta location
                               var unrotated = theta_array.map(t=>eval_ellipse_edge(xc,yc,xrad,yrad,t))
                               // rotate that result
                               var rotated = unrotated.map(p=>rotate_point(xc,yc,p.x,p.y,rot_theta))
                               new_ys.push(rotated.map(p=>p.y))
                               }
                       return new_ys
                       ''')
#then point the "sum" line renderer to run off this transform                     
#only "funny" part is that you still need to specify a field key but this is redundant as the transform does not require it to calculate stuff
#I put 'x' here but it really doesn't matter
f.patches(xs={'field':'x','transform':xs_tf},ys={'field':'x','transform':ys_tf}
          ,source=src,fill_color='color')
show(f)


You can zoom in and out, and mess with aspect and it holds true.

But…

Now what should be apparent in this example is that AFAIK I need to pass separate transforms for the xs and ys fields but they can’t actually be calculated separately because the ellipse rotation transformation requires both “unrotated” x and y coords as inputs (i.e. they are interdependent). This is actually a major inefficiency now, as it means i need to do the math twice (once to get xs, and once to get ys), so xs_tf and ys_tf end up being copypastas except they return the xs result and ys result respectively.

So that gets me thinking: what if CustomJSTransform offered an argument beyond just func (transforming a single value) and v_func (transforming an array of values). Call it o_func or something like that, and it would transform an entire object, or an entire object property (i’m not sure what’d be best really).

So in my case above, I’d have the ability to do something like this:

#initialize source with empty arrays for xs and ys
data = {'x':[10,20,30],'y':[10,20,30],'rad_x':[4,2,4],'rad_y':[2,3,5],'angle':[np.pi,np.pi/4,0],'color':['red','blue','green']
        ,'xs':[[],[],[]],'ys':[[],[],[]]}
#make a CDS of this data
src = ColumnDataSource(data)

f = figure()

tr = CustomJSTransform(args=dict(src=src,base_data=data)
                              ,o_func = '''
                              //...do all my math and populate the new_xs and new_ys arrays etc..
                              //Then...
                              //revise the base_data with new_xs, new_ys and return it
                              base_data['xs'] = new_xs
                              base_data['ys'] = new_ys                              
                              return base_data
                              ''')
#instead of source = source.. something like this maybe? 
#not sure exactly the best way to set this up but this is just an initial idea
f.patches(xs='xs',ys='ys',source={'object':src,'property':'data','transform':tr})

The idea being that this would facilitate transforming multiple fields in src in a single CustomJSTransform, which I think could be pretty dang powerful. A) Does something like this already exist and I’m just missing it? B) Is this idea worth of a GH discussion or has it been discussed before?

Thanks…

@gmerritt123 There was some preliminary work done for a “coordinate transforms” so that polar coordinates calculations could be added:

bokeh/coordinate_transform.ts at branch-3.0 · bokeh/bokeh · GitHub
bokeh/polar.ts at branch-3.0 · bokeh/bokeh · GitHub

However, AFAIK there are not “CustomJS” versions of this yet, so I think a custom extension would be required to take advantage of this machinery. cc @mateusz A GH discussion seems reasonable to explore the possibilities.

I could also imaged somewhat clunky solutions with the current API, e.g. passing a DataModel to the custom js transforms and the first that runs fills it in, while the second just reads off the already-computed results. But that seems both clunky and fragile.

I’m happy my question has sparked so much interest. Although I cannot follow the discussion due to the lack of background, I can recommend this site I found, when trying to understand ellipses: The Most Marvelous Theorem in Mathematics

Thanks @jonathansailing … I am actually fighting with rotating 3D ellipsoids for an Inverse Distance Weighted interpolation routine at my day job so this was a good “brain primer” :slight_smile:

@Bryan @mateusz Yeah I wasn’t really thinking about building in specific polar coord transforms, more just the concept of applying a CustomJSTransform on a ColumnDataSource’s “data” property when instantiating a renderer/glyph… and what that might look like.

I did also consider the concept of a “dummy” CDS/DataModel to act as a middleman but realized I wasn’t sure how to trigger that transform on “page load”/initialization. I also agree that it’d probably get clunky and fragile when put into real use application.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.