🏡 index : quine-global-labs/bazaar.git

/* bz-data-graph.c
 *
 * Copyright 2025 Adam Masciola
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

#include "config.h"

#include <adwaita.h>

#include "bz-data-graph.h"
#include "bz-data-point.h"
#include <math.h>

#define LABEL_MARGIN       75.0
#define LABEL_MARGIN_RIGHT 35.0
#define TICK_LENGTH        5.0

struct _BzDataGraph
{
  GtkWidget parent_instance;

  GListModel *model;
  char       *independent_axis_label;
  char       *dependent_axis_label;
  char       *tooltip_prefix;
  int         independent_decimals;
  int         dependent_decimals;
  double      transition_progress;
  double      rounded_axis_max;

  AdwStyleManager *style_manager;

  GskPath        *path;
  GskPathMeasure *path_measure;

  GskRenderNode *other;
  GskRenderNode *graph;
  GskRenderNode *overlay;

  gboolean wants_animate_open;

  GtkEventController *motion;
  double              motion_x;
  double              motion_y;
  GtkGesture         *gesture;

  GtkWidget *tooltip_box;
  GtkWidget *tooltip_label1;
  GtkWidget *tooltip_prefix_label;
  GtkWidget *tooltip_label2;

  int allocated_width;
  int allocated_height;
};

G_DEFINE_FINAL_TYPE (BzDataGraph, bz_data_graph, GTK_TYPE_WIDGET)

enum
{
  PROP_0,

  PROP_MODEL,
  PROP_INDEPENDENT_AXIS_LABEL,
  PROP_DEPENDENT_AXIS_LABEL,
  PROP_TOOLTIP_PREFIX,
  PROP_INDEPENDENT_DECIMALS,
  PROP_DEPENDENT_DECIMALS,
  PROP_TRANSITION_PROGRESS,

  LAST_PROP
};
static GParamSpec *props[LAST_PROP] = { 0 };

static void
items_changed (GListModel  *model,
               guint        position,
               guint        removed,
               guint        added,
               BzDataGraph *self);

static void
refresh_path (BzDataGraph *self,
              double       width,
              double       height);

static double
calculate_axis_tick_value (double value, gboolean round_up);

static void
on_style_changed (BzDataGraph     *self,
                  GParamSpec      *pspec,
                  AdwStyleManager *style_manager);

static void
refresh_graph (BzDataGraph *self);

static void
refresh_overlay (BzDataGraph *self);

static void
bz_data_graph_dispose (GObject *object)
{
  BzDataGraph *self = BZ_DATA_GRAPH (object);

  if (self->model != NULL)
    g_signal_handlers_disconnect_by_func (
        self->model, items_changed, self);

  g_signal_handlers_disconnect_by_func (self->style_manager,
                                        on_style_changed,
                                        self);

  g_clear_object (&self->model);
  g_clear_pointer (&self->independent_axis_label, g_free);
  g_clear_pointer (&self->dependent_axis_label, g_free);
  g_clear_pointer (&self->tooltip_prefix, g_free);
  g_clear_pointer (&self->path, gsk_path_unref);
  g_clear_pointer (&self->path_measure, gsk_path_measure_unref);
  g_clear_pointer (&self->other, gsk_render_node_unref);
  g_clear_pointer (&self->graph, gsk_render_node_unref);
  g_clear_pointer (&self->overlay, gsk_render_node_unref);

  if (self->tooltip_box != NULL)
    gtk_widget_unparent (self->tooltip_box);

  G_OBJECT_CLASS (bz_data_graph_parent_class)->dispose (object);
}

static void
bz_data_graph_get_property (GObject    *object,
                            guint       prop_id,
                            GValue     *value,
                            GParamSpec *pspec)
{
  BzDataGraph *self = BZ_DATA_GRAPH (object);

  switch (prop_id)
    {
    case PROP_MODEL:
      g_value_set_object (value, bz_data_graph_get_model (self));
      break;
    case PROP_INDEPENDENT_AXIS_LABEL:
      g_value_set_string (value, bz_data_graph_get_independent_axis_label (self));
      break;
    case PROP_DEPENDENT_AXIS_LABEL:
      g_value_set_string (value, bz_data_graph_get_dependent_axis_label (self));
      break;
    case PROP_TOOLTIP_PREFIX:
      g_value_set_string (value, bz_data_graph_get_tooltip_prefix (self));
      break;
    case PROP_INDEPENDENT_DECIMALS:
      g_value_set_int (value, bz_data_graph_get_independent_decimals (self));
      break;
    case PROP_DEPENDENT_DECIMALS:
      g_value_set_int (value, bz_data_graph_get_dependent_decimals (self));
      break;
    case PROP_TRANSITION_PROGRESS:
      g_value_set_double (value, bz_data_graph_get_transition_progress (self));
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static void
bz_data_graph_set_property (GObject      *object,
                            guint         prop_id,
                            const GValue *value,
                            GParamSpec   *pspec)
{
  BzDataGraph *self = BZ_DATA_GRAPH (object);

  switch (prop_id)
    {
    case PROP_MODEL:
      bz_data_graph_set_model (self, g_value_get_object (value));
      break;
    case PROP_INDEPENDENT_AXIS_LABEL:
      bz_data_graph_set_independent_axis_label (self, g_value_get_string (value));
      break;
    case PROP_DEPENDENT_AXIS_LABEL:
      bz_data_graph_set_dependent_axis_label (self, g_value_get_string (value));
      break;
    case PROP_TOOLTIP_PREFIX:
      bz_data_graph_set_tooltip_prefix (self, g_value_get_string (value));
      break;
    case PROP_INDEPENDENT_DECIMALS:
      bz_data_graph_set_independent_decimals (self, g_value_get_int (value));
      break;
    case PROP_DEPENDENT_DECIMALS:
      bz_data_graph_set_dependent_decimals (self, g_value_get_int (value));
      break;
    case PROP_TRANSITION_PROGRESS:
      bz_data_graph_set_transition_progress (self, g_value_get_double (value));
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
    }
}

static void
bz_data_graph_size_allocate (GtkWidget *widget,
                             int        width,
                             int        height,
                             int        baseline)
{
  BzDataGraph     *self          = BZ_DATA_GRAPH (widget);
  GtkRequisition   natural_size  = { 0 };
  graphene_point_t card_position = { 0 };

  if (width != self->allocated_width ||
      height != self->allocated_height)
    {
      refresh_path (
          self,
          (double) width - LABEL_MARGIN - LABEL_MARGIN_RIGHT,
          (double) height - LABEL_MARGIN);
      refresh_graph (self);
      refresh_overlay (self);

      self->allocated_width  = width;
      self->allocated_height = height;
    }

  gtk_widget_get_preferred_size (self->tooltip_box, NULL, &natural_size);
  if (self->motion_x > width / 2.0)
    card_position.x = self->motion_x - natural_size.width - 10.0;
  else
    card_position.x = self->motion_x + 10.0;
  card_position.y = self->motion_y + 10.0;

  gtk_widget_allocate (
      self->tooltip_box,
      natural_size.width,
      natural_size.height,
      baseline,
      gsk_transform_translate (NULL, &card_position));

  gtk_widget_queue_draw (widget);
}

static void
on_style_changed (BzDataGraph     *self,
                  GParamSpec      *pspec,
                  AdwStyleManager *style_manager)
{
  refresh_graph (self);
  gtk_widget_queue_draw (GTK_WIDGET (self));
}

static void
bz_data_graph_snapshot (GtkWidget   *widget,
                        GtkSnapshot *snapshot)
{
  BzDataGraph *self = BZ_DATA_GRAPH (widget);

  if (self->path == NULL)
    return;

  if (self->graph != NULL)
    gtk_snapshot_append_node (snapshot, self->graph);

  if (self->other != NULL)
    {
      GdkRGBA widget_color = { 0 };

      gtk_widget_get_color (widget, &widget_color);
      if (self->other != NULL)
        {
          graphene_rect_t bounds = { 0 };

          gsk_render_node_get_bounds (self->other, &bounds);

          gtk_snapshot_push_mask (snapshot, GSK_MASK_MODE_ALPHA);
          gtk_snapshot_append_node (snapshot, self->other);
          gtk_snapshot_pop (snapshot);
          gtk_snapshot_append_color (snapshot, &widget_color, &bounds);
          gtk_snapshot_pop (snapshot);
        }
    }

  if (self->overlay != NULL)
    {
      gtk_snapshot_append_node (snapshot, self->overlay);

      /* tooltip card */
      gtk_widget_snapshot_child (GTK_WIDGET (self), self->tooltip_box, snapshot);
    }

  if (self->wants_animate_open)
    {
      AdwAnimationTarget *transition_target = NULL;
      AdwSpringParams    *transition_spring = NULL;
      g_autoptr (AdwAnimation) transition   = NULL;

      self->wants_animate_open = FALSE;

      transition_target = adw_property_animation_target_new (G_OBJECT (self), "transition-progress");
      transition_spring = adw_spring_params_new (1.0, 1.0, 80.0);
      transition        = adw_spring_animation_new (GTK_WIDGET (self), 0.0, 1.0, transition_spring, transition_target);
      adw_spring_animation_set_epsilon (ADW_SPRING_ANIMATION (transition), 0.000001);
      adw_animation_play (transition);
    }
}

static void
bz_data_graph_class_init (BzDataGraphClass *klass)
{
  GObjectClass   *object_class = G_OBJECT_CLASS (klass);
  GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);

  object_class->dispose      = bz_data_graph_dispose;
  object_class->get_property = bz_data_graph_get_property;
  object_class->set_property = bz_data_graph_set_property;

  props[PROP_MODEL] =
      g_param_spec_object (
          "model",
          NULL, NULL,
          G_TYPE_LIST_MODEL,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_INDEPENDENT_AXIS_LABEL] =
      g_param_spec_string (
          "independent-axis-label",
          NULL, NULL, NULL,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_DEPENDENT_AXIS_LABEL] =
      g_param_spec_string (
          "dependent-axis-label",
          NULL, NULL, NULL,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_TOOLTIP_PREFIX] =
      g_param_spec_string (
          "tooltip-prefix",
          NULL, NULL, NULL,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_INDEPENDENT_DECIMALS] =
      g_param_spec_int (
          "independent-decimals",
          NULL, NULL,
          -1, 4, (int) 0,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_DEPENDENT_DECIMALS] =
      g_param_spec_int (
          "dependent-decimals",
          NULL, NULL,
          -1, 4, (int) 0,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  props[PROP_TRANSITION_PROGRESS] =
      g_param_spec_double (
          "transition-progress",
          NULL, NULL,
          0.0, G_MAXDOUBLE, 0.0,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);

  g_object_class_install_properties (object_class, LAST_PROP, props);

  widget_class->size_allocate = bz_data_graph_size_allocate;
  widget_class->snapshot      = bz_data_graph_snapshot;
}

static void
update_cursor (BzDataGraph *self,
               gdouble      x,
               gdouble      y)
{
  double widget_width  = gtk_widget_get_width (GTK_WIDGET (self));
  double widget_height = gtk_widget_get_height (GTK_WIDGET (self));

  if (x >= LABEL_MARGIN &&
      y >= 0.0 &&
      x < widget_width - LABEL_MARGIN_RIGHT &&
      y < widget_height - LABEL_MARGIN)
    gtk_widget_set_cursor_from_name (GTK_WIDGET (self), "crosshair");
  else
    gtk_widget_set_cursor (GTK_WIDGET (self), NULL);

  refresh_overlay (self);
  gtk_widget_queue_allocate (GTK_WIDGET (self));
}

static void
motion_enter (BzDataGraph              *self,
              gdouble                   x,
              gdouble                   y,
              GtkEventControllerMotion *controller)
{
  self->motion_x = x;
  self->motion_y = y;
  update_cursor (self, x, y);
}

static void
motion_event (BzDataGraph              *self,
              gdouble                   x,
              gdouble                   y,
              GtkEventControllerMotion *controller)
{
  self->motion_x = x;
  self->motion_y = y;
  update_cursor (self, x, y);
}

static void
motion_leave (BzDataGraph              *self,
              GtkEventControllerMotion *controller)
{
  self->motion_x = -1.0;
  self->motion_y = -1.0;
  update_cursor (self, -1.0, -1.0);
}

static void
gesture_begin (BzDataGraph    *self,
               double          start_x,
               double          start_y,
               GtkGestureDrag *gesture)
{
  self->motion_x = start_x;
  self->motion_y = start_y;
  gtk_widget_queue_draw (GTK_WIDGET (self));
}

static void
gesture_update (BzDataGraph    *self,
                double          offset_x,
                double          offset_y,
                GtkGestureDrag *gesture)
{
  double start_x, start_y;
  gtk_gesture_drag_get_start_point (gesture, &start_x, &start_y);
  self->motion_x = start_x + offset_x;
  self->motion_y = start_y + offset_y;
  gtk_widget_queue_draw (GTK_WIDGET (self));
}

static void
gesture_end (BzDataGraph    *self,
             double          offset_x,
             double          offset_y,
             GtkGestureDrag *gesture)
{
  gtk_widget_queue_draw (GTK_WIDGET (self));
}

static void
bz_data_graph_init (BzDataGraph *self)
{
  GtkWidget *inner_box  = NULL;
  GtkWidget *label2_box = NULL;
  GtkWidget *icon_image = NULL;

  self->style_manager = adw_style_manager_get_default ();

  g_signal_connect_swapped (self->style_manager, "notify::dark",
                            G_CALLBACK (on_style_changed), self);
  g_signal_connect_swapped (self->style_manager, "notify::accent-color",
                            G_CALLBACK (on_style_changed), self);

  self->motion = gtk_event_controller_motion_new ();
  g_signal_connect_swapped (self->motion, "enter", G_CALLBACK (motion_enter), self);
  g_signal_connect_swapped (self->motion, "motion", G_CALLBACK (motion_event), self);
  g_signal_connect_swapped (self->motion, "leave", G_CALLBACK (motion_leave), self);
  gtk_widget_add_controller (GTK_WIDGET (self), self->motion);

  self->gesture = gtk_gesture_drag_new ();
  gtk_gesture_single_set_touch_only (GTK_GESTURE_SINGLE (self->gesture), TRUE);
  g_signal_connect_swapped (self->gesture, "drag-begin", G_CALLBACK (gesture_begin), self);
  g_signal_connect_swapped (self->gesture, "drag-update", G_CALLBACK (gesture_update), self);
  g_signal_connect_swapped (self->gesture, "drag-end", G_CALLBACK (gesture_end), self);
  gtk_widget_add_controller (GTK_WIDGET (self), GTK_EVENT_CONTROLLER (self->gesture));

  self->motion_x         = -1.0;
  self->motion_y         = -1.0;
  self->rounded_axis_max = 0.0;

  self->tooltip_box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0);
  gtk_widget_add_css_class (self->tooltip_box, "card");
  gtk_widget_add_css_class (self->tooltip_box, "floating-tooltip");

  inner_box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 4);
  gtk_widget_set_margin_start (inner_box, 12);
  gtk_widget_set_margin_end (inner_box, 12);
  gtk_widget_set_margin_top (inner_box, 12);
  gtk_widget_set_margin_bottom (inner_box, 12);

  self->tooltip_label1 = gtk_label_new ("");
  gtk_widget_add_css_class (self->tooltip_label1, "heading");
  gtk_label_set_xalign (GTK_LABEL (self->tooltip_label1), 0.0);
  gtk_box_append (GTK_BOX (inner_box), self->tooltip_label1);

  label2_box = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6);

  icon_image = gtk_image_new_from_icon_name ("square-filled-symbolic");
  gtk_widget_add_css_class (icon_image, "accent");
  gtk_box_append (GTK_BOX (label2_box), icon_image);

  self->tooltip_prefix_label = gtk_label_new ("");
  gtk_widget_add_css_class (self->tooltip_prefix_label, "body");
  gtk_widget_add_css_class (self->tooltip_prefix_label, "dim-label");
  gtk_label_set_xalign (GTK_LABEL (self->tooltip_prefix_label), 0.0);
  gtk_box_append (GTK_BOX (label2_box), self->tooltip_prefix_label);

  self->tooltip_label2 = gtk_label_new ("");
  gtk_widget_add_css_class (self->tooltip_label2, "monospace");
  gtk_widget_add_css_class (self->tooltip_label2, "dimmed");
  gtk_label_set_xalign (GTK_LABEL (self->tooltip_label2), 0.0);

  gtk_box_append (GTK_BOX (label2_box), self->tooltip_label2);
  gtk_box_append (GTK_BOX (inner_box), label2_box);
  gtk_box_append (GTK_BOX (self->tooltip_box), inner_box);
  gtk_widget_set_parent (self->tooltip_box, GTK_WIDGET (self));
}

GtkWidget *
bz_data_graph_new (void)
{
  return g_object_new (BZ_TYPE_DATA_GRAPH, NULL);
}

GListModel *
bz_data_graph_get_model (BzDataGraph *self)
{
  g_return_val_if_fail (BZ_IS_DATA_GRAPH (self), NULL);
  return self->model;
}

const char *
bz_data_graph_get_independent_axis_label (BzDataGraph *self)
{
  g_return_val_if_fail (BZ_IS_DATA_GRAPH (self), NULL);
  return self->independent_axis_label;
}

const char *
bz_data_graph_get_dependent_axis_label (BzDataGraph *self)
{
  g_return_val_if_fail (BZ_IS_DATA_GRAPH (self), NULL);
  return self->dependent_axis_label;
}

const char *
bz_data_graph_get_tooltip_prefix (BzDataGraph *self)
{
  g_return_val_if_fail (BZ_IS_DATA_GRAPH (self), NULL);
  return self->tooltip_prefix;
}

int
bz_data_graph_get_independent_decimals (BzDataGraph *self)
{
  g_return_val_if_fail (BZ_IS_DATA_GRAPH (self), 0);
  return self->independent_decimals;
}

int
bz_data_graph_get_dependent_decimals (BzDataGraph *self)
{
  g_return_val_if_fail (BZ_IS_DATA_GRAPH (self), 0);
  return self->dependent_decimals;
}

double
bz_data_graph_get_transition_progress (BzDataGraph *self)
{
  g_return_val_if_fail (BZ_IS_DATA_GRAPH (self), 0.0);
  return self->transition_progress;
}

void
bz_data_graph_set_model (BzDataGraph *self,
                         GListModel  *model)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));
  g_return_if_fail (model == NULL || G_IS_LIST_MODEL (model));

  if (self->model != NULL)
    g_signal_handlers_disconnect_by_func (
        self->model, items_changed, self);
  g_clear_object (&self->model);

  if (model != NULL)
    self->model = g_object_ref (model);

  self->allocated_width  = -1;
  self->allocated_height = -1;
  gtk_widget_queue_allocate (GTK_WIDGET (self));

  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_MODEL]);
}

void
bz_data_graph_set_independent_axis_label (BzDataGraph *self,
                                          const char  *independent_axis_label)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));

  g_clear_pointer (&self->independent_axis_label, g_free);
  if (independent_axis_label != NULL)
    self->independent_axis_label = g_strdup (independent_axis_label);

  gtk_widget_queue_draw (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_INDEPENDENT_AXIS_LABEL]);
}

void
bz_data_graph_set_dependent_axis_label (BzDataGraph *self,
                                        const char  *dependent_axis_label)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));

  g_clear_pointer (&self->dependent_axis_label, g_free);
  if (dependent_axis_label != NULL)
    self->dependent_axis_label = g_strdup (dependent_axis_label);

  gtk_widget_queue_draw (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_DEPENDENT_AXIS_LABEL]);
}

void
bz_data_graph_set_tooltip_prefix (BzDataGraph *self,
                                  const char  *tooltip_prefix)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));

  g_clear_pointer (&self->tooltip_prefix, g_free);
  if (tooltip_prefix != NULL)
    self->tooltip_prefix = g_strdup (tooltip_prefix);

  gtk_widget_queue_draw (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_TOOLTIP_PREFIX]);
}

void
bz_data_graph_set_independent_decimals (BzDataGraph *self,
                                        int          independent_decimals)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));

  self->independent_decimals = CLAMP (independent_decimals, -1, 4);

  gtk_widget_queue_allocate (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_INDEPENDENT_DECIMALS]);
}

void
bz_data_graph_set_dependent_decimals (BzDataGraph *self,
                                      int          dependent_decimals)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));

  self->dependent_decimals = CLAMP (dependent_decimals, -1, 4);

  gtk_widget_queue_allocate (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_DEPENDENT_DECIMALS]);
}

void
bz_data_graph_set_transition_progress (BzDataGraph *self, double transition_progress)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));

  self->transition_progress = transition_progress;

  refresh_graph (self);
  gtk_widget_queue_draw (GTK_WIDGET (self));
  g_object_notify_by_pspec (G_OBJECT (self), props[PROP_TRANSITION_PROGRESS]);
}

void
bz_data_graph_animate_open (BzDataGraph *self)
{
  g_return_if_fail (BZ_IS_DATA_GRAPH (self));

  self->wants_animate_open = TRUE;
  gtk_widget_queue_draw (GTK_WIDGET (self));
}

static void
items_changed (GListModel  *model,
               guint        position,
               guint        removed,
               guint        added,
               BzDataGraph *self)
{
  gtk_widget_queue_allocate (GTK_WIDGET (self));
}

static double
calculate_axis_tick_value (double value, gboolean round_up)
{
  double exponent              = 0.0;
  double fraction              = 0.0;
  double rounded_axis_fraction = 0.0;

  exponent = floor (log10 (value));
  fraction = value / pow (10, exponent);

  if (round_up)
    {
      if (fraction <= 1.0)
        rounded_axis_fraction = 1.0;
      else if (fraction <= 2.0)
        rounded_axis_fraction = 2.0;
      else if (fraction <= 5.0)
        rounded_axis_fraction = 5.0;
      else
        rounded_axis_fraction = 10.0;
    }
  else if (fraction < 1.5)
    rounded_axis_fraction = 1.0;
  else if (fraction < 3.0)
    rounded_axis_fraction = 2.0;
  else if (fraction < 7.0)
    rounded_axis_fraction = 5.0;
  else
    rounded_axis_fraction = 10.0;

  return rounded_axis_fraction * pow (10, exponent);
}

static void
refresh_path (BzDataGraph *self,
              double       width,
              double       height)
{
  guint             n_items                = 0;
  double            min_independent        = 0.0;
  double            max_independent        = 0.0;
  double            max_dependent          = 0.0;
  PangoContext     *pango                  = NULL;
  PangoFontMetrics *metrics                = NULL;
  double            font_height            = 0.0;
  int               independent_label_step = 0;
  g_autoptr (GskPathBuilder) curve_builder = NULL;
  g_autoptr (GtkSnapshot) snapshot         = NULL;
  g_autoptr (GskPathBuilder) grid_builder  = NULL;
  g_autoptr (GskPath) grid                 = NULL;
  g_autoptr (GskStroke) grid_stroke        = NULL;
  double rounded_axis_max                  = 0.0;
  double tick_spacing                      = 0.0;
  int    num_ticks                         = 0;

  g_clear_pointer (&self->path, gsk_path_unref);
  g_clear_pointer (&self->path_measure, gsk_path_measure_unref);
  g_clear_pointer (&self->other, gsk_render_node_unref);

  if (self->model == NULL)
    return;
  if (width < LABEL_MARGIN || height < LABEL_MARGIN)
    return;

  n_items = g_list_model_get_n_items (self->model);
  if (n_items <= 1)
    return;

  for (guint i = 0; i < n_items; i++)
    {
      g_autoptr (BzDataPoint) point = NULL;
      double independent            = 0.0;
      double dependent              = 0.0;

      point       = g_list_model_get_item (self->model, i);
      independent = bz_data_point_get_independent (point);
      dependent   = bz_data_point_get_dependent (point);

      if (i == 0)
        {
          min_independent = independent;
          max_independent = independent;
          max_dependent   = dependent;
        }
      else
        {
          min_independent = MIN (independent, min_independent);
          max_independent = MAX (independent, max_independent);
          max_dependent   = MAX (dependent, max_dependent);
        }
    }

  rounded_axis_max = calculate_axis_tick_value (max_dependent, TRUE);

  pango       = gtk_widget_get_pango_context (GTK_WIDGET (self));
  metrics     = pango_context_get_metrics (pango, NULL, NULL);
  font_height = (double) (int) PANGO_PIXELS_CEIL (pango_font_metrics_get_height (metrics));
  g_clear_pointer (&metrics, pango_font_metrics_unref);

  num_ticks = MIN (5, floor (height / (font_height + 25.0)));
  if (num_ticks < 2)
    num_ticks = 2;

  tick_spacing = calculate_axis_tick_value (rounded_axis_max / (double) num_ticks, FALSE);
  if (tick_spacing == 0.0)
    tick_spacing = 1.0;

  rounded_axis_max       = ceil (max_dependent / tick_spacing) * tick_spacing;
  self->rounded_axis_max = rounded_axis_max;

  independent_label_step = MAX (1, n_items / MAX (1, floor (width / MAX (font_height + 10.0, LABEL_MARGIN)))) * 1.5;

  curve_builder = gsk_path_builder_new ();
  snapshot      = gtk_snapshot_new ();
  grid_builder  = gsk_path_builder_new ();

  gtk_snapshot_save (snapshot);
  gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (LABEL_MARGIN, 0.0));

  for (guint i = 0; i < n_items; i++)
    {
      g_autoptr (BzDataPoint) point = NULL;
      double independent            = 0.0;
      double dependent              = 0.0;
      double x                      = 0.0;
      double y                      = 0.0;

      point       = g_list_model_get_item (self->model, i);
      independent = bz_data_point_get_independent (point);
      dependent   = bz_data_point_get_dependent (point);

      x = (independent - min_independent) / (max_independent - min_independent) * width;
      y = (1.0 - dependent / rounded_axis_max) * height;

      if (i == 0)
        gsk_path_builder_move_to (curve_builder, x, y);
      else
        gsk_path_builder_line_to (curve_builder, x, y);

      if (i % independent_label_step == 0)
        {
          const char *label              = NULL;
          char        buf[32]            = { 0 };
          g_autoptr (PangoLayout) layout = NULL;
          PangoRectangle extents;

          label = bz_data_point_get_label (point);
          if (label == NULL)
            {
              switch (self->independent_decimals)
                {
                case 0:
                  g_snprintf (buf, sizeof (buf), "%d", (int) round (independent));
                  break;
                case 1:
                  g_snprintf (buf, sizeof (buf), "%.1f", independent);
                  break;
                case 2:
                  g_snprintf (buf, sizeof (buf), "%.2f", independent);
                  break;
                case 3:
                  g_snprintf (buf, sizeof (buf), "%.3f", independent);
                  break;
                default:
                  g_snprintf (buf, sizeof (buf), "%f", independent);
                  break;
                }
              label = buf;
            }

          layout = pango_layout_new (pango);
          pango_layout_set_text (layout, label, -1);

          pango_layout_get_pixel_extents (layout, NULL, &extents);

          gtk_snapshot_save (snapshot);
          gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (x, height + LABEL_MARGIN / 10.0));
          gtk_snapshot_rotate (snapshot, -LABEL_MARGIN_RIGHT);
          gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (-extents.width, 0));
          gtk_snapshot_append_layout (snapshot, layout, &(GdkRGBA){ 1.0, 1.0, 1.0, 1.0 });
          gtk_snapshot_restore (snapshot);

          gsk_path_builder_move_to (grid_builder, x, height);
          gsk_path_builder_line_to (grid_builder, x, height + TICK_LENGTH);
        }
    }

  gsk_path_builder_move_to (grid_builder, 0.0, height);
  gsk_path_builder_line_to (grid_builder, width, height);

  gtk_snapshot_save (snapshot);
  gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (-LABEL_MARGIN * 0.75, -font_height / 2.0));

  for (double value = 0.0; value <= rounded_axis_max; value += tick_spacing)
    {
      char buf[32]                   = { 0 };
      g_autoptr (PangoLayout) layout = NULL;
      double y_pos                   = (1.0 - value / rounded_axis_max) * height;

      switch (self->dependent_decimals)
        {
        case 0:
          g_snprintf (buf, sizeof (buf), "%d", (int) round (value));
          break;
        case 1:
          g_snprintf (buf, sizeof (buf), "%.1f", value);
          break;
        case 2:
          g_snprintf (buf, sizeof (buf), "%.2f", value);
          break;
        case 3:
          g_snprintf (buf, sizeof (buf), "%.3f", value);
          break;
        default:
          g_snprintf (buf, sizeof (buf), "%f", value);
          break;
        }

      layout = pango_layout_new (pango);
      pango_layout_set_text (layout, buf, -1);

      gtk_snapshot_save (snapshot);
      gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (0, y_pos));
      gtk_snapshot_append_layout (snapshot, layout, &(GdkRGBA){ 1.0, 1.0, 1.0, 1.0 });
      gtk_snapshot_restore (snapshot);

      gsk_path_builder_move_to (grid_builder, -TICK_LENGTH, y_pos);
      gsk_path_builder_line_to (grid_builder, 0.0, y_pos);
    }
  gtk_snapshot_restore (snapshot);

  gsk_path_builder_move_to (grid_builder, 0.0, 0.0);
  gsk_path_builder_line_to (grid_builder, 0.0, height);

  grid        = gsk_path_builder_to_path (grid_builder);
  grid_stroke = gsk_stroke_new (1.0);

  gtk_snapshot_append_stroke (snapshot, grid, grid_stroke, &(GdkRGBA){ 1.0, 1.0, 1.0, 1.0 });
  gtk_snapshot_restore (snapshot);

  self->path         = gsk_path_builder_to_path (curve_builder);
  self->path_measure = gsk_path_measure_new (self->path);
  self->other        = gtk_snapshot_to_node (snapshot);
}

static void
refresh_graph (BzDataGraph *self)
{
  double           widget_width     = 0.0;
  double           widget_height    = 0.0;
  AdwStyleManager *style_manager    = NULL;
  g_autoptr (GdkRGBA) accent_color  = NULL;
  GdkRGBA widget_color              = { 0 };
  g_autoptr (GskPath) transitioning = NULL;
  g_autoptr (GskStroke) stroke      = NULL;
  g_autoptr (GtkSnapshot) snapshot  = NULL;

  if (self->path == NULL)
    {
      g_clear_pointer (&self->graph, gsk_render_node_unref);
      return;
    }

  widget_width = gtk_widget_get_width (GTK_WIDGET (self));
  (void) widget_width;
  widget_height = gtk_widget_get_height (GTK_WIDGET (self));

  style_manager = adw_style_manager_get_default ();
  accent_color  = adw_style_manager_get_accent_color_rgba (style_manager);
  gtk_widget_get_color (GTK_WIDGET (self), &widget_color);

  if (self->transition_progress > 0.0 && self->transition_progress < 1.0)
    {
      GskPathPoint point0                = { 0 };
      double       path_distance         = 0.0;
      GskPathPoint point1                = { 0 };
      g_autoptr (GskPathBuilder) builder = NULL;

      gsk_path_get_start_point (self->path, &point0);
      path_distance = gsk_path_measure_get_length (self->path_measure) * self->transition_progress;
      gsk_path_measure_get_point (self->path_measure, path_distance, &point1);

      builder = gsk_path_builder_new ();
      gsk_path_builder_add_segment (builder, self->path, &point0, &point1);
      transitioning = gsk_path_builder_to_path (builder);
    }

  stroke = gsk_stroke_new (3.0);
  gsk_stroke_set_line_cap (stroke, GSK_LINE_CAP_ROUND);

  snapshot = gtk_snapshot_new ();
  gtk_snapshot_save (snapshot);
  gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (LABEL_MARGIN, 0.0));

  if (self->transition_progress > 0.0)
    {
      g_autoptr (GskPathBuilder) builder = NULL;
      GskPathPoint     point0            = { 0 };
      GskPathPoint     point1            = { 0 };
      graphene_point_t start_position    = { 0 };
      graphene_point_t end_position      = { 0 };
      g_autoptr (GskPath) path           = NULL;

      const GskColorStop gradient_stops[] = {
        {
         0.0,
         {
         .red   = accent_color->red,
         .green = accent_color->green,
         .blue  = accent_color->blue,
         .alpha = 0.75,
         },
         },
        {
         1.0,
         {
         .red   = accent_color->red,
         .green = accent_color->green,
         .blue  = accent_color->blue,
         .alpha = 0.0,
         },
         },
      };
      const GskColorStop hfadeout_mask_stops[] = {
        {
         0.0,
         {
         .red   = 0.0,
         .green = 0.0,
         .blue  = 0.0,
         .alpha = 1.0,
         },
         },
        {
         0.75,
         {
         .red   = 0.0,
         .green = 0.0,
         .blue  = 0.0,
         .alpha = 1.0,
         },
         },
        {
         1.0,
         {
         .red   = 0.0,
         .green = 0.0,
         .blue  = 0.0,
         .alpha = 0.0,
         },
         },
      };

      gtk_snapshot_append_stroke (
          snapshot,
          transitioning != NULL
              ? transitioning
              : self->path,
          stroke, accent_color);

      /* Gradient under the graph line */
      builder = gsk_path_builder_new ();

      gsk_path_get_start_point (
          transitioning != NULL
              ? transitioning
              : self->path,
          &point0);
      gsk_path_point_get_position (
          &point0,
          transitioning != NULL
              ? transitioning
              : self->path,
          &start_position);
      gsk_path_get_end_point (
          transitioning != NULL
              ? transitioning
              : self->path,
          &point1);
      gsk_path_point_get_position (
          &point1,
          transitioning != NULL
              ? transitioning
              : self->path,
          &end_position);

      gsk_path_builder_move_to (builder, start_position.x, start_position.y);
      gsk_path_builder_add_segment (
          builder,
          transitioning != NULL
              ? transitioning
              : self->path,
          &point0, &point1);
      gsk_path_builder_move_to (builder, end_position.x, end_position.y);

      /* close the loop for `gtk_snapshot_push_fill` */
      gsk_path_builder_line_to (builder, end_position.x, widget_height - LABEL_MARGIN);
      gsk_path_builder_line_to (builder, start_position.x, widget_height - LABEL_MARGIN);
      gsk_path_builder_line_to (builder, start_position.x, start_position.y);

      path = gsk_path_builder_to_path (builder);
      gtk_snapshot_push_fill (
          snapshot,
          path,
          GSK_FILL_RULE_WINDING);

      gtk_snapshot_push_mask (snapshot, GSK_MASK_MODE_ALPHA);

      gtk_snapshot_append_linear_gradient (
          snapshot,
          &GRAPHENE_RECT_INIT (
              0.0,
              0.0,
              end_position.x,
              widget_height - LABEL_MARGIN),
          &GRAPHENE_POINT_INIT (0.0, 0.0),
          &GRAPHENE_POINT_INIT (end_position.x, 0.0),
          hfadeout_mask_stops,
          G_N_ELEMENTS (hfadeout_mask_stops));
      gtk_snapshot_pop (snapshot); /* Push Mask (mask) */

      gtk_snapshot_append_linear_gradient (
          snapshot,
          &GRAPHENE_RECT_INIT (
              0.0,
              0.0,
              end_position.x,
              widget_height - LABEL_MARGIN),
          &GRAPHENE_POINT_INIT (0.0, 0.0),
          &GRAPHENE_POINT_INIT (0.0, widget_height - LABEL_MARGIN),
          gradient_stops,
          G_N_ELEMENTS (gradient_stops));

      gtk_snapshot_pop (snapshot); /* Push Mask (image) */
      gtk_snapshot_pop (snapshot); /* Push Fill */
    }
  gtk_snapshot_restore (snapshot);

  g_clear_pointer (&self->graph, gsk_render_node_unref);
  self->graph = gtk_snapshot_to_node (snapshot);
}

static void
refresh_overlay (BzDataGraph *self)
{
  double           widget_width          = 0.0;
  double           widget_height         = 0.0;
  GdkRGBA          widget_color          = { 0 };
  AdwStyleManager *style_manager         = NULL;
  g_autoptr (GdkRGBA) accent_color       = NULL;
  guint n_items                          = 0;
  guint hovered_idx                      = 0;
  g_autoptr (BzDataPoint) point          = NULL;
  g_autoptr (GskStroke) crosshair_stroke = NULL;
  double           graph_height          = 0.0;
  double           graph_width           = 0.0;
  double           fraction              = 0.0;
  double           point_x               = 0.0;
  double           point_y               = 0.0;
  GskRoundedRect   rounded_rect          = { { { 0 } } };
  GdkRGBA          line_color            = { 0 };
  double           rounded_axis_max      = 0.0;
  const char      *prefix                = NULL;
  g_autofree char *line2_text            = NULL;
  g_autoptr (GtkSnapshot) snapshot       = NULL;

  if (self->path == NULL)
    {
      g_clear_pointer (&self->overlay, gsk_render_node_unref);
      return;
    }

  widget_width  = gtk_widget_get_width (GTK_WIDGET (self));
  widget_height = gtk_widget_get_height (GTK_WIDGET (self));

  if (self->motion_x < LABEL_MARGIN ||
      self->motion_y < 0.0 ||
      self->motion_x >= widget_width - LABEL_MARGIN_RIGHT ||
      self->motion_y >= widget_height - LABEL_MARGIN)
    {
      g_clear_pointer (&self->overlay, gsk_render_node_unref);
      return;
    }

  gtk_widget_get_color (GTK_WIDGET (self), &widget_color);
  style_manager = adw_style_manager_get_default ();
  accent_color  = adw_style_manager_get_accent_color_rgba (style_manager);

  n_items     = g_list_model_get_n_items (self->model);
  graph_width = widget_width - LABEL_MARGIN - LABEL_MARGIN_RIGHT;
  fraction    = (self->motion_x - LABEL_MARGIN) / graph_width;
  hovered_idx = floor ((double) n_items * fraction);
  if (hovered_idx >= n_items)
    hovered_idx = n_items - 1;

  point = g_list_model_get_item (self->model, hovered_idx);

  if (self->rounded_axis_max > 0.0)
    rounded_axis_max = self->rounded_axis_max;
  else
    {
      double max_dependent = 0.0;
      for (guint i = 0; i < n_items; i++)
        {
          g_autoptr (BzDataPoint) p = g_list_model_get_item (self->model, i);
          double dep                = bz_data_point_get_dependent (p);
          if (i == 0 || dep > max_dependent)
            max_dependent = dep;
        }
      rounded_axis_max = calculate_axis_tick_value (max_dependent, TRUE);
    }

  snapshot = gtk_snapshot_new ();

  graph_height = widget_height - LABEL_MARGIN;

  point_x = ((double) hovered_idx / (double) (n_items - 1)) * graph_width + LABEL_MARGIN;
  point_y = (1.0 - bz_data_point_get_dependent (point) / rounded_axis_max) * graph_height;

  line_color       = widget_color;
  line_color.alpha = 0.5;

  crosshair_stroke = gsk_stroke_new (1.0);

#define APPEND_LINE(x0, y0, x1, y1, color)                                  \
  G_STMT_START                                                              \
  {                                                                         \
    g_autoptr (GskPathBuilder) builder = NULL;                              \
    g_autoptr (GskPath) path           = NULL;                              \
                                                                            \
    builder = gsk_path_builder_new ();                                      \
    gsk_path_builder_move_to (builder, (x0), (y0));                         \
    gsk_path_builder_line_to (builder, (x1), (y1));                         \
                                                                            \
    path = gsk_path_builder_to_path (builder);                              \
    gtk_snapshot_append_stroke (snapshot, path, crosshair_stroke, (color)); \
  }                                                                         \
  G_STMT_END

  APPEND_LINE (self->motion_x, 0.0, self->motion_x, widget_height - LABEL_MARGIN, &line_color);

#undef APPEND_LINE

  gsk_rounded_rect_init_from_rect (
      &rounded_rect,
      &GRAPHENE_RECT_INIT (point_x - 4.0, point_y - 4.0, 8.0, 8.0),
      4.0);
  gtk_snapshot_push_rounded_clip (snapshot, &rounded_rect);
  gtk_snapshot_append_color (snapshot, accent_color, &rounded_rect.bounds);
  gtk_snapshot_pop (snapshot);

  g_clear_pointer (&self->overlay, gsk_render_node_unref);
  self->overlay = gtk_snapshot_to_node (snapshot);

  gtk_label_set_label (GTK_LABEL (self->tooltip_label1), bz_data_point_get_label (point));
  prefix = self->tooltip_prefix != NULL ? self->tooltip_prefix : "";
  gtk_label_set_label (GTK_LABEL (self->tooltip_prefix_label), prefix);
  line2_text = g_strdup_printf ("%'.0f", bz_data_point_get_dependent (point));
  gtk_label_set_label (GTK_LABEL (self->tooltip_label2), line2_text);
}