Skip to content

plot_utils

Utility functions for generating plot objects and calculations

add_all_tools(p, tooltips=None) #

Adds all basic tools, modifies plot's toolbar.

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def add_all_tools(p: Plot, tooltips: Optional[str] = None) -> list[Model]:
    """Adds all basic tools, modifies plot's toolbar."""
    # create hovertool
    hover = HoverTool(
        tooltips=tooltips,
        visible=False,
    )

    # generate other tools
    pan = PanTool()
    boxzoom = BoxZoomTool()
    wz = WheelZoomTool()
    box_select = BoxSelectTool()
    lasoo = LassoSelectTool(continuous=False)  # only evaluate on LMB.up
    reset = ResetTool()
    save = SaveTool()
    tools = [pan, boxzoom, box_select, lasoo, hover, wz, save, reset]

    if DEV:
        tools.append(
            ExamineTool())  # debugging tool to inspect JS props directly
    p.add_tools(*tools)
    p.toolbar.active_scroll = wz  # sets scroll wheelzoom
    p.toolbar.autohide = True  # hide when not hovered

    return tools

add_axes(plotstate, p) #

Generates axes and corresponding grids for plots, modifies object inplace.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
p Plot

figure

required
Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def add_axes(plotstate: PlotState, p: Plot) -> None:
    """Generates axes and corresponding grids for plots, modifies object inplace.

    Args:
        plotstate: plot variables
        p: figure

    """
    xaxis = LinearAxis(axis_label=generate_label(plotstate, "x"))
    yaxis = LinearAxis(axis_label=generate_label(plotstate, "y"))
    grid_x = Grid(dimension=0, ticker=xaxis.ticker, visible=True)
    grid_y = Grid(dimension=1, ticker=yaxis.ticker, visible=True)
    p.add_layout(xaxis, "below")
    p.add_layout(yaxis, "left")
    p.add_layout(grid_x, "center")
    p.add_layout(grid_y, "center")

add_callbacks(plotstate, dff, p, source, set_filter) #

Adds various callbacks, for filtering, context menu, and resets.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
dff DataFrame

filtered dataframe

required
p Plot

figure

required
source ColumnDataSource

data source object

required
set_filter Callable

filter setter function

required
Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
def add_callbacks(
    plotstate: PlotState,
    dff: vx.DataFrame,
    p: Plot,
    source: ColumnDataSource,
    set_filter: Callable,
) -> None:
    """
    Adds various callbacks, for filtering, context menu, and resets.

    Args:
        plotstate: plot variables
        dff: filtered dataframe
        p: figure
        source: data source object
        set_filter: filter setter function
    """
    # add filter reset on 'clear selection' button
    filterResetCJS = CustomJS(
        args=dict(source=source),
        code="""
             source.selected.indices = [];
             source.selected.indices.change.emit();
             """,
    )

    item = p.select(name="menu-clear")[0]
    item.action = filterResetCJS

    # selection callback to disable/enable these items
    def on_select(attr, old, new):
        if len(new) == 0:
            # disable button
            item.update(disabled=True)
        else:
            item.update(disabled=False)

    source.selected.on_change("indices", on_select)

    # add reset range event
    def on_reset(event):
        """Range resets"""
        name = p.name
        p.update(name=str(int(name) + 1))

    p.on_event("reset", on_reset)

    # check for zora base cookie, default to public site
    zbase = sl.lab.cookies.value.get('sdss_zora_base', 'dr19.sdss.org')
    base = f'http://{zbase}' if "localhost" in zbase else f'https://{zbase}/zora'

    # zora jump
    if (plotstate.plottype == "scatter") or (plotstate.plottype == "skyplot"):
        # TODO: chnage to envvar
        tapcb = CustomJS(
            args=dict(source=source),
            code=f"""
            window.open(`{base}/target/${{source.data.sdss_id[source.inspected.indices[0]]}}`, '_blank').focus();
            """,
        )
        tap = TapTool(
            behavior="inspect",
            callback=tapcb,
            gesture="doubletap",
            visible=False,  # hidden
        )
        p.add_tools(tap)

add_colorbar(plotstate, p, color_mapper, data) #

Adds a colorbar to plot. Used during initialization.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
p Plot

figure

required
color_mapper LinearColorMapper

color mapper object, generated by generate_color_mapper

required
data ArrayLike

data column of color, used for initializing limits

required
Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def add_colorbar(plotstate: PlotState, p: Plot,
                 color_mapper: LinearColorMapper, data) -> None:
    """Adds a colorbar to plot. Used during initialization.

    Args:
        plotstate: plot variables
        p: figure
        color_mapper: color mapper object, generated by `generate_color_mapper`
        data (np.ArrayLike): data column of color, used for initializing limits

    """
    cb = ColorBar(
        color_mapper=color_mapper,  # color_mapper object
        ticker=FixedTicker(
            ticks=calculate_colorbar_ticks(np.nanmin(data), np.nanmax(data))),
        location=(5, 6),
        title=generate_label(plotstate, axis="color"),
    )
    p.add_layout(cb, "right")

calculate_colorbar_ticks(low, high) #

Manually calculates colorbar ticks to bypas low-level Bokeh object replacement locks.

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
def calculate_colorbar_ticks(low, high) -> list[float]:
    """Manually calculates colorbar ticks to bypas low-level Bokeh object replacement locks."""

    def get_interval(data_low, data_high, desired_n_ticks):
        """Helper to get ticks interval. Translated from AdaptiveTicker in BokehJS."""
        data_range = data_high - data_low
        ideal_interval = (data_high - data_low) / desired_n_ticks

        def extended_mantissas():
            mantissas = [1, 2, 5]
            prefix_mantissa = mantissas[-1]
            suffix_mantissa = mantissas[0]
            return [prefix_mantissa] + mantissas + [suffix_mantissa]

        def clamp(value, min_val, max_val):
            return max(min_val, min(value, max_val))

        interval_exponent = np.floor(np.log10(ideal_interval))
        ideal_magnitude = 10**interval_exponent

        candidate_mantissas = extended_mantissas()
        errors = [
            abs(desired_n_ticks - (data_range / (mantissa * ideal_magnitude)))
            for mantissa in candidate_mantissas
        ]

        best_mantissa = candidate_mantissas[np.argmin(errors)]
        interval = best_mantissa * ideal_magnitude
        return clamp(interval, 0, float("inf"))

    try:
        assert low != high
        interval = get_interval(low, high, 6)
        return np.arange(round(low / interval) * interval, high,
                         interval).tolist()
    except Exception as e:
        # dummy data on failure, will show nothing on colorbar
        return np.arange(0, 3 + 0.5, 0.5)

calculate_range(plotstate, dff, axis='x') #

Fetches a new reset-like start/end value based on the flip, log, and column.

Note

This already accounts for log scaling and flipping. One simply just has to set start/end props on the range.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
dff DataFrame

filtered dataframe

required
axis str

the axis to perform on ('x' or 'y')

'x'

Returns:

Type Description
tuple[float, float]

tuple for start/end props of range.

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
def calculate_range(plotstate: PlotState,
                    dff: vx.DataFrame,
                    axis: str = "x") -> tuple[float, float]:
    """
    Fetches a new reset-like start/end value based on the flip, log, and column.

    Note:
        This already accounts for log scaling and flipping. One simply just has to set start/end props on the range.

    Args:
        plotstate: plot variables
        dff: filtered dataframe
        axis: the axis to perform on ('x' or 'y')

    Returns:
        tuple for start/end props of range.
    """
    df = SubsetState.subsets.value[plotstate.subset.value].df

    # bug checking
    assert axis in ("x", "y"), f"expected axis x or y but got {axis}"

    # fetch
    if (plotstate.plottype == "histogram") and (axis == "y"):
        raise Exception("shouldnt be here")
    col = plotstate.x.value if axis == "x" else plotstate.y.value
    flip = plotstate.flipx.value if axis == "x" else plotstate.flipy.value
    log = plotstate.logx.value if axis == "x" else plotstate.logy.value

    expr = dff[col]
    if check_categorical(expr):
        expr = df[col]
        limits = (0, expr.nunique() - 1)
    else:
        if log:  # limit to > 0 for log mapping
            expr = np.log10(dff[dff[col] > 0]
                            [col])  # TODO: may cause assertion error crashes
        try:
            limits = expr.minmax()
        except RuntimeError:
            logger.debug("dodging stride bug")
            limits = (expr.min()[()], expr.max()[()])

    datarange = abs(limits[1] - limits[0])

    # padding logic
    if (plotstate.plottype == "histogram") and check_categorical(col):
        pad = 1.2
    elif plotstate.plottype == "heatmap":
        if check_categorical(dff[col]):
            pad = 0.5
        else:
            pad = 0
    else:
        # bokeh uses 10% of range as padding by default
        pad = datarange / 20

    start = limits[0]
    end = limits[1]
    start = start - pad
    end = end + pad
    if log:
        start = 10**start
        end = 10**end

    if not flip:
        return start, end
    else:
        return end, start

generate_categorical_hover_formatter(plotstate, axis='x') #

Generates tooltips for a hovertool based on current plotstate.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
axis str

axis to generate for, any of 'x','y', or 'color'.

'x'

Returns:

Name Type Description
CustomJSHover CustomJSHover

a Bokeh CustomJSHover to convert categorical data into actual data.

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
def generate_categorical_hover_formatter(plotstate: PlotState,
                                         axis: str = "x") -> CustomJSHover:
    """Generates tooltips for a hovertool based on current plotstate.

    Args:
        plotstate: plot variables
        axis: axis to generate for, any of 'x','y', or 'color'.

    Returns:
        CustomJSHover: a Bokeh CustomJSHover to convert categorical data into actual data.
    """
    assert axis in ("x", "y", "color"), (
        f'expected axis to be "x","y", or "color" but got {axis}')
    if (plotstate.plottype == "histogram") and (axis == "y"):
        # early exit
        return CustomJSHover(code="return value.toFixed(4);")
    else:
        col = getattr(plotstate, axis).value
    mapping = getattr(plotstate, f"{axis}mapping")
    if check_categorical(col):
        cjs = f"return ({json.dumps({v: k for k, v in mapping.items()})})[Math.floor(value)];"
    else:
        cjs = "return value.toFixed(4);"

    return CustomJSHover(code=cjs)

generate_categorical_tick_formatter(mapping) #

Generates a categorical tick formatter. Works by reversing the mapping and compiling a new JS code.

Parameters:

Name Type Description Default
mapping dict[str | bool, int]

Pre-generated mapping of categories to integers.

required

Returns:

Name Type Description
formatter CustomJSTickFormatter

new formatter to perform mapping

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
def generate_categorical_tick_formatter(
    mapping: dict[str | bool, int], ) -> CustomJSTickFormatter:
    """
    Generates a categorical tick formatter.
    Works by reversing the mapping and compiling a new JS code.

    Args:
        mapping: Pre-generated mapping of categories to integers.

    Returns:
        formatter: new formatter to perform mapping
    """
    reverseMapping = {v: k for k, v in mapping.items()}
    cjs = """
    var mapper = new Object(mapping);
    return mapper.get(tick) || ""
    """
    return CustomJSTickFormatter(args=dict(mapping=reverseMapping), code=cjs)

generate_color_mapper(plotstate, dff=None, color=None) #

Create a colormapper.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
color Optional[ndarray]

pre-computed aggregated data array.

None
Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def generate_color_mapper(
    plotstate: PlotState,
    dff: Optional[vx.DataFrame] = None,
    color: Optional[np.ndarray] = None,
) -> LinearColorMapper:
    """Create a colormapper.

    Args:
        plotstate: plot variables
        color: pre-computed aggregated data array.
    """
    low, high = _calculate_color_range(plotstate, dff=dff, color=color)

    return LinearColorMapper(
        palette=plotstate.Lookup["colorscales"][plotstate.colorscale.value],
        low=low,
        high=high,
    )

generate_datamap(expr) #

Generates a mapping for categorical data

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
445
446
447
448
449
def generate_datamap(expr: vx.Expression) -> dict[str | bool, int]:
    """Generates a mapping for categorical data"""
    n: int = expr.nunique()
    factors: list[str | bool] = expr.unique()
    return {k: v for (k, v) in zip(factors, range(n))}

generate_label(plotstate, axis='x') #

Generates an axis label.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
axis str

which axis to generate a label for. Any of ('x', 'y', or 'color')

'x'

Returns:

Type Description
str

A formatted, pretty axis label

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def generate_label(plotstate: PlotState, axis: str = "x") -> str:
    """Generates an axis label.

    Args:
        plotstate: plot variables
        axis: which axis to generate a label for. Any of ('x', 'y', or 'color')

    Returns:
        A formatted, pretty axis label
    """
    assert axis in ("x", "y", "color")
    if (plotstate.plottype == "histogram") and (axis == "y"):
        col = getattr(plotstate, "x").value
    else:
        col = getattr(plotstate, axis).value
    log = getattr(plotstate, f"log{axis}").value
    cond = log
    if plotstate.plottype != "histogram":
        cond = cond and not check_categorical(col)
    if (axis == "color") and (plotstate.plottype == "heatmap"):
        bintype = getattr(plotstate, "bintype").value
        bincond = (bintype != "count") and (bintype != "")
        if bintype == "count":
            # no col data if just counting
            col = ""
    elif (plotstate.plottype == "histogram") and (axis == "y"):
        bintype = "count"
        bincond = True
    else:
        bintype = ""
        bincond = False

    # very long oneliner
    return f"{'log(' if cond else ''}{bintype}{'(' if bincond else ''}{col}{')' if bincond else ''}{')' if cond else ''}"

generate_plot(range_padding=0.1) #

Generates basic plot object with context menu, with object bindings.

Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def generate_plot(range_padding: float = 0.1):
    """Generates basic plot object with context menu, with object bindings."""
    # create menu
    menu = BokehMenu()
    menu.styles = {"color": "black", "font-size": "16px"}

    # generate main Plot model
    # NOTE: if you change default viewcard height, this must also change
    p = Plot(
        name="0",
        context_menu=menu,
        toolbar_location="above",
        height=360,
        height_policy=
        "fixed",  # do what we tell you and don't try to go find bounds
        # height_policy=
        # "fit",  # NOTE: this doesn't work in the Lumino context of the cards
        width_policy="max",
        reset_policy=
        "event_only",  # NOTE: we handle resets ourselves bc changing scales crashes it
        output_backend=
        "webgl",  # for performance, will fallback to HTML5 if unsupported
        lod_factor=20000,
        lod_interval=300,
        lod_threshold=1000,
        lod_timeout=10000,
        x_range=DataRange1d(range_padding=range_padding),
        y_range=DataRange1d(range_padding=range_padding),
    )
    name = "menu-propogate"
    items = [
        ActionItem(label="Clear selection", disabled=True, name="menu-clear"),
        ActionItem(
            label="Reset plot",
            name="reset-view",
            action=CustomJS(args=dict(p=p), code="""p.reset.emit()"""),
        ),
    ]
    menu.update(items=items)

    # add extra ranges
    # NOTE: categorical swaps aren't supported, so we do server-side mapping
    p.extra_x_scales = {
        "lin": LinearScale(),
        "log": LogScale(),
    }
    p.extra_y_scales = {
        "lin": LinearScale(),
        "log": LogScale(),
    }
    p.extra_x_ranges = {
        "lin": DataRange1d(range_padding=range_padding),
        "log": DataRange1d(range_padding=range_padding),
    }
    p.extra_y_ranges = {
        "lin": DataRange1d(range_padding=range_padding),
        "log": DataRange1d(range_padding=range_padding),
    }

    return p, menu

generate_tooltips(plotstate) #

Generates tooltips for a hovertool based on current plotstate.

Parameters:

Name Type Description Default
plotstate PlotState

plot variables

required
Source code in src/sdss_explorer/dashboard/components/views/plot_utils.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
def generate_tooltips(plotstate: PlotState) -> str:
    """Generates tooltips for a hovertool based on current plotstate.

    Args:
        plotstate: plot variables
    """

    def generate_row(label, value):
        return f"""
            <div style="display: table-row">
                <div style="display: table-cell; color: #0D47A1; text-align: right;">
                    {label}:
                </div>
                <div style="display: table-cell;">
                    {value}
                </div>
            </div>
        """

    # define the labels and corresponding values based on plottype
    if plotstate.plottype == "histogram":
        labels_values = [
            (generate_label(plotstate, axis="x"), "@centers{0}"),
            (generate_label(plotstate, axis="y"), "@y{0}"),
        ]
    else:
        labels_values = [
            (generate_label(plotstate, axis="x"), "$snap_x{0}"),
            (generate_label(plotstate, axis="y"), "$snap_y{0}"),
            (generate_label(plotstate, axis="color"), "@color{0}"),
        ]
        if plotstate.plottype == "scatter":
            labels_values.append(("sdss_id", "@sdss_id"))

    # generate the rows dynamically
    rows = "".join(
        generate_row(label, value) for label, value in labels_values)

    # construct the full HTML structure
    return (
        f"""
    <div>
        <div style="display: table; border-spacing: 2px;">
            {rows}
        </div>
    </div>""" + """
    <style>
        div.bk-tooltip-content > div > div:not(:first-child) {
            display:none !important;
        }
    </style>"""
    )  # this is a hack to stop multiple point's tips from displaying at once