qbeeq

Sisense

Building a Dynamic Control Chart (Mean, UCL, LCL) on a Line Chart

This guide explains a widget script that turns a Line chart into a statistical control chart by calculating the mean, upper/lower control limits, and an optional target line dynamically from the chart's own data, with styling to distinguish each line and flag out-of-control points.

Overview

A statistical process control (SPC) chart helps identify whether a metric (such as incident counts) is within an expected range of variation over time. It typically includes the actual data, a center line (the mean), an upper control limit (UCL), a lower control limit (LCL), and sometimes a target value.

Sisense does not provide this chart type natively, but it can be built on a standard Line chart by adding placeholder series for each control line and using a widget script to calculate and style them dynamically based on the chart's own data. With this approach, the control limits automatically recalculate whenever the underlying data changes (for example, due to a filter or date range change).

This article describes a script that performs this calculation and styling in two stages.

Use Cases

Apply this script when:

  • A Line chart needs to display a statistical control chart (mean, ±2 standard deviation control limits) that recalculates automatically as filters or data change
  • Out-of-control data points (values outside the expected range) need to be visually flagged without manual review
  • A fixed target or goal line needs to be shown alongside actual performance and calculated control limits

Prerequisites

  • Edit access to the widget script (Widget Options menu > Edit Script)
  • A Line chart widget with multiple series added to the Values panel, in this order:
      1. The actual metric being tracked (for example, incident count)
      1. A placeholder series for the center line (can be a duplicate of the main measure, or any measure with the same number of data points — its values will be overwritten by the script)
      1. A placeholder series for the upper control limit (UCL)
      1. A placeholder series for the lower control limit (LCL)
      1. (Optional) A placeholder series for a target line

This script does not create series — it expects these placeholder series to already exist on the widget and overwrites their values. The order above must match the order series appear in args.result.series, which generally reflects the order fields were added to the Values panel.

Script

Stage 1: Calculate the mean, control limits, and flag out-of-control points

widget.on('processresult', function (widget, args) {
  // Identify your series in the order they appear in the "Values" list
  // Adjust indices as needed if the order is different.
  var incidentSeries = args.result.series[0];   // The actual data
  var centerLineSeries = args.result.series[1];
  var uclSeries = args.result.series[2];
  var lclSeries = args.result.series[3];
  var targetSeries = args.result.series[4];     // Optional

  if (!incidentSeries || !centerLineSeries || !uclSeries || !lclSeries) {
    return; // necessary series aren't present
  }

  // -- 1) Gather the data
  var dataPoints = incidentSeries.data;
  var n = dataPoints.length;
  if (n === 0) return;

  // -- 2) Compute mean & stdev
  var sum = 0;
  dataPoints.forEach(pt => sum += pt.y);
  var mean = sum / n;

  var sqDiffSum = 0;
  dataPoints.forEach(pt => {
    var diff = pt.y - mean;
    sqDiffSum += diff * diff;
  });
  // use sample or population stdev as needed
  var variance = sqDiffSum / (n - 1);
  var stdev = Math.sqrt(variance);

  // -- 3) Fill the "CenterLine", "UCL", "LCL"
  for (var i = 0; i < n; i++) {
    centerLineSeries.data[i].y = mean;
    uclSeries.data[i].y = mean + 2 * stdev;
    lclSeries.data[i].y = Math.max(mean - 2 * stdev, 0);
    // ^ if your data can't be negative, you might clamp it to zero
  }

  // -- 4) Optionally, fill a "Target" line
  if (targetSeries) {
    // let's say your target is 50 incidents
    for (var i = 0; i < n; i++) {
      targetSeries.data[i].y = 50;
    }
  }

  // -- 5) Optionally highlight out-of-control points
  // For instance, color points in the main series red if they exceed the UCL or LCL
  for (var i = 0; i < n; i++) {
    var val = dataPoints[i].y;
    if (val > (mean + 2 * stdev) || val < (mean - 2 * stdev)) {
      dataPoints[i].marker = { fillColor: 'red', lineColor: 'red' };
    } else {
      dataPoints[i].marker = { fillColor: '#888', lineColor: '#888' };
    }
  }
});

Stage 2: Style each series so the control lines are visually distinct

widget.on('processresult', function (widget, args) {
  // Assume:
  // series[0] = main incidents (with markers/labels)
  // series[1] = center line
  // series[2] = upper control limit (UCL)
  // series[3] = lower control limit (LCL)
  var incidentSeries = args.result.series[0];
  var centerLineSeries = args.result.series[1];
  var uclSeries = args.result.series[2];
  var lclSeries = args.result.series[3];

  if (!incidentSeries || !centerLineSeries || !uclSeries || !lclSeries) return;

  // ----- 1) Style the main incident series -----
  incidentSeries.marker = incidentSeries.marker || {};
  incidentSeries.marker.enabled = true;
  incidentSeries.marker.symbol = 'circle'; // optional
  incidentSeries.marker.radius = 5;        // optional

  // Force numeric data labels above the points:
  incidentSeries.dataLabels = {
    enabled: true,
    format: '{y}',
    style: { fontWeight: 'bold', color: '#000' }
  };

  // ----- 2) Style the center line -----
  centerLineSeries.lineWidth = 2;
  centerLineSeries.color = '#111'; // black or dark gray
  centerLineSeries.dashStyle = 'Solid';
  centerLineSeries.marker = { enabled: false };
  centerLineSeries.dataLabels = { enabled: false };

  // ----- 3) Style the UCL & LCL -----
  uclSeries.lineWidth = 2;
  uclSeries.color = 'red';
  uclSeries.dashStyle = 'Dash';
  uclSeries.marker = { enabled: false };
  uclSeries.dataLabels = { enabled: false };

  lclSeries.lineWidth = 2;
  lclSeries.color = 'red';
  lclSeries.dashStyle = 'Dash';
  lclSeries.marker = { enabled: false };
  lclSeries.dataLabels = { enabled: false };
});

How It Works

Step
Description
Trigger
Both stages run on widget.on('processresult', ...), after the widget's data is processed and before rendering. They are registered as two separate handlers and run in the order shown.
Identify series
Each stage maps args.result.series[N] to a named variable based on the order series were added to the Values panel. If any expected series is missing, the script exits without making changes.
Calculate mean & standard deviation
The script sums all data points in the main series to get the mean, then calculates the sample standard deviation (using n - 1 in the variance calculation) to measure variation around that mean.
Populate control lines
The center line, UCL, and LCL placeholder series have every data point overwritten with the calculated mean, mean + 2×stdev, and mean − 2×stdev (clamped at zero) respectively, so each renders as a flat reference line across the chart.
Populate the target line
If a fifth series is present, every point is set to a fixed target value.
Flag out-of-control points
Each point in the main series is checked against the calculated control limits; points outside the range are colored red, all others are colored gray.
Apply styling
The second stage sets line width, color, and dash style for each series, and disables markers/labels on the control lines so only the main series displays point markers and data labels.

Customization

Property
Description
Series indices (series[0] through series[4])
Must match the actual order of series in the Values panel. Update if your widget's measure order differs.
2 * stdev
The number of standard deviations used for the control limits. Adjust to widen or narrow the control band (for example, 3 * stdev for a wider tolerance).
Math.max(mean - 2 * stdev, 0)
Clamps the LCL at zero. Remove the Math.max(...) wrapper if negative values are valid for your metric.
targetSeries.data[i].y = 50
The fixed target value. Replace 50 with the actual goal for your metric, or remove the if (targetSeries) block entirely if no target line is needed.
Marker/line colors and dashStyle
Adjust to match dashboard branding or to differentiate the lines further.
variance = sqDiffSum / (n - 1)
Uses sample standard deviation. Change to sqDiffSum / n for population standard deviation if that is the correct statistical basis for your data.

Limitations and Considerations

  • This script requires placeholder series to already exist in the Values panel in the expected order; it does not add or remove series itself. Reordering measures in the panel will require updating the series indices in the script.
  • With only one data point (n = 1), the sample standard deviation calculation divides by zero (n - 1 = 0), producing NaN for the control limits. Ensure the chart has enough data points for a meaningful calculation.
  • The target value and control limit multiplier are hardcoded. For a fully dynamic target, the value would need to be sourced from a formula or external configuration rather than a fixed number in the script.
  • Because both stages run on processresult, any additional script logic added later that also targets args.result.series should be checked for conflicts in execution order.
Did this answer your question?
😞
😐
🤩