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…