FITARA Remastered: Charts

Filter this, map that. Transmogrifying data for great visuals.

One of the things the original FITARA website did that I did not think I could replicate was converting the data from one structure to another for the purpose of charting. The data structure was set in the data files and there was no way to query against it, so things got really creative.

My initial thought was to modify the data using Hugo and then spit out the new structure inline in each page’s template file as a global JavaScript variable. When the page loaded, I would just drop that new variable into the chart configuration and a chart would appear. A few hours of making a huge mess of things revealed this was not the way and I needed a new avenue.

Something recent versions of Hugo offers is js.Build. This function takes a bunch of JavaScript as an argument and then a dict of options. Inside those options, you can define parameters that are passed into the compiled JavaScript that js.Build returns.

Building JavaScript with Hugo

 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
{{- $agencies := slice }}
{{- range .Site.Sections }}
  {{- if eq .Type "agencies" }}
    {{- range .Pages }}
      {{- $agency := dict
        "id" (strings.ToLower .Params.acronym)
        "acronym" .Params.acronym
        "title" .Title
      }}
      {{- $agencies = $agencies | append $agency }}
    {{- end }}
  {{- end }}
{{- end }}

{{- $params := dict
  "reports" .Site.Data.reports
  "agencies" $agencies
}}
{{- $opts := dict
  "params" $params
  "minify" true
}}
{{- with resources.Match "js/**.js" | resources.Concat "js/bundle.js" | js.Build $opts | fingerprint }}
  <script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" defer></script>
{{- end }}

With the code above, I create a dictionary with an array of agency objects in the same order as the weighted agencies on the site - this helps establish the correct order for the graphs. The second element of the dictionary contains all the data from all the TOML files. This huge blob is pushed into the JavaScript bundle and downloaded only once - at 6kb, it isn’t much of an ask.

Traversing With JavaScript

 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
window.addEventListener('DOMContentLoaded', function() {
  const id = 'agency-progress-chart';
  const target = document.getElementById(id);
  if (target == null) {
    return;            
  }                                                                                 

  const agencyId = target.dataset.agency;
  const keys = getReportKeys();

  let series = agencies.map(function(agency) {
    return {
      name: agency.acronym,
      visible: agency.id == agencyId,
      data: keys.map(function(key) {
        return gradeToNum(reports[key].data[agency.id].grade);
      }),
    };
  });

  series.unshift({
    name: 'Average',
    visible: true,
    data: keys.map(function(key, i) {
      const total = series.map(function(data) {
        return data.data[i];
      }).reduce(function(prev, curr) {
        return prev + curr;
      });
      return Math.round(total / agencies.length * 100) / 100;
    }),
  });

  // Build and display chart
});

As you can see, one of the tags in the agency template includes a data-agency attribute to signify which agency the user is viewing. getReportKeys() just grabs all the filenames from the reports, which are in YYYYMM format, and sorts them in an array. After the setup, some simple array mapping does the heavy lifting. In the case of the averages, a reduce helps squish the data down for final calculations.

I wouldn’t normally do these types of operations in JavaScript, but it all runs so fast that I am now looking back on some other projects and wondering if I can port their logic to the client side. Being forced into this corner really opened my eyes to doing things in different ways.