swimlane/ngx-charts

View on GitHub
projects/swimlane/ngx-charts/src/lib/common/axes/y-axis-ticks.component.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  Component,
  Input,
  Output,
  OnChanges,
  ElementRef,
  ViewChild,
  EventEmitter,
  AfterViewInit,
  ChangeDetectionStrategy,
  SimpleChanges,
  PLATFORM_ID,
  Inject
} from '@angular/core';
import { trimLabel } from '../trim-label.helper';
import { getTickLines, reduceTicks } from './ticks.helper';
import { roundedRect } from '../../common/shape.helper';
import { isPlatformBrowser } from '@angular/common';
import { Orientation } from '../types/orientation.enum';
import { TextAnchor } from '../types/text-anchor.enum';

@Component({
  selector: 'g[ngx-charts-y-axis-ticks]',
  template: `
    <svg:g #ticksel>
      <svg:g *ngFor="let tick of ticks" class="tick" [attr.transform]="transform(tick)">
        <ng-container *ngIf="tickFormat(tick) as tickFormatted">
          <title>{{ tickFormatted }}</title>
          <svg:text
            stroke-width="0.01"
            [attr.dy]="dy"
            [attr.x]="x1"
            [attr.y]="y1"
            [attr.text-anchor]="textAnchor"
            [style.font-size]="'12px'"
          >
            <ng-container *ngIf="wrapTicks; then tmplMultilineTick; else tmplSinglelineTick"></ng-container>
          </svg:text>

          <ng-template #tmplMultilineTick>
            <ng-container *ngIf="tickChunks(tick) as tickLines">
              <ng-container *ngIf="tickLines.length > 1; else tmplSinglelineTick">
                <svg:tspan *ngFor="let tickLine of tickLines; let i = index" x="0" [attr.y]="i * (8 + tickSpacing)">
                  {{ tickLine }}
                </svg:tspan>
              </ng-container>
            </ng-container>
          </ng-template>

          <ng-template #tmplSinglelineTick>
            {{ tickTrim(tickFormatted) }}
          </ng-template>
        </ng-container>
      </svg:g>
    </svg:g>

    <svg:path
      *ngIf="referenceLineLength > 1 && refMax && refMin && showRefLines"
      class="reference-area"
      [attr.d]="referenceAreaPath"
      [attr.transform]="gridLineTransform()"
    />
    <svg:g *ngFor="let tick of ticks" [attr.transform]="transform(tick)">
      <svg:g *ngIf="showGridLines" [attr.transform]="gridLineTransform()">
        <svg:line
          *ngIf="orient === Orientation.Left"
          class="gridline-path gridline-path-horizontal"
          x1="0"
          [attr.x2]="gridLineWidth"
        />
        <svg:line
          *ngIf="orient === Orientation.Right"
          class="gridline-path gridline-path-horizontal"
          x1="0"
          [attr.x2]="-gridLineWidth"
        />
      </svg:g>
    </svg:g>

    <svg:g *ngFor="let refLine of referenceLines">
      <svg:g *ngIf="showRefLines" [attr.transform]="transform(refLine.value)">
        <svg:line
          class="refline-path gridline-path-horizontal"
          x1="0"
          [attr.x2]="gridLineWidth"
          [attr.transform]="gridLineTransform()"
        />
        <svg:g *ngIf="showRefLabels">
          <title>{{ tickTrim(tickFormat(refLine.value)) }}</title>
          <svg:text
            class="refline-label"
            [attr.dy]="dy"
            [attr.y]="-6"
            [attr.x]="gridLineWidth"
            [attr.text-anchor]="textAnchor"
          >
            {{ refLine.name }}
          </svg:text>
        </svg:g>
      </svg:g>
    </svg:g>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class YAxisTicksComponent implements OnChanges, AfterViewInit {
  @Input() scale;
  @Input() orient: Orientation;
  @Input() tickArguments: number[] = [5];
  @Input() tickValues: string[] | number[];
  @Input() tickStroke = '#ccc';
  @Input() trimTicks: boolean = true;
  @Input() maxTickLength: number = 16;
  @Input() tickFormatting;
  @Input() showGridLines: boolean = false;
  @Input() gridLineWidth: number;
  @Input() height: number;
  @Input() referenceLines;
  @Input() showRefLabels: boolean = false;
  @Input() showRefLines: boolean = false;
  @Input() wrapTicks = false;

  @Output() dimensionsChanged = new EventEmitter();

  innerTickSize: number = 6;
  tickPadding: number = 3;
  tickSpacing: number;
  verticalSpacing: number = 20;
  textAnchor: TextAnchor = TextAnchor.Middle;
  dy: string;
  x1: number;
  x2: number;
  y1: number;
  y2: number;
  adjustedScale: any;
  transform: (o: any) => string;
  tickFormat: (o: any) => string;
  ticks: any[];
  width: number = 0;
  outerTickSize: number = 6;
  rotateLabels: boolean = false;
  refMax: number;
  refMin: number;
  referenceLineLength: number = 0;
  referenceAreaPath: string;

  readonly Orientation = Orientation;

  @ViewChild('ticksel') ticksElement: ElementRef;

  constructor(@Inject(PLATFORM_ID) private platformId: any) {}

  ngOnChanges(changes: SimpleChanges): void {
    this.update();
  }

  ngAfterViewInit(): void {
    setTimeout(() => this.updateDims());
  }

  updateDims(): void {
    if (!isPlatformBrowser(this.platformId)) {
      // for SSR, use approximate value instead of measured
      this.width = this.getApproximateAxisWidth();
      this.dimensionsChanged.emit({ width: this.width });
      return;
    }

    const width = parseInt(this.ticksElement.nativeElement.getBoundingClientRect().width, 10);
    if (width !== this.width) {
      this.width = width;
      this.dimensionsChanged.emit({ width });
      setTimeout(() => this.updateDims());
    }
  }

  update(): void {
    const scale = this.scale;
    const sign = this.orient === Orientation.Top || this.orient === Orientation.Right ? -1 : 1;
    this.tickSpacing = Math.max(this.innerTickSize, 0) + this.tickPadding;

    this.ticks = this.getTicks();

    if (this.tickFormatting) {
      this.tickFormat = this.tickFormatting;
    } else if (scale.tickFormat) {
      // eslint-disable-next-line prefer-spread
      this.tickFormat = scale.tickFormat.apply(scale, this.tickArguments);
    } else {
      this.tickFormat = function (d) {
        if (d.constructor.name === 'Date') {
          return d.toLocaleDateString();
        }
        return d.toLocaleString();
      };
    }

    this.adjustedScale = scale.bandwidth
      ? d => {
          // position the tick to middle considering number of lines of the tick
          const positionMiddle = scale(d) + scale.bandwidth() * 0.5;
          if (this.wrapTicks && d.toString().length > this.maxTickLength) {
            const chunksLength = this.tickChunks(d).length;

            if (chunksLength === 1) {
              return positionMiddle;
            }

            const bandWidth = scale.bandwidth();
            const heightOfLines = chunksLength * 8;
            const availableFreeSpace = bandWidth * 0.5 - heightOfLines * 0.5;

            return scale(d) + availableFreeSpace;
          }

          return positionMiddle;
        }
      : scale;

    if (this.showRefLines && this.referenceLines) {
      this.setReferencelines();
    }

    switch (this.orient) {
      case Orientation.Top:
        this.transform = function (tick) {
          return 'translate(' + this.adjustedScale(tick) + ',0)';
        };
        this.textAnchor = TextAnchor.Middle;
        this.y2 = this.innerTickSize * sign;
        this.y1 = this.tickSpacing * sign;
        this.dy = sign < 0 ? '0em' : '.71em';
        break;
      case Orientation.Bottom:
        this.transform = function (tick) {
          return 'translate(' + this.adjustedScale(tick) + ',0)';
        };
        this.textAnchor = TextAnchor.Middle;
        this.y2 = this.innerTickSize * sign;
        this.y1 = this.tickSpacing * sign;
        this.dy = sign < 0 ? '0em' : '.71em';
        break;
      case Orientation.Left:
        this.transform = function (tick) {
          return 'translate(0,' + this.adjustedScale(tick) + ')';
        };
        this.textAnchor = TextAnchor.End;
        this.x2 = this.innerTickSize * -sign;
        this.x1 = this.tickSpacing * -sign;
        this.dy = '.32em';
        break;
      case Orientation.Right:
        this.transform = function (tick) {
          return 'translate(0,' + this.adjustedScale(tick) + ')';
        };
        this.textAnchor = TextAnchor.Start;
        this.x2 = this.innerTickSize * -sign;
        this.x1 = this.tickSpacing * -sign;
        this.dy = '.32em';
        break;
      default:
    }
    setTimeout(() => this.updateDims());
  }

  setReferencelines(): void {
    this.refMin = this.adjustedScale(
      Math.min.apply(
        null,
        this.referenceLines.map(item => item.value)
      )
    );
    this.refMax = this.adjustedScale(
      Math.max.apply(
        null,
        this.referenceLines.map(item => item.value)
      )
    );
    this.referenceLineLength = this.referenceLines.length;

    this.referenceAreaPath = roundedRect(0, this.refMax, this.gridLineWidth, this.refMin - this.refMax, 0, [
      false,
      false,
      false,
      false
    ]);
  }

  getTicks(): any[] {
    let ticks;
    const maxTicks = this.getMaxTicks(20);
    const maxScaleTicks = this.getMaxTicks(50);

    if (this.tickValues) {
      ticks = this.tickValues;
    } else if (this.scale.ticks) {
      ticks = this.scale.ticks.apply(this.scale, [maxScaleTicks]);
    } else {
      ticks = this.scale.domain();
      ticks = reduceTicks(ticks, maxTicks);
    }

    return ticks;
  }

  getMaxTicks(tickHeight: number): number {
    return Math.floor(this.height / tickHeight);
  }

  tickTransform(tick: number): string {
    return `translate(${this.adjustedScale(tick)},${this.verticalSpacing})`;
  }

  gridLineTransform(): string {
    return `translate(5,0)`;
  }

  tickTrim(label: string): string {
    return this.trimTicks ? trimLabel(label, this.maxTickLength) : label;
  }

  getApproximateAxisWidth(): number {
    const maxChars = Math.max(...this.ticks.map(t => this.tickTrim(this.tickFormat(t)).length));
    const charWidth = 7;
    return maxChars * charWidth;
  }

  tickChunks(label: string): string[] {
    if (label.toString().length > this.maxTickLength && this.scale.bandwidth) {
      // for y-axis the width of the tick is fixed
      const preferredWidth = this.maxTickLength;
      const maxLines = Math.floor(this.scale.bandwidth() / 15);

      if (maxLines <= 1) {
        return [this.tickTrim(label)];
      }

      return getTickLines(label, preferredWidth, Math.min(maxLines, 5));
    }

    return [this.tickFormat(label)];
  }
}