Repository: WeblateOrg/weblate

Issue:
Replace Chartist by own svg chart renderer
**Is your feature request related to a problem? Please describe.**
The current activity charts do not look nice are not aligned with rest of the charts. It should have similar look as status widgets:

![](https://hosted.weblate.org/widgets/weblate/-/horizontal-auto.svg)

**Describe the solution you'd like**
Render SVG directly instead of rendering JSON and then doing client side transform into SVG.

**Describe alternatives you've considered**
Tuning Chartist to produce nicer charts is also option, but I think for the minimal feature set we need it's not needed.


Discussion:
Hi, could I work on this?
@apitulea Thanks, I've assigned you this issue.

Resolving patch:
diff --git a/weblate/trans/views/charts.py b/weblate/trans/views/charts.py
--- a/weblate/trans/views/charts.py
+++ b/weblate/trans/views/charts.py
@@ -18,15 +18,17 @@
 #
 """Charting library for Weblate."""
 
+from datetime import datetime
+from typing import Callable, Optional
+
 from django.core.cache import cache
-from django.http import JsonResponse
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, render
 from django.utils.translation import pgettext
 
 from weblate.auth.models import User
 from weblate.lang.models import Language
 from weblate.trans.models import Change
-from weblate.utils.views import get_project_translation
+from weblate.utils.views import get_percent_color, get_project_translation
 
 
 def cache_key(*args):
@@ -40,8 +42,14 @@ def makekey(val):
     return "activity-{}".format("-".join(makekey(arg) for arg in args))
 
 
-def get_json_stats(
-    request, days, step, project=None, component=None, lang=None, user=None
+def get_activity_stats(
+    request,
+    days: int,
+    step: int,
+    project: Optional[str] = None,
+    component: Optional[str] = None,
+    lang: Optional[str] = None,
+    user: Optional[str] = None,
 ):
     """Parse json stats URL params."""
     if project is None and lang is None and user is None:
@@ -81,45 +89,86 @@ def get_json_stats(
     return result
 
 
-def yearly_activity(request, project=None, component=None, lang=None, user=None):
-    """Return yearly activity for matching changes as json."""
-    activity = get_json_stats(request, 364, 7, project, component, lang, user)
+def get_label_month(pos: int, previous_month: int, timestamp: datetime) -> str:
+    if previous_month != timestamp.month:
+        return pgettext(
+            "Format string for yearly activity chart", "{month}/{year}"
+        ).format(month=timestamp.month, year=timestamp.year)
+    return ""
 
-    # Format
-    serie = []
-    labels = []
-    month = -1
-    for item in activity:
-        serie.append(item[1])
-        if month != item[0].month:
-            labels.append(
-                pgettext(
-                    "Format string for yearly activity chart", "{month}/{year}"
-                ).format(month=item[0].month, year=item[0].year)
-            )
-            month = item[0].month
-        else:
-            labels.append("")
 
-    return JsonResponse(data={"series": [serie], "labels": labels})
+def get_label_day(pos: int, previous_month: int, timestamp: datetime) -> str:
+    if pos % 5 == 0:
+        return pgettext(
+            "Format string for monthly activity chart", "{day}/{month}"
+        ).format(day=timestamp.day, month=timestamp.month, year=timestamp.year)
+    return ""
 
 
-def monthly_activity(request, project=None, component=None, lang=None, user=None):
-    """Return monthly activity for matching changes as json."""
-    activity = get_json_stats(request, 31, 1, project, component, lang, user)
+def render_activity(
+    request,
+    days: int,
+    step: int,
+    label_func: Callable[[int, int, datetime], str],
+    project: Optional[str] = None,
+    component: Optional[str] = None,
+    lang: Optional[str] = None,
+    user: Optional[str] = None,
+):
+    """Return yearly activity for matching changes as json."""
+    activity = get_activity_stats(request, days, step, project, component, lang, user)
+
+    max_value = max(item[1] for item in activity)
 
-    # Format
     serie = []
-    labels = []
+    previous_month = -1
+    offset = 0
     for pos, item in enumerate(activity):
-        serie.append(item[1])
-        if pos % 5 == 0:
-            labels.append(
-                pgettext(
-                    "Format string for monthly activity chart", "{day}/{month}"
-                ).format(day=item[0].day, month=item[0].month, year=item[0].year)
+        timestamp, value = item
+        percent = value * 100 // max_value if max_value else 0
+        if value and percent < 4:
+            percent = 4
+        label = label_func(pos, previous_month, timestamp)
+        previous_month = timestamp.month
+        offset += 15
+        height = int(1.5 * percent)
+        serie.append(
+            (
+                value,
+                label,
+                offset,
+                get_percent_color(percent),
+                height,
+                10 + (150 - height),
             )
-        else:
-            labels.append("")
+        )
 
-    return JsonResponse(data={"series": [serie], "labels": labels})
+    return render(
+        request, "svg/activity.svg", {"serie": serie}, content_type="image/svg+xml"
+    )
+
+
+def yearly_activity(
+    request,
+    project: Optional[str] = None,
+    component: Optional[str] = None,
+    lang: Optional[str] = None,
+    user: Optional[str] = None,
+):
+    """Return yearly activity for matching changes as json."""
+    return render_activity(
+        request, 364, 7, get_label_month, project, component, lang, user
+    )
+
+
+def monthly_activity(
+    request,
+    project: Optional[str] = None,
+    component: Optional[str] = None,
+    lang: Optional[str] = None,
+    user: Optional[str] = None,
+):
+    """Return monthly activity for matching changes as json."""
+    return render_activity(
+        request, 52, 1, get_label_day, project, component, lang, user
+    )
diff --git a/weblate/trans/widgets.py b/weblate/trans/widgets.py
--- a/weblate/trans/widgets.py
+++ b/weblate/trans/widgets.py
@@ -35,6 +35,7 @@
 from weblate.fonts.utils import configure_fontconfig, render_size
 from weblate.utils.site import get_site_url
 from weblate.utils.stats import GlobalStats
+from weblate.utils.views import get_percent_color
 
 gi.require_version("PangoCairo", "1.0")
 gi.require_version("Pango", "1.0")
@@ -355,7 +356,7 @@ class SVGBadgeWidget(SVGWidget):
     name = "svg"
     colors = ("badge",)
     order = 80
-    template_name = "badge.svg"
+    template_name = "svg/badge.svg"
     verbose = gettext_lazy("Status badge")
 
     def render(self, response):
@@ -395,6 +396,7 @@ def render(self, response):
                     "translated_offset": translated_width // 2,
                     "percent_offset": translated_width + percent_width // 2,
                     "lang": get_language(),
+                    "fonts_cdn_url": settings.FONTS_CDN_URL,
                 },
             )
         )
@@ -405,7 +407,7 @@ class MultiLanguageWidget(SVGWidget):
     name = "multi"
     order = 81
     colors = ("auto", "red", "green", "blue")
-    template_name = "multi-language-badge.svg"
+    template_name = "svg/multi-language-badge.svg"
     verbose = gettext_lazy("Vertical multi language status widget")
 
     COLOR_MAP = {"red": "#fa3939", "green": "#3fed48", "blue": "#3f85ed", "auto": None}
@@ -419,12 +421,7 @@ def render(self, response):
             language = stats.language
             percent = stats.translated_percent
             if self.color == "auto":
-                if percent >= 85:
-                    color = "#2eccaa"
-                elif percent >= 50:
-                    color = "#38f"
-                else:
-                    color = "#f6664c"
+                color = get_percent_color(percent)
             language_name = force_str(language)
 
             language_width = max(
@@ -475,6 +472,7 @@ def render(self, response):
                     "translations": translations,
                     "site_url": get_site_url(),
                     "horizontal_height": language_width + 130,
+                    "fonts_cdn_url": settings.FONTS_CDN_URL,
                 },
             )
         )
@@ -484,5 +482,5 @@ def render(self, response):
 class HorizontalMultiLanguageWidget(MultiLanguageWidget):
     name = "horizontal"
     order = 82
-    template_name = "multi-language-badge-horizontal.svg"
+    template_name = "svg/multi-language-badge-horizontal.svg"
     verbose = gettext_lazy("Horizontal multi language status widget")
diff --git a/weblate/utils/views.py b/weblate/utils/views.py
--- a/weblate/utils/views.py
+++ b/weblate/utils/views.py
@@ -37,6 +37,14 @@
 from weblate.utils import messages
 
 
+def get_percent_color(percent):
+    if percent >= 85:
+        return "#2eccaa"
+    if percent >= 50:
+        return "#38f"
+    return "#f6664c"
+
+
 def get_page_limit(request, default):
     """Return page and limit as integers."""
     try:


Test patch:
diff --git a/weblate/trans/tests/test_charts.py b/weblate/trans/tests/test_charts.py
--- a/weblate/trans/tests/test_charts.py
+++ b/weblate/trans/tests/test_charts.py
@@ -19,8 +19,6 @@
 
 """Test for charts and widgets."""
 
-import json
-
 from django.urls import reverse
 
 from weblate.trans.tests.test_views import FixtureTestCase
@@ -29,59 +27,52 @@
 class ChartsTest(FixtureTestCase):
     """Testing of charts."""
 
-    def assert_json_chart_data(self, response):
-        """Test whether response has valid json chart data."""
-        self.assertEqual(response.get("Content-Type"), "application/json")
-        data = json.loads(response.content.decode())
-        self.assertTrue("series" in data)
-        self.assertTrue("labels" in data)
-
     def test_activity_monthly(self):
         """Test of monthly activity charts."""
         response = self.client.get(reverse("monthly_activity"))
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(reverse("monthly_activity", kwargs=self.kw_project))
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(
             reverse("monthly_activity", kwargs=self.kw_component)
         )
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(
             reverse("monthly_activity", kwargs=self.kw_translation)
         )
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(reverse("monthly_activity", kwargs={"lang": "cs"}))
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(
             reverse("monthly_activity", kwargs={"user": self.user.username})
         )
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
     def test_activity_yearly(self):
         """Test of yearly activity charts."""
         response = self.client.get(reverse("yearly_activity"))
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(reverse("yearly_activity", kwargs=self.kw_project))
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(reverse("yearly_activity", kwargs=self.kw_component))
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(
             reverse("yearly_activity", kwargs=self.kw_translation)
         )
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(reverse("yearly_activity", kwargs={"lang": "cs"}))
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)
 
         response = self.client.get(
             reverse("yearly_activity", kwargs={"user": self.user.username})
         )
-        self.assert_json_chart_data(response)
+        self.assert_svg(response)