ng2-nvd3를 활용하여 스파크라인 차트 만들기 (Creating Sparkline Chart Directive with ng2-nvd3)

4 분 소요

보통의 차트 라이브러리가 Sparkline Chart를 지원하지 않아 (특히 Angular를 지원하는 차트 라이브러리) 직접 만들어 사용해보려고 합니다.
다양한 차트 라이브러리 중 ng2-nvd3로 그린 차트가 가장 마음에 들어서 이 라이브러리를 활용하여 만들어 보겠습니다.
손쉬운 재활용을 위해 Directive로 제작하였습니다.

필요사항

nvd3를 활용하되 angular 에서 사용하려면 ng2-nvd3를 install 해야합니다.

npm install ng2-nvd3

특징

  • Sparkline Chart는 하나의 라인에 기준점을 기준으로 상, 하에 각기 다른 색을 지정할 수 있는 차트를 의미합니다. 일반 라인 차트는 이러한 기능을 제공하지 않습니다.
  • 같은 차트 2개를 겹쳐 그리고 한쪽 차트의 칠하는 범위를 반전한 뒤 기준점을 기준으로 잘라내는 방법을 사용합니다.
  • nvd3 명령을 사용하지 않고 dom에 직접 svg를 그려보려고 합니다.

Directive params

Directive 호출할 때 사용할 옵션 입니다. default가 없는 파라미터는 반드시 채워 넣어야 합니다.


<div id="sparklineThreshold"
        [sparklineThreshold]="sparklineThresholddata"  
        [height]="100" 
        [width]="400" 
        [type]="linear" 
        [threshold]="100" 
        [strokecolors]="['red','blue']" 
        [fillcolors]="['#da343452', '#c7daea']">
</div>

설명

  • id: 여러 directive를 한 페이지에서 동시에 활용하기 위해 dom은 반드시 id를 가지도록 설계하였습니다.
  • sparklineThreshold: directive 이름 입니다, 여기에 chart data를 전송하면 됩니다. 데이터는 Array<number>로 구조로 보냅니다.
  • height: svg의 높이
  • width: svg의 길이. width를 기준으로 x축을 데이터 길이에 따라 나누어 만들어 냅니다.
  • type: linear와 basis 두 가지가 있는데 default는 linear를 적용합니다. basis는 곡선으로 그려주는 옵션입니다.
  • threshold: 기준점. 상, 하 차트를 그리는 기준점이 되므로 이 기준에 따라 색의 적용 범위가 결정됩니다.
  • strokecolors: 상, 하 차트의 선 색. default는 ‘red’, ‘blue’
  • fillcolors: 상, 하 차트의 배경색. default는 ‘#da343452’, ‘#c7daea’

데이터 구성

  • threshold: min, max값을 넘지 않도록 하고 이 값을 비율로 계산합니다.
this.maxData = Math.max(...this.rawData);
this.minData = Math.min(...this.rawData);

// threshold가 max를 넘지 않는 범위에서 퍼센트로 변경
let threshold = (this.rawThreshold > this.maxData) ? this.maxData : this.rawThreshold;
threshold = (this.rawThreshold < this.minData) ? this.minData : this.rawThreshold;
this.threshold = Math.round((1 - (this.maxData - threshold) / this.maxData) * 100);
  • x축: width를 기준으로 구성.
  • y축: 최대값을 기준으로 비율로 구성.
const dataLength = this.rawData.length;
const step = Math.round(this.width / this.rawData.length);
let i = 0;

for (const item of this.rawData) {
    this.data.push({
        x: i, y: Math.round((1 - (this.maxData - item) / this.maxData) * 100)
    });
    i += step;
}

그래프 그리기

  • 동일한 두 개의 그래프를 그립니다.
  • domain을 min 부터 max 데이터까지 범위를 설정합니다.
  • range를 height 부터 0까지 범위를 설정합니다.

상한가 그래프

  • clipPath의 y를 0. height를 scale된 threshold 로 설정합니다.
  • path에서 clipPath를 설정합니다.
  • clipPath에 의해 위쪽 데이터만 구현됩니다.

하한가 그래프

  • clipPath의 y를 scale된 threshold 로 설정하고, height는 maxData로 설정합니다.
  • path에서 clipPath를 설정합니다.
  • clipPath에 의해 아래쪽 데이터만 구현됩니다.

전체소스

import { Directive, Input, ViewContainerRef, AfterViewInit } from '@angular/core';
declare let d3: any;

@Directive({
    selector: '[sparklineThreshold]'
})

export class SparkLineThresholdDirective implements AfterViewInit {
    rawData = [];
    data = [];
    maxData = 0;
    minData = 0;
    maxWidth = 0;
    rawThreshold = 0;
    threshold = 0;
    topStrokeColor = 'red';
    bottomStrokeColor = 'blue';
    topFillColor = '#da343452';
    bottomFillColor = '#c7daea';
    elementId;

    // 실 데이터 = [ 숫자 ] 형태
    @Input('sparklineThreshold') set setData(data: Array<number>) {
      this.rawData = data;
      
      // 여기는 테스트
     // const numPoints = 50;
     //   for (let i = 1; i < numPoints; i++) {
     //       const rnd = Math.floor(Math.random() * (250)) + 1;
     //      this.rawData.push(rnd);
      //  }
    }

    @Input('width') width: number;
    @Input('height') height: number;
    @Input('type') type: string;
    @Input('threshold') set setThreshold(threshold: number) { this.rawThreshold = threshold; }
    @Input('strokecolors') set setStrokeTopColor(colors: Array<string>) {
        this.topStrokeColor = colors[0];
        this.bottomStrokeColor = colors[1];
    }
    @Input('fillcolors') set setFillColors(colors: Array<string>) {
        this.topFillColor = colors[0];
        this.bottomFillColor = colors[1];
    }

    constructor(private viewContainer: ViewContainerRef) {
        this.elementId = this.viewContainer.element.nativeElement.id;
    }

    // rawdata가 있어야 하고, id가 있어야 한다.
    ngAfterViewInit() {
        if (!this.rawData || !this.rawData.length) {
            console.log('rawData are required');
            return;
        }
        if (!this.elementId) {
            console.error('element Id is required');
            return;
        }

        // 초기값
        this.maxData = Math.max(...this.rawData);
        this.minData = Math.min(...this.rawData);
        // threshold가 max를 넘지 않는 범위에서 퍼센트로 변경
        let threshold = (this.rawThreshold > this.maxData) ? this.maxData : this.rawThreshold;
        threshold = (this.rawThreshold < this.minData) ? this.minData : this.rawThreshold;
        this.threshold = (100 - Math.round(((this.maxData - threshold) / this.maxData) * 100));

        this.type = this.type ? this.type : 'linear';
        this.setSparklineData();
        this.drawSparkLine();

        // resize https://github.com/novus/nvd3/issues/645
    }

    // rawData를 mapping 한다.
    setSparklineData() {
        const dataLength = this.rawData.length;
        const step = Math.round(this.width / this.rawData.length);

        let i = 0;
        for (const item of this.rawData) {
            this.maxWidth = step * i;

            this.data.push({
                x: this.maxWidth, y: (100 - Math.round(((this.maxData - item) / this.maxData) * 100))
            });

            i++;
        }
    }

    // 그리자
    drawSparkLine() {
        const svg = d3.select(this.viewContainer.element.nativeElement).append('svg');
        svg.attr('width', this.width).attr('height', this.height + 5); 
        // 안보이는 margin이 있어서 5 정도 여유를 두어야 한다. 
        // 안그러면 끝점이 가려지는 부분이 생긴다.

        const draw = svg.append('g');

        const yScale = d3.scale.linear()
        .domain([this.minData, this.maxData])
        .range([this.height, 0]);

        // top
        const setTopLine = d3.svg.area()
        .x((d) => d.x)
        .y((d) => yScale(d.y))
        .y0(this.height)
        .interpolate(this.type);

        draw.append('clipPath')
        .attr('id', this.elementId + '-top')
        .append('rect')
        .attr('x', 1) // area라 0점에서 시작하기 때문에 좌측 시작점을 가리기 위해 1을 준다.
        .attr('y', 0)
        .attr('rx', 0)
        .attr('ry', 0)
        .attr('height', yScale(this.threshold))
        .attr('width', this.maxWidth - 2); // area라 끝점에서 0으로 수렴하는 부분을 가리기 위해 2를 뺀다.

        draw.append('path')
        .data([this.data])
        .attr('d', setTopLine)
        .attr('clip-path', 'url(#' + this.elementId + '-top)')
        .attr('stroke', this.topStrokeColor)
        .attr('stroke-width', 1)
        .attr('fill', this.topFillColor);

        // bottom
        const setBottomLine = d3.svg.area()
        .x((d) => d.x)
        .y((d) => yScale(d.y))
        .y0(this.threshold)
        .interpolate(this.type);

        draw.append('clipPath')
        .attr('id', this.elementId + '-bottom')
        .append('rect')
        .attr('x', 1) // area라 0점에서 시작하기 때문에 좌측 시작점을 가리기 위해 1을 준다.
        .attr('y', yScale(this.threshold))
        .attr('rx', 0)
        .attr('ry', 0)
        .attr('height', this.maxData)
        .attr('width', this.maxWidth - 2); // area라 끝점에서 0으로 수렴하는 부분을 가리기 위해 2를 뺀다.

        draw.append('path')
        .data([this.data])
        .attr('d', setBottomLine)
        .attr('clip-path', 'url(#' + this.elementId + '-bottom)')
        .attr('stroke', this.bottomStrokeColor)
        .attr('stroke-width', 1)
        .attr('fill', this.bottomFillColor);
    }
}

끝.

댓글남기기