Water Silhouette in Angular

Using ApexCharts with Angular

This Water Silhouette 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';

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 WATER: any = {
        values: [576, 168, 42, 34],
        labels: ['Safely managed', 'Basic', 'Limited', 'Unsafe'],
        colors: ['#008FFB', '#00B8D9', '#FFAB00', '#FF4560'],
      };

  private DROPLET: any = 'M50 4 C50 4 14 46 14 66 A36 36 0 0 0 86 66 C86 46 50 4 50 4 Z';

  private DROPLET_CORE: any = { x: 50, y: 60 };

  private DROPLET_REACH: any = 40;

  private _path: any = null;

  private dropletPath = (): any => {
        if (this._path) return this._path
        var NS = 'http://www.w3.org/2000/svg'
        var svg = document.createElementNS(NS, 'svg')
        svg.setAttribute('width', '0')
        svg.setAttribute('height', '0')
        svg.setAttribute(
          'style',
          'position:absolute;width:0;height:0;overflow:hidden',
        )
        var p = document.createElementNS(NS, 'path')
        p.setAttribute('d', this.DROPLET)
        svg.appendChild(p)
        // getTotalLength needs the element in the document to measure reliably.
        document.body.appendChild(svg)
        this._path = p
        return this._path
      };

  private silhouetteLayout = (objects: any, rect: any): any => {
        var path = this.dropletPath()
        var len = path.getTotalLength()
        if (!len) return []
  
        // Fit the 100x100 box into the plot rect, leaving a little breathing room.
        var scale = (Math.min(rect.width, rect.height) / 100) * 0.94
        var offX = rect.x + rect.width / 2 - 50 * scale
        var offY = rect.y + rect.height / 2 - 50 * scale
  
        // Inset copies of the outline, outermost first. Sampling one path only draws
        // a wire outline; nesting scaled copies fills the shape, and keeps this to
        // the widely supported getPointAtLength rather than point-in-fill testing.
        //
        // The inset step comes from the mark radius the engine hands us, so rings sit
        // about one dot apart whatever the plot size or dot count. A fixed list of
        // insets looks like a bullseye as soon as the dots are smaller than the gaps.
        var r = objects[0] && objects[0].r > 0 ? objects[0].r : 3
        var step = (2.1 * r) / (scale * this.DROPLET_REACH)
        var rings = []
        for (var k = 1; k > 0.06 && rings.length < 60; k -= step) rings.push(k)
        if (!rings.length) rings.push(1)
  
        // Each ring gets dots in proportion to its perimeter, so density stays even
        // rather than crowding the middle.
        var totalWeight = rings.reduce(function (a, kk) {
          return a + kk
        }, 0)
  
        var out = []
        var idx = 0
        rings.forEach(function (k, ri) {
          var remaining = objects.length - idx
          var count =
            ri === rings.length - 1
              ? remaining
              : Math.min(remaining, Math.round((objects.length * k) / totalWeight))
          for (var i = 0; i < count; i++) {
            var pt = path.getPointAtLength(((i + 0.5) / count) * len)
            // Shrink toward the core to make this ring's inset copy.
            var sx = this.DROPLET_CORE.x + (pt.x - this.DROPLET_CORE.x) * k
            var sy = this.DROPLET_CORE.y + (pt.y - this.DROPLET_CORE.y) * k
            out.push({
              id: objects[idx].id,
              x: offX + sx * scale,
              y: offY + sy * scale,
            })
            idx++
          }
        })
        return out
      };

  private VIEWS: any = {
        droplet: { layout: 'custom', positions: 'droplet' },
        packed: { layout: 'packed', positions: undefined },
        columns: { layout: 'columns', positions: undefined },
      };

  private setActive = (id: any): any => {
        document
          .querySelectorAll('.actions button[data-view]')
          .forEach(function (btn) {
            btn.classList.toggle('active', btn.getAttribute('data-view') === id)
          })
      };

  private current: any = 'droplet';

  public chartOptions: Partial<ChartOptions> = {
          series: [576, 168, 42, 34],
          chart: {
            id: 'waterChart',
            type: 'unit',
            height: 460,
            animations: {
              enabled: true,
              speed: 900,
            },
          },
          labels: ['Safely managed', 'Basic', 'Limited', 'Unsafe'],
          colors: ['#008FFB', '#00B8D9', '#FFAB00', '#FF4560'],
          plotOptions: {
            unit: {
              layout: 'custom',
              positions: 'droplet',
              transition: 'flow',
              spacing: 1.15,
              clusterLabels: {
                show: false,
              },
            },
          },
          legend: {
            position: 'bottom',
          },
        };
  ngAfterViewInit() {
    (window as any).ApexCharts.setLicense('APEX-eyJleHBpcnlEYXRlIjoiMjEyNi0wNy0wNCIsImlzc3VlRGF0ZSI6IjIwMjYtMDctMjgiLCJwbGFuIjoicHJlbWl1bSIsImRvbWFpbnMiOlsiYXBleGNoYXJ0cy5jb20iLCIxMjcuMC4wLjEiLCJsb2NhbGhvc3QiXSwic2lnIjoieVBmb1VCc0Z3TU9ZdUEyaEZkR0I2Y1FtZ0JITUtXcVdJSjB2NVRESXRZbFR3eDJMUmh6R2x0RUc3VXJ4X0s3b25ZMWRZb2Z2VGItN01ydFYyNDVyOWcifQ==');
    ApexCharts.registerUnitLayout('droplet', this.silhouetteLayout)
    this.setActive(this.current)

    document.querySelectorAll('.actions button[data-view]').forEach(function (btn) {
            btn.addEventListener('click', () => {
              var id = btn.getAttribute('data-view')
              if (id === this.current) return
              this.current = id
              this.setActive(this.current)
              this.chart.updateOptions({
                plotOptions: { unit: this.VIEWS[id] },
              })
            })
          })
  }

  ngOnDestroy() {
    // no cleanup needed
  }
}