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
}
}