Custom chart: Gear selection heatmap (Di2/eTap/EPS)

Building on top of from my work custom streams showing gear selection over time on the Activity screen, I’ve created a chart showing a gear selection heat map.

To make this work, I believe you first have to first add the “Rear Gear” and “Front Gear” custom streams (see above) and re-process your activity (or upload new ones).

A few comments out of the gate:

  • Since this looks only at the recorded data, I cannot chart gears that weren’t used in the activity. E.g. if you were never in the small chain ring, or if you never used the smallest cog the chart will not show that
  • This looks at all time, not pedalling time (which might be more interesting)
  • This is probably not the most interesting visualisation for this data
  • Getting some “Memory limit exceeded” on longer activities (4h+). I tried a few things, but couldn’t really figure out where the memory went.

I hope you can make something even cooler that builds upon this. The code is below.

Javascript code for the custom chart
{
  function unique(val, idx, arr) {
    return arr.indexOf(val) === idx
  }
  function nonNull(val, idx, arr) {
    return val !== null
  }
  function format_seconds(s) {
    var sec_num = parseInt(s, 10); // don't forget the second param
    var hours   = Math.floor(sec_num / 3600);
    var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
    var seconds = sec_num - (hours * 3600) - (minutes * 60);

    if (hours > 0 && minutes < 10) {minutes = "0"+minutes;}
    if (seconds < 10) {seconds = "0"+seconds;}
    return hours > 0 ? hours+':'+minutes+':'+seconds : minutes+':'+seconds;
  }
  
  let rear_gear = icu.streams.get('RearGear');
  let front_gear = icu.streams.get('FrontGear');
  
  if (rear_gear == null || front_gear == null) {
    let data = [];
    let layout = {
      title: {
        text: "Gear heatmap - Missing data",
        font: { color: 'black', size: 20 }
      },
      xaxis: { visible: false },
      yaxis: { visible: false }
    };
    chart = { data, layout };
  }
  else {
    // This logic means we cannot show gears which were never used
    // e.g. if the small ring or the smallest sprocket were never used, a row or column will be "missing"
    var rear_gears = rear_gear.data.filter(nonNull).filter(unique).sort();
    var front_gears = front_gear.data.filter(nonNull).filter(unique).sort().reverse();

    // Create zeroed 2D array to hold Z values
    let z = Array(front_gears.length).fill().map(() => Array(rear_gears.length).fill(0));

    // Count seconds in each gear into Z
    for (let i=0; i < rear_gear.data.length; i++) {
      let f = front_gear.data[i]
      let r = rear_gear.data[i]
      if (!f || !r) continue;
      z[front_gears.indexOf(f)][rear_gears.indexOf(r)] += 1;
    }

    // Create an array with hover texts (seconds formatted as HH:MM:SS)
    let text = []
    for (let i=0; i < z.length; i++) {
      let row = z[i];
      text.push(row.map(format_seconds));
    }

    let data = [
      {
        z: z,
        x: rear_gears,
        y: front_gears,
        text: text,
        type: 'heatmap',
        hoverongaps: false,
        hovertemplate: '%{y}x%{x}: %{text}<extra></extra>',
        colorscale: 'Viridis',
        showscale: false
      }
    ];

    let layout = {
      title: {
        text: "Gear heatmap",
        font: { color: 'black', size: 20 }
      },
      yaxis: { type: 'category', ticks: '' },
      xaxis: { type: 'category', ticks: '' }
    }

    chart = { data, layout }
  }
}
4 Likes

Thats really cool! Thanks!

I have updated those limits to scale with the duration of the activity which will hopefully sort out the memory problem. I will deploy later because people are busy doing bulk imports now.

2 Likes

I’m already getting memory exceeded on 1.5hr rides (for instance: Intervals.icu)

On rides where it does work, this is absolutely amazing stuff. Nice one Jonas

I did some experimentation and managed to get that script to use a great deal less memory. I am not sure exactly what sorted it out. Key changes:

let rear_gear = icu.streams.RearGear; // this is the data for the stream
// was let rear_gear = icu.streams.get('RearGear');
// this avoids lots of rear_gear.data calls 

I don’t know for sure but it is possible that each rear_gear.data reference creates a new proxy or something.

let rear_gears = []
for (let g of rear_gear) if (g && rear_gears.indexOf(g) < 0) rear_gears.push(g)
rear_gears.sort()
// was var rear_gears = rear_gear.data.filter(nonNull).filter(unique).sort();

Same change for front_gears. Not as nice idiomatic Javascript as filtering but faster and less memory usage.

Jonas and Marco I updated your versions of the chart. If anyone else has the older high memory version you can just search and re-add it to get the new version, then delete the old.

1 Like

Thanks David. Works like a charm now. That was a really neat idea Jonas.

Fantastic - thanks a lot for looking into this @david. Don’t really understand the first thing about these Javascript intricacies, but I guess the lesson is to avoid using .get('') - at least if you’re going to access it a lot. At least my immediate instinct is that’s the more likely culprit and not the filter. I’ll keep this in mind in the future.

2 Likes