Violin to Jitter Morph in Angular

Using ApexCharts with Angular

This Violin to Jitter Morph example wires ApexCharts into Angular through the ng-apexcharts component.

Install it with npm install ng-apexcharts apexcharts, then mount the chart with <apx-chart [series]="series" [chart]="chart"></apx-chart>.

Angular charts guide

This demo uses imperative chart updates. The generated code is a faithful Angular translation: open it in CodeSandbox to run and tweak.

import { Component, AfterViewInit, OnDestroy, ViewChild } from '@angular/core';
import {
  ChartComponent,
  ApexAxisChartSeries,
  ApexNonAxisChartSeries,
  ApexChart,
  ApexXAxis,
  ApexYAxis,
  ApexTitleSubtitle,
  ApexDataLabels,
  ApexStroke,
  ApexFill,
  ApexLegend,
  ApexTooltip,
  ApexMarkers,
  ApexPlotOptions,
  ApexResponsive,
  ApexGrid,
  ApexAnnotations,
  ApexStates,
  ApexTheme,
  NgApexchartsModule,
} from 'ng-apexcharts';

import 'apexcharts/unit';
import 'apexcharts/violin';

export type ChartOptions = {
  series?: ApexAxisChartSeries | ApexNonAxisChartSeries;
  chart?: ApexChart;
  xaxis?: ApexXAxis;
  yaxis?: ApexYAxis | ApexYAxis[];
  title?: ApexTitleSubtitle;
  subtitle?: ApexTitleSubtitle;
  dataLabels?: ApexDataLabels;
  stroke?: ApexStroke;
  fill?: ApexFill;
  legend?: ApexLegend;
  tooltip?: ApexTooltip;
  markers?: ApexMarkers;
  plotOptions?: ApexPlotOptions;
  responsive?: ApexResponsive[];
  grid?: ApexGrid;
  annotations?: ApexAnnotations;
  states?: ApexStates;
  theme?: ApexTheme;
  colors?: string[];
  labels?: any;
};

@Component({
  selector: 'app-chart',
  standalone: true,
  imports: [NgApexchartsModule],
  templateUrl: './chart.component.html',
})
export class AppChart implements AfterViewInit, OnDestroy {
  @ViewChild('chart') chart!: ChartComponent;
  private seed: any = 11;

  private rand = (): any => {
        this.seed = (this.seed * 16807) % 2147483647
        return (this.seed - 1) / 2147483646
      };

  private gauss = (): any => {
        var u1 = Math.max(this.rand(), 1e-9)
        var u2 = this.rand()
        return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
      };

  private logNormal = (n: any, median: any, sigma: any, lo: any, hi: any): any => {
        var out = []
        for (var i = 0; i < n; i++) {
          var v = Math.exp(Math.log(median) + sigma * this.gauss())
          out.push(Math.round(Math.min(hi, Math.max(lo, v))))
        }
        return out
      };

  private PLANS: any = [
        // The cap: anything the distribution puts past 30 lands ON 30 exactly.
        { name: 'Free', color: '#12b3a8', values: this.logNormal(150, 24, 0.5, 3, 30) },
        { name: 'Pro', color: '#5a67d8', values: this.logNormal(170, 34, 0.45, 6, 105) },
        { name: 'Trial', color: '#e8890c', values: this.logNormal(14, 26, 0.55, 4, 95) },
      ];

  private COLORS: any = this.PLANS.map(function (p) {
        return p.color
      });

  private VIOLIN_SERIES: any = [
        {
          name: 'Minutes',
          data: this.PLANS.map(function (p) {
            // Raw observations only: the library runs the density estimate.
            return { x: p.name, points: p.values }
          }),
        },
      ];

  private setActive = (exploded: any): any => {
        var buttons = [].slice.call(document.querySelectorAll('[data-explode]'))
        buttons.forEach(function (b) {
          b.className =
            (b.getAttribute('data-explode') === 'true') === exploded ? 'on' : ''
        })
      };

  private median = (values: any): any => {
        var s = values.slice().sort(function (a, b) {
          return a - b
        })
        var m = (s.length - 1) / 2
        return (s[Math.floor(m)] + s[Math.ceil(m)]) / 2
      };

  private renderSummary = (): any => {
        var el = document.querySelector('#summary')
        if (!el) return
        var rows = this.PLANS.map(function (p) {
          var pinned = p.values.filter(function (v) {
            return v === 30
          }).length
          return (
            '<tr><td>' +
            p.name +
            '</td>' +
            '<td>' +
            p.values.length +
            '</td>' +
            '<td>' +
            this.median(p.values) +
            ' min</td>' +
            '<td>' +
            (p.name === 'Free' ? pinned : '-') +
            '</td></tr>'
          )
        })
        el.innerHTML =
          '<table><thead><tr><th>Plan</th><th>Readings</th><th>Median</th>' +
          '<th>Pinned at the 30 min cap</th></tr></thead><tbody>' +
          rows.join('') +
          '</tbody></table>'
      };

  private wireExplode = (chart: any): any => {
        var buttons = [].slice.call(document.querySelectorAll('[data-explode]'))
  
        buttons.forEach(function (b) {
          b.addEventListener('click', function () {
            // The active view's button is a no-op: re-requesting the readings while
            // already exploded would ask rowSeries() of a unit chart, which has no
            // rows to hand back.
            if (b.className === 'on') return
            var explode = b.getAttribute('data-explode') === 'true'
            this.setActive(explode)
  
            if (explode) {
              // The violins were estimated from the observations, so the chart can
              // hand each violin's own readings back: every dot leaves from the
              // curve it was smoothed into.
              var rows = chart.rowSeries()
              // rowSeries() colours by series, and this violin is ONE series split
              // across three lanes (distributed). Re-key the colour by lane so each
              // violin's ink keeps its own colour on the way out.
              rows.forEach(function (cluster, k) {
                cluster.data.forEach(function (d) {
                  d.fillColor = this.COLORS[k]
                })
              })
              chart.updateOptions({
                chart: { type: 'unit' },
                series: rows,
                plotOptions: {
                  unit: {
                    layout: 'scatter',
                    unitValue: 1,
                    size: 3.5,
                    scatter: {
                      // Value stays on Y, one lane per plan across X, matching the
                      // violins. The value-axis keys keep their x* names in either
                      // orientation.
                      orientation: 'vertical',
                      spread: 'jitter',
                      xTitle: 'Minutes per day',
                      // The same 0..120 window the violin state pins its yaxis to;
                      // 7 ticks puts a line every 20 minutes, matching its grid.
                      xMin: 0,
                      xMax: 120,
                      tickAmount: 7,
                    },
                  },
                },
                legend: { show: false },
              })
            } else {
              chart.updateOptions({
                chart: { type: 'violin' },
                series: this.VIOLIN_SERIES,
                legend: { show: false },
              })
            }
          })
        })
  
        this.setActive(false)
        this.renderSummary()
      };

  public chartOptions: Partial<ChartOptions> = {
          series: this.VIOLIN_SERIES,
          chart: {
            id: 'violinJitter',
            type: 'violin',
            height: 430,
            toolbar: {
              show: false,
            },
            animations: {
              chartTypeMorph: {
                speed: 900,
              },
            },
          },
          colors: this.COLORS,
          plotOptions: {
            bar: { distributed: true }, // one colour per plan
            violin: {
              normalize: 'group',
              // The toggle is the reveal here; the built-in overlay would spoil it.
              points: { show: false },
            },
          },
          stroke: {
            width: 1,
            colors: ['#8a97a3'],
          },
          legend: {
            show: false,
          },
          yaxis: {
            // Same domain and ticks as the jitter view, so the two states share one
            // grid and the morph never re-scales the room. Minutes cannot be negative,
            // which the auto-domain's padding would otherwise imply.
            min: 0,
            max: 120,
            tickAmount: 6,
            labels: {
              formatter: (v) => {
                return Math.round(v) + ' min'
              },
            },
          },
        };
  ngAfterViewInit() {
    (window as any).ApexCharts.setLicense('APEX-eyJleHBpcnlEYXRlIjoiMjEyNi0wNy0wNCIsImlzc3VlRGF0ZSI6IjIwMjYtMDctMjgiLCJwbGFuIjoicHJlbWl1bSIsImRvbWFpbnMiOlsiYXBleGNoYXJ0cy5jb20iLCIxMjcuMC4wLjEiLCJsb2NhbGhvc3QiXSwic2lnIjoieVBmb1VCc0Z3TU9ZdUEyaEZkR0I2Y1FtZ0JITUtXcVdJSjB2NVRESXRZbFR3eDJMUmh6R2x0RUc3VXJ4X0s3b25ZMWRZb2Z2VGItN01ydFYyNDVyOWcifQ==');
    this.wireExplode(this.chart)

    function wireExplode(chart) {
          var buttons = [].slice.call(document.querySelectorAll('[data-explode]'))

          buttons.forEach(function (b) {
            b.addEventListener('click', () => {
              // The active view's button is a no-op: re-requesting the readings while
              // already exploded would ask rowSeries() of a unit chart, which has no
              // rows to hand back.
              if (b.className === 'on') return
              var explode = b.getAttribute('data-explode') === 'true'
              this.setActive(explode)

              if (explode) {
                // The violins were estimated from the observations, so the chart can
                // hand each violin's own readings back: every dot leaves from the
                // curve it was smoothed into.
                var rows = this.chart.rowSeries()
                // rowSeries() colours by series, and this violin is ONE series split
                // across three lanes (distributed). Re-key the colour by lane so each
                // violin's ink keeps its own colour on the way out.
                rows.forEach(function (cluster, k) {
                  cluster.data.forEach(function (d) {
                    d.fillColor = this.COLORS[k]
                  })
                })
                this.chart.updateOptions({
                  chart: { type: 'unit' },
                  series: rows,
                  plotOptions: {
                    unit: {
                      layout: 'scatter',
                      unitValue: 1,
                      size: 3.5,
                      scatter: {
                        // Value stays on Y, one lane per plan across X, matching the
                        // violins. The value-axis keys keep their x* names in either
                        // orientation.
                        orientation: 'vertical',
                        spread: 'jitter',
                        xTitle: 'Minutes per day',
                        // The same 0..120 window the violin state pins its yaxis to;
                        // 7 ticks puts a line every 20 minutes, matching its grid.
                        xMin: 0,
                        xMax: 120,
                        tickAmount: 7,
                      },
                    },
                  },
                  legend: { show: false },
                })
              } else {
                this.chart.updateOptions({
                  chart: { type: 'violin' },
                  series: this.VIOLIN_SERIES,
                  legend: { show: false },
                })
              }
            })
          })

          this.setActive(false)
          this.renderSummary()
        }
  }

  ngOnDestroy() {
    // no cleanup needed
  }
}