Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Twin axis #396

Closed
vascotenner opened this issue Jan 7, 2016 · 43 comments
Closed

Twin axis #396

vascotenner opened this issue Jan 7, 2016 · 43 comments
Labels
type: feature A major new feature wishlist
Milestone

Comments

@vascotenner
Copy link
Contributor

Is it possible to create overlays with two y-axis (a left and a right axis)? With matplotlib this is possible with ax1.twinx().

Twin axes are currently not supported bit yet. For now you could try to write a finalize_hook to do it.

@vascotenner
Copy link
Contributor Author

I have a really uggly solution:

%%opts Curve [finalize_hooks=[setglobal_hook], show_frame=True]
a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2], [30,20]), vdims=['B'])

def setglobal_hook(plot, element):
    global fig, el
    fig = plot
    el = element

a
%%opts Curve [finalize_hooks=[overlay], show_frame=True]
b = hv.Curve(([1,2], [30,20]), vdims=['B'])

def overlay(plot, element):
    ax = plot.handles['axis']
    ax2 = ax.twinx()
    ax2.set_yticklabels([t.get_text() for t in fig.handles['axis'].get_yticklabels()])
    ax2.set_ylabel(fig.handles['axis'].get_ylabel())
    ax2.plot(*fig.handles['axis'].lines[0].get_data())
    ax2.lines[-1].set_color('r')

b

Is there a better way to get grips to the plotted version of a?

@philippjfr
Copy link
Member

I see, yes it's problematic because you have to create a new axis to plot on. I'll have to think about it a bit, one quick workaround would be to add support not just for finalize_hooks but also initialize_hooks, that way you could simply replace the axis on the plot ensuring that the Element is drawn on the correct axis.

@vascotenner
Copy link
Contributor Author

A slightly cleaner solution:

def overlay(first, plot, element):
    fig = hv.Store.renderers['matplotlib'].get_plot(first)
    ax = plot.handles['axis']
    ax2 = ax.twinx()
    ax2.set_yticks(fig.handles['axis'].get_yticks())
    #ax2.set_yticklabels([t.get_text() for t in fig.handles['axis'].get_yticklabels()])
    ax2.set_ylabel(fig.handles['axis'].get_ylabel())
    for line in fig.handles['axis'].lines:
        ax2.plot(*line.get_data())
        ax2.lines[-1].set_color('k')
        ax2.lines[-1].set_linestyle('--')

def tmp(plot, element):
    overlay(a, plot, element)
%%opts Curve [finalize_hooks=[tmp], show_frame=True]
b = hv.Curve(([1,2], [30,20]), vdims=['B'])

b

A small annoyance is that the x-axis is not set ok. Should go from 0 to 2, but has range 0,2.2.

@philippjfr
Copy link
Member

Here's what it could look like with a init_hooks parameter:

def twinx(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twinx()
    twinax.set_ylabel(str(element.last.get_dimension(1)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2], [5, 0]), vdims=['B'])(plot=dict(init_hooks=[twinx]), style=dict(color='red'))
a * b

image

Does that seem like a reasonable solution?

@vascotenner
Copy link
Contributor Author

This is a nice solution. It demonstrates again how powerfull holoviews is. But it show also how many undocumented gems are available. It might be good to add a cookbook like section on the website, where many of these things are demonstrated. Also on gitter many good solutions come along, but is is very hard to find them back.

Is this already possible with the current version?

@philippjfr
Copy link
Member

No, I'm suggesting to add the init_hooks parameter.

@jbednar
Copy link
Member

jbednar commented Jan 7, 2016

Sounds like a very good idea, to allow people to do more customization themselves.

@jlstevens
Copy link
Contributor

I also agree that supporting initial hooks is a good idea.

Just one minor gripe though - it should be initial_hooks and final_hooks for consistency (which would mean renaming finalize_hooks to final_hooks). Easy to do and not a big priority...

Edit: Or would setup_hooks and final_hooks make more sense?

@jbednar
Copy link
Member

jbednar commented Jan 7, 2016

I think setup_hooks is too easily read as a verb, i.e. to set up the hooks, when it is meant here as a noun. finalize_hooks has the same problem; it sounds like a request to finalize the hooks, not a declaration that these hooks should be run at the finalizing stage. initial_hooks and final_hooks makes good sense.

In any case, aren't these specifically meant for user extensions, and thus something that we expect and encourage people to specify in their own code? If so I'm not sure it's ok to change finalize_hooks at this point, unless we left an alias to it.

@jlstevens
Copy link
Contributor

I would consider having an alias and eventually deprecating finalize_hooks as a name. We have a mechanism for this we used to specify kdims and vdims instead of key_dimensions and value_dimensions (are those ready to remove yet?).

@philippjfr
Copy link
Member

We have a mechanism for this we used to specify kdims and vdims instead of key_dimensions and value_dimensions

Not sure it's worth using that mechanism, it's mostly to do with __setstate__ and since plots aren't usually pickled I don't think that's too important. This seems fairly straightforward:

if not self.final_hooks:
   if self.finalize_hooks:
      self.warning('Using deprecated finalize_hooks options, use final_hooks instead')
      self.final_hooks = self.finalize_hooks
elif self.finalize_hooks:
   raise ValueError('Set either final_hooks or deprecated finalize_hooks, not both.')

(are those ready to remove yet?).

Let's do that for v1.5.

@jlstevens
Copy link
Contributor

The suggested code looks good and I'll open an issue about removing key_dimensions and value_dimensions for 1.5. I don't think we really want inconsistent usage and kdims and vdims are so much shorter than the old names.

@jbednar
Copy link
Member

jbednar commented Jan 7, 2016

Sounds good.

@vascotenner
Copy link
Contributor Author

It is really super that you have such a fast response on feature request!

Today I tried this with elements with a group, but that does not work yet:

def twinx(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twinx()
    twinax.set_ylabel(str(element.last.get_dimension(1)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), kdims=[dim_pos], vdims=[dim_itensity], group='test')
b = hv.Curve(([1,2], [5, 0]), kdims=[dim_pos], vdims=[dim_phase], group='test'
     )plot=dict(initial_hooks=[twinx]))
a * b
Traceback (most recent call last):
  File "/home/a/src/holoviews/holoviews/ipython/display_hooks.py", line 101, in wrapped
    max_branches = OutputMagic.options['max_branches'])
  File "/home/a/src/holoviews/holoviews/ipython/display_hooks.py", line 150, in element_display
    return renderer.html(element, fmt=renderer.fig)
  File "/home/a/src/holoviews/holoviews/plotting/renderer.py", line 211, in html
    plot, fmt =  self._validate(obj, fmt)
  File "/home/a/src/holoviews/holoviews/plotting/renderer.py", line 171, in _validate
    plot = self.get_plot(obj)
  File "/home/a/src/holoviews/holoviews/plotting/renderer.py", line 158, in get_plot
    plot.update(0)
  File "/home/a/src/holoviews/holoviews/plotting/mpl/plot.py", line 208, in update
    return self.initialize_plot()
  File "/home/a/src/holoviews/holoviews/plotting/mpl/element.py", line 671, in initialize_plot
    plot.initialize_plot(ranges=ranges)
  File "/home/a/src/holoviews/holoviews/plotting/mpl/chart.py", line 124, in initialize_plot
    style = self.style[self.cyclic_index]
  File "/home/a/src/holoviews/holoviews/core/options.py", line 306, in __getitem__
    return dict(self._options[index % len(self._options)])
ZeroDivisionError: integer division or modulo by zero

@philippjfr
Copy link
Member

Odd seems to work fine for me. If you just copy and paste what you put there into a new notebook do you still get an error?

@vascotenner
Copy link
Contributor Author

Another point is that the range of the original axis is adjusted to fit to data of the second axis in. This is different from plt.twinx and unwanted behaviour.

def twinx(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twinx()
    twinax.set_ylabel(str(element.last.get_dimension(1)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2], [50, -10]), vdims=['B'])(plot=dict(initial_hooks=[twinx]), style=dict(color='red'))
a * b

download

(Note that both axis have the same range)

One can somehow overcome this by setting the extents for the first figure manually. These extents should be set by both the first and second element. Changing the extents for the second axis is not possible yet.

@philippjfr
Copy link
Member

Another point is that the range of the original axis is adjusted to fit to data of the second axis in. This is different from plt.twinx and unwanted behaviour.

You can disable HoloViews handling of ranges by setting apply_ranges=False as a plot option.

@vascotenner
Copy link
Contributor Author

Setting apply_ranges=False works fine!

Now I try to put this figure with two axis to a layout. Once of a sudden it is elongated:

a * b + a * b

download 1

@vascotenner
Copy link
Contributor Author

The same works for the y-axis

%%opts Curve [apply_ranges=False]
def twiny(plot, element):
    ax = plot.handles['axis']
    twinax = ax.twiny()
    twinax.set_xlabel(str(element.last.get_dimension(0)))
    plot.handles['axis'] = twinax

a = hv.Curve(([1,2], [3,21]), kdims=[dim_radius], vdims=['A'])
b = hv.Curve(([1,2,3], [50, -10,0]), kdims=[dim_radius], vdims=['B'])(plot=dict(initial_hooks=[twiny]), style=dict(color='red'))
a * b

@timehaven
Copy link

timehaven commented Aug 23, 2017

This works great for the matplotlib backend.

Is it possible to do the same with bokeh backend?

I cannot quite translate to the initial_hooks function as above using the info here:

https://stackoverflow.com/questions/25199665/one-chart-with-two-different-y-axis-ranges-in-bokeh

Specifically, in my hook function, how do I get the s1 object used in the answer on that page:

https://stackoverflow.com/a/30914348/1638996

That is, what is the bokeh equivalent of ax = plot.handles['axis']? I think that would give me what is needed.

Thank you.

@philippjfr philippjfr added type: feature A major new feature wishlist labels Oct 27, 2017
@philippjfr philippjfr added this to the v1.11 milestone Apr 23, 2018
@ahuang11
Copy link
Collaborator

@timehaven This works for me with bokeh backend.

import numpy as np
import pandas as pd
import holoviews as hv
hv.extension('bokeh')

def apply_formatter(plot, element):
    p = plot.state
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

dts = pd.date_range('2015-01-01', end='2015-01-10').values
c = hv.Curve((dts, np.arange(10))).options(finalize_hooks=[apply_formatter])
c

bokeh_plot 1

@chuard
Copy link

chuard commented Feb 6, 2019

@ahuang11 I'm confused. The technique you show, using finalize_hooks=[apply_formatter] with the bokeh backend, seems to display the second axis, but it doesn't actually plot the curve on it. The curve you show seems to still be plotting on the left axis even though you have added the scale on the right. I'm getting a similar result with my attempts:

import numpy as np
import pandas as pd
import holoviews as hv
hv.extension('bokeh')

def apply_formatter(plot, element):
    p = plot.state
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

a = hv.Curve(([1,2], [3,21]), vdims=['A'])
b = hv.Curve(([1,2,3], [50, -10,0]), vdims=['B'])
bb = hv.Curve(([1,2,3], [50, -10,0]), vdims=['B']).options(finalize_hooks=[apply_formatter])
display(a * b)
display(a * bb)

image

See that both curves are still plotting on the left axis, even after inserting the right axis on the bottom plot. Any ideas how to actually scale the figure to match the right axis?

@poplarShift
Copy link
Collaborator

poplarShift commented Feb 24, 2019

@chuard you could try running them through an "initialize_hooks" first to directly plot them into new axes at creation.

@philippjfr philippjfr modified the milestones: v1.12.0, v1.13.0 Mar 22, 2019
@adamlansky
Copy link

adamlansky commented Jun 30, 2019

The above example is almost complete. The only thing you need to do is to set y_range_name bokeh prop of your line glyph to the twiny Range you have created in your hook.

Please find a modified snippet below:

import pandas as pd
import holoviews as hv
from bokeh.models.renderers import GlyphRenderer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def apply_formatter(plot, element):

    p = plot.state
    
    # create secondary range and axis
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.select(dict(type=GlyphRenderer))[0]
    glyph.y_range_name = 'twiny'

dts = pd.date_range('2015-01-01', end='2015-01-10').values

c_def = hv.Curve((dts, np.arange(10)), name='default_axis').options(color='red', width=300)
c_sec = hv.Curve((dts, np.arange(10)), name='secondary_axis').options(color='blue',width=300, hooks=[apply_formatter])
c_def + c_def * c_sec + c_sec

@poplarShift
Copy link
Collaborator

@adamlansky took the liberty to add from bokeh.models import Range1d, LinearAxis in your code snippet.

@adamlansky
Copy link

adamlansky commented Jul 1, 2019

@poplarShift thank you, much appreciated!

I also feel like this issue could probably be closed, as the hooks solution should be general enough to handle any secondary-axis related tasks. Please let me know if i should cross-post it to #3011 to make sure similar issues are solved as well.

@apuignav
Copy link

apuignav commented Sep 5, 2019

I don't think it fully works, or at least I'm missing something. Somehow the left axis gets modified if you have something like this:

import pandas as pd
import holoviews as hv
from bokeh.models.renderers import GlyphRenderer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def apply_formatter(plot, element):

    p = plot.state
    
    # create secondary range and axis
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.select(dict(type=GlyphRenderer))[0]
    glyph.y_range_name = 'twiny'

dts = pd.date_range('2015-01-01', end='2015-01-10').values

c_def = hv.Curve((dts, np.arange(1, step=0.1)), name='default_axis').options(color='red', width=300)
c_sec = hv.Curve((dts, np.arange(10)), name='secondary_axis').options(color='blue',width=300, hooks=[apply_formatter])
c_def + c_def * c_sec + c_sec

You can see that the c_def axis is not correct, and somehow it's autocalculated using the data in s_dec. If you just do c_def at the end you will see how the y range is properly calculated. Am I missing something?

@zeneofa
Copy link

zeneofa commented Dec 6, 2019

Also for the above, in bokeh, how would you add an overlay to the second axis

import pandas as pd
import holoviews as hv
from bokeh.models.renderers import GlyphRenderer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def apply_formatter(plot, element):

    p = plot.state
    
    # create secondary range and axis
    p.extra_y_ranges = {"twiny": Range1d(start=0, end=35)}
    p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.select(dict(type=GlyphRenderer))[0]
    glyph.y_range_name = 'twiny'

dts = pd.date_range('2015-01-01', end='2015-01-10').values

c_def = hv.Curve((dts, np.arange(10)), name='default_axis').options(color='red', width=300)
c_sec = hv.Curve((dts, np.arange(10)), name='secondary_axis').options(color='blue',width=300, hooks=[apply_formatter])
c_ToAddToSecond = hv.Curve((dts, np.arange(10)*0.4), name='secondary_axis_to_add').options(color='blue',width=300, hooks=[apply_formatter])

c_def*c_sec*c_ToAddToSecond

This creates another secondary axis (resulting in two secondary axis, instead of 1)

@adamlansky
Copy link

adamlansky commented Jan 7, 2020

Here is a modified example that works around both issues highlighted by @apuignav and @zeneofa This example is meant to be run in a jupyter notebook and it is using streaming dataframes to show how changing data also modifies axis ranges.

import pandas as pd
import streamz
import streamz.dataframe
import holoviews as hv
from holoviews import opts
from holoviews.streams import Buffer
from bokeh.models import Range1d, LinearAxis

hv.extension('bokeh')

def plot_secondary(plot, element):
    ''' 
    A hook to put data on secondary axis
    '''
    p = plot.state
    
    # create secondary range and axis
    if 'twiny' not in [t for t in p.extra_y_ranges]:
        # you need to manually recreate primary axis to avoid weird behavior if you are going to 
        # use secondary_axis in your plots. From what i know this also relates to the way axis
        # behave in bokeh and unfortunately cannot be modified from hv unless you are 
        # willing to rewrite quite a bit of code
        p.y_range = Range1d(start=0, end=10)
        p.y_range.name = 'default'
        p.extra_y_ranges = {"twiny": Range1d(start=0, end=10)}
        p.add_layout(LinearAxis(y_range_name="twiny"), 'right')

    # set glyph y_range_name to the one we've just created
    glyph = p.renderers[-1]
    glyph.y_range_name = 'twiny'

    # set proper range
    glyph = p.renderers[-1]
    vals = glyph.data_source.data['y'] # ugly hardcoded solution, see notes below
    p.extra_y_ranges["twiny"].start = vals.min()* 0.99
    p.extra_y_ranges["twiny"].end = vals.max()* 1.01

# define two streamz random dfs to sim data for primary and secondary plots
simple_sdf = streamz.dataframe.Random(freq='10ms', interval='100ms')
secondary_sdf = streamz.dataframe.Random(freq='10ms', interval='100ms')

# do some transformation
pdf = (simple_sdf-0.5).cumsum()
sdf = (secondary_sdf-0.5).cumsum()

# create streams for holoviews from these dfs
prim_stream = Buffer(pdf.y)
sec_stream = Buffer(sdf.y)

# create dynamic maps to plot streaming data
primary = hv.DynamicMap(hv.Curve, streams=[prim_stream]).opts(width=400, show_grid=True, framewise=True)
secondary = hv.DynamicMap(hv.Curve, streams=[sec_stream]).opts(width=400, color='red', show_grid=True, framewise=True, hooks=[plot_secondary])
secondary_2 = hv.DynamicMap(hv.Curve, streams=[prim_stream]).opts(width=400, color='yellow', show_grid=True, framewise=True, hooks=[plot_secondary])

# plot these maps on the same figure
primary * secondary * secondary_2

If you want to stop streaming dataframes, run this:

# stop streaming objects
simple_sdf.stop()
secondary_sdf.stop()

Please note that this code is hardcoded for specific case when your data column name is 'y'.
This allows to modify the range for secondary axis, as it is not handled automatically by bokeh.

As a bottom line, plots utilizing multiple axis from holoviews currently stay in "advanced territory" where you need to know bokeh object model good enough to work your way through it and there is no "plug and play" way to do it for N axis like you do in matplotlib.

@philippjfr philippjfr modified the milestones: v1.13.0, v2.0 Jan 14, 2020
@jmakov
Copy link

jmakov commented Feb 21, 2020

One way to go around the non supported twin axes is having 2 plots one above, second under sharing the same x-axis:

plot_first = df_first.hvplot.line(height=800, width=3500, legend=False, value_label="first_y_label").opts(bgcolor="black")
plot_second = df_second.hvplot.scatter(height=800, width=3500, legend=False, value_label="second_y_label").opts(bgcolor="black")
(plot_first + plot_second).cols(1)

IMHO this kind of example should be documented, especially since value_label, which apparently behaves the same as vidm or value dimension, is undocumented.
Note that without value_label the 2 plots would share not only the x, but also the y-axis.

@gioarma
Copy link

gioarma commented Apr 24, 2020

I think it would be very nice and intuitive to achieve this simply by overlaying two plots with different values of yaxis

Example:

hv.Curve(([1,2,3], [1,2,3])).opts(yaxis = 'left')*\
hv.Curve(([1,2,3], [4,5,6])).opts(yaxis = 'right')

For the moment this returns a plot with yaxis on the left, but I think it is very clear from the code what the user would want to achieve: a single plot with the axis of the first curve on the left and the one of the second on the right
Would it be possible to implement something like this?

@piet8stevens
Copy link

Is there a good solution now for 2 y-axes? I try to create a plot with 5 curves, 3 of which need to be on the left y-axis and 2 on the right y-axis. It seems to me that @adamlansky 's solution would need to be modified for this ('y'), so ' y' would need to be passed in as a parameter to plot_secondary, but it exceeds my python skills to figure out how to do so. Has anyone done this?

@MarcSkovMadsen
Copy link
Collaborator

Saulo asks how to use a twin axis here https://discourse.holoviz.org/t/two-yaxis-in-one-plot/1829.

@saulobertolino
Copy link

I would like to know if is it possible to make something simple on this. Just like:

plotA.opts(yaxis='left')
plotB.opts(yaxis='right'0

plotA * plotB

??

@gh4ag
Copy link

gh4ag commented Apr 26, 2021

I have just run into this wanted feature for my own work. I would add that making it possible to easily associate the curve color with the axis color and the axis label color is a great helper for reading the plot well (e.g. in bokeh LinearAxis(axis_line_color="red", axis_label_text_color="red")). Any update would be appreciated!

@jbednar
Copy link
Member

jbednar commented Apr 26, 2021

I've no idea how to implement this feature, but if someone does, would be great to have a PR for this!

@danni2019
Copy link

danni2019 commented May 25, 2021

I've tried all the codes above, none of which gives me a secondary y axis...
I'm beyond frustrated..

`

class Plot:
def init(self):
self.layout = hv.Layout()
self.figs = []

def apply_formatter(plot, element):

    # tried all the codes above.. 

def draw_overlay_lines(
        self,
        data: pd.DataFrame,
        name: str,
        secondary_y: [str, None],
        width: int = 800,
        height: int = 600,
        filename: [str, None] = None,
):
    ls = []

    for i in data.columns:
        if i != secondary_y:
            img = hv.Curve(data[i], label=i, vdims=[i])
        else:
            img = hv.Curve(data[i], label=i, vdims=[i]).opts(hooks=[self.apply_formatter])
        ls.append(img)
    img = hv.Overlay(ls).opts(title=name, width=width, height=height)
    if filename is None:
        self.figs.append(img)
    else:
        hv.save(img, filename, backend='bokeh')

`

@peterroelants
Copy link
Contributor

peterroelants commented Jul 14, 2021

I tried to extend @adamlansky 's example by taking the data column name from the figure (not hardcoded to 'y') in this case:

import numpy as np
import holoviews as hv
from bokeh.models import Range1d, LinearAxis
from bokeh.models.renderers import GlyphRenderer
from bokeh.plotting.figure import Figure

hv.extension('bokeh')


def plot_secondary(plot, element):
    """
    Hook to plot data on a secondary (twin) axis on a Holoviews Plot with Bokeh backend.
    More info:
    - http://holoviews.org/user_guide/Customizing_Plots.html#plot-hooks
    - https://docs.bokeh.org/en/latest/docs/user_guide/plotting.html#twin-axes
    
    """
    fig: Figure = plot.state
    glyph_first: GlyphRenderer = fig.renderers[0]  # will be the original plot
    glyph_last: GlyphRenderer = fig.renderers[-1] # will be the new plot
    right_axis_name = "twiny"
    # Create both axes if right axis does not exist
    if right_axis_name not in fig.extra_y_ranges.keys():
        # Recreate primary axis (left)
        y_first_name = glyph_first.glyph.y
        y_first_min = glyph_first.data_source.data[y_first_name].min()
        y_first_max = glyph_first.data_source.data[y_first_name].max()
        y_first_offset = (y_first_max - y_first_min) * 0.1
        fig.y_range = Range1d(
            start=y_first_min - y_first_offset,
            end=y_first_max + y_first_offset
        )
        fig.y_range.name = glyph_first.y_range_name
        # Create secondary axis (right)
        y_last_name = glyph_last.glyph.y
        y_last_min = glyph_last.data_source.data[y_last_name].min()
        y_last_max = glyph_last.data_source.data[y_last_name].max()
        y_last_offset = (y_last_max - y_last_min) * 0.1
        fig.extra_y_ranges = {right_axis_name: Range1d(
            start=y_last_min - y_last_offset,
            end=y_last_max + y_last_offset
        )}
        fig.add_layout(LinearAxis(y_range_name=right_axis_name, axis_label=glyph_last.glyph.y), "right")
    # Set right axis for the last glyph added to the figure
    glyph_last.y_range_name = right_axis_name

# Define the data
x1 = np.arange(11)
x2 = np.arange(21) / 2
y1 = x1 + 1
y2 = x2**2 + 1

# Create individual curves
c1 = hv.Curve((x1, y1), kdims='x', vdims='y1').opts(width=400, show_grid=True, framewise=True, yaxis='left')
c2 = hv.Curve((x2, y2), kdims='x', vdims='y2').opts(width=400, show_grid=True, framewise=True, color='red', hooks=[plot_secondary])

# plot these maps on the same figure
twin_plot = (c1 * c2).opts(title='Twin y-axes demo')
twin_plot

Some documentation that proved to be useful:

Note:

@jmakov
Copy link

jmakov commented Oct 3, 2021

@peterroelants thanks for the solution that actually works. But do users really need to be experts in order to plot 2 lines in the same window? Anyhow, would be great to have this in the docs at least until a better solution is found. Also doesn't work with datashade=True.

@ZrowGz
Copy link

ZrowGz commented Oct 11, 2021

@peterroelants I'm just learning python and stumbled across this as a solution to what I was trying to do. I have a data frame in pandas/jupyter notebook where I am trying to plot one y-axis with one value ($ value) and a second y-axis for two more columns (percent change), all over an x-axis that is the date (daily, several years).

I'm trying to figure out where I would insert which data into your code to make that work. I can break the data frames up if that makes it work... would one data frame per y work better?

Thank you!

@peterroelants
Copy link
Contributor

peterroelants commented Aug 30, 2022

I want to point out that the previous example does not seem to work correctly with DynamicMaps:

import numpy as np
import holoviews as hv
from bokeh.models import Range1d, LinearAxis
from bokeh.models.renderers import GlyphRenderer
from bokeh.plotting.figure import Figure

hv.extension('bokeh')


def plot_secondary(plot, element):
    """
    Hook to plot data on a secondary (twin) axis on a Holoviews Plot with Bokeh backend.
    More info:
    - http://holoviews.org/user_guide/Customizing_Plots.html#plot-hooks
    - https://docs.bokeh.org/en/latest/docs/user_guide/plotting.html#twin-axes
    
    """
    fig: Figure = plot.state
    glyph_first: GlyphRenderer = fig.renderers[0]  # will be the original plot
    glyph_last: GlyphRenderer = fig.renderers[-1] # will be the new plot
    right_axis_name = "twiny"
    # Create both axes if right axis does not exist
    if right_axis_name not in fig.extra_y_ranges.keys():
        # Recreate primary axis (left)
        y_first_name = glyph_first.glyph.y
        y_first_min = glyph_first.data_source.data[y_first_name].min()
        y_first_max = glyph_first.data_source.data[y_first_name].max()
        y_first_offset = (y_first_max - y_first_min) * 0.1
        fig.y_range = Range1d(
            start=y_first_min - y_first_offset,
            end=y_first_max + y_first_offset
        )
        fig.y_range.name = glyph_first.y_range_name
        # Create secondary axis (right)
        y_last_name = glyph_last.glyph.y
        y_last_min = glyph_last.data_source.data[y_last_name].min()
        y_last_max = glyph_last.data_source.data[y_last_name].max()
        y_last_offset = (y_last_max - y_last_min) * 0.1
        fig.extra_y_ranges = {right_axis_name: Range1d(
            start=y_last_min - y_last_offset,
            end=y_last_max + y_last_offset
        )}
        fig.add_layout(LinearAxis(y_range_name=right_axis_name, axis_label=glyph_last.glyph.y), "right")
    # Set right axis for the last glyph added to the figure
    glyph_last.y_range_name = right_axis_name


def _callback_1(iteration):
    x = np.arange(10)
    y = np.random.rand(10)
    y.sort()
    return hv.Curve((x, y), kdims='x', vdims='y1').opts(width=400, show_grid=True, framewise=True, yaxis='left')


def _callback_2(iteration):
    x = np.arange(10)
    y = (np.random.rand(10) * 11) ** 2
    y.sort()
    return hv.Curve((x, y), kdims='x', vdims='y2').opts(width=400, show_grid=True, framewise=True, color='red', hooks=[plot_secondary])

# Create individual curves
dmap1 = hv.DynamicMap(_callback_1, kdims=['iteration']).redim.range(iteration=(0, 10))
dmap2 = hv.DynamicMap(_callback_2, kdims=['iteration']).redim.range(iteration=(0, 10))



# plot these maps on the same figure
twin_plot = (dmap1 * dmap2).opts(title='Twin y-axes demo')
twin_plot

Results in:
dyanmicmap_twinaxis
Notice the y1 axis being wrongly initialized, and the wrong axis being updated

@droumis
Copy link
Member

droumis commented Mar 27, 2024

Twin axes is now (finally) supported! https://holoviews.org/user_guide/Customizing_Plots.html#twin-axes

@droumis droumis closed this as completed Mar 27, 2024
@piet8stevens
Copy link

piet8stevens commented Mar 28, 2024 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: feature A major new feature wishlist
Projects
None yet
Development

No branches or pull requests