만들려는 것은 막대그래프를 애니메이션 하는 컴포넌트입니다.
데이터를 그래프로 나타낼 때, 가장 간단하면서도 가장 많이 쓰이는 것이 막대그래프일 겁니다.
간단하면서도 데이터의 전달 효과가 좋기 때문입니다.
근데 그렇게 간단하다는 막대그래프도, 막상 잘 그리려면 쉽지 않고 고려해야 할 것이 많습니다.
(그게 뭔지는 여기 참조)
또한 애니메이션을 효과도 넣고 예쁘게 하려면, 엑셀이나 파워포인트로는 한계가 있습니다.
여기서는 방송사의 뉴스 같은데서 나오는, 전문 디자이너가 공들여서 만드는 멋진 막대그래프 애니메이션을, 데이터만 입력하면 다른 필요한 사항 없이 애니메이션이 작성되는 컴포넌트를 만들고, 그 과정도 소개하겠습니다.
설계
클래스 타입
VGroup을 상속해서 작성:
라인, 바 등 여러 객체가 사용될 것이기에 VMobject보다는 VGroup이 적절할 것임
세로형/가로형
세로형과 가로형에 대해 각각 클래스를 달리해서 작성:
같은 클래스로하고 파라미터로 달리하기에는, 바의 방향/애니메이션 처리 등이 너무 달라서, 다른 클래스로 처리하는 것이 코드 복잡도를 줄일 수 있을 것임
파라미터 처리 방법
CONFIG를 이용해서, 필요한 모든 파라미터 사전 정의하고, 외부에서 조정할 수 있게 할 것임
생성자
__init__(self, data=[], **kwargs)
입력값 data만 디폴트로 입력하게 노출 (입력안하면 샘플 데이터로 그림)
kwargs로 모든 CONFIG 파라미터 값 사용할 수 있게 함
입력 데이터
("name", value, color) 형태의 튜플이 여러 개인 리스트로 처리
ex) data = [("A", 10, RED), ...]
x축, y축
축 라인을 표출하지는 않으나, 축척을 위해서 NumberLine 사용
전역 객체들
-
x_axis, y_axis : x축 , y축
-
lines: y축의 값을 나타내는 백그라운드 가로 선
-
line_texts: 라인에 대한 값
-
title_text: 차트 제목
-
bars: 막대 들
-
bar_heights: 막대들의 높이 리스트
-
bar_texts: 막대들에 대한 표출 이름
-
x_title, y_title: x축 제목, y축 제목
애니메이션
get_bar_animation이라는 메서드를 통해 막대들이 표출되는 애니메이션을 얻어내서 play 할 수 있는 구조로 만듦
구현 소스
아래 소스코드를 원본 파일로도 다운로드 받을 수 있습니다. : plot.py
from manimlib.imports import *
class VerticalBarChart(VGroup):
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,
}
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
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]
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
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)
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 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)
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)
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, remover=True) for bar in self.bars],
*[UpdateFromAlphaFunc(bar, func) for bar in self.bars],
lag_ratio=lag_ratio,
run_time=run_time,
)
self.animation_group = group
return group
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 + 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)
다음 페이지에서는 소스코드 중 핵심 되는 부분에 대해 설명합니다.
'Programming > Manim Project' 카테고리의 다른 글
원 위의 점이 돌면서 사인/코사인 곡선 그리기 (0) | 2020.09.15 |
---|---|
원 위의 점이 이동하면서 사인 곡선 그리기 (1) | 2020.09.14 |
python의 for 루프 설명하기 (0) | 2020.05.31 |
[컴포넌트]VerticalBarChart 만들기 (3. 사용법) (0) | 2020.05.17 |
[컴포넌트]VerticalBarChart 만들기 (2. 핵심코드 설명) (0) | 2020.05.16 |