Home SondeHub Stations Chart
Post
Cancel

SondeHub Stations Chart

This blog post contains instructions on how to use Chart.js to create a custom donut chart specifically designed for showing receiver data from SondeHub.

Chart.js

Chart.js is a simple JavaScript charting library for creating responsive visualisations of various types and styles.

Chart.js can easily be added on an existing website by including the latest version of the library from a CDN or locally.

1
<script type="text/javascript" language="javascript" src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.7.1/chart.js"></script>

Donut Chart

To render a chart a canvas element needs to be added to the website

1
<canvas id="myChart" width="400" height="400"></canvas>

The graph requires a data object which contains the dataset and formatting options for the graph.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const data = {
   labels: [
      'Red',
      'Blue',
      'Yellow'
   ],
   datasets: [{
      data: [300, 50, 100],
      backgroundColor: [
         'rgb(255, 99, 132)',
         'rgb(54, 162, 235)',
         'rgb(255, 205, 86)'
      ]
   }]
};

The graph can now be created and rendered by passing through the canvas element and data object.

1
2
3
4
5
6
const ctx = document.getElementById('myChart').getContext('2d');

const myChart = new Chart(ctx, {
   type: 'doughnut',
   data: data
});

Result

The complete code for this example can be found here.

Custom Item Tooltips

The default tooltip only shows the label and value for each entry.

This can be overriden by creating a custom tooltip which can show the percentage for each entry.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const myChart = new Chart(ctx, {
   type: 'doughnut',
   data: data,
   options: {
   plugins: {
      tooltip: {
         enabled: true,
         callbacks: {
            label: (ttItem) => {
               let sum = 0;
   
               let dataArr = ttItem.dataset.data;
               dataArr.map(data => {
                  sum += Number(data);
               });
   
               let percentage = (ttItem.parsed * 100 / sum).toFixed(2) + '%';
               return `${ttItem.parsed}: ${percentage}`;
            }
         }
      }
   }
   }
});

Result

The complete code for this example can be found here.

Nested Donut Chart

Chart.js includes limited nested data support with donut charts by passing through multiple datasets.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const data = {
   labels: [
      'Red',
      'Blue',
      'Yellow'
   ],
   datasets: [{
      data: [300, 50, 100],
      backgroundColor: [
         'rgb(255, 99, 132)',
         'rgb(54, 162, 235)',
         'rgb(255, 205, 86)'
      ]
   }, {
      data: [200, 75, 175],
      backgroundColor: [
         'rgb(255, 99, 132)',
         'rgb(54, 162, 235)',
         'rgb(255, 205, 86)'
      ]
   }]
};

Result

The complete code for this example can be found here.

This works well when the inner dataset is the same length as the outer dataset however to have sub-categories in the inner-dataset a custom legend handler is required.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
datasets: [{
   data: [300, 50, 100],
   backgroundColor: [
      'rgb(255, 99, 132)',
      'rgb(54, 162, 235)',
      'rgb(255, 205, 86)'
   ]
}, {
   data: [150, 150, 50, 75, 25],
   backgroundColor: [
      'rgb(255, 99, 132)',
      'rgb(255, 99, 132)',
      'rgb(54, 162, 235)',
      'rgb(255, 205, 86)',
      'rgb(255, 205, 86)'
   ]
}]

The sub-entries can be grouped with their parents if their values are perfectly nested using the following custom legend handler.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
plugins: {
   legend: {
      labels: {
         generateLabels: chart => chart.data.labels.map((l, i) => ({
            text: l,
            index: i,
            fillStyle: chart.data.datasets[0].backgroundColor[i],
            strokeStyle: chart.data.datasets[0].backgroundColor[i],
            hidden: chart.getDatasetMeta(0).data[i].hidden
         })),
      },
      onClick: (event, legendItem, legend) => {
         var start = 0;
         var end = 0;
         var sum = 0;
         let chart = legend.chart;
         let hidden = !chart.getDatasetMeta(0).data[legendItem.index].hidden;
         chart.getDatasetMeta(0).data[legendItem.index].hidden = hidden;
         chart.data.datasets[0].data.forEach((v, i) => {
            var value = chart.getDatasetMeta(0).data[i].$context.parsed;
            if (i == legendItem.index) {
               start = sum;
               end = sum + value;
            }
            sum += value;
         });
         sum = 0;
         chart.data.datasets[1].data.forEach((v, i) => {
            var value = chart.getDatasetMeta(1).data[i].$context.parsed;
            sum += value;
            if (sum > start && sum <= end) {
               chart.getDatasetMeta(1).data[i].hidden = hidden;
            }
         });
         chart.update();
      }
   }
}

Result

The complete code for this example can be found here.

Toggle Nested Data

The nested data can also be toggled on and off by creating a simple function.

1
2
3
4
5
6
7
8
document.getElementById('nested').addEventListener('click', () => {
   if (data.datasets[1].hasOwnProperty("hidden") && data.datasets[1].hidden == true) {
      data.datasets[1].hidden = false;
   } else {
      data.datasets[1].hidden = true;
   }
   myChart.update();
});

This function will toggle the nested data visiblity when the following button is pressed.

1
<button id="nested" class="button">Toggle Nested</button>

Result

The complete code for this example can be found here.

Switch datasets

To show multiple datasets on the graph switching functionality has to be implemented.

The first step is to move the datasets to a new object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const datasets = [
   [{
      data: [300, 50, 100],
      backgroundColor: [
         'rgb(255, 99, 132)',
         'rgb(54, 162, 235)',
         'rgb(255, 205, 86)'
      ]
   }, {
      data: [150, 150, 50, 75, 25],
      backgroundColor: [
         'rgb(255, 99, 132)',
         'rgb(255, 99, 132)',
         'rgb(54, 162, 235)',
         'rgb(255, 205, 86)',
         'rgb(255, 205, 86)'
      ]
   }],
   [{
      data: [150, 100, 200],
      backgroundColor: [
         'rgb(255, 99, 132)',
         'rgb(54, 162, 235)',
         'rgb(255, 205, 86)'
      ]
   }, {
      data: [150, 25, 75, 100, 50, 50],
      backgroundColor: [
         'rgb(255, 99, 132)',
         'rgb(54, 162, 235)',
         'rgb(54, 162, 235)',
         'rgb(255, 205, 86)',
         'rgb(255, 205, 86)',
         'rgb(255, 205, 86)'
      ]
   }]
]

The data object then needs to point to the first entry in datasets.

1
2
3
4
5
6
7
8
const data = {
   labels: [
      'Red',
      'Blue',
      'Yellow'
   ],
   datasets: datasets[0]
};

To switch between these datasets a new function is required.

1
2
3
4
5
6
7
8
document.getElementById('dataset').addEventListener('click', () => {
   if (datasets.indexOf(data.datasets) == -1 || datasets.indexOf(data.datasets) == 1) {
      data.datasets = datasets[0]
   } else {
      data.datasets = datasets[1]
   }
   myChart.update();
});

This function will switch the visible dataset when the following button is pressed.

1
<button id="dataset" class="button">Switch Dataset</button>

The toggle nested function will need to be updated so that both datasets share the same view.

1
2
3
4
5
6
7
8
9
10
document.getElementById('nested').addEventListener('click', () => {
   if (data.datasets[1].hasOwnProperty("hidden") && data.datasets[1].hidden == true) {
      datasets[0][1].hidden = false;
      datasets[1][1].hidden = false;
   } else {
      datasets[0][1].hidden = true;
      datasets[1][1].hidden = true;
   }
   myChart.update();
});

Result

The complete code for this example can be found here.

Inner text

To display additional text within the donut chart a Chart.js plugin must be created.

This plugin can be assigned to any graph and can be customised to display specific text with automatic resising to remain within the available space.

The specific plugin used is a slight modification to the answer posted by Shawn Corrigan.

The plugin script will calculate the available width and adjust the font-size and line-breaks so that the text fills the available space.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const countPlugin = {
  id: 'doughnut-centertext',
  beforeDraw: function(chart) {
    if (chart.config.options.elements.center) {
      // Get ctx from string
      var ctx = chart.ctx;

      var innerRadius = chart._metasets[chart._metasets.length - 2].controller.innerRadius;
      if (chart._metasets[chart._metasets.length - 1].controller.innerRadius > 0) {
        innerRadius = chart._metasets[chart._metasets.length - 1].controller.innerRadius;
      }

      // Get options from the center object in options
      var centerConfig = chart.config.options.elements.center;
      var fontStyle = centerConfig.fontStyle || 'Arial';
      var txt = centerConfig.text;
      var color = centerConfig.color || '#000';
      var maxFontSize = centerConfig.maxFontSize || 75;
      var sidePadding = centerConfig.sidePadding || 20;
      var sidePaddingCalculated = (sidePadding / 100) * (innerRadius * 2)
      // Start with a base font of 30px
      ctx.font = "30px " + fontStyle;

      // Get the width of the string and also the width of the element minus 10 to give it 5px side padding
      var stringWidth = ctx.measureText(txt).width;
      var elementWidth = (innerRadius * 2) - sidePaddingCalculated;


      // Find out how much the font can grow in width.
      var widthRatio = elementWidth / stringWidth;
      var newFontSize = Math.floor(30 * widthRatio);
      var elementHeight = (innerRadius * 2);

      // Pick a new font size so it will not be larger than the height of label.
      var fontSizeToUse = Math.min(newFontSize, elementHeight, maxFontSize);
      var minFontSize = centerConfig.minFontSize;
      var lineHeight = centerConfig.lineHeight || 25;
      var wrapText = false;

      if (minFontSize === undefined) {
        minFontSize = 30;
      }

      if (minFontSize && fontSizeToUse < minFontSize) {
        fontSizeToUse = minFontSize;
        wrapText = true;
      }

      // Set font settings to draw it correctly.
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      var centerX = ((chart.chartArea.left + chart.chartArea.right) / 2);
      var centerY = ((chart.chartArea.top + chart.chartArea.bottom) / 2);
      ctx.font = fontSizeToUse + "px " + fontStyle;
      ctx.fillStyle = color;

      if (!wrapText) {
        ctx.fillText(txt, centerX, centerY);
        return;
      }

      var words = txt.split(' ');
      var line = '';
      var lines = [];

      // Break words up into multiple lines if necessary
      for (var n = 0; n < words.length; n++) {
        var testLine = line + words[n] + ' ';
        var metrics = ctx.measureText(testLine);
        var testWidth = metrics.width;
        if (testWidth > elementWidth && n > 0) {
          lines.push(line);
          line = words[n] + ' ';
        } else {
          line = testLine;
        }
      }

      // Move the center up depending on line height and number of lines
      centerY -= (lines.length / 2) * lineHeight;

      for (var n = 0; n < lines.length; n++) {
        ctx.fillText(lines[n], centerX, centerY);
        centerY += lineHeight;
      }
      //Draw text in center
      ctx.fillText(line, centerX, centerY);
    }
  }
};

The chart options also need to be updated to include the displayed text and styling options.

1
2
3
4
5
6
7
8
9
10
11
12
plugins: [countPlugin],
options: {
   elements: {
      center: {
         text: "Inner Text",
         color: '#00a3d3',
         fontStyle: 'Arial',
         sidePadding: 30,
         minFontSize: false
      }
   }
}

Result

The complete code for this example can be found here.

SondeHub Listener Stats API

The SondeHub Listener Stats API returns information about the number of receiver stations that have uploaded telemetry to the SondeHub radiosonde tracking database.

The API can be integrated with the chart to ensure that the latest information is always shown.

The specific Python code and Elasticsearch Query for generating the API response can be found here.

1
https://api.v2.sondehub.org/listeners/stats
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{
   "radiosonde_auto_rx": {
      "telemetry_count": 46867907,
      "unique_callsigns": 620,
      "versions": {
         "1.5.10": {
            "telemetry_count": 31942436,
            "unique_callsigns": 390
         },
         "1.5.9": {
            "telemetry_count": 5278005,
            "unique_callsigns": 73
         },
         "1.5.8": {
            "telemetry_count": 2554674,
            "unique_callsigns": 55
         }
      }
   },
   "rdzTTGOsonde": {
      "telemetry_count": 11768771,
      "unique_callsigns": 221,
      "versions": {
         "devel20220426": {
            "telemetry_count": 3854971,
            "unique_callsigns": 71
         },
         "master_v0.9.1": {
            "telemetry_count": 3628923,
            "unique_callsigns": 67
         },
         "devel20220123": {
            "telemetry_count": 1931191,
            "unique_callsigns": 33
         }
      }
   },
   "SondeMonitor": {
      "telemetry_count": 100335,
      "unique_callsigns": 10,
      "versions": {
         "6.2.6.1": {
            "telemetry_count": 26773,
            "unique_callsigns": 6
         },
         "6.2.5.8":{
            "telemetry_count": 184,
            "unique_callsigns": 2
         },
         "6.2.4.7":{
            "telemetry_count": 73262,
            "unique_callsigns": 1
         }
      }
   },
   "totals": {
      "unique_callsigns": 847,
      "telemetry_count": 58737217
   }
}

Swagger UI API

The following Swagger UI component can be used to access the SondeHub Listener Stats API and get real results.

Working Example

The following JSFiddle contains a fully working demo incorportating everything discussed in this post.

You can also find the complete source code on the sondehub-listener-stats GitHub page.

This post is licensed under CC BY 4.0 by the author.

-

SondeHub Stats Badges