4.4.2 绘制图形
4.4.2 绘制图形
应用程序绘制饼图分两步:绘制填充的饼图,添加文本标签。这种方式,可以肯定的是,标签不会被饼图覆盖。
执行绘图代码的很大一部分可以被这两个步骤共享。对于每一步,我们需要迭代列表中的所有项目,计算饼图中占的角度。解决这个问题的办法是编写一个函数,执行共享的操作,取绘图函数作为参数值。该代码调用此函数两次。第一步中的绘图函数填充饼图部分,在第二步中的函数绘制文本标签。
创建随机颜色的画笔
让我们首先绘制这个饼形。我们想要使用随机的颜色填充的饼图指定部分,所以,先要写一个简单的工具函数,创建随机彩色画笔,我们可用于填充区域,如清单 4.7 所示。
Listing 4.7 Creating brush with random color (F#)
let rnd = new Random()
let randomBrush() =
let r, g, b = rnd.Next(256), rnd.Next(256), rnd.Next(256)
new SolidBrush(Color.FromArgb(r,g,b))
代码声明了两个顶级的值。第一个是 .NET 类 Random 的一个实例,用于生成随机数。第二是 randomBrush 函数,它有一个 unit 类型的参数,这是一种 F# 方法,说它不需要取任何有意义的参数值。多亏了此参数,我们正在声明的这个函数,可以运行多次得到不同的结果。如果我们忽略它,将创建一个值,当应用程序启动后,它才会计算,且只计算一次。unit 的值只可能是 (),因此,当我们在后面的代码中调用该函数,实际上给它这个 unit 作为参数值,虽然它看起来像一个根本不带任何参数值的函数调用。randomBrush 函数使用 rnd 值,并生成 SolidBrush 对象,它可以用于填充指定区域。它有副作用,如你已经知道的,在使用函数程序中有副作用的函数时,我们应该小心。
隐藏的副作用
函数 randomBrush 是函数有副作用的一个示例。这意味着,该函数每次调用时,可能会返回不同的结果,因为它依赖于一些不断变化的值,而不是函数的参数值。在此示例中,变化的值是值 rnd,它表示一个随机数发生器,每次调用 Next 方法后,改变其内部状态。清单 4.7 声明 rnd 作为一个全局值,尽管它只在函数 randomBrush 内部使用。当然,这是一个提示,我们应减少全局值到,把它声明为本地值。我们可以尝试改写代码,如下所示:
let randomBrush() =
let rnd = new Random()
let r, g, b = rnd.Next(256), rnd.Next(256), rnd.Next(256)
new SolidBrush(Color.FromArgb(r,g,b))
但是,这个代码不能工作 !问题是,我们每次调用该函数时,都将创建一个新的 Random 对象,内部状态的变化并不保留。在创建时,Random 使用当前时间初始化内部状态,但是,由于绘图执行很快,“当前时间”不足以改变,我们得到的整个图表都被画成相同的颜色。
不必惊讶,有一种方法写这个代码,而不用声明 rnd 作为全局值,但是,它必须保持可变状态,表示函数之间的调用。为了写这个,我们需要两个概念,将在第 5 章中讨论:闭包 和 lambda 函数。我们将在第 8 章会看到类似的例子,显示频繁模式隐藏副作用。
现在,您知道如何创建画笔填充图形,我们可以看一下第一个绘图函数。
绘制饼图部分
清单 4.8 实现了函数 drawPieSegment 。它使用随机的颜色,填充图形的指定部分。此函数将在另一个函数中使用,在应用程序中的第两个阶段中执行绘图。这个处理函数每绘制一部分,都将调用它,并将获得所需的所有信息作为参数值。
Listing 4.8 Drawing a segment of the pie chart (F#)
let drawPieSegment(gr:Graphics, title, startAngle, occupiedAngle) =
let br = randomBrush()
gr.FillPie
(br, 170, 70, 260, 260,
startAngle, occupiedAngle)
br.Dispose()
选择写函数的语法
已经目前为止,我们已经看到了有两种方法,来编写有多个参数值的函数:函数的参数值既可以是括在括号中的以逗号分隔的列表,也可以是以空格分隔的值列表。注意,第一个风格无论如何不是很特别的:
let add(a, b) = a + b
这是一个函数,取一个元组作为参数值。表达式 (a, b) 正常模式,在第 3 章中我们,用它来分解元组。问题是哪个选项更好。不幸的是,没有一个权威的答案,这是个人的选择。最重要的事是一贯使用这个选项。
在本书中,我们通常写函数的参数值,使用元组,尤其是写一些更复杂的工具函数的函数参数,使用 .NET 库。这将使代码与调用 .NET 方法时所使用的语法保持一致。在编写简单工具函数,且主要是处理 F# 值时,我们会使用空格。
当调用或声明的函数,只有一个参数值时,我们也写上括号,例如, sin(x),虽然括号是可选的,也可以写成 sin x。这一决定既符合函数通常写成数学式,也符合调用有多个参数值的 .NET 方法。我们会在第 5 和 6 章中,回到这个主题,届时,我们会更详细地讨论函数,此外,还讨论实现和使用高阶函数。
前面的清单中的 drawPieSegment 函数是两个绘图函数中的一个,我们将使它作为函数 drawStep 的参数值,在所有的饼图部分进行迭代,并画出图形。在看 drawStep 的代码之前,让我们先看看它的类型。虽然,在这个代码中我们不需要编写这些类型,看一下代码中使用值的类型还是有用的。
用函数绘图
DrawStep 函数的第一个参数值是两个绘图函数中的一个,所以,我们暂时先给绘图函数的类型一个名字 DrawingFunc ,以后再定义它。在讨论其余的参数值之前,让我们看看这个函数签名:
drawStep : (DrawingFunc * Graphics * float * (string * int) list) -> unit
我们再次使用元组语法来指定参数值,因此,该函数取一个大的元组作为参数。第二个参数值是 Graphics 对象,用来绘图,将传递给这个绘图函数。接下来的两个参数值指定使用的数据集,用来这个绘图, 一个 float 值是所有数值的和,所以,我们可以计算每个部分的角度,一个 (string * int) list 类型的值是我们熟悉的数据集,来自控制台版本的应用程序。它存储每个要绘制项的标签和值。
让我们看看 DrawingFunc 类型,它应该和清单 4.8 中的 drawPieSegment 函数有相同的签名。第二个绘图函数是 drawLabel,我们很快就会看到具有完全相同的签名。我们可以看看这个签名,声明 DrawingFunc 类型,与这个两函数的类型完全相同:
drawPieSegment : (Graphics * string * int * int) -> unit
drawLabel : (Graphics * string * int * int) -> unit
type DrawingFunc = (Graphics * string * int * int) -> unit
最后一行是类型声明,声明了一个类型别名(type alias)。这意味着,我们给复杂类型指定一个名字,可以换一种方式来写。我们只在此解释中使用 DrawingFunc 的名称,但可以使用它,例如,类型批注中,如果我们想要引导类型推断,或都使代码更具可读性。
正如我刚才所说的,不需要在代码中编写这些类型,但它能帮助我们理解代码的功能。最重要的事情是,我们已知道是 drawStep 函数取一个绘图函数作为第一个参数值。清单 4.9 显示了 drawStep 函数的代码。
Listing 4.9 Drawing items using specified drawing function (F#)
let drawStep(drawingFunc, gr:Graphics, sum, data) =
let rec drawStepUtil(data, angleSoFar) =
match data with
| [] –> ()
| [title, value] –>
let angle = 360 – angleSoFar
drawingFunc(gr, title, angleSoFar, angle)
| (title, value)::tail –>
let angle = int(float(value) / sum * 360.0)
drawingFunc(gr, title, angleSoFar, angle)
drawStepUtil(tail, angleSoFar + angle)
drawStepUtil(data, 0)
为使代码更具可读性,我们执行的这个函数,它实际是作为嵌套函数,它遍历应该画在图形上的所有项。这些项存储在标准的 F# 列表中,因此,代码很像我们熟悉的列表处理模式。有一个显著的区别,因为,这个列表匹配依据三种模式,而不是通常的两种情况的匹配,一个空列表和一个 cons cell。
模式匹配的第一个分支同,匹配一个空列表,且不执行任何动作。正如我们已经看到的,在 F# 中”什么也不做“表示 unit 值,那么,这个代码返回 unit,写作 ()。这是因为 F# 把每个构造看作表达式,表达式总是必须返回值。如果空列表的这个分支为空,它就不是一个有效的表达式。
第二分支使列表处理的代码不寻常。正如你可以看到的,这个分支中所使用的模式是 [[title, value]。这是一种由两个模式组成的嵌套模式模式,一个模式,匹配一个列表,包含单个项目 [it],另一个模式,匹配有一个元组的项,包含两个元素:(title, value)。我们使用的语法是 [(title, value)] 的速记,但它们意思相同。写出的第一种模式使用创建列表的普通语法,所以,如果要写一个匹配有三项列表的模式,可以写成 [a; b;c]。将此纳入特殊情况下,因为,我们想要纠正舍入错误:如果我们处理的列表中的最后项,要确保总角度正好是 360 度。在此分支中,只计算角度,调用 drawingFunc 函数,作为参数值传递给我们。
最后一个分支处理的列表不匹配任何以前的两种模式。这里,模式的顺序非常重要,因为,任何匹配第二
种模式的列表也匹配最后一个,仅仅是空列表作为尾。在代码中模式的顺序保证最后的分支不会调用最后一项。
最后分支的代码计算角度,并使用指定绘图函数绘制部分图形。这是唯一的不停止递归处理列表的分支,因为,它一直使用到列表中的最后一个元素,所以,代码的最后一行是递归调用。在递归过程中改变的唯一参数值,是列表中要绘制的剩余元素,和 angleSoFar,这是所有已处理的图形部分占的一个角度。由于使用了本地函数,我们不需要传递不会改变的其它参数值。drawStep 函数本身只做一件事是:调用工具函数,用所有数据,以及参数 angleSoFar 设置为 0。
绘制整个图表
去看第二个绘图函数之前,先看一下如何把这些放在一起。图 4.2 分别显示每个层次:左图是已经编写好的代码绘制的图形;仍然需要实现绘制标签的函数如右侧部分所示。
图 4.2 绘制图表的两个阶段:第一阶段使用 drawPieSegment 函数(左),第二阶段使用 drawLabel 函数 (右图)。图表显示在 1900 年的世界人口分布情况。
绘制图表的代码首先从文件中加载数据,然后,处理它,与控制台应用程序相同。代替打印数据到控制台,现在使用前面所述的函数绘制图表。在清单 4.10 中,可以看到函数 drawChart 绘图。
Listing 4.10 Drawing the chart (F#)
let drawChart(file) =
let lines = List.ofSeq(File.ReadAllLines(file))
let data = processLines(lines)
let sum = float(calculateSum(data))
let pieChart = new Bitmap(600, 400)
let gr = Graphics.FromImage(pieChart)
gr.Clear(Color.White)
drawStep(drawPieSegment, gr, sum, data)
drawStep(drawLabel, gr, sum, data)
gr.Dispose()
pieChart
该函数取 CSV 文件名作为参数值,并返回有饼形图的内存中的位图。在代码中,首先加载该文件,并处理它,使用我们已有的 processLines 和 calculateSum 函数。然后,绘制图表,并在最后一行,返回创建的位图,作为该函数的结果。
为了从根本上能绘制任何东西,首先要创建一个 Bitmap 对象,然后,关联到 Graphics 对象。我们在所有以前的函数中已经用 Graphics 来绘制,所以,一旦创建后,可以用白色背景填充位图,使用 drawStep 函数绘制图表。第一次调用绘制饼图,使用 drawPieSegment,第二次调用绘制文本标签,使用 drawLabel。可以尝试注释掉这两行中的一行,只绘制出步骤之一,得到在图 4.2 相同的结果。我们还没有实现 drawLabel 函数,因为,我们首先显示整个绘制如何工作,但现在我们已经可以完成此部分的应用程序了。
添加文本标签
我们已经实现了第一个绘图函数,第二个应具有相同的签名,以便我们可以使用它们中的每一个作为参数值传递给通用的 drawStep 函数。我们要填写的唯一的东西是绘制标签的代码,并计算其位置,可以看到如清单 4.11。
Listing 4.11 Drawing text labels (F#)
let fnt = new Font("Times New Roman", 11.0f)
let centerX, centerY = 300.0, 200.0
let labelDistance = 150.0
let drawLabel(gr:Graphics, title, startAngle, angle) =
let lblAngle = float(startAngle + angle/2)
let ra = Math.PI * 2.0 * lblAngle / 360.0
let x = centerX + labelDistance * cos(ra)
let y = centerY + labelDistance * sin(ra)
let size = gr.MeasureString(lbl, fnt)
let rc = new PointF(float32(x) - size.Width / 2.0f,
float32(y) - size.Height / 2.0f)
gr.DrawString(title, fnt, Brushes.Black, new RectangleF(rc, size))
我们首先声明一个顶级字体值,用于绘制文本。这样做是因为,我们不想每次的函数调用时,初始化这个字体的一个新实例。由于该字体在应用程序的整个生存期内都需要,我们不显式地释放它;当应用程序退出时,我们所依靠 .NET 释放它。函数本身以几行计算标签位置的代码开始。
第一行计算的角度(以度为单位),它指定饼图中心扇区由部分所占的。我们取部分的起始角度,加上将标签移动到中心段大小的一半。第二行将角度转换成以弧度表示。一旦有了以弧度表示的角,就可以使用三角函数 cos 和 sin,计算标签的 X 和 Y 的坐标 。我们使用 MeasureString 方法估算文本标签的大小,计算定界框的位置,其中将绘制文本。早前的计算 X 和 Y 坐标被用作定界框的中心。
现在,我们已经完成了绘制文本标签的代码,完成了用于绘制饼图的整个代码。实现了关键函数 (drawChart),它执行图表的绘制,在早先的清单 4.10 中。这个函数取 CSV 文件的文件名作为参数值,返回有这个图表的位图。我们现在是所有要做的,添加代码,从我们的用户界面调用此函数。