javascripthtmlcsschartsprogress-bar

Circular Progress and Graph


I've been trying to create this design using HTML, CSS and JS (particularly chart.io). I am wondering if there is a better way to do this without relying much on JS. The reason for this is that, this particular screenshot below is for a report. So I need to make sure that the design renders graciously with all browsers. And if a user decides to save it as PDF, the report should not have any problem rendering on different PDF viewers. I'm worried about having too much JS as I've had issues before where the designs break in iOS and Acrobat.

For starters, I'll use 12 o'clock as the top most part of the progress.

enter image description here

This is what I currently have at the moment.

    Chart.register(ChartDataLabels);

    const currentAge = 42;
    const biologicalAge = 44.37;
    const fullCircle = 440;
    const percent = Math.min(biologicalAge / currentAge, 1);
    const offset = fullCircle * (1 - percent);

    document.querySelector("circle[stroke-dashoffset='{{DASH_OFFSET}}']")
            .setAttribute("stroke-dashoffset", offset);

    const ctx = document.getElementById('ageChart').getContext('2d');
    const dataPoints = [40.44, 45.54, 44.37];

    const ageChart = new Chart(ctx, {
      type: 'line',
      data: {
        labels: ['Jan 2024', 'Apr 2024', 'Jul 2024'],
        datasets: [{
          label: 'Biological Age',
          data: dataPoints,
          fill: false,
          tension: 0.4,
          borderColor: function(context) {
            const chart = context.chart;
            const {ctx, chartArea} = chart;
            if (!chartArea) return;
            const gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0);
            gradient.addColorStop(0, '#36d1dc');
            gradient.addColorStop(1, '#a646d7');
            return gradient;
          },
          borderWidth: 3,
          pointRadius: 0
        }]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        layout: {
          padding: {
            left: 20,
            right: 20
          }
        },
        scales: {
          y: {
            suggestedMin: 35,
            suggestedMax: 50,
            ticks: {
              stepSize: 5
            },
            grid: {
              drawTicks: true,
              drawOnChartArea: true
            }
          },
          x: {
            grid: {
              drawTicks: false,
              drawOnChartArea: false,
              drawBorder: false,
              color: 'transparent',
              lineWidth: 0
            },
            border: {
              display: false
            },
            ticks: {
              padding: 8
            },
            offset: true
          }
        },
        plugins: {
          legend: {
            display: false
          },
          tooltip: {
            callbacks: {
              label: ctx => `Age: ${ctx.raw}`
            }
          },
          datalabels: {
            align: 'center',
            anchor: 'center',
            formatter: (value) => value.toFixed(2),
            backgroundColor: (context) => context.dataIndex === context.dataset.data.length - 1 ? '#000' : '#fff',
            borderRadius: 999,
            color: (context) => context.dataIndex === context.dataset.data.length - 1 ? '#fff' : '#000',
            font: {
              weight: 'bold'
            },
            padding: 6,
            borderWidth: 1,
            borderColor: '#000'
          }
        }
      },
      plugins: [ChartDataLabels]
    });
    body {
      font-family: sans-serif;
      display: flex;
      align-items: center;
      justify-content: space-between;
      background: #fff;
      margin: 0;
      padding: 2rem;
      width: 700px;
    }

    .progress-container {
      position: relative;
      width: 160px;
      height: 160px;
      margin-bottom: 2rem;
    }

    .progress-container svg {
      transform: rotate(-90deg);
    }

    .progress-text {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      text-align: center;
    }

    .progress-text h2 {
      margin: 0;
      font-size: 2rem;
    }

    .chart-container {
      width: 440px;
      height: 150px;
    }
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Age Progress and Graph</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>

</head>
<body>
  <div class="progress-container">
    <svg width="160" height="160">
      <circle
        cx="80"
        cy="80"
        r="70"
        stroke="#eee"
        stroke-width="8"
        fill="none"
      ></circle>
      <circle
        cx="80"
        cy="80"
        r="70"
        stroke="url(#gradient)"
        stroke-width="8"
        fill="none"
        stroke-dasharray="440"
        stroke-dashoffset="{{DASH_OFFSET}}"
        stroke-linecap="round"
      ></circle>
      <defs>
        <linearGradient id="gradient" x1="1" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#a646d7" />
          <stop offset="100%" stop-color="#e84fd1" />
        </linearGradient>
      </defs>
    </svg>
    <div class="progress-text">
      <h2 style="font-weight:300;">44.37</h2>
      <small>Older ⤴</small>
    </div>
  </div>

  <div class="chart-container">
    <canvas id="ageChart"></canvas>
  </div>
</body>

I would greatly appreciate if you could provide some recommendations or if you could help me with this. Thank you in advance.


Solution

  • You’ll be pleased to hear that a circular gauge requires neither Javascript nor SVG to implement.

    The solution is simple: conic-gradient.

    enter image description here

    body {
      font-family: sans-serif;
      margin: 1em;
      display: flex;
      flex-wrap: wrap;
      gap: 1em;
    }
    
    .circular-gauge {
      position: relative;
      width: 150px;
      height: 150px;
      margin-bottom: 1em;
    
      > :first-child {
        width: 100%;
        height: 100%;
        background: #eee;
        border-radius: 99em;
      }
    
      > :first-child::after {
        content: '';
        position: absolute;
        left: 7%;
        top: 7%;
        width: 86%;
        height: 86%;
        border-radius: 99em;
        background: white;
      }
    
      > :last-child {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        text-align: center;
      }
    
      > :last-child h2 {
        margin: 0;
        font-size: 2em;
        font-weight:300;
      }
    }
    
    .cg1 > :first-child {
      background: conic-gradient(#8aebc0 0turn, #72d2a5 0.7turn, #eee 0.7turn);
    }
    
    .cg2 > :first-child {
      background: conic-gradient(#a4b8f9 0turn, #6b62f1 1turn);
    }
    
    .cg3 > :first-child {
      background: conic-gradient(#c546d9 0turn, #832b99 1turn);
      transform: rotate(1.3turn);
    }
    <div class="circular-gauge cg1">
      <div></div>
      <div>
        <h2>0.7</h2>
      </div>
    </div>
    
    <div class="circular-gauge cg2">
      <div></div>
      <div>
        <h2>1.0</h2>
      </div>
    </div>
    
    <div class="circular-gauge cg3">
      <div></div>
      <div>
        <h2>1.3</h2>
      </div>
    </div>