apexcharts / apexcharts.js

📊 Interactive JavaScript Charts built on SVG
https://apexcharts.com
MIT License
14.23k stars 1.29k forks source link

Horizontal bar chart x-axis max property breaks labels display #3290

Open pavelbruyako opened 2 years ago

pavelbruyako commented 2 years ago

Description

I work with a horizontal bar chart.

When I set

xaxis: {
  max: {some value here}
}

the number of labels increases for some reason and:

  1. when this is done on a chart with small width labels start to overlap.
  2. when max value is something less than 1 (for example 0.4) labels start to repeat

Expected Behavior

  1. either number of labels increases until they start overlapping or number of labels stays the same.
  2. either labels automatically increase their precision or number of labels is adjusted so that no labels repeat.

Actual Behavior

  1. labels overlap
  2. labels repeat

Screenshots

Example with overlapping labels: image

Example with repeating labels: image

Reproduction Link

https://codesandbox.io/s/apx-bar-basic-forked-zp5xw5?file=/src/app/app.component.ts

Additional question

Can you please specify all the fields which get swapped between xaxis and yaxis when plotOptions.bar.horizontal = true?

pavelbruyako commented 2 years ago

Workaround

Okay, I examined apexchart's inner code and came up with the following solution. I wrote a directive, that calculates tickAmount based on chart's inner properties and sets it on every resize of chart's parent. There is the code:

import { Directive, ElementRef, Input, OnInit, OnDestroy, NgZone, ContentChild, AfterContentInit } from '@angular/core';
import { ChartComponent } from 'ng-apexcharts';

@Directive({
  selector: `[adjustedAxesOfChildApexChart]`,
})
// This directive should be applied to sizeable(!) parent of horizontal bar apexchart
export class AdjustedAxesOfChildApexChartDirective implements OnInit, AfterContentInit, OnDestroy {
  @ContentChild(ChartComponent)
  chart: any;

  observer: ResizeObserver;
  readonly minDistanceBetweenLabels = 10; // px
  readonly maxDistinguishableIncrement = 6; // if adjacent labels' difference is more than that, then we don't care if ticks are a little bit off
  readonly minIntegerLabel = 3.9; // apexhart starts producing labels with /10 presision if max label is < than minIntegerLabel

  constructor(private host: ElementRef, private readonly zone: NgZone) {}

  ngOnInit() {
    this.observer = new ResizeObserver(() => {
      this.zone.run(() => this.setTickAmount());
    });

    this.observer.observe(this.host.nativeElement);
  }

  ngAfterContentInit() {
    this.setTickAmount();
  }

  setTickAmount() {
    // this field can be uninitialised at the very first run
    if (!this.chart?.chartObj?.w?.globals) {
      return;
    }

    const chartGlobalOptions = this.chart.chartObj.w.globals;

    const xAxisLabels = chartGlobalOptions.yAxisScale[0].result;
    const firstLabel = xAxisLabels[0];
    const lastLabel = xAxisLabels[xAxisLabels.length - 1];

    // I don't care if this is not precise since I do Math.floor which gets us result bigger or equal than the actual size
    const firstLabelWidth =
      chartGlobalOptions.xAxisLabelsWidth / Math.max(Math.floor(`${lastLabel}`.length / `${firstLabel}`.length), 1);

    // this formula is based on apexchart code of getting x-values for labels (node_modules\apexcharts\src\modules\axes\YAxis.js drawYaxisInversed)
    const availableXAxisWidth =
      chartGlobalOptions.gridWidth + // gridWidth is an actual grid width (xaxis length)
      chartGlobalOptions.padHorizontal -
      firstLabelWidth - // we need to subtract first label width because it can be the same length as the rest of them. Btw, this case with very long first label has a bug: label starts earlier than left border. No fix for that)
      this.chart.chartObj.w.config.xaxis.labels.offsetX;

    const maxTickAmount = Math.floor(
      availableXAxisWidth / (chartGlobalOptions.xAxisLabelsWidth + this.minDistanceBetweenLabels)
    );

    this.chart?.updateOptions({
      xaxis: {
        tickAmount: this.getOptimalTickAmount(
          Math.round(lastLabel < this.minIntegerLabel ? lastLabel * 10 : lastLabel), // for actual labels apexchart rounds the actual numbers, so must I
          maxTickAmount
        ),
      },
    });
  }

  getOptimalTickAmount(lastLabel: number, maxTickAmount: number) {
    if (lastLabel / maxTickAmount > this.maxDistinguishableIncrement) {
      return maxTickAmount;
    }

    // if increment is indistinguishable with some tickAmount, then there is no reason in taking anything less than that
    const minTickAmount = Math.min(Math.max(Math.floor(lastLabel / this.maxDistinguishableIncrement), 1), maxTickAmount);

    // common algorithm of searching for divisors with some restrictions
    let lastDivisor = 1;
    for (let divisor = 1; divisor * divisor <= lastLabel && divisor <= maxTickAmount; ++divisor) {
      if (lastLabel % divisor === 0) {
        lastDivisor = divisor;
        const pairedDivisor = lastLabel / divisor;
        if (minTickAmount <= pairedDivisor && pairedDivisor <= maxTickAmount) {
          return pairedDivisor;
        }
      }
    }

    return lastDivisor === 1 || lastDivisor < minTickAmount ? minTickAmount : lastDivisor;
  }

  ngOnDestroy() {
    this.observer.unobserve(this.host.nativeElement);
  }
}
pavelbruyako commented 2 years ago

As the comment states, this directive should be applied to apexchart's sized parent, who can be observed with ResizeObserver. I had no luck applying it to an apexchart itself. So, the usage is:

<div adjustedAxesOfChildApexChart>
  <apx-chart
    [series]="series"
    [yaxis]="yaxis"
    [xaxis]="xaxis"
    [colors]="colors"
    [fill]="fill"
    [chart]="chart"
    [grid]="grid"
    [tooltip]="tooltip"
    [legend]="legend"
    [dataLabels]="dataLabels"
    [stroke]="stroke"
    [plotOptions]="plotOptions"
    [states]="states"
  ></apx-chart>
</div>
pavelbruyako commented 2 years ago

This workaround is not perfect, since there are still a bug with yaxis.max field being partially disregarded sometimes when there is .tickAmount set. I didn't go the whole way down through the apexchart's code to find out why that happens and I'll be grateful if someone can figure out that)) There is also a big problem with floating point numbers (example in the description). The algorithm of calculating yAxisScale should address them properly.