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 REGION_LABELS: any = ['Africa', 'Europe', 'Americas', 'Asia', 'Oceania'];
private REGION_COLORS: any = ['#F2994A', '#2D9CDB', '#27AE60', '#EB5757', '#9B51E0'];
private REGION_COUNT: any = [100, 170, 150, 140, 40];
private FINISH_LABELS: any = [
'Sub-3:00',
'3:00-3:30',
'3:30-4:00',
'4:00-4:30',
'4:30-5:00',
'5:00+',
];
private FINISH_COLORS: any = [
'#1E8E5A',
'#35B37E',
'#86C82B',
'#F2C037',
'#F2994A',
'#EB5757',
];
private FINISH_COUNT: any = [45, 120, 165, 140, 85, 45];
private LIGHT: any = { theme: { mode: 'light' } };
private BEATS: any = [
{
selector: '#sb-step-1',
view: this.LIGHT,
this.chartOptions: {
series: this.REGION_COUNT,
labels: this.REGION_LABELS,
colors: this.REGION_COLORS,
plotOptions: { unit: { layout: 'grouped', transition: 'flow' } },
},
announce: 'The field by region: five clusters of finishers',
},
{
selector: '#sb-step-2',
view: this.LIGHT,
this.chartOptions: {
series: this.REGION_COUNT,
labels: this.REGION_LABELS,
colors: this.REGION_COLORS,
plotOptions: { unit: { layout: 'packed', transition: 'flow' } },
},
announce:
'One field: the same finishers packed together, smallest region centred',
},
{
selector: '#sb-step-3',
view: this.LIGHT,
this.chartOptions: {
series: this.FINISH_COUNT,
labels: this.FINISH_LABELS,
colors: this.FINISH_COLORS,
plotOptions: { unit: { layout: 'columns', transition: 'flow' } },
},
announce: 'Regrouped by finish time: the finishers flow into six bars',
},
];
private steps: any = document.querySelectorAll('.sb-step');
private dots: any = document.querySelectorAll('.sb-dot');
private chip: any = document.getElementById('sb-chip');
private prevBtn: any = document.getElementById('sb-prev');
private nextBtn: any = document.getElementById('sb-next');
private scroller: any = document.getElementById('sb-scroller');
private current: any = 0;
private scrollToBeat = (i: any): any => {
var step = document.getElementById('sb-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: [100, 170, 150, 140, 40],
chart: {
id: 'marathonStory',
type: 'unit',
height: 420,
fontFamily: 'Helvetica, Arial, sans-serif',
animations: {
enabled: true,
speed: 850,
},
toolbar: { show: false },
},
labels: ['Africa', 'Europe', 'Americas', 'Asia', 'Oceania'],
colors: ['#F2994A', '#2D9CDB', '#27AE60', '#EB5757', '#9B51E0'],
legend: {
position: 'bottom',
},
plotOptions: {
unit: {
layout: 'grouped',
transition: 'flow',
// A fixed dot size keeps every dot the SAME size in all three layouts, so
// dots only move and recolour across the story (they never resize). The
// size is set by the tightest layout: 600 dots must fit the packed field
// and the widest region cluster, so the dots are small on purpose.
size: 2.3,
spacing: 1.15,
},
},
};
ngAfterViewInit() {
(window as any).ApexCharts.setLicense('APEX-eyJpc3N1ZURhdGUiOiIyMDI2LTA0LTE2IiwiZXhwaXJ5RGF0ZSI6IjIwNTItMDQtMTciLCJwbGFuIjoiZW50ZXJwcmlzZSIsImRvbWFpbnMiOlsiYXBleGNoYXJ0cy5jb20iLCIxMjcuMC4wLjEiXX0=');
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: '#sb-scroller' })
})
}
ngOnDestroy() {
// no cleanup needed
}
}