/* bz-world-map.c * * Copyright 2025 Alexander Vanhee * * 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 . * * SPDX-License-Identifier: GPL-3.0-or-later */ #include "config.h" #include #include #include "bz-country-data-point.h" #include "bz-country.h" #include "bz-world-map-parser.h" #include "bz-world-map.h" #define OPACITY_MULTIPLIER 2 struct _BzWorldMap { GtkWidget parent_instance; BzWorldMapParser *parser; GListModel *countries; GListModel *model; double min_lon; double max_lon; double min_lat; double max_lat; GskPath **country_paths; guint *path_to_country; guint n_paths; gboolean cache_valid; int last_width; int last_height; GtkEventController *motion; GtkGesture *gesture; double offset_x; double offset_y; double scale; int hovered_country; double motion_x; double motion_y; guint max_downloads; GtkWidget *tooltip_box; GtkWidget *tooltip_label1; GtkWidget *tooltip_prefix_label; GtkWidget *tooltip_label2; }; G_DEFINE_FINAL_TYPE (BzWorldMap, bz_world_map, GTK_TYPE_WIDGET) enum { PROP_0, PROP_MODEL, LAST_PROP }; static GParamSpec *props[LAST_PROP] = { 0 }; static guint get_downloads_for_country (BzWorldMap *self, const char *iso_code) { guint n_items = 0; if (self->model == NULL) return 0; n_items = g_list_model_get_n_items (self->model); for (guint i = 0; i < n_items; i++) { g_autoptr (BzCountryDataPoint) point = g_list_model_get_item (self->model, i); const char *country_code = bz_country_data_point_get_country_code (point); if (g_strcmp0 (country_code, iso_code) == 0) return bz_country_data_point_get_downloads (point); } return 0; } static void calculate_max_downloads (BzWorldMap *self) { guint n_items = 0; self->max_downloads = 0; if (self->model == NULL) return; n_items = g_list_model_get_n_items (self->model); for (guint i = 0; i < n_items; i++) { g_autoptr (BzCountryDataPoint) point = g_list_model_get_item (self->model, i); guint downloads = bz_country_data_point_get_downloads (point); if (downloads > self->max_downloads) self->max_downloads = downloads; } } static void calculate_bounds (BzWorldMap *self) { guint n_items = 0; if (self->countries == NULL) return; n_items = g_list_model_get_n_items (self->countries); self->min_lon = 180.0; self->max_lon = -180.0; self->min_lat = 90.0; self->max_lat = -90.0; for (guint i = 0; i < n_items; i++) { g_autoptr (BzCountry) country = g_list_model_get_item (self->countries, i); GVariant *coordinates = bz_country_get_coordinates (country); GVariantIter poly_iter, ring_iter, point_iter; GVariant *polygon = NULL; GVariant *ring = NULL; double lon, lat; if (coordinates == NULL) continue; g_variant_iter_init (&poly_iter, coordinates); while ((polygon = g_variant_iter_next_value (&poly_iter))) { g_variant_iter_init (&ring_iter, polygon); while ((ring = g_variant_iter_next_value (&ring_iter))) { g_variant_iter_init (&point_iter, ring); while (g_variant_iter_next (&point_iter, "(dd)", &lon, &lat)) { if (lon < self->min_lon) self->min_lon = lon; if (lon > self->max_lon) self->max_lon = lon; if (lat < self->min_lat) self->min_lat = lat; if (lat > self->max_lat) self->max_lat = lat; } g_clear_pointer (&ring, g_variant_unref); } g_clear_pointer (&polygon, g_variant_unref); } } } static void project_point (BzWorldMap *self, double lon, double lat, double width, double height, double *x, double *y) { double lon_range = self->max_lon - self->min_lon; double lat_range = self->max_lat - self->min_lat; *x = ((lon - self->min_lon) / lon_range) * width; *y = height - ((lat - self->min_lat) / lat_range) * height; } static void calculate_transform (BzWorldMap *self, double widget_width, double widget_height, double map_width, double map_height) { double scale_x = widget_width / map_width; double scale_y = widget_height / map_height; self->scale = MIN (scale_x, scale_y); self->offset_x = (widget_width - map_width * self->scale) / 2.0; self->offset_y = (widget_height - map_height * self->scale) / 2.0; } static void build_paths (BzWorldMap *self, double width, double height) { guint n_items = 0; guint path_index = 0; if (self->countries == NULL) return; if (self->country_paths != NULL) { for (guint i = 0; i < self->n_paths; i++) g_clear_pointer (&self->country_paths[i], gsk_path_unref); g_clear_pointer (&self->country_paths, g_free); } g_clear_pointer (&self->path_to_country, g_free); n_items = g_list_model_get_n_items (self->countries); self->n_paths = 0; for (guint i = 0; i < n_items; i++) { g_autoptr (BzCountry) country = NULL; GVariant *coordinates = NULL; GVariantIter poly_iter = { 0 }; GVariant *polygon = NULL; country = g_list_model_get_item (self->countries, i); coordinates = bz_country_get_coordinates (country); if (coordinates == NULL) continue; g_variant_iter_init (&poly_iter, coordinates); while ((polygon = g_variant_iter_next_value (&poly_iter))) { self->n_paths += g_variant_n_children (polygon); g_clear_pointer (&polygon, g_variant_unref); } } self->country_paths = g_new0 (GskPath *, self->n_paths); self->path_to_country = g_new0 (guint, self->n_paths); for (guint i = 0; i < n_items; i++) { g_autoptr (BzCountry) country = NULL; GVariant *coordinates = NULL; GVariantIter poly_iter = { 0 }; GVariant *polygon = NULL; country = g_list_model_get_item (self->countries, i); coordinates = bz_country_get_coordinates (country); if (coordinates == NULL) continue; g_variant_iter_init (&poly_iter, coordinates); while ((polygon = g_variant_iter_next_value (&poly_iter))) { GVariantIter ring_iter = { 0 }; GVariant *ring = NULL; g_variant_iter_init (&ring_iter, polygon); while ((ring = g_variant_iter_next_value (&ring_iter))) { g_autoptr (GskPathBuilder) builder = NULL; GVariantIter point_iter = { 0 }; double lon = 0.0; double lat = 0.0; gboolean first = TRUE; builder = gsk_path_builder_new (); g_variant_iter_init (&point_iter, ring); while (g_variant_iter_next (&point_iter, "(dd)", &lon, &lat)) { double x = 0.0; double y = 0.0; project_point (self, lon, lat, width, height, &x, &y); if (first) { gsk_path_builder_move_to (builder, x, y); first = FALSE; } else { gsk_path_builder_line_to (builder, x, y); } } gsk_path_builder_close (builder); self->country_paths[path_index] = gsk_path_builder_to_path (builder); self->path_to_country[path_index] = i; path_index++; g_clear_pointer (&ring, g_variant_unref); } g_clear_pointer (&polygon, g_variant_unref); } } self->cache_valid = TRUE; } static void invalidate_cache (BzWorldMap *self) { self->cache_valid = FALSE; gtk_widget_queue_draw (GTK_WIDGET (self)); } static void on_style_changed (AdwStyleManager *style_manager, GParamSpec *pspec, BzWorldMap *self) { invalidate_cache (self); } static void update_hovered_country (BzWorldMap *self, double x, double y) { double map_x; double map_y; map_x = (x - self->offset_x) / self->scale; map_y = (y - self->offset_y) / self->scale; self->motion_x = x; self->motion_y = y; self->hovered_country = -1; for (guint i = 0; i < self->n_paths; i++) { if (gsk_path_in_fill (self->country_paths[i], &GRAPHENE_POINT_INIT (map_x, map_y), GSK_FILL_RULE_WINDING)) { self->hovered_country = self->path_to_country[i]; break; } } } static void motion_event (BzWorldMap *self, gdouble x, gdouble y, GtkEventControllerMotion *controller) { int old_hovered; old_hovered = self->hovered_country; update_hovered_country (self, x, y); if (old_hovered != self->hovered_country || self->hovered_country >= 0) gtk_widget_queue_draw (GTK_WIDGET (self)); } static void motion_leave (BzWorldMap *self, GtkEventControllerMotion *controller) { if (self->hovered_country != -1) { self->hovered_country = -1; self->motion_x = -1.0; self->motion_y = -1.0; gtk_widget_queue_draw (GTK_WIDGET (self)); } } static void gesture_begin (BzWorldMap *self, double start_x, double start_y, GtkGestureDrag *gesture) { update_hovered_country (self, start_x, start_y); gtk_widget_queue_draw (GTK_WIDGET (self)); } static void gesture_update (BzWorldMap *self, double offset_x, double offset_y, GtkGestureDrag *gesture) { double start_x; double start_y; gtk_gesture_drag_get_start_point (gesture, &start_x, &start_y); update_hovered_country (self, start_x + offset_x, start_y + offset_y); gtk_widget_queue_draw (GTK_WIDGET (self)); } static void gesture_end (BzWorldMap *self, double offset_x, double offset_y, GtkGestureDrag *gesture) { gtk_widget_queue_draw (GTK_WIDGET (self)); } static void bz_world_map_dispose (GObject *object) { BzWorldMap *self = BZ_WORLD_MAP (object); g_signal_handlers_disconnect_by_func (adw_style_manager_get_default (), on_style_changed, self); if (self->country_paths != NULL) { for (guint i = 0; i < self->n_paths; i++) g_clear_pointer (&self->country_paths[i], gsk_path_unref); g_free (self->country_paths); self->country_paths = NULL; } if (self->path_to_country != NULL) { g_free (self->path_to_country); self->path_to_country = NULL; } if (self->tooltip_box != NULL) gtk_widget_unparent (self->tooltip_box); g_clear_object (&self->countries); g_clear_object (&self->model); G_OBJECT_CLASS (bz_world_map_parent_class)->dispose (object); } static void bz_world_map_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) { BzWorldMap *self = BZ_WORLD_MAP (object); switch (prop_id) { case PROP_MODEL: g_value_set_object (value, self->model); break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); } } static void bz_world_map_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) { BzWorldMap *self = BZ_WORLD_MAP (object); switch (prop_id) { case PROP_MODEL: g_clear_object (&self->model); self->model = g_value_dup_object (value); calculate_max_downloads (self); gtk_widget_queue_draw (GTK_WIDGET (self)); break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); } } static void bz_world_map_size_allocate (GtkWidget *widget, int width, int height, int baseline) { BzWorldMap *self = BZ_WORLD_MAP (widget); if (width == self->last_width && height == self->last_height) return; self->last_width = width; self->last_height = height; invalidate_cache (self); } static void bz_world_map_snapshot (GtkWidget *widget, GtkSnapshot *snapshot) { BzWorldMap *self = BZ_WORLD_MAP (widget); double widget_width = gtk_widget_get_width (widget); double widget_height = gtk_widget_get_height (widget); AdwStyleManager *style_manager = adw_style_manager_get_default (); g_autoptr (GdkRGBA) accent_color = adw_style_manager_get_accent_color_rgba (style_manager); GdkRGBA stroke_color = { 0 }; g_autoptr (GskStroke) stroke = gsk_stroke_new (0.5); g_autoptr (GskStroke) hover_stroke = gsk_stroke_new (1.5); double map_width = 1000.0; double map_height = 500.0; if (self->countries == NULL) return; gtk_widget_get_color (widget, &stroke_color); stroke_color.alpha = 0.3; if (!self->cache_valid) build_paths (self, map_width, map_height); calculate_transform (self, widget_width, widget_height, map_width, map_height); gtk_snapshot_save (snapshot); gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (self->offset_x, self->offset_y)); gtk_snapshot_scale (snapshot, self->scale, self->scale); for (guint i = 0; i < self->n_paths; i++) { guint country_idx = self->path_to_country[i]; g_autoptr (BzCountry) country = g_list_model_get_item (self->countries, country_idx); const char *iso_code = bz_country_get_iso_code (country); guint downloads = get_downloads_for_country (self, iso_code); GdkRGBA fill_color = *accent_color; if (self->max_downloads > 0 && downloads > 0) { double ratio = (double) downloads / (double) self->max_downloads; fill_color.alpha = CLAMP (ratio * OPACITY_MULTIPLIER, 0.1, 1.0); } else { fill_color.alpha = 0.0; } gtk_snapshot_append_fill (snapshot, self->country_paths[i], GSK_FILL_RULE_WINDING, &fill_color); gtk_snapshot_append_stroke (snapshot, self->country_paths[i], stroke, &stroke_color); } gtk_snapshot_restore (snapshot); if (self->hovered_country >= 0) { GdkRGBA hover_color = stroke_color; hover_color.alpha = 1.0; gtk_snapshot_save (snapshot); gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (self->offset_x, self->offset_y)); gtk_snapshot_scale (snapshot, self->scale, self->scale); for (guint i = 0; i < self->n_paths; i++) { if (self->path_to_country[i] == (guint) self->hovered_country) { gtk_snapshot_append_stroke (snapshot, self->country_paths[i], hover_stroke, &hover_color); } } gtk_snapshot_restore (snapshot); } if (self->hovered_country >= 0 && self->motion_x >= 0.0 && self->motion_y >= 0.0) { g_autoptr (BzCountry) country = g_list_model_get_item (self->countries, self->hovered_country); const char *iso_code = bz_country_get_iso_code (country); guint download_number = get_downloads_for_country (self, iso_code); const char *country_name = bz_country_get_name (country); g_autofree char *label1_text = g_strdup_printf ("%s", country_name); g_autofree char *label2_text = g_strdup_printf ("%'u", download_number); GtkRequisition natural_size; double card_x = 0.0; double card_y = 0.0; gtk_label_set_markup (GTK_LABEL (self->tooltip_label1), label1_text); /* Translators: As in, "1 Install" / "100 Installs" */ gtk_label_set_text (GTK_LABEL (self->tooltip_prefix_label), ngettext ("Install", "Installs", download_number)); gtk_label_set_text (GTK_LABEL (self->tooltip_label2), label2_text); gtk_widget_get_preferred_size (self->tooltip_box, NULL, &natural_size); gtk_widget_allocate (self->tooltip_box, natural_size.width, natural_size.height, -1, NULL); if (self->motion_x > widget_width / 2.0) card_x = self->motion_x - natural_size.width - 10.0; else card_x = self->motion_x + 10.0; card_y = self->motion_y + 10.0; gtk_snapshot_save (snapshot); gtk_snapshot_translate (snapshot, &GRAPHENE_POINT_INIT (card_x, card_y)); gtk_widget_snapshot_child (widget, self->tooltip_box, snapshot); gtk_snapshot_restore (snapshot); } } static void bz_world_map_class_init (BzWorldMapClass *klass) { GObjectClass *object_class = G_OBJECT_CLASS (klass); GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); object_class->dispose = bz_world_map_dispose; object_class->get_property = bz_world_map_get_property; object_class->set_property = bz_world_map_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); g_object_class_install_properties (object_class, LAST_PROP, props); widget_class->snapshot = bz_world_map_snapshot; widget_class->size_allocate = bz_world_map_size_allocate; } static void bz_world_map_init (BzWorldMap *self) { AdwStyleManager *style_manager = adw_style_manager_get_default (); g_autoptr (GError) error = NULL; GtkWidget *inner_box = NULL; GtkWidget *label2_box = NULL; self->parser = bz_world_map_parser_new (); self->hovered_country = -1; self->motion_x = -1.0; self->motion_y = -1.0; self->max_downloads = 0; self->motion = gtk_event_controller_motion_new (); 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->tooltip_box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0); gtk_widget_add_css_class (self->tooltip_box, "floating-tooltip"); gtk_widget_add_css_class (self->tooltip_box, "card"); 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_label_set_use_markup (GTK_LABEL (self->tooltip_label1), TRUE); gtk_box_append (GTK_BOX (inner_box), self->tooltip_label1); label2_box = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6); self->tooltip_label2 = gtk_label_new (""); gtk_widget_add_css_class (self->tooltip_label2, "monospace"); gtk_label_set_xalign (GTK_LABEL (self->tooltip_label2), 0.0); gtk_box_append (GTK_BOX (label2_box), self->tooltip_label2); 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); 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)); g_signal_connect (style_manager, "notify::dark", G_CALLBACK (on_style_changed), self); g_signal_connect (style_manager, "notify::accent-color", G_CALLBACK (on_style_changed), self); if (bz_world_map_parser_load_from_resource (self->parser, "/io/github/kolunmi/Bazaar/countries.gvariant", &error)) { self->countries = g_object_ref (bz_world_map_parser_get_countries (self->parser)); calculate_bounds (self); g_clear_object (&self->parser); } else { g_warning ("BzWorldMap: Failed to load countries: %s", error->message); g_clear_object (&self->parser); } } GtkWidget * bz_world_map_new (void) { return g_object_new (BZ_TYPE_WORLD_MAP, NULL); }