본문 바로가기

Programming/Manim Project

[컴포넌트]VerticalBarChart 만들기 (2. 핵심코드 설명)

반응형

글로벌 객체 생성을 모듈화 함

클래스의 생성자에서 바 차트를 구성하는 요소들을 생성하고, 이 루틴들을 각 메서드로 구분해서 모듈화 했습니다.

 

    def __init__(self, data=[], **kwargs):
        digest_config(self, kwargs, locals())
        super().__init__(**kwargs)

        self.read_data(data)
        self.setup_axes()   #x, y axis
        self.add_lines()    # background line and value text
        self.add_titles()
        self.add_bars()     # draw rectangle
        self.add_bar_names()    # name of bars below x_axis
        self.add_axes_titles()  # x/y axis title

 

digest_config 메서드는 이 클래스에 있는 CONFIG 딕셔너리를 전역 변수로 할당해주는 메서드입니다.

 

VerticalBarChart 클래스에 정의되어 있는 CONFIG 값은 아래와 같고, 바 차트의 속성들이 이 값에 의해 결정되고, 또한 외부에서 VerticalBarChart를 생성할 때 파라미터를 통해 이 값들을 지정할 수 있습니다.

 

    CONFIG = {
        "is_add_bars": False, # False: not add, later want to do animation
        "title": "Bar Chart", # or None
        "title_color": WHITE,
        "title_size": 0.6,
        "title_buff": 0.5,
        "title_font": None,

        "x_axis_length": 10,
        "x_min":0,
        "x_max":100,

        "x_axis_title": 'X values',
        "x_axis_title_color": WHITE,
        "x_axis_title_size": 0.4,
        "x_axis_title_buff": 0.2,

        "y_axis_title": 'Y values',
        "y_axis_title_color": WHITE,
        "y_axis_title_size": 0.4,
        "y_axis_title_buff": 0.2,

        "y_min": 0,
        "y_max": 100,
        "y_tick_frequency": 20,
        "y_axis_length": 5,


        "y_labels_color": WHITE,
        "y_labels_size": 0.3,
        "y_labels_buff": 0.2,
        "y_labels_decimal_places": 0,

        "graph_origin": np.array([-5,-2, 0]),

        "bar_names": ["A", "B", "C", "D", "E"],
        "bar_names_color": WHITE,
        "bar_names_buff": 0.2,
        "bar_names_size": 0.3,

        "bar_values": [30, 50, 80, 60, 40],
        "bar_values_position": UP, # UP: above the bar,  DOWN: below the bar
        "bar_values_buff": 0.3,  #buff to the bar edge
        "gap_ratio": 0.2,  # gap ratio with the rectangle's width -> gap_width / rect_width
        "edge_buff": 0.5,  # for the space between x_axis's start and first rectangle, last rect and the end

        "bar_colors": [RED, RED, RED, RED, RED],
        "bar_fill_opacity": 1.0,
        "bar_stroke_width": 0,

 

입력 데이터 읽기

막대를 그리기 위한 필수 요소는 1) 이름 2) 값 3) 막대 색상입니다. 이 값들을 각각 따로 입력하게 한다면 개수를 다르게 한다거나 누락이 생기거나 하는 사용자 실수가 있을 수 있기에, 3가지 값이 같이 움직이게 튜플로 구성하고, 이 튜플들로 구성된 리스트를 입력받는 구조가 낫겠습니다.

 

그런데, 컴포넌트 코드 내부에서는 리스트에 대한 튜플 접근을 하게 되면 변수명이 길어지기에, 3가지 요소를 각각의 리스트로 따로 관리해서 사용합니다. 이래야 코드가 조금이라도 간단해지니깐. 

 

따라서, 입력되는 튜플 리스트를 3개의 리스트로 변환하는 과정이 필요하고, 그것을 담당하는 메서드가 read_data입니다.

 

    def read_data(self, data):
        # data = [("A", 10, RED), ...]
        if len(data) == 0:
            return

        self.bar_names = [d[0] for d in data]
        self.bar_values = [d[1] for d in data]
        self.bar_colors = [d[2] for d in data]

 

축 만들기

바 차트에서는 x축과 y축의 표출이 필요하지는 않으나, 막대의 x 위치, 그리고 막대의 높이를 결정하기 위해서는 축척이 필요하고, 그러한 용도로 NumberLine을 사용해서, 보이지 않는 축을 만들었습니다.

 

    def setup_axes(self):
        # 1. generate x_axis, y_axis (not display itself)
        x_axis = NumberLine(
            x_min=self.x_min,
            x_max=self.x_max,
            unit_size=self.x_axis_length / 100,
        )
        x_axis.shift(self.graph_origin - x_axis.number_to_point(0))

        y_axis = NumberLine(
            x_min=self.y_min,
            x_max=self.y_max,
            unit_size=self.y_axis_length / (self.y_max - self.y_min),
        )
        y_axis.shift(self.graph_origin - y_axis.number_to_point(0))
        y_axis.rotate(np.pi / 2, about_point=y_axis.number_to_point(0))  # to y_min

        self.x_axis = x_axis
        self.y_axis = y_axis

 

가로선 만들기

세로 막대그래프이기에 y축이 높이를 나타내는 축이 되고, 이 y 값 구분선을 그려줘야 합니다.

 

선 들은 [y_min, y_max] 사이를 y_tick_frequency 간격마다 그려지게 됩니다. 

 

여기서, y_max에서도 그려지게 하기 위해서 약간의 트릭을 사용했습니다. `np.arange`에서 `end`에 해당하는 부분입니다.

for y in np.arange(self.y_min, self.y_max+(self.y_tick_frequency/2), self.y_tick_frequency):

 

라인의 왼쪽 편에는 해당 라인의 y축 값을 넣습니다.

    line_text = get_num_text(y)
    line_text.next_to(sp, LEFT, buff=self.y_labels_buff)

 

    def add_lines(self):
        def get_num_text(val):
            num=0
            if self.y_labels_decimal_places == 0:
                num = int(round(val))    # no floating point i.e 2
            else:
                num = round(val, self.y_labels_decimal_places)
            return Text(str(num), stroke_width=0, color=self.y_labels_color, size=self.y_labels_size)

        lines = VGroup()
        line_texts = VGroup()
        for y in np.arange(self.y_min, self.y_max+(self.y_tick_frequency/2), self.y_tick_frequency):
            sp = self.x_axis.number_to_point(self.x_min) * RIGHT
            sp += self.y_axis.number_to_point(y) * UP

            ep = self.x_axis.number_to_point(self.x_max) * RIGHT
            ep += self.y_axis.number_to_point(y) * UP

            line = Line(sp, ep, buff=0, color=GREY)
            line_text = get_num_text(y)
            line_text.next_to(sp, LEFT, buff=self.y_labels_buff)

            line_texts.add(line_text)
            lines.add(line)

        self.lines, self.line_texts = lines, line_texts
        self.add(self.lines, self.line_texts)

 

제목 넣기

self.title==None 이면 제목을 넣지 않고, self.title_font가 지정되어 있으면 그 폰트로 제목 텍스트를 만듭니다.

 

제목의 위치는 제일 위 편 가로선의 위 쪽입니다.

 

    def add_titles(self):
        if self.title == None:
            return

        if self.title_font == None:
            title_text = Text(self.title, stroke_width=0, color=self.title_color, 
            size=self.title_size)
        else:
            title_text = Text(self.title, stroke_width=0, color=self.title_color, 
            size=self.title_size, font=self.title_font)

        title_text.next_to(self.lines[-1], UP, buff=self.title_buff)

        self.title_text = title_text
        self.add(self.title_text)

 

막대 바 만들기

바 차트에서 가장 핵심 되는 부분입니다. 막대의 넓이를 어떻게 할지, 주어진 값에 해당하는 막대의 높이는 어떻게 되는지를 계산하는 로직을 만들어야 합니다.

 

막대 넓이 구하기

막대 넓이는 `선분 길이 = 전체 막대 넓이 + 전체 여백 넓이 + 양쪽 여백 넓이`이고, `여백 비율 = 여백/막대 넓이`를 이용해서 구할 수 있습니다.  

 

아래 그림에 그 수식을 구하는 방법을 적어 놨습니다.

 

코드로는,

        def cal_width(n, l, e, r):
            w = (l - 2*e) / (n + r*n - r)
            g = r*w
            return (w, g)

 

막대 높이 구하기

막대의 높이는 NumberLine으로 만든 y_axis에서, val일 때의 지점 값에서 0일 때의 지점 값을 빼면 구해지게 됩니다.

 

 

코드는,

        def cal_height():
            zero_point = self.y_axis.number_to_point(0)
            return [(self.y_axis.number_to_point(val) - zero_point)[1] for val in self.bar_values]

 

막대 정렬하기

위의 식으로 구한 넓이와 높이를 가진 막대들을 생성한 후, 이 막대들을 가로로 펼치고, x축의 위치로 내립니다

 

        bars.arrange(RIGHT, aligned_edge=DOWN ,buff=gap)
        shift_down_val = (bars.get_bottom() - self.x_axis.get_start())[1]
        shift_left_val = (bars.get_left() - self.x_axis.get_start())[0] - self.edge_buff
        bars.shift(DOWN * shift_down_val + LEFT * shift_left_val)

 

막대 생성에 대한 전체 소스 코드는,

    def add_bars(self):
        # n: number of bars, l:x_axis lenght, e: start/end edge, r: gap/width
        # Basic Idea: l = nw + (n-1)g + 2e  and w=rg
        def cal_width(n, l, e, r):
            w = (l - 2*e) / (n + r*n - r)
            g = r*w
            return (w, g)

        def cal_height():
            zero_point = self.y_axis.number_to_point(0)
            return [(self.y_axis.number_to_point(val) - zero_point)[1] for val in self.bar_values]

        def get_bar(idx, val, bar_w, bar_h):
            return Bar(id=idx, num_val=val, width=bar_w, height=bar_h,
                       decimal_position=self.bar_values_position, decimal_buff=self.bar_values_buff,
                       fill_color=self.bar_colors[idx], fill_opacity=self.bar_fill_opacity,
                       )

        #1. calculate the width/height for the bars and make bars
        bar_cnt = len(self.bar_names)
        bar_w, gap  = cal_width(bar_cnt, self.x_axis_length, self.edge_buff, self.gap_ratio)
        bar_h_list = cal_height()

        bars = VGroup(*[get_bar(idx, val, bar_w, bar_h)
                        for (idx, val), bar_h in zip(enumerate(self.bar_values), bar_h_list)])

        #2. arrange bars onto the axes
        bars.arrange(RIGHT, aligned_edge=DOWN ,buff=gap)
        shift_down_val = (bars.get_bottom() - self.x_axis.get_start())[1]
        shift_left_val = (bars.get_left() - self.x_axis.get_start())[0] - self.edge_buff
        bars.shift(DOWN * shift_down_val + LEFT * shift_left_val)

        self.bar_heights = bar_h_list
        self.bars = bars
        if self.is_add_bars:  # otherwise animates the bars with get_bar_animation
            self.add(self.bars)

 

막대에 대한 이름 넣기

각 막대의 밑 부분에 막대의 이름을 넣습니다. 

 

    def add_bar_names(self):
        def get_bar_names_text(str):
            return Text(str, stroke_width=0, size=self.bar_names_size, color=self.bar_names_color)

        bar_texts = VGroup(*[get_bar_names_text(name) for name in self.bar_names])

        for idx, bar_text in enumerate(bar_texts):
            bar_text.next_to(self.bars[idx], DOWN, buff=self.bar_names_buff)

        self.bar_texts = bar_texts
        self.add(self.bar_texts)

 

x축/y축의 이름 넣기

x축은 가로로 그대로 쓰면 되지만, y축 이름은 90도 회전해서 눕혀진 텍스트로 표현해야 합니다. 

 

        def get_x_text(str):
            return Text(str, stroke_width=0, color=self.x_axis_title_color, size=self.x_axis_title_size)

        def get_y_text(str):
            t = Text(str, stroke_width=0, color=self.y_axis_title_color, size=self.y_axis_title_size)
            return t.rotate(90 * DEGREES)

 

위치는, x축 이름은 막대 이름들 밑 편으로, y축 이름은 라인 값들 왼쪽 편으로 위치하게 하면 됩니다.

        x_title.next_to(self.bar_texts, DOWN, buff=self.x_axis_title_buff)
        y_title.next_to(self.line_texts, LEFT, buff=self.y_axis_title_buff)

 

축 이름 넣기 전체 코드는,

    def add_axes_titles(self):

        def get_x_text(str):
            return Text(str, stroke_width=0, color=self.x_axis_title_color, size=self.x_axis_title_size)

        def get_y_text(str):
            t = Text(str, stroke_width=0, color=self.y_axis_title_color, size=self.y_axis_title_size)
            return t.rotate(90 * DEGREES)

        x_title = get_x_text(self.x_axis_title)
        y_title = get_y_text(self.y_axis_title)

        x_title.next_to(self.bar_texts, DOWN, buff=self.x_axis_title_buff)
        y_title.next_to(self.line_texts, LEFT, buff=self.y_axis_title_buff)

        self.x_title, self.y_title = x_title, y_title
        self.add(self.x_title, self.y_title)

 

막대 표출에 대한 애니메이션 만들기

외부에서 막대 차트 컴포넌트를 이용해서 막대 차트를 만든 후, 이 막대 차트에 대해 애니메이션을 걸어 화면에 나타내야 하는데, 이때, 막대 차트 컴포넌트가 VGroup을 상속받아 만들기에, 전체 차트에 대해서 혹은 전체를 구성하는 하나하나 하부 객체에 대한 애니메이션은 가능하나 막대만을 표출하는 애니메이션을 외부에서 걸 수는 없습니다. 

 

그래서, 컴포넌트 자체 내에서 막대만을 표출하는 애니메이션을 만드는 메서드를 만들고, 이 메서드를 통해 외부에서 애니메이션을 얻어내서 사용하게 했습니다. 

 

즉, 외부에서 이러한 형태로 사용하는 겁니다.

self.play(bar_chart.get_bar_animation('GrowFromBottomLine'))

 

막대 차트 전체에 대해 FadeIn을 하게 되면 배경 라인/축 이름/막대 등이 다 같이 FadeIn 되어 나타납니다. 이 보다는, 배경 라인/축 이름 등은 미리 표출된 상태에서 막대만이 FadeIn 되거나 Grow 되는 것이 더 표현력이 좋기에, 위와 같이 별도로 애니메이션을 만드는 메서드를 만들어서 외부에서 얻어낼 수 있게 하는 것입니다.

 

 

애니메이션은 2가지를 지원합니다. 

 

하나는 막대의 바닥 한 점에서부터 위 쪽으로 확대되어 커지는 GrowFromEdgePoint, 다른 하나는 막대의 바닥 라인에서 막대의 높이가 자라듯이 커지는 GrowFromBottomLine 

 

[GrowFromEdgePoint]바닥의 한 점에서부터 커지는 효과

 

 

 

 

GrowFromEdgePoint 애니메이션

이 애니메이션은 마님(manim)이 이미 가지고 있는 GrowFromEdge 애니메이션을 막대들에 대해 적용합니다.

 

        if ani_type == 'GrowFromEdgePoint':  # grow from bottom point
            group = AnimationGroup(
                *[GrowFromEdge(bar, edge=DOWN) for bar in self.bars],
                lag_ratio = lag_ratio,
                run_time=run_time,
            )

 

GrowFromBottomLine 애니메이션

이 애니메이션은 막대의 넓이는 고정된 상태에서 높이만 자다듯이 커져야 합니다. 

 

따라서, 별도의 알파 함수를 만들어서, 애니메이션이 진행되는 동안 작은 높이에서부터 최종 막대 높이까지 서서히 커지게 합니다. 

 

알파 함수에 따라 애니메이션을 만드는 애니메이션 클래스는 UpdateFromAlphaFunc이고, 알파 함수에서는 alpha값이 애니메이션 진행 시간 동안에 0~1까지 변하기에, 이 alpha값을 이용해서 막대의 높이를 점차 커지게 하고, 예전 Rectangle이 새로운 높이로 바뀐 새로운 Rectangle가 되게끔 become 메서드를 쓰면 되겠습니다.  

        # function for GrowFromBottom animation
        def func(bar, alpha):
            # should be avoid zero case. if zero never displyed
            tgt_h = self.bar_heights[bar.id] * alpha + 0.01
            tgt_val = self.bar_values[bar.id] * alpha

            new_bar = bar.set_value_height(tgt_val, tgt_h)
            bar.become(new_bar)
        ...
        
        elif ani_type == 'GrowFromBottomLine': # grow from bottom line
            group = AnimationGroup(
                *[UpdateFromAlphaFunc(bar, func) for bar in self.bars],
                lag_ratio=lag_ratio,
                run_time=run_time,
            )   

 

AnimationGroup 파라미터

lag_ration : [0,1]까지 지정 가능하고, 막대들의 시작 시간 비율을 지정할 수 있습니다. 0일 때는 막대들이 동시에 커지고, 1이면 막대 하나가 다 커지고 그다음 막대가 커지는 효과를 보이고, 0.5이면 막대가 절반 정도 커진 상태에서 그다음 막대의 애니메이션이 시작되게 됩니다.

 

run_time: 전체 애니메이션 진행시간을 초 단위로 지정 가능합니다.

 

 

get_bar_animation 메서드에 대한 전체 코드는,  

    def get_bar_animation(self, ani_type='GrowFromBottomLine', lag_ratio=0.2, run_time=4):
        group = AnimationGroup()

        # function for GrowFromBottom animation
        def func(bar, alpha):
            # should be avoid zero case. if zero never displyed
            tgt_h = self.bar_heights[bar.id] * alpha + 0.01
            tgt_val = self.bar_values[bar.id] * alpha

            new_bar = bar.set_value_height(tgt_val, tgt_h)
            bar.become(new_bar)

        if ani_type == 'GrowFromEdgePoint':  # grow from bottom point
            group = AnimationGroup(
                *[GrowFromEdge(bar, edge=DOWN) for bar in self.bars],
                lag_ratio = lag_ratio,
                run_time=run_time,
            )
        elif ani_type == 'GrowFromBottomLine': # grow from bottom line
            group = AnimationGroup(
                *[UpdateFromAlphaFunc(bar, func) for bar in self.bars],
                lag_ratio=lag_ratio,
                run_time=run_time,
            )

        return group

 

애니메이션에 의해 표출된 막대들 없애기

is_add_bars=Faslse로 하고 get_animation_bar에 의해 막대를 표출했다면, 이 막대들을 화면에서 사라지게 하려면 remove_bars 혹은 fadeout_bars 메서드를 사용해야합니다. 

 

# 외부에서 막대들을 사라지게 할 때

chart.remove_bars(self)  # 이때 self는 Scene

#혹은
chart.fadeout_bars(self)

 

remove_bars와 fadeout_bars의 동작 핵심은, 애니메이션을 하면서 생성된 객체들을 Animation 클래스가 가지고 있기에, 이 객체들을 얻어내서 지우는 것입니다.

 

    def remove_bars(self, scene):
        scene.remove(self.animation_group.get_all_mobjects())

    def fadeout_bars(self, scene, run_time=1):
        scene.play(FadeOut(self.animation_group.get_all_mobjects()), run_time=run_time)

 

 

Bar 클래스

막대와 그 막대의 값을 가지고 있는 클래스를 따로 만들었습니다.

 

막대는 Rectangle로, 값은 DecimalNumber를 이용합니다.  Text가 아닌 DecimalNumber를 사용한 이유는 막대가 커질 때 값도 변하는 애니메이션을 쉽게 하기 위해서입니다. (DecimalNumber의 경우 set_value 메서드를 통해 값 변환이 쉬움)

 

두 객체를 모두 가지고 있어야기에 Bar 클래스를 VGroup을 상속받게 합니다. (VMobject가 아닌)

 

생성자

Bar 클래스가 생성될 때, 막대와 값을 나타내는 DecimalNumber를 만듭니다. 또한, 만들어지는 Bar를 구분하기 위해서 id 지정이 가능하게 합니다.

    def __init__(self, id=0, num_val=10, **kwargs):
        # digest_config(self, kwargs, locals())
        super().__init__(**kwargs)
        self.id = id
        self.num_val = num_val
        self.generate_objects()

 

숫자 값의 위치

DecimalNumber의 위치는 막대의 위 편이거나, 막대의 윗 라인의 바로 밑에 위치하게 되고, decimal_position 값에 의해 결정되게 됩니다.

    # move decimal to rect according to decimal_position. Reason to make method: called twice in other place
    def move_decimal_pos(self, decimal, rect):
        decimal.next_to(rect.get_top(), self.decimal_position, buff=self.decimal_buff)

 

 

막대의 높이/값 지정하는 메서드: set_value_height

막대의 높이와 DecimalNumber의 값을 지정하는 메서드를 만듭니다.

 

이유는, 막대가 커지는 애니메이션을 만들 때, 이 메서드를 호출해서 막대가 커지고 막대 값이 변하는 효과를 낼 수 있게 하기 위함입니다.

그러한 이유로, 높이 값을 바꾸고 난 후 객체 자신을 리턴합니다. 즉, 값이 변경된 자기 자신 객체를 리턴하는 겁니다.

    def set_value_height(self, decimal_value, rect_height ):
        self.rect.set_height(rect_height, stretch=True, about_edge=DOWN)
        self.decimal.set_value(decimal_value)

        self.move_decimal_pos(self.decimal, self.rect)
        return self

 

 

 

전체 Bar 클래스 소스 코드는,

# Bar = (rectangle + decimal) with id
class Bar(VGroup):
    CONFIG ={
        "width": 1.0,
        "height": 2.5,

        "fill_color": RED,
        "fill_opacity": 1,
        "stroke_width": 0,
        "stroke_opacity": 0,
        "stroke_color": GREY,

        "decimal_scale": 0.8,
        "decimal_color": YELLOW,
        "decimal_buff": 0.3,
        "decimal_position": UP,
        "num_decimal_places": 0,

        "id": 0,
        "num_val": 10,
    }

    def __init__(self, id=0, num_val=10, **kwargs):
        # digest_config(self, kwargs, locals())
        super().__init__(**kwargs)
        self.id = id
        self.num_val = num_val
        self.generate_objects()

    def generate_objects(self):
        #1. rectangle
        rect = Rectangle(
            width=self.width, height=self.height, fill_color=self.fill_color, fill_opacity=self.fill_opacity,
            stroke_opacity=self.stroke_opacity, stroke_color=self.stroke_color,
        )
        self.add(rect)

        #2. decimal number
        decimal = DecimalNumber(number=self.num_val, color=self.decimal_color, num_decimal_places=self.num_decimal_places,
                                background_stroke_color=self.decimal_color, background_stroke_opacity=0.4)
        decimal.scale(self.decimal_scale)

        self.move_decimal_pos(decimal, rect)
        self.add(decimal)

        self.rect = rect
        self.decimal = decimal

    def set_value_height(self, decimal_value, rect_height ):
        self.rect.set_height(rect_height, stretch=True, about_edge=DOWN)
        self.decimal.set_value(decimal_value)

        self.move_decimal_pos(self.decimal, self.rect)
        return self

    # move decimal to rect according to decimal_position. Reason to make method: called twice in other place
    def move_decimal_pos(self, decimal, rect):
        decimal.next_to(rect.get_top(), self.decimal_position, buff=self.decimal_buff)

 


핵심 코드에 대한 설명이었습니다.

 

다음 페이지에서는, BarChart 컴포넌트를 사용해서 어떻게 여러 가지 바 차트를 만드는지 그 사용법을 알아보겠습니다.

 

-끝-

반응형