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

Apply subcoordinate zoom by group #5901

Closed
droumis opened this issue Sep 22, 2023 · 6 comments · Fixed by #6122
Closed

Apply subcoordinate zoom by group #5901

droumis opened this issue Sep 22, 2023 · 6 comments · Fixed by #6122
Assignees

Comments

@droumis
Copy link
Member

droumis commented Sep 22, 2023

Often, researchers will display a subcoordinate_y-like plot with timeseries belonging to different groups (e.g. EEG, MEG, ECG, etc). In this context, each group likely has a different unit, sampling rate, and typical amplitude range.

This feature request is to make subcoordinate_y group-aware, allowing users to specify to which group each timeseries belongs, and have a subplot-configured zoom tool act only on that group.

Potential implementations:

  • A wheel zoom tool that would apply only to the group to which the timeseries under the hovering cursor belongs.
  • A discrete zoom in/out tool that would apply only to the group to which a selected timeseries belongs.
@droumis droumis added the TRIAGE Needs triaging label Sep 22, 2023
@maximlt maximlt added the type: feature A major new feature label Dec 20, 2023
@maximlt
Copy link
Member

maximlt commented Dec 20, 2023

feature request is to make subcoordinate_y group-aware

I assume this means users would set the group parameter on an Element with e.g. hv.Curve(..., group='EEG', label='1').opts(subcoordinate_y=True) * hv.Curve(..., group='MEG', label='2').opts(subcoordinate_y=True).

have a bokeh/bokeh#13345 act only on that group.

Here's a simple pure Bokeh approach to that, registering Zoom In/Out tools for each group with their own description. The same could be done for the wheel zoom tool.

Code

import numpy as np

from bokeh.core.properties import field
from bokeh.io import show
from bokeh.models import (ColumnDataSource, FactorRange,
                          HoverTool, Range1d, ZoomInTool, ZoomOutTool)
from bokeh.plotting import figure
from bokeh.palettes import Category10

np.random.seed(0)

n_channels = 10
n_seconds = 15
fs = 512
max_ch_disp = 5  # max channels to initially display
max_t_disp = 3 # max time in seconds to initially display

total_samples = n_seconds * fs
time = np.linspace(0, n_seconds, total_samples)
data = np.random.randn(n_channels, total_samples).cumsum(axis=1)
group1 = [f'GROUP1 - {i}' for i in range(n_channels - 5)]
group2 = [f'GROUP2 - {i}' for i in range(5, n_channels)]
channels = group1 + group2

hover = HoverTool(tooltips=[
    ("Channel", "$name"),
    ("Time", "$x s"),
    ("Amplitude", "$y µV"),
])

x_range = Range1d(start=time.min(), end=time.max())
y_range = FactorRange(factors=channels)

p = figure(x_range=x_range, y_range=y_range, lod_threshold=None)

source = ColumnDataSource(data=dict(time=time))
from collections import defaultdict
renderers = defaultdict(list)

for i, channel in enumerate(channels):
    xy = p.subplot(
        x_source=p.x_range,
        y_source=Range1d(start=data[i].min(), end=data[i].max()),
        x_target=p.x_range,
        y_target=Range1d(start=i, end=i + 1),
    )
    source.data[channel] = data[i]
    line = xy.line(field("time"), field(channel), color=Category10[10][i], source=source, name=channel)
    renderers[channel.split('-')[0].strip()].append(line)

zoom_in_grp1 = ZoomInTool(renderers=renderers['GROUP1'], level=1, dimensions='height', description='Zoom in (GROUP1)')
zoom_in_grp2 = ZoomInTool(renderers=renderers['GROUP2'], level=1, dimensions='height', description='Zoom in (GROUP`2)')
zoom_out_grp1 = ZoomOutTool(renderers=renderers['GROUP1'], level=1, dimensions='height', description='Zoom out (GROUP1)')
zoom_out_grp2 = ZoomOutTool(renderers=renderers['GROUP2'], level=1, dimensions='height', description='Zoom out (GROUP2)')

p.add_tools(zoom_in_grp1, zoom_out_grp1, zoom_in_grp2, zoom_out_grp2)

show(p)

hv_subcoord_group_zoom

@droumis Would this approach be fine for a first implementation of this feature?


If not, or if we wish to refine it later, here are some comments on the suggested implementations (granting that I don't know much about Bokeh tools):

A wheel zoom tool that would apply only to the group to which the timeseries under the hovering cursor

I don't think that a wheel zoom tool can be limited to execute its action only if the cursor is in a specific portion of the frame?

A discrete zoom in/out tool that would apply only to the group to which a selected timeseries belongs.

Just to get a brief feeling of how that might work, here's a pure Bokeh implementation that emulates the group selection via a widget and updates the renderers attached to the Zoom In/Out tools and their description. I haven't looked into how line selection could be driving this.

Details

import numpy as np

from bokeh.core.properties import field
from bokeh.io import show
from bokeh.models import (ColumnDataSource, FactorRange,
                          HoverTool, Range1d, ZoomInTool, ZoomOutTool)
from bokeh.plotting import figure
from bokeh.palettes import Category10

np.random.seed(0)

n_channels = 10
n_seconds = 15
fs = 512
max_ch_disp = 5  # max channels to initially display
max_t_disp = 3 # max time in seconds to initially display

total_samples = n_seconds * fs
time = np.linspace(0, n_seconds, total_samples)
data = np.random.randn(n_channels, total_samples).cumsum(axis=1)
group1 = [f'GROUP1 - {i}' for i in range(n_channels - 5)]
group2 = [f'GROUP2 - {i}' for i in range(5, n_channels)]
channels = group1 + group2

hover = HoverTool(tooltips=[
    ("Channel", "$name"),
    ("Time", "$x s"),
    ("Amplitude", "$y µV"),
])

x_range = Range1d(start=time.min(), end=time.max())
y_range = FactorRange(factors=channels)

p = figure(x_range=x_range, y_range=y_range, lod_threshold=None)

source = ColumnDataSource(data=dict(time=time))
from collections import defaultdict
renderers = defaultdict(list)

for i, channel in enumerate(channels):
    xy = p.subplot(
        x_source=p.x_range,
        y_source=Range1d(start=data[i].min(), end=data[i].max()),
        x_target=p.x_range,
        y_target=Range1d(start=i, end=i + 1),
    )
    source.data[channel] = data[i]
    line = xy.line(field("time"), field(channel), color=Category10[10][i], source=source, name=channel)
    renderers[channel.split('-')[0].strip()].append(line)


zoom_in_grp = ZoomInTool(renderers=renderers['GROUP1'], level=1, dimensions='height', description="GROUP1")
zoom_out_grp = ZoomOutTool(renderers=renderers['GROUP1'], level=1, dimensions='height', description="GROUP1")

from bokeh.layouts import column
from bokeh.models import CustomJS, Select
select = Select(title="Group:", value="GROUP1", options=["GROUP1", "GROUP2"])
select.js_on_change("value", CustomJS(args=dict(zoomin=zoom_in_grp, zoomout=zoom_out_grp, renderers=renderers), code="""
    zoomin.renderers = renderers[this.value]
    zoomin.description = 'Zoom In (' + this.value + ')'
    zoomout.renderers = renderers[this.value]
    zoomout.description = 'Zoom In (' + this.value + ')'
"""))

p.add_tools(zoom_in_grp, zoom_out_grp)

show(column(select, p))

hv_subcoord_group_zoom2

@droumis
Copy link
Member Author

droumis commented Dec 20, 2023

Nice examples, @maximlt!

Yes, we can work around the wheel zoom limitations with the discrete approach.

Regarding which discrete approach:

The toolbar approach is cleaner and could be automatic - but it isn't very scalable to keep adding two toolbar tools per group and also to have those icons identical (even if the description is different). We could potentially nest the various discrete zoom tools, but I don't think that really resolves the issue, it just hides it.

Using a select widget approach as you demo'd is more scalable for many groups, but would it likely require more lines of code to produce for users and they would have to configure widget location. Maybe this is niche enough that requiring that users add a select widget to utilize group-specific zoom is fine, with sufficient documentation. Also, if the widget indicated the 'active group' broadly, then people could also use it to potentially trigger/focus other actions/plots. I'm leaning toward this approach for now, especially if it could involve the Panel version of the Select widget.

@droumis
Copy link
Member Author

droumis commented Dec 20, 2023

@mattpap is it in scope of your toolbar customization PR to support one custom tool (e.g. select) impacting the renderers of another tool (e.g. zoom), similar to what Maxime is showing in the gif above with the select widget and zoom tools, but all in the toolbar?

@mattpap
Copy link

mattpap commented Dec 20, 2023

@droumis, yes, that's a possibility I considered in general (not specifically related to zoom) when working on that PR.

@droumis
Copy link
Member Author

droumis commented Jan 4, 2024

@maximlt, another consideration is to ensure that y-axis range scaling remains groupwise for the HoloViews implementation of subcoordinate_y (I don't think that's true for the pure Bokeh versions); the timeseries within a group should all have the same scaling factor, but it can be different for different groups.

@maximlt
Copy link
Member

maximlt commented Jan 8, 2024

We discussed today and clarified (at least for me 🙃 ) that we'd like to normalize the groups independently by default, potentially offering an option to disable that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants