Interactive Chart.js visualization

Interactive Chart.js visualization
Interactive Chart.js visualization

We are going to build an interactive multi-chart visualization using chart.js. We will illustrate the inner state of the water-filling algorithm, which we implemented in our previous post. The illustration uses only HTML5 and Javascript.

We use a range slider to move around different steps of the algorithm. For each step, the algorithm state is shown.

Interaction with the charts is done using a range type input element

<div>
    <input type="range" min="1" max="35" value="21" id="myRange"  
           onchange="updateStep(this.value)" 
           oninput="updateStep(this.value)">
</div>

Changes in the input are registered using onchange and oninput callbacks

  • onchange is called after the value has changed.
  • oninput is called while the range slider is being moved.

The updateStep callback function takes care of updating the charts and will be defined later.

Next, we define the placeholders for four charts.

<div>Convergence error</div>
<div>
    <canvas id="alphaId"></canvas>
</div>
<div>Sum power level</div>
<div>
    <canvas  id="powerId"></canvas>
</div>
<div>Power allocation</div>
<div>
    <canvas  id="powerAllocationId"></canvas>
</div>
<div>Channel gains</div>
<div>
    <canvas id="channelId"></canvas>
</div>

Each chart is given a unique ID, which we will use to fetch the right canvas for the corresponding charts.

We are ready to move on to the Javascript code for the charts. First, we define our datasets

// Current step to illustrate (initial 21)
var step = 21;

// Convergence error parameters
const alpha_data = [305175.781,152587.891,76293.9453, ...];
        
// Sum power data
const sum_power_data = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,.695780064754675,3.945661168612298, ...];

// Fixed power budged line (fixed to value 10)
const P_data = Array(alpha_data.length).fill(10);

// Step data for power allocation bar graphs. Indexed by step - 1.
const step_data = [
    [0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0],
    [0,.38391202,0,0,0,.21447334,0,0,0,.0973947],
    [0,1.24290548,0,.67290073,0,1.0734668,0,0,0,.95638816],
    ... ];

// Fixed channel gains, used in the simulation
const gains = [0.11415057, 2.10490228, 0.50843188, 0.95685888, 
               0.3945875, 1.55154195, 0.52328784, 0.05926548, 
               0.19887587, 1.31302735];

The full datasets can be found from the source.

We update the range element min/max attributes to reflect the dataset sizes by

var range = document.getElementById("myRange");
range.setAttribute('min', 1);
range.setAttribute('max', alpha_data.length);

The charts are defined one-by-one, by fetching the corresponding canvas ID and constructing a new chart in that context. The convergence error chart uses labels from 1, ..., step, logarithmic scale, and width/height ratio of 4.

var ctx = document.getElementById("alphaId");
var alphaChart = new Chart(ctx, {
    type: 'line',
    data: {
        labels: [...Array(step).keys()].map(x => x + 1), 
        datasets: [{
            label: 'Alpha error',
            data: alpha_data.slice(0, step),
            borderColor: 'rgb(75, 192, 192)',
            backgroundColor: 'rgb(75, 192, 192)',
            borderWidth: 1
        }]
    },
    options: {
        aspectRatio: 4,
        scales: {
            y: {
                beginAtZero: true,
                type: 'logarithmic'	
            }
        }
    }
});

The sum power chart has two lines. First, the sum power line from sum_power_data sliced from between [0, step], similar to the convergence chart. The next line is the fixed power budget line, which we also splice from [0, step] to match the number of elements.

var ctx = document.getElementById("powerId");
var powerChart = new Chart(ctx, {
    type: 'line',
    data: {
        labels: [...Array(step).keys()].map(x => x + 1),
        datasets: [
            {
            label: 'Sum power level',
            data: sum_power_data.slice(0, step),
            borderColor: 'rgb(54, 162, 235)',
            backgroundColor: 'rgb(54, 162, 235)',
            borderWidth: 1
            },
            {
            label: 'Power budget',
            data: P_data.slice(0, step),
            borderColor: 'rgb(255, 99, 132)',
            backgroundColor: 'rgb(255, 99, 132)',
            borderWidth: 1
            },
            ]
    },
    options: {
        aspectRatio: 3,
        scales: {
            y: {
                beginAtZero: true,
            }
        }
    }
});

The power allocation shows the channel-specific power allocation for each step. As step changes, we fetch a new bar chart for the corresponding step-index.

var ctx = document.getElementById("powerAllocationId");
var powerAllocationChart = new Chart(ctx, {
    type: 'bar',
    data: {
        labels: [...Array(10).keys()].map(x => x + 1),
        datasets: [{
            label: 'Power allocation',
            data: step_data[step],
            borderColor: 'rgb(54, 162, 235)',
            backgroundColor: 'rgb(54, 162, 235)',
            borderWidth: 1
        }]
    },
    options: {
        aspectRatio: 2,
        scales: {
            y: {
                beginAtZero: true
            }
        }
    }
});

Similar, to the power allocation bar chart, we show the channel gains as a bar chart. This chart will remain fixed and is not updated after initialization.

var ctx = document.getElementById("channelId");
var channelChart = new Chart(ctx, {
    type: 'bar',
    data: {
        labels: [...Array(10).keys()].map(x => x + 1),
        datasets: [{
            label: 'Channel gains',
            data: gains, 
            borderColor: 'rgb(255, 99, 132)',
            backgroundColor: 'rgb(255, 99, 132)',
            borderWidth: 1
        }]
    },
    options: {
        aspectRatio: 2,
        scales: {
            y: {
                beginAtZero: true
            }
        }
    }
});

Finally, we define the chart update callback function, which gets called from the range slider element. This takes care of the chart dataset updates. On each change step (range slider adjustment), we update the datasets for each chart to match the new value for step

function updateStep(val) {
    step = parseInt(val); // Parse new integer value from the slider update

    // Step labels from [1, step]
    steps = [...Array(step).keys()].map(x => x + 1); 
    
    // Update the datasets to match the value of index step
    
    alphaChart.data.labels = steps;
    alphaChart.data.datasets[0].data = alpha_data.slice(0, step);
    alphaChart.update()

    powerChart.data.labels = steps;
    powerChart.data.datasets[0].data = sum_power_data.slice(0, step);
    powerChart.data.datasets[1].data = P_data.slice(0, step);
    powerChart.update()

    powerAllocationChart.data.datasets[0].data = step_data[step-1];
    powerAllocationChart.update();
}

The full standalone code for this simple interactive illustration can be found on Github.

The result

Bisection step
Convergence error
Sum power level
Power allocation
Channel gains