Source code for seiscat.plot.plot_timeline_plotly

# -*- coding: utf8 -*-
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Plot catalog timeline using Plotly (interactive HTML).

:copyright:
    2022-2026 Claudio Satriano <satriano@ipgp.fr>
:license:
    GNU General Public License v3.0 or later
    (https://www.gnu.org/licenses/gpl-3.0-standalone.html)
"""
import webbrowser
import tempfile
import os
from datetime import datetime, timezone
try:
    import plotly.graph_objects as go
except ImportError:
    from ..utils import err_exit
    err_exit(
        'Plotly is not installed. '
        'Please install it to use the web backend.\n'
        'Run: pip install plotly'
    )
from .plot_timeline_utils import (
    get_event_times_values_and_events, bin_events_by_time,
    bin_label, get_bin_size_label, get_cumulative_event_times_and_counts,
)
from .plot_utils import (
    get_label_for_attribute, get_event_popup_html, get_plotly_colorscale,
    get_plotly_time_colorbar_kwargs,
    LARGE_N_PLOTLY_THRESHOLD, is_large_n_plotly_mode,
)
from ..database.dbfunctions import get_catalog_stats
from ..utils import err_exit


def _build_attribute_figure(events, args):
    """
    Build a Plotly figure for attribute-vs-time scatter.

    :param events: EventList of Event dicts
    :param args: parsed command-line arguments
    :returns: plotly Figure
    """
    attribute = args.attribute
    color_attr = getattr(args, 'colorby', None) or attribute
    data = get_event_times_values_and_events(events, attribute, color_attr)
    if not data:
        err_exit(
            f'No events with valid values for attribute "{attribute}" '
            f'and color attribute "{color_attr}".'
        )
    times = [item[0] for item in data]
    values = [item[1] for item in data]
    source_events = [item[2] for item in data]
    large_n_mode = is_large_n_plotly_mode(len(source_events))
    if large_n_mode:
        print(
            f'Large-N mode enabled for plotly backend '
            f'({len(source_events)} events > {LARGE_N_PLOTLY_THRESHOLD}): '
            'disabling hover popups and using WebGL rendering.'
        )
    hover_texts = None
    if not large_n_mode:
        hover_texts = [get_event_popup_html(event) for event in source_events]

    label = get_label_for_attribute(attribute)
    color_label = get_label_for_attribute(color_attr)

    if color_attr == attribute:
        color_values = values
    else:
        color_values = [float(event[color_attr]) for event in source_events]

    y_values = values
    yaxis_kwargs = {}
    if attribute == 'time':
        y_values = [
            datetime.fromtimestamp(v, tz=timezone.utc)
            for v in values
        ]
        yaxis_kwargs = {'tickformat': '%Y-%m-%d<br>%H:%M'}

    colorbar_kwargs = {'title': color_label}
    if color_attr == 'time':
        colorbar_kwargs |= get_plotly_time_colorbar_kwargs(color_values)
    colorscale = get_plotly_colorscale(getattr(args, 'colormap', None))

    fig = go.Figure()
    # Scattergl (WebGL) is much faster for very large point clouds, but it
    # can have slightly lower visual quality/compatibility than Scatter
    # (SVG) for smaller datasets. Keep Scatter by default and switch to
    # Scattergl only in large-N mode.
    trace_cls = go.Scattergl if large_n_mode else go.Scatter
    trace_kwargs = dict(
        x=list(times),
        y=list(y_values),
        mode='markers',
        marker=dict(
            size=8,
            color=list(color_values),
            colorscale=colorscale,
            showscale=True,
            colorbar=colorbar_kwargs,
            opacity=0.8,
        ),
    )
    if large_n_mode:
        trace_kwargs['hoverinfo'] = 'skip'
        trace_kwargs['hovertemplate'] = None
    else:
        trace_kwargs['text'] = hover_texts
        trace_kwargs['hovertemplate'] = '%{text}<extra></extra>'
    fig.add_trace(trace_cls(**trace_kwargs))
    fig.update_layout(
        xaxis_title='Time',
        yaxis_title=label,
        title=f'{label} vs. Time (color: {color_label})',
        yaxis=yaxis_kwargs,
    )
    return fig


def _build_count_figure(events, args):
    """
    Build a Plotly figure for event-count bar chart with optional overlay.

    Modes:
    - Count only: histogram of binned events
    - Both count and cumulative: dual-axis with histogram and cumulative

    :param events: EventList of Event dicts
    :param args: parsed command-line arguments
    :returns: plotly Figure
    """
    bins_spec = getattr(args, 'bins', None)
    bins = bin_events_by_time(events, bins_spec)
    if not bins:
        err_exit('No events to plot.')
    bin_size_label = get_bin_size_label(bins, bins_spec)

    bin_centers = [s + (e - s) / 2 for s, e in [(b[0], b[1]) for b in bins]]
    counts = [b[2] for b in bins]
    labels = [bin_label(b[0], b[1]) for b in bins]
    widths_ms = [
        (b[1] - b[0]).total_seconds() * 1000 * 0.9
        for b in bins
    ]

    fig = go.Figure()
    fig.add_trace(go.Bar(
        x=bin_centers,
        y=counts,
        name='Event Count',
        width=widths_ms,
        marker_color='steelblue',
        hovertemplate='%{customdata}<br>Count: %{y}<extra></extra>',
        customdata=labels,
    ))

    title = f'Event Count vs. Time (bin size: {bin_size_label})'
    layout_kwargs = {}

    # Dual-axis mode: overlay raw-event cumulative on secondary y-axis
    if getattr(args, 'cumulative', False):
        cumulative_times, cumulative_counts = (
            get_cumulative_event_times_and_counts(events)
        )
        if not cumulative_times:
            err_exit('No events to plot.')
        fig.add_trace(go.Scatter(
            x=cumulative_times,
            y=cumulative_counts,
            mode='lines',
            name='Cumulative Count',
            line=dict(color='firebrick', width=2, shape='hv'),
            yaxis='y2',
            hovertemplate='%{x|%Y-%m-%d %H:%M}<br>'
                          'Cumulative: %{y}<extra></extra>',
        ))
        layout_kwargs['yaxis2'] = dict(
            title='Cumulative Event Count',
            overlaying='y',
            side='right',
            rangemode='tozero',
            showgrid=False,
        )
        title = (
            'Event Count and Cumulative Count vs. Time '
            f'(bin size: {bin_size_label})'
        )

    fig.update_layout(
        xaxis_title='Time',
        yaxis_title='Event Count',
        title=title,
        bargap=0,
        **layout_kwargs,
    )
    return fig


def _build_cumulative_count_figure(events):
    """Build a Plotly figure for raw-event cumulative count over time."""
    times, cumulative = get_cumulative_event_times_and_counts(events)
    if not times:
        err_exit('No events to plot.')
    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=times,
        y=cumulative,
        mode='lines',
        name='Cumulative Count',
        line=dict(color='firebrick', width=2, shape='hv'),
        hovertemplate='%{x|%Y-%m-%d %H:%M}<br>'
                      'Cumulative: %{y}<extra></extra>',
    ))
    fig.update_layout(
        xaxis_title='Time',
        yaxis_title='Cumulative Event Count',
        yaxis=dict(rangemode='tozero'),
        title='Cumulative Event Count vs. Time',
    )
    return fig


def _dispatch_count_figure(events, args):
    """Dispatch to appropriate count-mode figure builder."""
    if getattr(args, 'cumulative', False) and not args.count:
        # cumulative-only mode: plot raw-event cumulative count without binning
        return _build_cumulative_count_figure(events)
    return _build_count_figure(events, args)


[docs] def plot_catalog_timeline_plotly(events, config): """ Plot the catalog timeline as an interactive HTML page using Plotly. :param events: EventList of Event dicts :param config: config object """ args = config['args'] if args.count or getattr(args, 'cumulative', False): fig = _dispatch_count_figure(events, args) else: fig = _build_attribute_figure(events, args) # Catalog stats in a figure annotation stats = get_catalog_stats(config) fig.add_annotation( text=stats.replace('\n', '<br>'), xref='paper', yref='paper', x=0, y=-0.18, showarrow=False, font=dict(size=10, color='grey'), align='left', ) fig.update_layout(margin=dict(b=120)) if out_file := getattr(args, 'out_file', None): fig.write_html(out_file) print(f'Timeline saved to {out_file}') else: # Write to a temp file and open in the default browser with tempfile.NamedTemporaryFile( suffix='.html', delete=False, mode='w', encoding='utf-8' ) as tmp: tmp_path = tmp.name fig.write_html(tmp_path) webbrowser.open(f'file://{os.path.abspath(tmp_path)}')