From a83002679df5c1d72b41f2ca015c5cb3e0c6d10d Mon Sep 17 00:00:00 2001
From: Gusted <postmaster@gusted.xyz>
Date: Thu, 18 Jul 2024 22:05:02 +0200
Subject: [PATCH] [UI] Replace `vue-bar-graph` with `chart.js`

- The usage of the `vue-bar-graph` is complicated, because of the `GSAP`
dependency they pull in, the dependency uses a non-free license.
- The code is rewritten to use the `chart.js` library, which is already
used to draw other charts in the activity tab. Due to the limitation of
`chart.js`, we have to create a plugin in order to have images as labels
and do click handling for those images.
- The chart isn't the same as the previous one, once again simply due to
how `chart.js` works, the amount of commits isn't drawn anymore in the
bar, you instead have to hover over it or look at the y-axis.
- Resolves #4569
---
 options/locale/locale_en-US.ini               |   1 +
 package-lock.json                             |  17 --
 package.json                                  |   1 -
 templates/repo/pulse.tmpl                     |   2 +-
 web_src/css/base.css                          |   4 -
 web_src/css/repo.css                          |   4 +
 .../js/components/RepoActivityTopAuthors.vue  | 186 +++++++++++-------
 7 files changed, 121 insertions(+), 94 deletions(-)

diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 6c21589c7f..e528c62dcd 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2117,6 +2117,7 @@ activity.git_stats_addition_n = %d additions
 activity.git_stats_and_deletions = and
 activity.git_stats_deletion_1 = %d deletion
 activity.git_stats_deletion_n = %d deletions
+activity.commit = Commit activity
 
 contributors.contribution_type.filter_label = Contribution type:
 contributors.contribution_type.commits = Commits
diff --git a/package-lock.json b/package-lock.json
index 6e907943e2..f8c33d9b39 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -55,7 +55,6 @@
         "uint8-to-base64": "0.2.0",
         "vanilla-colorful": "0.7.2",
         "vue": "3.4.32",
-        "vue-bar-graph": "2.0.0",
         "vue-chartjs": "5.3.1",
         "vue-loader": "17.4.2",
         "vue3-calendar-heatmap": "2.0.5",
@@ -7335,12 +7334,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/gsap": {
-      "version": "3.12.5",
-      "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.5.tgz",
-      "integrity": "sha512-srBfnk4n+Oe/ZnMIOXt3gT605BX9x5+rh/prT2F1SsNJsU1XuMiP0E2aptW481OnonOGACZWBqseH5Z7csHxhQ==",
-      "license": "Standard 'no charge' license: https://gsap.com/standard-license. Club GSAP members get more: https://gsap.com/licensing/. Why GreenSock doesn't employ an MIT license: https://gsap.com/why-license/"
-    },
     "node_modules/hammerjs": {
       "version": "2.0.8",
       "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
@@ -13707,16 +13700,6 @@
         }
       }
     },
-    "node_modules/vue-bar-graph": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/vue-bar-graph/-/vue-bar-graph-2.0.0.tgz",
-      "integrity": "sha512-IoYP+r5Ggjys6QdUNYFPh7qD41wi/uDOJj9nMawvDgvV6niOz3Dw8O2/98ZnUgjTpcgcGFDaaAaK6qa9x1jgpw==",
-      "license": "MIT",
-      "dependencies": {
-        "gsap": "^3.10.4",
-        "vue": "^3.2.37"
-      }
-    },
     "node_modules/vue-chartjs": {
       "version": "5.3.1",
       "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.1.tgz",
diff --git a/package.json b/package.json
index 38fda76e0d..7f67dbdc8d 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,6 @@
     "uint8-to-base64": "0.2.0",
     "vanilla-colorful": "0.7.2",
     "vue": "3.4.32",
-    "vue-bar-graph": "2.0.0",
     "vue-chartjs": "5.3.1",
     "vue-loader": "17.4.2",
     "vue3-calendar-heatmap": "2.0.5",
diff --git a/templates/repo/pulse.tmpl b/templates/repo/pulse.tmpl
index bc25563d48..4790208541 100644
--- a/templates/repo/pulse.tmpl
+++ b/templates/repo/pulse.tmpl
@@ -105,7 +105,7 @@
 				<strong class="text red">{{ctx.Locale.TrN .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n" .Activity.Code.Deletions}}</strong>.
 			</div>
 			<div class="ui attached segment">
-				<div id="repo-activity-top-authors-chart"></div>
+				<div id="repo-activity-top-authors-chart" data-locale-commit-activity="{{ctx.Locale.Tr "repo.activity.commit"}}"></div>
 			</div>
 		</div>
 	{{end}}
diff --git a/web_src/css/base.css b/web_src/css/base.css
index 8f75a63cd0..4c1317ba79 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -1138,10 +1138,6 @@ overflow-menu .ui.label {
   color: var(--color-primary-contrast);
 }
 
-.activity-bar-graph-alt {
-  color: var(--color-primary-contrast);
-}
-
 .archived-icon {
   color: var(--color-secondary-dark-2) !important;
 }
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 1a901e07a2..bd2da5fa04 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -2995,3 +2995,7 @@ tbody.commit-list {
   font-size: inherit;
   line-height: inherit;
 }
+
+#repo-activity-top-authors-chart {
+  height: 150px; /* Pre-allocate the height that will be taken up by the chart, to avoid the container 'jumping'. */
+}
diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue
index a41fb61d78..52986c0493 100644
--- a/web_src/js/components/RepoActivityTopAuthors.vue
+++ b/web_src/js/components/RepoActivityTopAuthors.vue
@@ -1,14 +1,36 @@
 <script>
-import VueBarGraph from 'vue-bar-graph';
+import {Bar} from 'vue-chartjs';
+import {
+  Chart,
+  Tooltip,
+  BarElement,
+  CategoryScale,
+  LinearScale,
+} from 'chart.js';
+import {chartJsColors} from '../utils/color.js';
 import {createApp} from 'vue';
 
+Chart.defaults.color = chartJsColors.text;
+Chart.defaults.borderColor = chartJsColors.border;
+
+Chart.register(
+  CategoryScale,
+  LinearScale,
+  BarElement,
+  Tooltip,
+);
+
 const sfc = {
-  components: {VueBarGraph},
+  components: {Bar},
+  props: {
+    locale: {
+      type: Object,
+      required: true,
+    },
+  },
   data: () => ({
     colors: {
       barColor: 'green',
-      textColor: 'black',
-      textAltColor: 'white',
     },
 
     // possible keys:
@@ -18,42 +40,108 @@ const sfc = {
     // * login: (...)
     // * name: (...)
     activityTopAuthors: window.config.pageData.repoActivityTopAuthors || [],
+    i18nCommitActivity: this,
   }),
-  computed: {
+  methods: {
     graphPoints() {
-      return this.activityTopAuthors.map((item) => {
-        return {
-          value: item.commits,
-          label: item.name,
-        };
-      });
+      return {
+        datasets: [{
+          label: this.locale.commitActivity,
+          data: this.activityTopAuthors.map((item) => item.commits),
+          backgroundColor: this.colors.barColor,
+          barThickness: 40,
+          borderWidth: 0,
+          tension: 0.3,
+        }],
+        labels: this.activityTopAuthors.map((item) => item.name),
+      };
     },
-    graphAuthors() {
-      return this.activityTopAuthors.map((item, idx) => {
-        return {
-          position: idx + 1,
-          ...item,
-        };
-      });
-    },
-    graphWidth() {
-      return this.activityTopAuthors.length * 40;
+    getOptions() {
+      return {
+        responsive: true,
+        maintainAspectRatio: false,
+        animation: true,
+        scales: {
+          x: {
+            type: 'category',
+            grid: {
+              display: false,
+            },
+            ticks: {
+              color: 'transparent', // Disable drawing of labels on the x-axis.
+            },
+          },
+          y: {
+            ticks: {
+              stepSize: 1,
+            },
+          },
+        },
+      };
     },
   },
   mounted() {
     const refStyle = window.getComputedStyle(this.$refs.style);
-    const refAltStyle = window.getComputedStyle(this.$refs.altStyle);
-
     this.colors.barColor = refStyle.backgroundColor;
-    this.colors.textColor = refStyle.color;
-    this.colors.textAltColor = refAltStyle.color;
+
+    for (const item of this.activityTopAuthors) {
+      const img = new Image();
+      img.src = item.avatar_link;
+      item.avatar_img = img;
+    }
+
+    Chart.register({
+      id: 'image_label',
+      afterDraw: (chart) => {
+        const xAxis = chart.boxes[0];
+        const yAxis = chart.boxes[1];
+        for (const [index] of xAxis.ticks.entries()) {
+          const x = xAxis.getPixelForTick(index);
+          const img = this.activityTopAuthors[index].avatar_img;
+
+          chart.ctx.save();
+          chart.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, x - 10, yAxis.bottom + 10, 20, 20);
+          chart.ctx.restore();
+        }
+      },
+      beforeEvent: (chart, args) => {
+        const event = args.event;
+        if (event.type !== 'mousemove' && event.type !== 'click') return;
+
+        const yAxis = chart.boxes[1];
+        if (event.y < yAxis.bottom + 10 || event.y > yAxis.bottom + 30) {
+          chart.canvas.style.cursor = '';
+          return;
+        }
+
+        const xAxis = chart.boxes[0];
+        const pointIdx = xAxis.ticks.findIndex((_, index) => {
+          const x = xAxis.getPixelForTick(index);
+          return event.x >= x - 10 && event.x <= x + 10;
+        });
+
+        if (pointIdx === -1) {
+          chart.canvas.style.cursor = '';
+          return;
+        }
+
+        chart.canvas.style.cursor = 'pointer';
+        if (event.type === 'click' && this.activityTopAuthors[pointIdx].home_link) {
+          window.location.href = this.activityTopAuthors[pointIdx].home_link;
+        }
+      },
+    });
   },
 };
 
 export function initRepoActivityTopAuthorsChart() {
   const el = document.getElementById('repo-activity-top-authors-chart');
   if (el) {
-    createApp(sfc).mount(el);
+    createApp(sfc, {
+      locale: {
+        commitActivity: el.getAttribute('data-locale-commit-activity'),
+      },
+    }).mount(el);
   }
 }
 
@@ -62,50 +150,6 @@ export default sfc; // activate the IDE's Vue plugin
 <template>
   <div>
     <div class="activity-bar-graph" ref="style" style="width: 0; height: 0;"/>
-    <div class="activity-bar-graph-alt" ref="altStyle" style="width: 0; height: 0;"/>
-    <vue-bar-graph
-      :points="graphPoints"
-      :show-x-axis="true"
-      :show-y-axis="false"
-      :show-values="true"
-      :width="graphWidth"
-      :bar-color="colors.barColor"
-      :text-color="colors.textColor"
-      :text-alt-color="colors.textAltColor"
-      :height="100"
-      :label-height="20"
-    >
-      <template #label="opt">
-        <g v-for="(author, idx) in graphAuthors" :key="author.position">
-          <a
-            v-if="opt.bar.index === idx && author.home_link"
-            :href="author.home_link"
-          >
-            <image
-              :x="`${opt.bar.midPoint - 10}px`"
-              :y="`${opt.bar.yLabel}px`"
-              height="20"
-              width="20"
-              :href="author.avatar_link"
-            />
-          </a>
-          <image
-            v-else-if="opt.bar.index === idx"
-            :x="`${opt.bar.midPoint - 10}px`"
-            :y="`${opt.bar.yLabel}px`"
-            height="20"
-            width="20"
-            :href="author.avatar_link"
-          />
-        </g>
-      </template>
-      <template #title="opt">
-        <tspan v-for="(author, idx) in graphAuthors" :key="author.position">
-          <tspan v-if="opt.bar.index === idx">
-            {{ author.name }}
-          </tspan>
-        </tspan>
-      </template>
-    </vue-bar-graph>
+    <Bar height="150px" :data="graphPoints()" :options="getOptions()"/>
   </div>
 </template>