{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "\n# Packed-bubble chart\n\nCreate a packed-bubble chart to represent scalar data.\nThe presented algorithm tries to move all bubbles as close to the center of\nmass as possible while avoiding some collisions by moving around colliding\nobjects. In this example we plot the market share of different desktop\nbrowsers.\n(source: https://gs.statcounter.com/browser-market-share/desktop/worldwidev)\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "collapsed": false
      },
      "outputs": [],
      "source": [
        "import matplotlib.pyplot as plt\nimport numpy as np\n\nbrowser_market_share = {\n    'browsers': ['firefox', 'chrome', 'safari', 'edge', 'ie', 'opera'],\n    'market_share': [8.61, 69.55, 8.36, 4.12, 2.76, 2.43],\n    'color': ['#5A69AF', '#579E65', '#F9C784', '#FC944A', '#F24C00', '#00B825']\n}\n\n\nclass BubbleChart:\n    def __init__(self, area, bubble_spacing=0):\n        \"\"\"\n        Setup for bubble collapse.\n\n        Parameters\n        ----------\n        area : array-like\n            Area of the bubbles.\n        bubble_spacing : float, default: 0\n            Minimal spacing between bubbles after collapsing.\n\n        Notes\n        -----\n        If \"area\" is sorted, the results might look weird.\n        \"\"\"\n        area = np.asarray(area)\n        r = np.sqrt(area / np.pi)\n\n        self.bubble_spacing = bubble_spacing\n        self.bubbles = np.ones((len(area), 4))\n        self.bubbles[:, 2] = r\n        self.bubbles[:, 3] = area\n        self.maxstep = 2 * self.bubbles[:, 2].max() + self.bubble_spacing\n        self.step_dist = self.maxstep / 2\n\n        # calculate initial grid layout for bubbles\n        length = np.ceil(np.sqrt(len(self.bubbles)))\n        grid = np.arange(length) * self.maxstep\n        gx, gy = np.meshgrid(grid, grid)\n        self.bubbles[:, 0] = gx.flatten()[:len(self.bubbles)]\n        self.bubbles[:, 1] = gy.flatten()[:len(self.bubbles)]\n\n        self.com = self.center_of_mass()\n\n    def center_of_mass(self):\n        return np.average(\n            self.bubbles[:, :2], axis=0, weights=self.bubbles[:, 3]\n        )\n\n    def center_distance(self, bubble, bubbles):\n        return np.hypot(bubble[0] - bubbles[:, 0],\n                        bubble[1] - bubbles[:, 1])\n\n    def outline_distance(self, bubble, bubbles):\n        center_distance = self.center_distance(bubble, bubbles)\n        return center_distance - bubble[2] - \\\n            bubbles[:, 2] - self.bubble_spacing\n\n    def check_collisions(self, bubble, bubbles):\n        distance = self.outline_distance(bubble, bubbles)\n        return len(distance[distance < 0])\n\n    def collides_with(self, bubble, bubbles):\n        distance = self.outline_distance(bubble, bubbles)\n        return np.argmin(distance, keepdims=True)\n\n    def collapse(self, n_iterations=50):\n        \"\"\"\n        Move bubbles to the center of mass.\n\n        Parameters\n        ----------\n        n_iterations : int, default: 50\n            Number of moves to perform.\n        \"\"\"\n        for _i in range(n_iterations):\n            moves = 0\n            for i in range(len(self.bubbles)):\n                rest_bub = np.delete(self.bubbles, i, 0)\n                # try to move directly towards the center of mass\n                # direction vector from bubble to the center of mass\n                dir_vec = self.com - self.bubbles[i, :2]\n\n                # shorten direction vector to have length of 1\n                dir_vec = dir_vec / np.sqrt(dir_vec.dot(dir_vec))\n\n                # calculate new bubble position\n                new_point = self.bubbles[i, :2] + dir_vec * self.step_dist\n                new_bubble = np.append(new_point, self.bubbles[i, 2:4])\n\n                # check whether new bubble collides with other bubbles\n                if not self.check_collisions(new_bubble, rest_bub):\n                    self.bubbles[i, :] = new_bubble\n                    self.com = self.center_of_mass()\n                    moves += 1\n                else:\n                    # try to move around a bubble that you collide with\n                    # find colliding bubble\n                    for colliding in self.collides_with(new_bubble, rest_bub):\n                        # calculate direction vector\n                        dir_vec = rest_bub[colliding, :2] - self.bubbles[i, :2]\n                        dir_vec = dir_vec / np.sqrt(dir_vec.dot(dir_vec))\n                        # calculate orthogonal vector\n                        orth = np.array([dir_vec[1], -dir_vec[0]])\n                        # test which direction to go\n                        new_point1 = (self.bubbles[i, :2] + orth *\n                                      self.step_dist)\n                        new_point2 = (self.bubbles[i, :2] - orth *\n                                      self.step_dist)\n                        dist1 = self.center_distance(\n                            self.com, np.array([new_point1]))\n                        dist2 = self.center_distance(\n                            self.com, np.array([new_point2]))\n                        new_point = new_point1 if dist1 < dist2 else new_point2\n                        new_bubble = np.append(new_point, self.bubbles[i, 2:4])\n                        if not self.check_collisions(new_bubble, rest_bub):\n                            self.bubbles[i, :] = new_bubble\n                            self.com = self.center_of_mass()\n\n            if moves / len(self.bubbles) < 0.1:\n                self.step_dist = self.step_dist / 2\n\n    def plot(self, ax, labels, colors):\n        \"\"\"\n        Draw the bubble plot.\n\n        Parameters\n        ----------\n        ax : matplotlib.axes.Axes\n        labels : list\n            Labels of the bubbles.\n        colors : list\n            Colors of the bubbles.\n        \"\"\"\n        for i in range(len(self.bubbles)):\n            circ = plt.Circle(\n                self.bubbles[i, :2], self.bubbles[i, 2], color=colors[i])\n            ax.add_patch(circ)\n            ax.text(*self.bubbles[i, :2], labels[i],\n                    horizontalalignment='center', verticalalignment='center')\n\n\nbubble_chart = BubbleChart(area=browser_market_share['market_share'],\n                           bubble_spacing=0.1)\n\nbubble_chart.collapse()\n\nfig, ax = plt.subplots(subplot_kw=dict(aspect=\"equal\"))\nbubble_chart.plot(\n    ax, browser_market_share['browsers'], browser_market_share['color'])\nax.axis(\"off\")\nax.relim()\nax.autoscale_view()\nax.set_title('Browser market share')\n\nplt.show()"
      ]
    }
  ],
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "codemirror_mode": {
        "name": "ipython",
        "version": 3
      },
      "file_extension": ".py",
      "mimetype": "text/x-python",
      "name": "python",
      "nbconvert_exporter": "python",
      "pygments_lexer": "ipython3",
      "version": "3.14.6"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 0
}