电报图表竞赛

因此,有一天,我从Telegram收到了有关他们将举行的比赛的消息。 在上面我附加了他们提供的示例的gif图像。 是的,没有更多关于它应该如何工作的信息,只有gif。 该应用程序可以适用于iOS,Android或网络。 我决定为iOS开发它-为什么不这样做。 这是关于我制作的应用,使用的技术和体系结构的故事。 需要注意的是,我在这里描述的版本使用Metal作为图表渲染器。 在比赛中,我使用了CoreGraphics-由于有两个孩子和大量工作,所以我没有机会及时完成。 这会很长(duhh),让我们开始吧。

目录

制备

建筑

  1. 数据源
  2. 渲染阶段
  3. 主持人

实作

  1. X坐标
  2. Y坐标
  3. 亮点渲染
  4. 信息板
  5. 图表渲染

下一场比赛

结论

制备

首先,让我描述一下我使用的架构。 更改几乎没有迭代,但最终结果如下所示:

它分为三部分。

  1. 渲染阶段。 这部分包含提供渲染环境的所有代码。
  2. 主持人。 它配置渲染所需的所有零件
  3. 数据提供者。 它负责提供应显示的数据。

它可以使您想起MVC(MVP,MVVM,MVCCPVMMP?),而且还可以。

主控制器创建演示者和数据提供者,然后将图表视图(由演示者提供)放置在希望显示的位置。 因此,对于此电报图表,我们将有两个演示者,一个用于大图表视图,一个用于小型视图,以及一个数据提供者,因为它们显示的数据是相同的。

在详细介绍之前,我想介绍视口的概念。 这个概念并不是什么新鲜事物,所有游戏都使用它。 基本上,它是相机在某个坐标空间内。 在我们的例子中,就是图表坐标空间中的摄影机,我们将进行移动,收缩,扩展和移动。 然后,我们将仅显示它所看到的坐标空间的一部分。 它将大大简化渲染的过程。 这将使我们做一个非常简单的渲染循环:视图控制器更新presenter的视图端口(假设用户移动了滑块),presenter从数据源获取此视图端口的数据切片。 调整视口,例如,如果视口不能垂直容纳数据,则更新视口高度。 然后,只需将此视图端口传递到渲染阶段,并使用新的数据切片更新渲染器。 就是这样,现在渲染器具有要为视口渲染的新数据部分,也有要渲染数据的新视口。

我稍后将描述一些微妙之处,但是总体流程看起来完全像这样。

为了方便起见,我在下面附上了示意图,其中显示了组件之间的交互方式。 不要害怕,我将描述所有部分以及我们为什么需要它们。

现在,让我们研究一下该体系结构的每个部分以及如何使用它。

建筑

数据源

此组件是最直接的组件,其主要目的是为演示者提供数据。 它由演示者的外部用户创建并提供给演示者。 演示者可以要求某个范围内的图表切片(可视端口的可视区域),数据源应提供该切片。

关于它的复杂之处在于,我想考虑一下图表上可能有很多点,也许是数千个。 因此,我决定有一些逻辑来稀疏数据。 逻辑就是这样。 首先,我希望不再有N点,一次可以看到图表。 为此,我们可以为每个比例设置几个版本的图表数据。 例如,当视口处于其最小宽度时(并且在此比例级别上不再可见,则N个点可见),我们可以拥有所有点的版本。 当用户开始缩放视口的宽度时,在某些点上会更多,然后将显示N个点。 在这种情况下,我们从海图数据中消除点,直到获得少于N点的视口为止。 等等。 这意味着我们将只有很少的比例级别,并且每个级别都将具有不同稀疏性级别的图表数据版本。 用户看到的区域越多,我们显示的图表数据的稀疏版本越多。

好了,现在就开始稀疏吧。 最受欢迎的是Ramer–Douglas–Peucker算法。 本质上这很简单。 您可以获取第一个和最后一个点并将其添加到结果中。 这两点成一条线。 接下来,我们遍历这些点之间的所有点,然后选择离它们所形成的线最远的点。 该距离被认为是重要的,如果更高,则将某个值指定为算法的输入,然后将其添加到结果中。 然后在添加点的情况下递归重复这些步骤。

该算法不符合我的需求,因为我想传递最大点数(N),并且该算法应为我提供具有N个最大重要点的数据。 此外,算法的递归性质可能会使堆栈溢出。

该算法采用的版本如下所示。 我们有两个数据结构来保存计算结果。 第一个只是保留我们要考虑但尚未找到最重要要点的数据范围的数组。 另一个是堆,其中保存了我们已经计算出最重要点的数据范围(heap max的范围是点,在所有范围中,点最重要)。 直到我们具有任何结构的数据为止,我们才进行迭代。 首先,如果我们在range数组中有数据,我们将计算每个范围的重要点并将其放入堆中。 当数组中没有更多范围时,我们从堆中弹出一个范围并将其点添加到结果中(这意味着这是到目前为止我们计算出的所有范围中最重要的点)。 我们通过将考虑的这一点拆分为一个范围来创建新范围,并将其添加到range数组。 然后,一切重复。 让我给你和例子。

首先,我们有一个范围,即第一个点和最后一个点的范围。 我们放入range数组。 算法将这个范围超出范围数组,并计算重要点(我们称其为P),并将此范围放入堆中。 现在,数组中不再有范围,因此我们开始弹出堆。 我们弹出刚刚放入的范围,将刚刚计算的点放入结果中,并创建两个新范围(第一个,P),(P,最后一个),然后将它们放入ranges数组。 由于ranges数组不为空,因此我们开始对其进行处理。 再次,对于第一个范围(开始; P),我们计算大多数导入蚂蚁点(我们将其称为A),对于第二个范围(我们将其称为B点)进行计算,然后将它们放入堆中。 在不失一般性的前提下,我们可以说A更重要,然后是B(距离(开始,P)所组成的行更远,然后,B是由(P,最后)所构成的行,则更重要。 Ranges数组为空,我们开始弹出堆。 因为A更重要,所以B得到了它的范围,我们将A添加到结果中,为数组添加两个新范围,然后再次开始处理数组。 重复所有这些操作,直到处理完所有数据点或找到N个最重要的点为止。

为什么这样做? 嗯,这实际上很简单,我们总是首先将所有最重要的要点添加进去。 更严格地说,堆顶部始终指向最重要的范围。

原始算法在最坏的情况下具有O(N²)复杂度,在平均情况下具有O(Nlog(N))。 快速排序的作用非常相似,因此在这里并不奇怪。 我不是100%知道我的,但是应该是这样。 不会再有N / 2个范围,并且每个范围都将被推入并弹出一次(堆的两个都有log(N)),这将使O((N / 2)* log(N / 2)+N²)最坏的情况与原始情况相同,平均O((N / 2)* log(N / 2)+ Nlog(N))也与原始情况相同。

这就是它。 我们有稀疏算法,现在为了具有不同的稀疏度,我们创建了L(比例等级数)数据变体。 然后,我们发现的每个重要点都会添加到每个数据变量中。 如果某个变体已满,我们将停止向其添加点。

渲染阶段

渲染阶段是试图通过为其提供便捷的api来隐藏渲染的所有平台相关部分的部分。 它的意思是,如果我想为Android制作图表应用,基本上只有这一部分会发生变化,并且特定的渲染调用(例如metal或CoreGraphics)也会发生变化,而其他部分我只会复制粘贴并转换为Kotlin或smth那。

首先,它提供了演示者可以传递给外部用户的主视图,可以用于非Core Animation部件的动画器以及尺寸转换器,可以将视口空间中的坐标转换为主视图坐标空间。

开始时,我不知道所有渲染逻辑将在哪里发生,以及哪个组件将对其进行控制。 我也不确定组件执行其任务将需要哪种接口。 这就是为什么我决定将尽可能多的此类逻辑委派给外部组件,并仅提供它们将使用的某些接口,并在需要时向此接口添加额外功能的原因。 这就是Render Controller进入的地方。Render Controller是Render Stage的用户将添加到Render Stage的外部组件,而Render Stage本身将提供Render Controllers将使用的某些界面-Render Environment。 这种间接级别使Render Stage和Render Controller的演化或多或少地独立。

我还想尝试几种渲染技术。 就像尝试使用Core Graphics和Metal渲染图表一样。 同时一次具有多个渲染选项,例如使用简单的UILabel以及使用Core Graphics进行渲染。 这具有挑战性,因为不同的渲染技术需要不同的渲染工具,有时甚至需要不同的渲染方法。 就像使用Core Graphics进行绘制,只是布置UILabels。 两者都是某种渲染,但是使用不同的工具。 这是我介绍Render Surface和Renderer的地方。 渲染表面是一些抽象的容器,我们可以将其添加到渲染器中,然后渲染器可以在该渲染表面上显示某些内容。 在内部,Render Surfaces不能有任何共同点,唯一需要做的就是为Render Stage提供一个UIView实例,Render Stage可以将其添加到主视图中。 为了强调这里的渲染曲面的不同,下面是一个示例,我使用了两个示例:ViewRenderSurface和CGRenderSurface。 首先,您可以添加渲染器,该渲染器将通过向其添加子视图并对其进行布局来管理某些UIView(也支持约束)。 我使用此类渲染器来管理y / x坐标标签。 另一个允许您添加使用CGContext绘制线条或所需内容的渲染器。 尽管事件本质上是不同的,但渲染环境中有相同的api来创建渲染表面并将渲染器附加到它们。 每个Render Surface可以不同,具有不同的参数等。我介绍了RenderSurfaceManager,可以在Render Stage中注册它,其主要目的是基于提供给它的RenderSurfaceParams创建Render Surfaces。

渲染难题的最后一部分并不是很重要,但是在性能方面可能是有益的-渲染目标。 所有“渲染表面”都分组到“渲染目标”中,并附加了“渲染控制器”。 它实际上只是Render Controller的Render Surfaces请求的集合。 好处是,当控制器请求重新渲染(某些数据已更改或其他内容)时,我们将仅使其特定的渲染表面无效。

渲染阶段的体系结构基本上就是这样。 在这里回顾一下(渲染阶段)用法。 Render Stage的每个用户都可以向其添加Render Controller。 Render Controller将附加到Render Target,还将获得它可以使用的Render Environment组件。 要绘制某些渲染控制器,请使用渲染环境,请求所需类型的渲染表面(UIView,CoreGraphics,当前支持的Metal),创建将执行渲染的渲染器,并将渲染器附加到“渲染表面”。 就是这样,渲染器可以使用渲染表面进行渲染。

关于视口及其更新的几句话。 渲染阶段保留视口并提供有关其状态的地面真相。 外部用户可以请求其更新,并且可以对该更新进行动画处理。 每个更新都报告给渲染控制器。 这就是他们进行适当渲染所需的全部……几乎。 其实有趣的部分是动画更新。

这里应谨慎处理两件事。 第一个是观点的每一面都应独立更新。 就是这样,用户可以请求不使用动画就将视口x更新为X1,也可以使用动画来更新视口y端。 这实际上是此图表动画所需的。 您可以看到用户可以来回滑动,x应该立即更新,但是y坐标不依赖于此,它应该基于图表的最大y点进行更新。 因此,您将看到更新视口x坐标不应停止y动画。 为了解决此要求,外部用户可以独立更新视口的不同侧面。 就像要求x立即更新,也要求y用动画更新(或只是在更新中跳过此值)。

另一个是,仅在实际视口更新时通知控制器是不够的(我们将在讨论y标签动画时看到原因)。 还应将动画驱动到的视口的目标值通知它们。 假设我们用动画更新了视口y,那么控制器将首先得到通知,将视口从旧值更新为新值。 然后,每次实际更新也会通知它。

主持人

Presenter是设置一切的组件。 它创建渲染阶段,创建所有必需的渲染控制器,在渲染阶段注册它们,添加用于交互的手势识别器。 在此之后,其角色大部分完成。

专门针对此比赛的演示者将创建4个控制器-Y坐标控制器,x坐标控制器,图表控制器,无数据控制器。 在渲染阶段注册几个手势识别器,以处理图表的突出显示。 然后,对于每个视图端口更新,它将基于提供的视图端口更新渲染阶段视图端口x和x末端(y坐标由图表数据驱动)。 获取视口的数据。 数据到达时,它将解析视口max Y,并在需要时更新渲染阶段视口y(数据maxY较低或较高,然后是上一个)。

实作

现在,我将描述使用上述框架实现与第一个gif相同的结果的实现。 我将其分为5个部分:X坐标,Y坐标,高光渲染,信息面板渲染,图表渲染。

X坐标

该逻辑在XCoordinatesController中实现,并使用View类型的一个渲染表面。 要求-根据提供的gif,我可以说应该标记每个N坐标,并且适合视口的坐标数量应在3–6范围内。 如果我们可以拟合更多,则为6个坐标,则应删除每个第二个带标签的坐标,如果我们可以拟合为小于3个坐标,则应在每对当前显示的标签坐标之间添加额外的标签坐标。 3–6范围只是我的选择,另一个可以是4–8。 显然,max应该是min的两倍,因为每次我们确定视口可容纳的标记坐标过多时,我们将移除其中一半,反之亦然,如果要添加标记坐标。

因此,当我们满足需求时,实现就很简单了,几乎没有什么方法可以实现。 您可以获取数据的宽度并将其分成两部分,直到所需的坐标量适合视口为止。 然后使用此距离标记坐标-基本上每个标记的坐标将具有N *距离x坐标,其中N是整数。

我实际上采用了不依赖数据的不同方法。 我基本上计算视口的宽度幂为2,例如如果它是64,那么我将得到6,我们称它为P。然后到标签的距离为2 ^(P – 2)。 这意味着我应该大致获得4个用于查看端口的标签。 我实际上想要更多,所以我实际上针对缩小的视口宽度计算了2的幂:width * 0.7。 这样,我们的实际视口将获得额外的标签。 这只是我发现很合适的经验值。

最后要考虑的一件事是,应为不同的视口宽度触发添加额外坐标和删除多余坐标的动作。 我的意思是,如果存在触发添加坐标的操作的视口宽度值,我们将其称为W。然后,应在W – x处执行删除坐标的操作,其中x是一个较小的值(我将其取为W * 0.05 )。 这将有助于克服一些在标注坐标之间来回跳转的问题。

Y坐标

好吧,这确实很奇怪,花了一些时间来正确配置。 让我尝试先解决需求。 我们应该标注6个Y坐标,其中一个始终为y =0。另外5个基于视口高度,并且应始终在屏幕坐标空间中具有固定位置。 我们还应该为每个这样的坐标画一条线。 当视口高度更改为某个值H时,我们应基于新高度标记五个新坐标。 显示带有淡入淡出动画的新标签,删除带有淡入淡出动画的旧标签。

好的,我不能说gif上的动画看起来不错,但是它向我们展示了完美的世界场景。 用户幻灯片,图表数据中出现新的最大Y。 我们过渡到新的y标签。 不幸的是,在现实生活中,用户可以很快地更改视口,然后我们可以进行很多y坐标转换,看起来像废话。

因此,这里是尝试尽可能多地执行的实现。 在此,控制器使用两个Render Surfaces-一个用于标签的ViewRenderSurface,另一个用于线CGRenderSurface。 这意味着y标签将只是UILabel,而线将使用CoreGraphics绘制。

y坐标的计算非常简单,我们只需计算0到最大视口Y之间的坐标即可。 困难的部分是过渡。 如我们所见,视口的高度始终随动画而变化。 这意味着,如果我们将基于当前视口高度计算坐标,那么每次动画更新都会触发标签过渡的级联。 这是我们使用零件的地方,当动画开始时,渲染阶段会通知控制器有关视口的目标值。 就像我们将高度从100设置为150一样,渲染阶段将通知此过渡,然后开始动画。 当Controller收到此通知时,它将为目标视口高度计算新的y标签并开始动画。 这解决了部分问题,但是还有更多问题-视口高度快速变化的问题。 例如,如果用户来回更改视口位置,则很有可能在某些值之间来回移动图表数据的最大Y值。 我通过忽略更新直到过渡的某些部分完成来解决它,然后开始新的过渡。 我推迟到过渡完成50%,然后根据需要开始新的过渡。 从我的角度来看,这很好。

亮点渲染

突出显示,图表和信息板在ChartController中处理。 我想将它们拆分为不同的控制器,但是当您考虑时,就没有办法这样做,因为高亮和信息面板应在呈现图表的位置上呈现。 这是因为突出显示点应准确地放置在图表线上,因此只有计算图表呈现方式的逻辑才能告诉我们突出显示点的坐标。 与信息面板相同,因为它取决于突出显示点的位置。

高光使用两个曲面CGRenderSurface渲染,这意味着它们都将使用CoreGraphics渲染。 再次,此过程没有任何花哨的内容-获取抽头坐标,在图表上找到最接近的点,在此坐标上绘制垂直线,并为每个图表在此处绘制一个圆。 这够了吗? 并不是的。 我想提醒我们,稀疏呈现的数据,这就是为什么我们可以在图表上选择一个由稀疏切出的坐标的原因。 为了克服这个问题,我们使用渲染数据的线性。 假设我们在某个地方点了一下,然后计算出该点P是最接近该抽头的点。 如果没有切出该点,我们只需在该点的坐标处绘制圆。 如果通过稀疏算法将该点切掉,则可以在稀疏版本的数据中找到该点的邻居,并使用线性方程式在渲染的邻居制作的直线上找到x值的y坐标。 例如,我们有点(1; 1)(2; 3)(3; 2)(4; 10)(5; 5)。 稀疏数据具有这些点(1; 1)(4; 10)(5; 5),我们选择了数据稀疏变体中缺少的点(3; 2)。 那么(1; 1)和(4; 10)是稀疏版本数据中的邻居。 现在我们在由(1; 1)和(4; 10)组成的直线上找到x = 3的y值:

k =(10-1)/(4-1)= 3

b = 1 – k * 1 = 1 – 3 * 1 = -2

y = k * x + b = 3 * 3 – 2 = 7

因此,这是点(3; 2):(3; 7)上高亮显示的坐标。

基本上就是这样。 现在我们可以绘制圆和垂直线。

信息板

信息板是您在图表上选择某个点时看到的小板。 ChartController对其进行处理,并使用ViewRenderSurface对其进行呈现,因此它将只是一个视图。

同样,此板没有明显的要求,但我认为,如果该板具有功能,并且永远不会与突出显示的圆圈重叠,那就太酷了。 这个有点挑战。 主要代码在HighlightViewManager的布局方法中。 首先,我使用目标视口布局此板(动画将当前视口驱动到的视口,如果没有动画,则将当前视口驱动到当前视口)。 这样,我们可以根据视口力争的屏幕状态来调整板的位置。 下一个逻辑避免高光圆的重叠。 基本上有两种情况要考虑:

  1. 如果将视图中心放在高光位置,则不会重叠高光。 在这种情况下,我们完成了,我们什么都不重叠,我们很好。
  2. 在这里,我们重叠了圆圈,必须将木板向右或向左移动。 如果这样做,我们不将屏幕x重叠,则将板向左移动。 否则,我们将其移至右侧。

最后一步是调整板子x的位置,以防止屏幕重叠。 我们可以做最后一步,因为如果进入案例1,那么我们就可以自由移动木板,并且它不会与高光重叠。 如果在情况2中,那么我们已经计算出不与屏幕重叠的边。

图表渲染

图表呈现是在ChartController中处理的,这是我使用CoreGraphics和CGRenerSurface制作的最简单的部分。 这是我发送给Telegram的实现。 但是后来我决定尝试金属。

这是我第一次使用Metal,这得归功于苹果公司,这一次,他们做得很好。 它是如此容易使用。 每次我使用OpenGL实现某些功能时,都会遇到很多麻烦。 您不知道该如何修复和修复来自OpenGL的奇怪错误的黑屏……哎呀。 如果使用绝对不同的金属,简单的设置,将参数传递给着色器,那将是一件轻松的事。 尽管我本来可以使用的一些功能(例如几何体着色器)缺失了,但是它们不是强制性的。

如果决定检查其工作方式,则应考虑的一件事是,我在渲染器中创建了有限的数据缓冲区。 这意味着如果有更多点,那么它可以构成,您将有问题🙂。 解决方案非常简单-使用某种缓冲池,渲染器应该从中获取缓冲。 但这是我未完成的。 另外,我以苹果教程示例为起点,因此您可以在代码中偶然发现它的某些部分。 好的,让我们看看金属渲染的工作原理。

使用Metal渲染使用MetalSurface,渲染发生在MetalChartRenderer中,着色器文件为ChartShaders.metal。 呈现周期如下所示:ChartController将新数据提供给呈现器。 渲染表面调用渲染器以重新渲染数据。 渲染器生成用于填充顶点缓冲区和索引缓冲区中的线的几何。 将视口和屏幕大小作为额外的参数传递,着色器完成所有其余工作。

关于渲染线的难点是它的宽度。 我使用了paul.houx描述的算法。 关于术语的几句话:我将使用perp来表示与其他向量的垂直向量,而norm将表示标准化的perp。 好的,基本上,算法每个点需要2个perps(我们称之为“当前”)。 第一个特征是垂直于当前点及其左邻点所形成的向量的向量。 另一个垂直于当前点及其右邻点所形成的向量。 计算很简单:

perp1 =(-(Current.y — left.y),Current.x — left.x)

perp2 =(-(right.y-Current.y),right.x-Current.x)

这是因为矢量V的位具有坐标(-Vy,Vx),使用点积可以轻松检查该坐标:

V·perp = Vx * perx.x + Vy * pery.y = Vx *(-Vy)+ Vy * Vx = 0

从A点到B点的向量就是(Bx – Ax; By – Ay)。

对于第一个点和最后一个点,我们只获取伪点,其坐标在x中偏移:FirstPseudo(First.x – 1; First.y),LastPseudo(Last.x + 1; Last.y)。

对于每个点,我们传递其坐标和我们刚刚计算的2个向量。 其余计算在顶点着色器中完成。

该算法未考虑的一件事是我们可以按比例放大或缩小。 我们的视口可以更改,但线宽仍应相同。

顶点着色器中的每个点都经过几次转换,输出应将顶点x和y坐标转换为[-1; 1]范围。 例如,如果我们的视口具有x起始坐标100和x结束坐标500,并且点具有x坐标100,则顶点着色器应返回-1作为点x坐标的结果。 对于x坐标为300的点,顶点着色器应返回0,依此类推。 这意味着点将获得缩放坐标作为输出,并且如果视口的宽度与高度不同,那么我们就不能真正直接使用算法,因为在缩放比例的版本中perps不会相同。

为了避免这个问题,我们将使用点的线性变换下perps的线性特性。 为了了解其工作原理,假设我们有两个要点,分别是P1和P2。 可以像以前一样计算此点的收益。

(-(P2.y-P1.y),P2.x-P1.x)

好的,让我们分别缩放每个坐标并转换它们(由着色器完成),然后再次计算性能。 我们有:

P1’=(P1.x * cx + ox,P1.y * cy + oy)

P2’=(P2.x * cx + ox,P2.y * cy + oy)

其中cx,cy是比例值,而ox,oy是偏移值。 那我们有

(-(P2′.y-P1′.y),P2′.x-P1′.x)=

(-(P1.y * cy + oy –(P1.y * cy + oy)),P2.x * cx + ox –(P1.x * cx + ox))=

(-P1.y * cy-oy + P1.y * cy + oy),P2.x * cx + ox – P1.x * cx + ox)=

(-(P1.y + P1.y)* cy),(P2.x * cx – P1.x)* cx)

这就告诉我们,要计算此变换后的点的perps,我们应采用原始perp,并将其x乘以y比例(cy),然后将y乘以x比例(cx)。

在顶点着色器中,每个图表点的缩放比例如下:position =(位置/视口)* 2 – 1.0。 这意味着,我们应该按viewport.yx * 2缩放通过的perps。从计算中可以看出,x和y比例被交换了。 我们可以跳过乘以2,因为perps归一化会丢弃它。 然后我们在上面应用算法。 就是这样……至少那是我的想法。 其实还有我第一次错过的一件事。 顶点着色器返回转换后的点后,实际上实际上将再次转换为屏幕大小。 基本上,此转换将-1坐标转换为0屏幕坐标,将1坐标转换为screen。(width | height)坐标。 这意味着我们应该考虑到这一点。 为了使它起作用,我只需将终点坐标和perps转换为屏幕坐标,应用算法,然后将点转换回[-1:1]范围。 可能有一种方法可以简化此操作,但我不确定。

好的,我们完成了,我们有了Metal渲染器!

结果,我获得了令人印象深刻的性能,即使在iPod上也能很好地工作。

下一场比赛

现在,Telegram开始了下一场比赛,由于我现在没有很多闲暇时间,所以我不会参加。 但似乎所有新图表都可以使用Metal使用相同算法轻松地以相同方式呈现。 我看到的唯一困难的部分是执行过渡。

结论

即使我没有获得奖金,由于我在实现过程中遗漏了一个细微的错误,性能(没有Metal)也被评为9/10。 我很高兴我决定参加比赛,因为那很有趣,并且让我想起了我还是学生的时候🙂

这是源代码的链接:https://github.com/AndreLami/TelegramCharts