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';

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 HALO_PATH: any = 'M 72 42 L 62 72 C 62 76, 55 77, 46 77 C 37 77, 30 76, 30 72 L 20 42 Z ' +
        // the rim, an ellipse overlapping the cup's flat top so it reads as a vessel
        // seen slightly from above
        'M 20 42 A 26 7 0 0 1 72 42 A 26 7 0 0 1 20 42 Z ' +
        // the foot
        'M 38 75 L 54 75 L 57 81.5 L 35 81.5 Z ' +
        // the saucer
        'M 4 86.5 A 42 6.5 0 0 1 88 86.5 A 42 6.5 0 0 1 4 86.5 Z ' +
        // the handle: a solid loop overlapping the cup's right wall...
        'M 64 58 A 14 11 0 0 1 92 58 A 14 11 0 0 1 64 58 Z ' +
        // ...and the finger hole, the SAME kind of ellipse wound the other way
        // (`sweep 0`), which is what turns it from a blob into a handle
        'M 74 58 A 7 5 0 0 0 88 58 A 7 5 0 0 0 74 58 Z ' +
        // two wisps of steam, each tapering to a point at both ends
        'M 48 37 C 36 27, 56 19, 47 5 C 50 4, 52 5, 54 7 C 60 19, 44 26, 54 37 ' +
        'C 52 38, 50 38, 48 37 Z ' +
        'M 30 36 C 21 28, 35 21, 28 9 C 30 8, 32 9, 34 11 C 39 21, 28 27, 36 36 ' +
        'C 34 37, 32 37, 30 36 Z';

  private halo: any = ApexUnitShapes.shapeFrom(this.HALO_PATH, {
        name: 'halo',
        // Measured, not guessed: at 600 dots the steam has thinned to a single file
        // and the finger hole is one dot from closing, which is the floor. Ask for
        // fewer and the chart warns in the console rather than drawing mush. Every
        // logo has a number like this; finding yours is the one piece of homework a
        // custom shape asks for.
        minUnits: 600,
      });

  private haloCore: any = this.halo.with({ order: 'centerOut' });

  private total: any = ApexUnitShapes.glyphs('2,400');

  private LINES: any = ['Espresso', 'Filter', 'Decaf', 'Cold brew'];

  private KILOS: any = [980, 720, 300, 400];

  private ROAST: any = ['#5C3A21', '#C77B30', '#6F9B4A', '#2E8B9A'];

  private LIGHT: any = { theme: { mode: 'light' } };

  private beat = (unit: any): any => {
        return {
          series: this.KILOS,
          labels: this.LINES,
          colors: this.ROAST,
          // 'flow' keys the dots by global order, so the SAME dot glides from one
          // arrangement into the next instead of being rebuilt. It is the whole
          // reason this reads as one crowd moving rather than five charts.
          plotOptions: { unit: Object.assign({ transition: 'flow' }, unit) },
        }
      };

  private BEATS: any = [
        {
          selector: '#lg-step-1',
          view: this.LIGHT,
          this.chartOptions: this.beat({ layout: 'packed' }),
          announce: 'A year of roasting: 2,400 kilos in one packed crowd',
        },
        {
          selector: '#lg-step-2',
          view: this.LIGHT,
          this.chartOptions: this.beat({ layout: 'custom', positions: this.halo }),
          announce:
            'The same 2,400 kilos, gathered into the company mark: a cup of coffee',
        },
        {
          selector: '#lg-step-3',
          view: this.LIGHT,
          this.chartOptions: this.beat({ layout: 'custom', positions: this.haloCore }),
          announce: 'Refilled from the centre out: espresso fills the cup',
        },
        {
          selector: '#lg-step-4',
          view: this.LIGHT,
          this.chartOptions: this.beat({ layout: 'columns' }),
          announce: 'The same kilos as four plain columns, in true proportion',
        },
        {
          selector: '#lg-step-5',
          view: this.LIGHT,
          this.chartOptions: this.beat({ layout: 'custom', positions: this.total }),
          announce: 'The dots spell out their own total: 2,400',
        },
      ];

  private steps: any = document.querySelectorAll('.lg-step');

  private dots: any = document.querySelectorAll('.lg-dot');

  private chip: any = document.getElementById('lg-chip');

  private prevBtn: any = document.getElementById('lg-prev');

  private nextBtn: any = document.getElementById('lg-next');

  private scroller: any = document.getElementById('lg-scroller');

  private current: any = 0;

  private scrollToBeat = (i: any): any => {
          var step = document.getElementById('lg-step-' + (i + 1))
          if (!step || !this.scroller) return
          var sRect = step.getBoundingClientRect()
          var cRect = this.scroller.getBoundingClientRect()
          this.scroller.scrollTop +=
            sRect.top - cRect.top - (this.scroller.clientHeight - step.clientHeight) / 2
        };

  private goToBeat = (i: any): any => {
          i = Math.max(0, Math.min(this.BEATS.length - 1, i))
          this.chart.storyboard.goTo(i)
          this.scrollToBeat(i)
        };

  public chartOptions: Partial<ChartOptions> = {
          series: [980, 720, 300, 400],
          chart: {
            id: 'haloStory',
            type: 'unit',
            height: 420,
            fontFamily: 'Helvetica, Arial, sans-serif',
            animations: {
              enabled: true,
              speed: 850,
            },
            toolbar: { show: false },
          },
          labels: ['Espresso', 'Filter', 'Decaf', 'Cold brew'],
          colors: ['#5C3A21', '#C77B30', '#6F9B4A', '#2E8B9A'],
          legend: {
            position: 'bottom',
          },
          plotOptions: {
            unit: {
              layout: 'packed',
              transition: 'flow',
              // A fixed dot size keeps every dot the SAME size in all five layouts, so
              // dots only move across the story and never resize.
              //
              // Which beat sets the number is worth knowing, because it is not the one it
              // looks like. A silhouette fits the dot gap to its OWN area and then clamps
              // the radius to that gap, so the cup ignores anything above ~1.7 and beats
              // 2, 3 and 5 render identically at 1.7 or at 3. The binding constraint is
              // beat 1: the packed blob grows with the size it is given, and at 2 it
              // reaches 17px past the top of the legend.
              size: 1.7,
              spacing: 1.05,
              clusterLabels: {
                show: false,
              },
            },
          },
        };
  ngAfterViewInit() {
    (window as any).ApexCharts.setLicense('APEX-eyJleHBpcnlEYXRlIjoiMjEyNi0wNy0wNCIsImlzc3VlRGF0ZSI6IjIwMjYtMDctMjgiLCJwbGFuIjoicHJlbWl1bSIsImRvbWFpbnMiOlsiYXBleGNoYXJ0cy5jb20iLCIxMjcuMC4wLjEiLCJsb2NhbGhvc3QiXSwic2lnIjoieVBmb1VCc0Z3TU9ZdUEyaEZkR0I2Y1FtZ0JITUtXcVdJSjB2NVRESXRZbFR3eDJMUmh6R2x0RUc3VXJ4X0s3b25ZMWRZb2Z2VGItN01ydFYyNDVyOWcifQ==');

    this.chart.addEventListener('beatChange', (c, info) => {
            this.current = info.index
            this.steps.forEach(function (el, i) {
              el.classList.toggle('is-active', i === info.index)
            })
            this.dots.forEach(function (dot, i) {
              dot.classList.toggle('is-active', i === info.index)
            })
            this.chip.textContent = 'Beat ' + (info.index + 1) + ' of ' + this.BEATS.length
            this.prevBtn.disabled = info.index === 0
            this.nextBtn.disabled = info.index === this.BEATS.length - 1
          })
    this.dots.forEach(function (dot, i) {
            dot.addEventListener('click', () => {
              this.goToBeat(i)
            })
          })
    this.prevBtn.addEventListener('click', () => {
            this.goToBeat(this.current - 1)
          })
    this.nextBtn.addEventListener('click', () => {
            this.goToBeat(this.current + 1)
          })
    window.addEventListener('load', () => {
            // scroller binds the observer to the story column, so the story is driven by
            // that panel's own scroll (not the page/iframe viewport).
            this.chart.storyboard.bind({ beats: this.BEATS, scroller: '#lg-scroller' })
          })
  }

  ngOnDestroy() {
    // no cleanup needed
  }
}