[ECharts] Bar 차트의 X축 라벨을 최대한 화면에 출력하는 방법

2023. 6. 6. 22:35재주껏 하는 Front-End/자바스크립트

반응형

ECharts의 Bar 차트에서 모든 데이터의 X축 라벨이 출력되어야 하는 경우 아래와 같이 라벨이 겹치는 문제가 발생한다. 기본적으로 ECharts의 X축에는 HideOverlap 속성이 true로 되어 있어 라벨이 겹치면 자동으로 라벨이 숨겨지는데, 때로는 모든 라벨이 출력되어야 하는 경우가 있다.

 

 

이 경우, 개발자가 X축 라벨의 너비를 자동으로 계산해서 겹치지 않도록 말 줄임표 (ellipsis) 처리를 하거나 라벨을 회전하여 더 많은 글자수가 출력되도록 동적으로 X축 옵션을 조절하는 것이 좋다. 나는 아래와 같은 순서대로 속성을 동적으로 변경하도록 기능을 구현하였다.

 

1. 기능 설계

 

X축 라벨의 최대 너비 (axisTick 한 칸의 최대 너비)를 구하기 위해 아래와 같이 계산식을 구상하였다.

 

1) 차트가 화면에 실제로 그려지는 영역을 구하기 위해 ClientWidth (상위 요소) - Grid.left - Grid.right를 뺀다.

2) 1) 번의 결과에서 Y축 라벨의 너비를 뺀다. Y축 라벨의 너비는 차트에 입력된 데이터에서 가장 큰 값을 찾은 후 fontSize * 0.6 * Y축 라벨 문자열 길이로 구한다.

⚠️ 참고 : Y축 라벨의 너비를 구할 때 0.6을 곱한 이유는 3자리 문자열의 너비가 19.41px로 측정되었기 때문이다. ECharts 라벨의 Default FontSize는 12px이기 때문에 문자열 "100"을 기준으로 보면 너비가 36px이 나와야 하는데, 그보다 작게 나왔다는 것은 배율이 1보다 작다는 의미다. 19.41 / 36을 하면 대략적으로 0.53이 나오지만, 폰트를 표시할 때 필요한 공간 (여백, 그림자 등) 을 감안하여 0.6으로 계산했다.

3) 1) 번과 2) 번의 수치는 Bar 차트의 실제 X축의 너비라고 볼 수 있다. X축 한 칸의 너비는 데이터 개수를 나눈 값이 되므로 데이터 개수로 나누어 axisTick 한 칸의 너비를 구한다. 이때, 오차를 줄이기 위해 양쪽에 있는 axisTick Line의 두께도 같이 빼준다.

 

위의 계산식을 기준으로 코드를 작성해 보았다.

 

2. 기능 구현

 

아래와 같이 ECharts의 속성을 변경하는 코드를 작성하였다.

 

_getResizeOptions() {
    let axisOption = {}
    
    // 0. 상위 요소의 clientWidth와 clientHeight 값 가져오기
    const wrapperWidth = this.$refs.chartWrapper.clientWidth
    const wrapperHeight = this.$refs.chartWrapper.clientHeight
    
    // 1. Y축 라벨의 너비 구하기 (위에 설계에서 1번에 해당)
    const yLabelWidth = String(Math.max(...this.chartData.map(item => item.value))).length * 12 * 0.6
    
    // 2. ECharts의 Grid.left와 Grid.right 구하기. 여기서 Grid의 값은 %로 설정 되었을때를 가정하였음.
    const xLabelLeftM = wrapperWidth * (parseInt(this.grid.left) / 100), xLabelRightM = wrapperWidth * (parseInt(this.grid.right) / 100)
    let xLabelWidth = (wrapperWidth - xLabelLeftM - xLabelRightM) / this.chartData.length
    
    // 3. 각 라벨끼리 겹칠 경우, 라벨을 돌릴 각도가 저장되는 변수
    let rotate = 0
    
    // 4. 차트 데이터
    const data = this.chartData.map(item => item.name)
    const count = data.length;
    
    // 5. 한 칸의 axisTick 너비 구하기. -2는 axisTick Line 1px * 2를 뜻함
    const axisWidth = wrapperWidth / count - 2
    
    // 6. 기준 라벨이 전, 후의 라벨과 겹쳐지는지 확인
    for (let i = 0; i < count; i++) {
        const label = data[i];
        const prevLabel = data[i - 1] ? data[i - 1] : '';
        const nextLabel = data[i + 1] ? data[i + 1] : '';

        const labelWidth = 12 * 0.6 * label.length
        const prevLabelWidth = prevLabel ? 12 * 0.6 * prevLabel.length : 0;
        const nextLabelWidth = nextLabel ? 12 * 0.6 * nextLabel.length : 0;
        
        // 6-1. 전, 후 라벨이 겹쳐지는 경우 라벨의 각도를 45도로 돌림
        if (prevLabel && nextLabel && (labelWidth + prevLabelWidth > axisWidth || labelWidth + nextLabelWidth > axisWidth)) {
            rotate = 45
            
            // 6-1-1. 상위 요소의 높이보다 너비가 크거나 같으면 크기 제한 (라벨이 차트보다 커지는 것을 방지)
            xLabelWidth = wrapperHeight <= xLabelWidth ? wrapperHeight : xLabelWidth
            break;
        } else {
            rotate = 0
        }
    }
    
    // 7. ECharts 옵션 수정
    axisOption = {
        xAxis: {
            data,
            axisLabel: {
                rotate,
                width: xLabelWidth,
                interval: 0,
                overflow: 'truncate'
            }
        }
    }
}

 

3. 결과 확인

 

위에서 작성한 코드를 ECharts 프로젝트에 적용하여 브라우저가 Resize 될 때 Bar 차트의 X축 라벨이 동적으로 변경되는지 확인해 보자.

 

 

뭐 여기서 조금만 더 다듬으면 유료 차트에서 볼 수 있는 자연스러운 라벨 변경이 가능하지 않을까 생각한다.

 

4. 소스 코드 (Vue 기반)

 

해당 코드는 Vue 2.7.X 샘플 프로젝트를 기반으로 한 예제 코드이다. (업무에서 사용하는 환경과 비슷하게 세팅하기 위해 최신 버전의 Vue나 Typescript는 사용하지 않았다.)

 

4-1. mixin.js

import * as ECharts from 'echarts'
import { debounce, cloneDeep } from 'lodash-es'

export default {
  data() {
    return {
      instance: null,
      chartData: [],
    }
  },
  props: {
    grid: {
      type: Object,
      default: () => ({
        top: '5%',
        left: '5%',
        right: '5%',
        bottom: '5%',
        containLabel: true,
      }),
    },
    data: {
      type: Array,
      default: () => [],
    },
    width: {
      type: Number,
      default: null,
    },
    height: {
      type: Number,
      default: null,
    },
    events: {
      type: Object,
      default: () => {},
    },
    options: {
      type: Object,
      default: () => {},
    },
    autoResize: {
      type: Boolean,
      default: false,
    },
  },
  async mounted() {
    await this.$nextTick()

    if (this.autoResize) {
      window.addEventListener('resize', this.onResizeHandler)
      this.onResizeHandler()
    }

    const width = this.$el.clientWidth
    const height = this.$el.clientHeight

    this.instance = ECharts.init(this.$refs.chartInstance, null, {
      width,
      height,
      renderer: 'svg',
    })

    this.chartData = this.cloneDeep(this.data)
    this._setOptions()
  },
  beforeDestroy() {
    if (this.autoResize) {
      window.removeEventListener('resize', this.onResizeHandler)
    }

    this.instance.clear()
    this.instance.dispose()
    this.instance = null

    this.chartData = []
  },
  methods: {
    debounce,
    cloneDeep,
    setSize(width, height) {
      this.instance.resize({ width, height })
    },
    setOption(options) {
      this.instance.setOption(options)
    },
    async onResizeHandler() {
      await this.$nextTick()

      const width = this.$refs.chartWrapper.clientWidth
      const height = this.$refs.chartWrapper.clientHeight

      this.setSize(width, height)

      if (this._getResizeOptions) {
        this._getResizeOptions(true)
      }
    },
  },
  watch: {
    data(value) {
      this.chartData = this.cloneDeep(value)
      this._setOptions()
    },
    options(value) {
      this._setOptions()
    }
  }
}

 

4-2. Charts Component

<template>
    <div ref="chartWrapper" class="chart-container">
        <div ref="chartInstance" class="chart-instnace" />
    </div>
</template>
  
<script>
import CommMixins from './mixins.js'

export default {
    name: 'chart-sample',
    mixins: [CommMixins],
    props: {
        type: {
            type: String,
            default: 'bar'
        }
    },
    methods: {
        async _setOptions() {
            await this.$nextTick()

            let chartOptions = {}

            const _getResizeOptions = this._getResizeOptions()

            if (this.type === 'bar') {
                chartOptions = {
                    grid: {
                        ...this.grid
                    },
                    series: [
                        {
                            type: 'bar',
                            data: this.chartData.map(item => item.value)
                        }
                    ],
                    ..._getResizeOptions,
                    ...this.options
                }
            } else if (this.type === 'column') {
                chartOptions = {
                    grid: {
                        ...this.grid
                    },
                    series: [
                        {
                            type: 'bar',
                            data: this.chartData.map(item => item.value)
                        }
                    ],
                    ..._getResizeOptions,
                    ...this.options
                }
            } else if (this.type === 'pie') {
                chartOptions = {
                    grid: {
                        ...this.grid
                    },
                    legend: {
                        left: 'right',
                        orient: 'vertical'
                    },
                    series: [
                        {
                            type: 'pie',
                            data: this.chartData
                        }
                    ],
                    tooltip: {
                        trigger: 'item'
                    },
                    ..._getResizeOptions,
                    ...this.options
                }
            }

            this.instance.setOption(chartOptions)
        },
        _getResizeOptions(immediately = false) {
            let axisOption = {}

            const wrapperWidth = this.$refs.chartWrapper.clientWidth
            const wrapperHeight = this.$refs.chartWrapper.clientHeight

            const yLabelWidth = String(Math.max(...this.chartData.map(item => item.value))).length * 12 * 0.6

            const xLabelLeftM = wrapperWidth * (parseInt(this.grid.left) / 100), xLabelRightM = wrapperWidth * (parseInt(this.grid.right) / 100)
            let xLabelWidth = (wrapperWidth - xLabelLeftM - xLabelRightM) / this.chartData.length

            if (this.type === 'bar') {
                let rotate = 0

                const data = this.chartData.map(item => item.name)
                const count = data.length;
                const axisWidth = wrapperWidth / count - 2

                for (let i = 0; i < count; i++) {
                    const label = data[i];
                    const prevLabel = data[i - 1] ? data[i - 1] : '';
                    const nextLabel = data[i + 1] ? data[i + 1] : '';

                    const labelWidth = 12 * 0.6 * label.length
                    const prevLabelWidth = prevLabel ? 12 * 0.6 * prevLabel.length : 0;
                    const nextLabelWidth = nextLabel ? 12 * 0.6 * nextLabel.length : 0;

                    if (prevLabel && nextLabel && (labelWidth + prevLabelWidth > axisWidth || labelWidth + nextLabelWidth > axisWidth)) {
                        rotate = 45
                        xLabelWidth = wrapperHeight <= xLabelWidth ? wrapperHeight : xLabelWidth
                        break;
                    } else {
                        rotate = 0
                    }
                }

                axisOption = {
                    xAxis: {
                        data,
                        axisLabel: {
                            rotate,
                            width: xLabelWidth,
                            interval: 0,
                            overflow: 'truncate'
                        }
                    }
                }
            } else if (this.type === 'column') {
                const yLabelWidth = wrapperWidth * 0.3

                axisOption = {
                    xAxis: {
                        axisLabel: {
                            hideOverlap: true
                        }
                    },
                    yAxis: {
                        data: this.chartData.map(item => item.name),
                        axisLabel: {
                            width: yLabelWidth,
                            overflow: 'truncate'
                        }
                    },
                }
            } else {
                axisOption = {
                    xAxis: {
                        show: false
                    },
                    yAxis: {
                        show: false
                    },
                }
            }

            if (immediately) {
                this.instance.setOption(axisOption)
            }

            return axisOption
        }
    },
    watch: {
        type() {
            this._setOptions()
        }
    }
}
</script>
  
<style scoped>
.chart-container {
    width: 100%;
    height: 100% !important;
}
</style>

 

혹시나 같은 문제로 고민하고 있는 개발자들에게 도움이 됬으면 하는 마음으로 올려본다.

반응형