在本书的前三部分中,我们一直在应用各种 D3 技术来开发众所周知的可视化布局,如条形图、流图、直方图、地图等。但是,如果您选择 D3 作为数据可视化工具,那么您很有可能还希望构建复杂且不寻常的可视化。若要创建独特的项目,需要了解 D3 可以使用的不同方法和布局。与其说是详细了解每种方法,不如说是掌握 D3 背后的哲学,并知道在需要时在哪里查找信息。附录 C 中,我们映射了所有 D3 模块及其内容,可以为您提供帮助。创建自定义布局所需的另一项技能是将想法和几何分解为代码的能力,我们将在本章的项目中执行此操作。
该项目将带您了解创建完全自定义可视化的幕后情况,从草图创意到将项目分解为组件,再到将视觉元素渲染到径向布局上。我们将建造的项目探索了文森特梵高在他生命的最后十年中产生的艺术遗产。您可以在 https://d3js-in-action-third-edition.github.io/van_gogh_work/ 找到已完成的项目。
我们将遵循一个六步过程来使这个项目栩栩如生。虽然这不是一成不变的,但这大致是任何数据可视化项目都可以遵循的方法。
收集和清理数据是任何数据可视化项目中最关键的一步。如果幸运的话,我们得到了现成的数据集,可以直接开始可视化,就像本书以前的项目一样。但通常情况下,我们需要从不同来源收集数据,对其进行分析,清理数据并对其进行格式化。数据收集和操作可能需要大量的时间。它需要耐心和勤奋。在本节中,我们将讨论为本章项目准备数据所经历的不同步骤。
但在我们寻找数据之前,让我们花点时间定义我们想要可视化的信息类型。这个项目的灵感来自Frederica Fragapane的数据可视化研讨会,在此期间,我们使用文森特梵高写给他兄弟西奥的信的数据集。我们对梵高的丰富文学遗产感到震惊,并认为将其与他的著名绘画和素描相结合以深入了解他的整个艺术遗产会很有趣。
所以,我们知道我们想收集有关梵高的绘画、素描和信件的数据。理想情况下,我们希望及时放置这些作品,以可视化他艺术作品的起伏。经过几次谷歌搜索,我们找到了以下资源:
通过探索这些资源,我们还注意到,我们可以根据梵高居住的城市将他的生活分解为几个阶段。例如,他于1886年从荷兰搬到巴黎,在那里他遇到了保罗·高更和亨利·德·图卢兹-劳特累克,仅举两例。这些艺术相遇无疑影响了梵高的作品。我们还知道,他从 1889 年 1890 月到 1890 年 <> 月在圣保罗德莫索莱精神病院住院。在此期间,他开始将漩涡融入他对医院花园的描绘中。最后,梵高于 <> 年 <> 月自杀身亡,标志着他多产的十年艺术创作的戛然而止。意识到这些事件,我们希望我们的可视化构成梵高过去十年的时间线。
现在,我们需要从找到的资源中提取数据。让我们以绘画为例(https://en.wikipedia.org/wiki/List_of_works_by_Vincent_van_Gogh)。这个维基百科页面包含一系列表格,列出了一千多幅画作。不是我们想要手动提取的东西!您可以找到从网页中提取表并将其转换为 CSV 文件(如 tableconvert.com)的联机服务。此类工具使用方便快捷。但是如果我们想要更细粒度的控制,我们可以编写一个简单的脚本来完成这项工作。
14.1 例包含一个脚本,您可以使用该脚本从维基百科页面中提取每幅画的标题、图像 URL 和媒介。要使用此脚本,请打开浏览器的控制台,复制粘贴整个代码段,然后单击 Enter 。
如果我们看一下页面结构,我们会发现它由一系列HTML表格组成,每个表格都包含使用相同媒介制作的绘画列表。前六张表是关于油画的;第七幅包含水彩画;第八和第九是关于石版画和蚀刻版画的,我们将将它们归入“印刷”媒介。最后一个表格包含字母草图,我们还不想提取。在示例 14.1 中,我们声明了一个数组,其中包含我们感兴趣的表的索引及其相关介质。
然后,我们使用文档方法querySelectorAll()和类“wikitable”和“sortable”作为选择器从页面中提取所有HTML表。我们通过打开浏览器检查器并仔细查看标记来找到这个选择器,以找到我们感兴趣的表的唯一且通用的选择器。
在循环遍历这些表时,我们检查它们是否已存在于脚本开头声明的 tables 数组中。这种验证使我们能够避免从字母草图表中提取信息。然后,我们可以遍历每个表格行并提取绘画图像的标题和 URL。请注意我们必须如何在代码中适应不同的 DOM 结构,因为表行的格式不一致。与这些HTML结构不匹配的绘画将被赋予标题和图像URL为null,稍后将手动完成。处理现实生活中的数据通常是混乱的!您还将看到我们从 srcset 属性而不是 src 中提取图像 URL,因为此图像更小,并且在我们的项目中需要更少的加载时间。
最后,我们将绘画信息构建成一个对象,并将其推送到一个名为“绘画”的数组中。但是,通过将此数组记录到控制台中,我们可以将其复制粘贴到代码编辑器中并创建一个 JSON 文件。
此脚本针对此特定示例量身定制,在其他网页上没有帮助。但是你可以看到你的JavaScript技能对于从网页中提取任何信息是多么有价值。
const tables=[ #A
{ index: 0, medium: "oil" }, #A
{ index: 1, medium: "oil" }, #A
{ index: 2, medium: "oil" }, #A
{ index: 3, medium: "oil" }, #A
{ index: 4, medium: "oil" }, #A
{ index: 5, medium: "oil" }, #A
{ index: 6, medium: "watercolor" }, #A
{ index: 7, medium: "print" }, #A
{ index: 8, medium: "print" }, #A
]; #A
const domTables=document.querySelectorAll(".wikitable.sortable"); #B
const paintings=[]; #C
domTables.forEach((table, i)=> { #D
if (i <=tables.length - 1) { #D
const medium=tables[i].medium; #D
#D
const rows=table.querySelectorAll("tbody tr"); #D
rows.forEach(row=> { #D
let title; #E
if (row.querySelector(".thumbcaption i a")) { #E
title=row.querySelector(".thumbcaption i a").textContent; #E
} else if (row.querySelector(".thumbcaption i")) { #E
title=row.querySelector(".thumbcaption i").textContent; #E
} else { #E
title=null; #E
} #E
let imageLink; #F
if (row.querySelector(".thumbinner img")) { #F
const image=row.querySelector(".thumbinner img").srcset; #F
imageLink=`https${image.slice(image.indexOf("1.5x, ")+6,-3)}`; #F
} else { #F
imageLink=null; #F
} #F
paintings.push({ #G
title: title, #G
imageLink: imageLink, #G
medium: medium #G
}); #G
})
}
});
console.log(paintings); #H
清单 14.1 中的脚本示例不完整。我们仍然需要提取每幅画的日期、尺寸、当前位置和创作位置。为了避免本节太长,我们不会在这里这样做,但如果您想练习从网页中提取数据,请尝试一下!请注意,我们还必须操作提取的数据以分别存储绘画的宽度和高度,以及创作的月份和年份。需要一些额外的研究来找到一些绘画的创作月份并找到它们的主题(肖像、静物、风景等)。
如果您想直接跳转到使用数据,本章的代码文件包含梵高的绘画、素描、信件和他所居住城市的时间轴的现成数据集(见 https://github.com/d3js-in-action-third-edition/code-files/tree/main/chapter_14/14.4.1-Responsive_SVG_container/start/src/data)。
在第3章中,我们定义了两类主要数据:定量和定性,如图14.1所示。定量数据由数字信息组成,例如股票市场行为价值的起伏或教室里的学生人数。定量数据可以是离散的,由无法细分的整数组成,也可以是连续的,其中数字在细分为较小的单位时仍然有意义。另一方面,定性数据是非数字信息,例如国家列表或星巴克咖啡订单的可用尺寸(矮、高、大、通风等)。定性数据可以是名义数据(值没有特定顺序)或顺序(顺序很重要)。
由于我们不会使用相同的通道来可视化不同的数据类型,因此编写一个可用于项目的变量列表并按数据类型组织它们通常很有帮助。此步骤可以帮助我们识别可以使用的不同视觉通道或编码数据的方法。图14.2说明了定量数据通常通过位置(如散点图)、长度(如条形图)、面积(如我们的罗密欧与朱丽叶项目中节点的大小)(见第12章)、角度(如饼图)或连续色标进行可视化。另一方面,定性数据通常使用分类色阶、图案、符号、连接(如网络图)或分层数据的外壳(如圆形包)进行翻译。这样的列表只能是不完整的,因为只要有一点创造力,我们就可以设计出可视化数据的新方法。但它提供了我们可以使用的主要视觉编码的概述。
在这一点上,一个有用的练习包括列出数据集中包含的不同数据属性,识别定量和定性数据,并集思广益我们希望如何可视化主要属性。在图 14.3 中,我们列出了该项目的四个数据集(梵高的画作列表、他的绘画列表、他每月写的信数量以及他职业生涯中居住的城市的时间线),并确定数据属性是定量的(蓝点)还是定性的(红点)。基于这些信息,我们可以开始考虑要创建的可视化。
在这个项目中,我们希望在时间轴上展示梵高的艺术作品(绘画、素描和信件),以探索每种表达方式的使用与艺术家在荷兰和法国的移动如何演变之间的相关性。我们希望更多地关注绘画,并允许用户单独探索它们。如果圆圈代表每幅画,我们可以使用圆圈的颜色来传达绘画的主题(肖像、静物、风景等),使用它们的大小作为作品的尺寸,并用圆圈的边框突出显示介质(油画、水彩或印刷品),如图 14.4 所示。这些圆圈将定位在某种时间轴上。
每月制作的图纸和字母数量可以通过条形图或面积图的长度作为次要信息添加。最后,我们知道我们需要一些可点击的时间线来选择和突出梵高在他生命不同时期的作品。
一旦选择了视觉通道,我们就可以开始绘制项目的布局。我们已经确定每幅画将由一个圆圈表示并定位在时间轴上。水平轴或垂直轴可以工作,尽管它对于屏幕来说可能太大。一个有趣的解决方法可能是径向时间轴。与其有一个很难适应移动屏幕的大圆圈,不如使用小倍数方法。小型序列图是一系列可视化效果,使用相同的比例和轴,但表示数据的不同方面。通过这种方法,我们可以每年有一个轮子,允许我们将它们定位到一个网格中,如图 14.5 所示。在桌面上,我们将在左侧显示可点击的时间线,在右侧以三列网格形式布置小型序列可视化。在平板电脑上,网格将减少到两列,而在移动设备上,我们将使用没有时间轴功能的单列网格。
每个小倍数将可视化一整年,月份沿圆周分布。对于每个月,图纸的数量将由面积图和条形长度的字母数量表示。代表一个月内绘画的圆圈将聚集在一起,如图 14.6 所示。
下一步是创建调色板并选择字体。我们需要为八种不同的绘画主题提供一个分类的调色板:自画像、肖像、农民生活、室内场景、静物、风景、城市景观建筑等,以及字母和素描的另一种颜色。创建任何调色板时,请考虑要在项目中安装的氛围。例如,在这里,我们想使用一种欢快的调色板,灵感来自梵高生命中最后几年的画作中的色调。我们通过从绘画中提取金色并使用 coolors.co 生成匹配的颜色,从图 14.7 创建了分类调色板。对于分类调色板来说,八种颜色已经很多了,因此我们不得不对某些类别使用类似的色调。例如,我们为肖像(#c16e70)选择了旧玫瑰色,为自画像选择了相同颜色的较亮版本(#f7a3a6)。您还可以在 adobe.color.com 和 colorhunt.co 上找到调色板的灵感。
对于字体,我们发现 font.google.com 是免费网络字体的绝佳资源。通常,您希望每个项目最多坚持两个字体系列,一个用于标题,一个用于文本正文。一个简单的谷歌搜索将为谷歌字体组合提供很多想法。对于这个项目,我们选择了“Libre Baskerville Bold”作为标题,一种与19世纪相呼应的衬线字体,文本和标签为“Source Sans Pro”,一种无衬线字体,对用户界面具有出色的可读性。
一旦我们知道我们想要构建什么,我们必须决定我们要使用的基础设施。因为这个项目比我们在本书前面创建的项目更复杂,所以我们将转向JavaScript框架。使用框架将使我们能够将项目分解为小组件,使其更易于开发和维护。我们已经在第 8 章中使用 React 构建了一个项目,所以这一次,我们将选择 Svelte,一个数据可视化社区特别喜欢的简单编译器。如果您还不熟悉Svelte,请不要担心。本章的重点仍将放在创建复杂数据可视化项目背后的一般原则上。您可以一起阅读并收集一点点智慧,而不必潜入 Svelte。如果您以前玩过 Svelte 或想尝试一下,您会发现它非常直观,并且可以很好地与 D3 配合使用。您可以在附录 E 中找到对 Svelte 的简要介绍,并在 https://svelte.dev/tutorial 中找到方便的一口大小的教程。
我们希望在将 D3 与 JavaScript 框架或 Svelte 等编译器相结合时将职责分开。该框架负责添加、删除和操作 DOM 元素,而 D3 用于执行与比例、形状生成器、力布局等可视化相关的计算。简而言之,您需要忘记数据绑定模式,并谨慎使用 D3 转换以避免 D3 和 Svelte 之间的冲突。回到第8章,深入讨论将D3与框架相结合的可能方法。
若要开始处理本章的项目,请在代码编辑器中打开 https://github.com/d3js-in-action-third-edition/code-files/tree/main/chapter_14/14.4.1-Responsive_SVG_container/start/src/data 的开始文件夹。打开集成终端并使用 npm install 安装项目依赖项。然后用 npm run dev 启动项目。该项目将在您的浏览器中提供,网址为 http://localhost:5173/ .您将在 src/ 文件夹中找到我们将处理的所有文件。
从第一章开始,我们采用了一种简单而有效的方法来使SVG图形响应:通过设置SVG容器的viewBox属性并将宽度和高度属性留空。这种方法非常容易实现,并且开箱即用。唯一真正的缺点是,当 SVG 容器变小时,它包含的文本元素会按比例变小,使它们可能难以阅读。
在此项目中,我们将采用不同的方法,设置 SVG 容器的宽度和高度属性并将 viewBox 留空。每当屏幕尺寸发生变化时,我们将使用事件侦听器来更新这些属性。尽管这种方法需要我们作为程序员和浏览器付出更多的努力,但它使我们能够根据屏幕宽度调整可视化的布局。此外,随着屏幕尺寸的减小,它会保持文本标签的大小。
在之前的项目讨论中,我们决定显示一个由小型多个可视化效果组成的网格。所有这些可视化将包含在单个 SVG 元素中。此外,我们将使用一个 12 列的 flexbox 网格,类似于第 9 章中讨论的网格,用于包括时间轴和可视化效果在内的整体页面布局。
在图 14.8 中,您可以看到三种不同屏幕宽度的页面布局:大于或等于 1400px(flexbox 网格容器的宽度)、小于 1400px 但大于 748px 和小于 748px。对于这三种屏幕尺寸中的每一种,SVG 容器宽度的计算略有不同。当屏幕大于或等于 748px 时,时间线显示在左侧,从 12 列网格中取出两列,可视化效果或 SVG 容器显示在右侧剩余的十列上。当屏幕小于 748px 时,我们会删除时间线,可视化效果可以扩展到 12 列。弹性框网格容器还应用了 30px 的填充到左侧和右侧。
弹性框网格在其 CSS 属性中的最大宽度限制为 1400 像素。这意味着即使在较大的屏幕上,内容也不会超过此宽度。要计算宽度超过 1400px 的屏幕上 SVG 容器的宽度,我们可以减去 flexbox 网格容器两侧的填充,将其乘以 12,然后除以 <>。这是因为 SVG 容器跨越十二列中的十列。
svg宽度=10/12 * (网格容器 - 2*填充)
当屏幕小于 1400 像素时,SVG 容器的大小会按比例变小。在 svgWidth 的方程中,我们只需要更改窗口宽度的 gridContainer。
svg宽度=10/12 * (窗口宽度 - 2*填充)
最后,当屏幕小于 768px 时,SVG 容器将分布在屏幕的整个宽度减去填充。
svg宽度=窗口宽度 - 2*填充
在示例 14.2 中,我们使用这些方程来动态计算 SVG 容器的宽度。为此,我们在文件Grid.svelte中工作。我们首先声明两个变量,一个用于 windowWidth,一个用于 SVG width。使用 switch 语句,我们根据屏幕的宽度和刚才讨论的方程设置 SVG width 变量的值。因为switch语句是用Svelte反应符号($)声明的,所以只要变量包含更改,它就会运行。
请注意我们如何将 windowWidth 变量绑定到 window 对象的 innerWidth 属性。在 Svelte 中,我们可以从任何组件访问窗口对象 <svelte:window /> .
最后,我们使用 svgWidth 变量动态设置 SVG 容器的 width 属性。因为我们使用的是 JavaScript 框架,所以我们不使用 D3 将 SVG 元素附加到 DOM 中,而是直接将其添加到组件的标记中。
<script>
let windowWidth;
const gridContainer=1400;
const padding=30;
let svgWidth;
$: switch (true) { #A
case windowWidth >=gridContainer: #A
svgWidth=(10 / 12) * (gridContainer - 2 * padding); #A
break; #A
case windowWidth < gridContainer && windowWidth >=768: #A
svgWidth=(10 / 12) * (windowWidth - 2 * padding); #A
break; #A
default: #A
svgWidth=windowWidth - 2 * padding; #A
} #A
</script>
<svelte:window bind:innerWidth={windowWidth} /> #B
<svg width={svgWidth} /> #C
<style>
svg {
border: 1px solid magenta;
}
</style>
在“样式”部分中,已将洋红色边框添加到 SVG 元素中。您可以尝试调整屏幕大小,以查看它如何影响 SVG 元素的宽度。
响应式 SVG 宽度
现在处理了 SVG 容器的宽度,我们需要设置它的高度。由于 SVG 元素将包含一个由多个小型可视化组成的网格,因此如果我们知道:可视化的数量、它们的高度和网格中的列数,我们可以计算它的高度。在示例 14.3 中,我们首先声明了我们想要可视化梵高工作的年份数组。我们使用 D3 的范围方法做到这一点。然后,我们根据屏幕的宽度设置网格的列数。如果屏幕大于 900px,我们需要三列,如果小于 600px,我们需要一列,在两者之间,我们需要两列。我们现在使用大概的数字,如果需要,我们会在以后进行调整。
一旦我们知道了列数,我们就可以通过将小型多个可视化的数量除以列数并将结果四舍五入来计算行数。通过将 SVG 元素的宽度除以列数来找到每个小序列图的宽度。我们还任意将它们的高度设置为宽度加 40px。最后,我们通过将行数乘以每个小倍数的高度来找到 SVG 元素的总高度。
由于 svgWidth 和 svgHeight 变量在组件挂载时为 null,因此浏览器将引发错误。这就是为什么我们仅在定义了这两个变量后才使用条件语句将 SVG 元素添加到标记中。请注意 switch 语句和维度变量如何使用 $ 符号进行响应。每次屏幕宽度更改时,它们都会更新。
我们有一个响应式 SVG 元素!这个实现需要比我们以前的策略更多的工作,但在下一节中使用响应式 SVG 网格时会很有帮助。
<script>
import { range } from "d3-array";
...
const years=range(1881, 1891); #A
let numColumns; #B
$: switch (true) { #B
case windowWidth > 900: #B
numColumns=3; #B
break; #B
case windowWidth <=900 && windowWidth > 600: #B
numColumns=2; #B
break; #B
default: #B
numColumns=1; #B
} #B
$: numRows=Math.ceil(years.length / numColumns); #C
$: smWidth=svgWidth / numColumns; #D
$: smHeight=smWidth + 40; #D
$: svgHeight=numRows * smHeight; #E
</script>
<svelte:window bind:innerWidth={windowWidth} />
{#if svgWidth && svgHeight} #F
<svg width={svgWidth} height={svgHeight} /> #F
{/if} #F
<style>
svg {
border: 1px solid magenta;
}
</style>
在最后一个列表中,我们使用变量 smWidth 和 smHeight 确定每个网格项的宽度和高度。使用这些值,我们将构建将保存所有可视化效果的网格。由于我们在 SVG 容器中工作,因此我们将使用组元素来包围每个小倍数。
首先,在清单 14.4 中,我们在 SVG 容器中插入一个 each 块,用于遍历先前创建的 years 数组。值得注意的是,我们可以访问每年的索引(i)作为第二个参数。我们每年创建一个组元素,然后使用 transform 属性应用翻译。为了确定每个组属于哪一列,我们使用索引的余数,也称为模数(% ),除以列数。下面的等式说明了三列布局中介于 <> 和 <> 之间的索引的余数。然后,我们通过将余数乘以 smWidth 来计算水平平移。
0 % 3=0
1 % 3=1
2 % 3=2
3 % 3=0
4 % 3=1
5 % 3=2
等等...
对于垂直平移,我们将索引四舍五入除以列数,以了解我们在哪一行,然后将结果乘以网格元素的高度。然后,我们在组中附加一个矩形元素,将其尺寸设置为网格项的宽度和高度,并为其提供蓝色笔触。我们添加此矩形以确保网格按预期工作,并在屏幕宽度更改时正确调整大小,但我们不会将其保留在最终可视化效果中。
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i} #A
<g transform="translate( #B
{(i % numColumns) * smWidth}, #B
{Math.floor(i / numColumns) * smHeight})" #B
> #B
<rect x={0} y={0} width={smWidth} height={smHeight} /> #C
</g>
{/each}
</svg>
{/if}
<style>
svg {
border: 1px solid magenta;
}
rect {
fill: none;
stroke: cyan;
}
</style>
实现网格后,调整屏幕大小以确保网格项的列数和位置按预期调整。当屏幕大于 900px 时,网格应有三列,600 到 900px 之间应有两列,如果小于 600px,则有一列,如图 14.9 所示。
响应式 SVG 网格
准备好项目骨架后,我们可以开始利用 D3 来创建梵高作品的可视化!在本节中,我们将构建我们的小的多重可视化,从轴和标签开始,继续绘画,最后是绘图和字母。
我们的小型多重可视化的主干可以简化为背景圆圈和年份标签。但在实施这些元素之前,我们需要定义它们的确切位置。图 14.10 显示了在定位圆圈和年份标签之前需要考虑的不同参数的草图。我们已经计算了每个小倍数的宽度(smWidth)和高度(smHeight)。为了确保可视化之间有足够的空间并为月份标签留出空间,我们可以定义要在每个圆圈周围应用的填充,比如说 60px。根据这个值和网格元素的宽度,我们可以计算背景圆的半径。
我们将开始在 Grid.svelte 的子组件中构建可视化,名为 GridItem.svelte 。在清单 14.5 中,我们首先将此组件导入到 Grid.svelte 中。然后,我们将 GridItem 附加到每个块中,这将从年份数组中生成每年的 GridItem。我们将 smWidth 、smHeight 和当前年份作为道具传递给这个子组件。
<script>
import GridItem from "./GridItem.svelte"; #A
...
</script>
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i}
<g transform="translate(
{(i % numColumns) * smWidth},
{Math.floor(i / numColumns) * smHeight})"
>
<rect x={0} y={0} width={smWidth} height={smHeight} />
<GridItem {smWidth} {smHeight} {year} /> #B
</g>
{/each}
</svg>
{/if}
在清单 14.6 中,我们开始在 GridItem.svelte 中工作。我们在脚本标签中导入道具 smWidth , smHeight 和 year。然后,我们将填充常量设置为值 60,并根据填充和 smWidth 计算圆的半径。因为半径被声明为一个反应变量 ($),所以只要 smWidth 发生变化,它就会被重新计算。
在标记中,我们使用两个组元素来设置可视化的相对坐标系的原点。第一个水平转换为半 smWidth .它用作年份标签的参考点,然后只需将其垂直平移到网格项的底部。第二组元素垂直平移到背景圆的中心。当我们开始向可视化追加其他形状以表示绘画、素描和字母时,此策略将特别方便。
<script>
export let smWidth; #A
export let smHeight; #A
export let year; #A
const padding=60; #B
$: radius=(smWidth - 2 * padding) / 2; #B
</script>
<g transform="translate({smWidth / 2}, 0)"> #C
<g transform="translate(0, {padding + radius})"> #C
<circle cx={0} cy={0} r={radius} />
</g>
<text x={0} y={smHeight - 5} text-anchor="middle">{year}</text>
</g>
<style lang="scss">
circle {
fill: none;
stroke: $text;
}
</style>
下一步是为每个月添加一个轴和标签,如图 14.10 所示。此图显示圆坐标系的原点位于其中心,这要归功于之前翻译的 SVG 组。每个月的轴将是一条从原点开始并到达圆周的线,每个月的角度都不同。
为了计算轴端点的位置,我们需要做一些三角函数。让我们以二月的轴为例。在图 14.11 的右侧,您可以看到我们可以通过将轴与其水平 (x) 和垂直 (y) 边长连接起来来形成一个直角三角形(其中一个角为 90° 角的三角形)。我们也可以称θ(θ)为12点钟位置(零度时)与二月轴之间的角度。
三角函数告诉我们,θ 的正弦等于 x 除以二月轴的长度或背景圆的半径。因此,我们可以通过将半径乘以sinθ来计算端点的水平位置。类似地,θ 的余弦等于 y 除以二月轴的长度。因此,我们可以通过将半径乘以 cosθ 和 -1 来计算端点的垂直位置,因为我们正朝着垂直轴的负方向前进。
sinθ=x / 半径=> x=半径 * sinθ
余量θ=y / 半径=> y=半径 * 余量θ
为了绘制月份轴,我们继续在 网格项目.svelte .我们首先声明一个点刻度,该刻度将月份数组作为输入(该数组在文件 /utils/months.js 中可用)并返回相应的角度。我们希望 12 月显示在 360 点钟位置,对应于零角度。我们知道,一个完整的圆覆盖 2° 或 2π 弧度。因为一年有十二个月,所以我们将刻度中的最后一个角度设置为 2π - 12π/<> 弧度,或一个完整的圆减去十二分之一的圆。
在标记中,我们使用每个块为每个月附加一个行元素。每条线的起点是 (0, 0) ,而它的端点是用刚才讨论的三角函数计算的。
<script>
import { scalePoint } from "d3-scale";
import { months } from "../utils/months";
export let smWidth;
export let smHeight;
export let year;
const padding=60;
$: radius=(smWidth - 2 * padding) / 2;
const monthScale=scalePoint() #A
.domain(months) #A
.range([0, 2 * Math.PI - (2 * Math.PI) / 12]); #A
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle cx={0} cy={0} r={radius} />
{#each months as month} #B
<line #B
x1="0" #B
y1="0" #B
x2={radius * Math.sin(monthScale(month))} #B
y2={-1 * radius * Math.cos(monthScale(month))} #B
stroke-linecap="round" #B
/> #B
{/each} #B
</g>
<text x={0} y={smHeight - 5} text-anchor="middle">{year}</text>
</g>
<style lang="scss">
circle {
fill: none;
stroke: $text;
}
line {
stroke: $text;
stroke-opacity: 0.2;
}
</style>
作为最后一步,我们要在每个月的轴上添加一个标签,在圆圈外 30px。在示例 14.8 中,我们为每个月附加一个文本元素,并使用 JavaScript slice() 方法将文本设置为该月的前三个字母。为了正确定位文本标签,我们执行翻译,然后旋转。我们发现带有三角函数的平移,类似于我们计算轴端点的方式。对于圆圈上半部分(9 点钟和 3 点钟之间)显示的标签,旋转与其轴相同。对于下半部分(3点钟和9点钟之间)的标签,我们给它们额外的180°旋转,以便它们更容易阅读。
虽然我们在 JavaScript Math.sin() 和 Math.cos() 函数中使用弧度,但旋转的转换属性需要度数。为了便于从弧度到度数的转换,我们创建了一个名为 radiansToDegrees() 的辅助函数,您可以在 /utils/helper.js 中找到该函数。它采用弧度的角度作为输入,并返回相同的弧度角度。
<script>
import { scalePoint } from "d3-scale";
import { months } from "../utils/months";
import { radiansToDegrees } from "../utils/helpers";
export let smWidth;
export let smHeight;
export let year;
const padding=60;
$: radius=(smWidth - 2 * padding) / 2;
const monthScale=scalePoint()
.domain(months)
.range([0, 2 * Math.PI - (2 * Math.PI) / 12]);
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle cx={0} cy={0} r={radius} />
{#each months as month}
<line
x1="0"
y1="0"
x2={radius * Math.sin(monthScale(month))}
y2={-1 * radius * Math.cos(monthScale(month))}
stroke-linecap="round"
/>
<text #A
class="month-label"
transform="translate( #B
{(radius + 30) * Math.sin(monthScale(month))}, #B
{-1 * (radius + 30) * Math.cos(monthScale(month))} #B
) #B
rotate({ #C
monthScale(month) <=Math.PI / 2 || #C
monthScale(month) >=(3 * Math.PI) / 2 #C
? radiansToDegrees(monthScale(month)) #C
: radiansToDegrees(monthScale(month)) - 180 #C
})" #C
text-anchor="middle"
dominant-baseline="middle">{month.slice(0, 3)}</text #D
>
{/each}
</g>
<text x={0} y={smHeight - 5} text-anchor="middle">{year}</text>
</g>
<style lang="scss">
circle {
fill: none;
stroke: $text;
}
line {
stroke: $text;
stroke-opacity: 0.2;
}
.month-label {
font-size: 1.4rem;
}
</style>
准备好轴后,我们可以开始绘制可视化效果了。我们将首先用一个圆圈来表示梵高的每幅画。这些圆圈将围绕相应月份轴的端点按年份分组,然后按创建月份分组。如第14.3节所述,圆圈的颜色将基于绘画的主题及其在媒介上的边框。最后,我们将圆的面积设置为与相关图稿的尺寸成比例。图 14.12 显示了我们所追求的效果。为了在避免重叠的同时生成圆簇,我们将使用 D3 的力布局。
在进一步进入代码之前,让我们花点时间思考一下组件的体系结构,并制定最佳前进方向的战略。我们的小倍数可视化由三层组件组成,如图 14.13 所示。第一个由 Grid.svelte 组件持有,该组件负责将 SVG 容器添加到标记中,并将年份分解为类似网格的布局。该组件“知道”我们将生成可视化的所有年份。
第二层由 GridItem.svelte 处理。此组件仅“感知”单个年份的数据,并显示其相应的年份标签和月份轴。最后,还有组件 绘画.svelte , 图纸.svelte 和 字母.svelte 。我们还没有处理这些文件,但它们包含在 chart_components/ 文件夹中。顾名思义,这些组件负责可视化一年中产生的绘画、素描和信件。因为它们将从GridItem.svelte调用,所以它们也将知道一年的数据。
考虑到这种分层架构,我们看到加载整个绘画数据集的最佳位置是 Grid.svelte ,因为该组件监督可视化的整体性,并且在应用程序中仅加载一次。然后,该组件会将每年对应的绘画作为道具传递给GridItem.svelte,然后将它们传递给Paintings.svelte。
基于这个逻辑,在清单 14.9 中,我们回到 Grid.svelte 并导入绘画数据集。由于我们稍后希望根据绘画的尺寸调整代表绘画的圆圈的大小,因此我们计算这些作品的面积,并使用此信息查找数据集中最大的绘画尺寸。请注意,数据集中有一些绘画的尺寸不可用。在这种情况下,我们将相应圆的半径设置为 3px。
要将绘画的面积(以cm 2为单位)缩放为屏幕上的圆形区域(以px2为单位),我们可以使用线性比例。我们称此比例为绘画AreaScale,并使用圆的面积公式找到范围覆盖的最大面积:
a=πr2
最后,我们将显示绘画所需的数据和函数传递给 GridItem .请注意我们如何过滤绘画数据集以仅传递与当前年份对应的绘画。
<script>
import { range, max } from "d3-array";
import { scaleLinear } from "d3-scale";
import paintings from "../data/paintings.json";
...
paintings.forEach((painting)=> { #A
if (painting.width_cm && painting.height_cm) { #A
painting["area_cm2"]=painting.width_cm * painting.height_cm; #A
} #A
}); #A
const maxPaintingArea=max(paintings, (d)=> d.area_cm2); #A
const maxPaintingRadius=8; #B
const paintingDefaultRadius=3; #B
const paintingAreaScale=scaleLinear() #B
.domain([0, maxPaintingArea]) #B
.range([0, Math.PI * Math.pow(maxPaintingRadius, 2)]); #B
</script>
<svelte:window bind:innerWidth={windowWidth} />
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i}
<g
transform="translate(
{(i % numColumns) * smWidth},
{Math.floor(i / numColumns) * smHeight})"
>
<rect x={0} y={0} width={smWidth} height={smHeight} />
<GridItem
{smWidth}
{smHeight}
{year}
{paintingAreaScale} #C
{paintingDefaultRadius} #C
paintings={paintings.filter((painting)=> #C
painting.year===year)} #C
/>
</g>
{/each}
</svg>
{/if}
在 GridItem.svelte 中,我们所要做的就是声明从 Grid.svelte 接收的道具,导入 Paintings 组件,将 Paintings 组件添加到标记中,然后传递相同的 props,如清单 14.10 所示。
<script>
import Paintings from "../chart_components/Paintings.svelte"; #A
export let paintingAreaScale; #B
export let paintingDefaultRadius; #B
export let paintings; #B
...
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle ... />
{#each months as month}
<line ... />
<text ... >{month.slice(0, 3)}</text>
{/each}
<Paintings #C
{paintingAreaScale} #C
{paintingDefaultRadius} #C
{paintings} #C
{monthScale} #C
{radius} #C
/> #C
</g>
<text ... >{year}</text>
</g>
最后,真正的动作发生在《画画》中。现在,我们循环浏览作为道具收到的画作,并在每幅画的标记中添加一个圆圈。这些圆的初始位置是它们相关月份轴的尖端,这可以通过我们之前使用的三角函数找到。我们还必须考虑我们不知道它们是在哪个月创作的画作。我们将它们放置在可视化效果的中心。
为了计算圆的半径,我们称之为 绘画面积比例 .由于此刻度返回一个面积,因此我们需要使用以下公式计算相应的半径:
r=√(a/π)
<script>
export let paintingAreaScale; #A
export let paintingDefaultRadius; #A
export let paintings; #A
export let monthScale; #A
export let radius; #A
</script>
{#each paintings as painting} #B
<circle #B
cx={painting.month !=="" #C
? radius * Math.sin(monthScale(painting.month)) #C
: 0} #C
cy={painting.month !=="" #C
? -1 * radius * Math.cos(monthScale(painting.month)) #C
: 0} #C
r={painting.area_cm2 #D
? Math.sqrt(paintingAreaScale(painting.area_cm2) / Math.PI) #D
: paintingDefaultRadius} #D
/> #D
{/each}
在这个阶段,绘画的圆圈在其月轴的顶端重叠,如图 14.14 所示。我们将在一分钟内通过 D3 的力布局解决这个问题。
为了在每个月轴的尖端创建节点集群,我们将使用 D3 的力布局。这种布局有点复杂,所以如果你需要更深入的介绍,我们建议阅读第12章。在示例 14.12 中,我们使用 forceSimulation() 方法初始化一个新的力模拟,我们将绘画数组传递给该方法。我们还声明了一个空节点数组,在每次报价后,我们使用模拟的节点进行更新。然后,我们遍历此节点数组而不是绘画,以将圆圈附加到标记中。
我们计算施加在反应块($ )内节点的力,以便在相关变量发生变化时触发重新计算。在这个块内,定位力(forceX和forceY)将节点推向其月轴的尖端,而碰撞力(forceCollide)确保节点之间没有重叠。
我们还降低了 alpha(模拟的“温度”)并提高了 alpha 衰减率,以帮助模拟更快地收敛。这种调整需要反复试验的方法才能找到正确的设置。
最后,我们使用仿真添加到节点的 x 和 y 属性来设置相应圆的 cx 和 cy 属性。
<script>
import { forceSimulation, forceX, forceY, forceCollide } from "d3-force";
...
let simulation=forceSimulation(paintings); #A
let nodes=[]; #A
simulation.on("tick", ()=> { #B
nodes=simulation.nodes(); #B
}); #B
$: { #C
simulation #C
.force("x", #D
forceX((d)=> d.month !=="" #D
? radius * Math.sin(monthScale(d.month)) #D
: 0 #D
).strength(0.5) #D
) #D
.force("y", #D
forceY((d)=> d.month !=="" #D
? -1 * radius * Math.cos(monthScale(d.month)) #D
: 0 #D
).strength(0.5) #D
) #D
.force("collide", #E
forceCollide() #E
.radius((d)=> d.width_cm===null && d.height_cm===null #E
? paintingDefaultRadius + 1 #E
: Math.sqrt(paintingAreaScale(d.area_cm2) / Math.PI) + 1 #E
).strength(1) #E
) #E
.alpha(0.5) #F
.alphaDecay(0.1); #F
}
</script>
{#each nodes as node}
<circle
cx={node.x} #G
cy={node.y} #G
r={node.area_cm2
? Math.sqrt(paintingAreaScale(node.area_cm2) / Math.PI)
: paintingDefaultRadius}
/>
{/each}
现在,您应该会看到节点群集出现在月份轴的提示处。为了完成绘画可视化,我们将根据圆圈相应绘画的主题设置圆圈的颜色。该文件实用程序/主题.js包含可用的绘画主题及其颜色的数组。在示例 14.13 中,我们声明了一个序数尺度,它将主题作为输入并返回相应的圆圈。然后,我们所要做的就是通过调用此刻度来设置圆圈的填充属性。
<script>
import { scaleOrdinal } from "d3-scale";
import { subjects } from "../utils/subjects";
...
const colorScale=scaleOrdinal() #A
.domain(subjects.map((d)=> d.subject)) #A
.range(subjects.map((d)=> d.color)); #A
</script>
{#each nodes as node}
<circle
cx={node.x}
cy={node.y}
r={node.area_cm2
? Math.sqrt(paintingAreaScale(node.area_cm2) / Math.PI)
: paintingDefaultRadius}
fill={colorScale(node.subject)} #B
/>
{/each}
我们已经完成了对绘画的可视化!此时,您的可视化效果将类似于图 14.15 中的可视化效果。
我们的下一步是绘制一个面积图,可视化梵高每年完成的绘画数量。在第 4 章中,我们学习了如何使用 D3 的形状生成器来计算折线图和面积图路径元素的 d 属性。在这里,我们将使用与形状生成器 lineRadial() 类似的策略,该策略在 d3 形状模块中可用。
与上一节一样,我们希望考虑用于渲染可视化的三层 Svelte 组件。我们将在 Grid.svelte 中加载整个图纸数据集,并计算一个月的最大图纸数量。我们还将重新组织数据集以每年拆分信息,如清单 14.14 所示。我们将此信息传递给 GridItem.svelte,并初始化一个刻度,负责计算与许多绘图对应的沿月轴的径向位置(参见示例 14.15),并将所有这些信息传递给 Drawings.svelte,后者将绘制面积图。
<script>
import drawings from "../data/drawings.json";
import { months } from "../utils/months";
...
const yearlyDrawings=[]; #A
years.forEach((year)=> { #A
const relatedDrawings={ year: year, months: [] }; #A
months.forEach((month)=> { #A
relatedDrawings.months.push({ #A
month: month, #A
drawings: drawings.filter(drawing=> #A
drawing.year===year.toString() && #A
drawing.month===month), #A
}); #A
}); #A
yearlyDrawings.push(relatedDrawings); #A
}); #A
const maxDrawings=max(yearlyDrawings, d=> #B
max(d.months, (i)=> i.drawings.length) #B
); #B
</scrip>
<svelte:window bind:innerWidth={windowWidth} />
{#if svgWidth && svgHeight}
<svg width={svgWidth} height={svgHeight}>
{#each years as year, i}
<g
transform="translate(
{(i % numColumns) * smWidth},
{Math.floor(i / numColumns) * smHeight})"
>
<rect x={0} y={0} width={smWidth} height={smHeight} />
<GridItem
{smWidth}
{smHeight}
{year}
{paintingAreaScale}
{paintingDefaultRadius}
paintings={paintings.filter((painting)=>
painting.year===year)}
{maxDrawings} #C
drawings={yearlyDrawings.find((d)=> d.year===year).months} #C
/>
</g>
{/each}
</svg>
{/if}
<script>
import Drawings from "../chart_components/Drawings.svelte";
export let maxDrawings;
export let drawings;
...
$: radialScale=scaleLinear() #A
.domain([0, maxDrawings]) #A
.range([0, 2 * radius]); #A
</script>
<g transform="translate({smWidth / 2}, 0)">
<g transform="translate(0, {padding + radius})">
<circle ... />
...
<Drawings {drawings} {monthScale} {radialScale} /> #B
</g>
<text ... >{year}</text>
</g>
在清单 14.16 中,我们使用 D3 的 lineRadial() 方法来初始化一个线生成器。如第 4 章所述,我们设置其访问器函数来计算每个数据点的位置。但是这一次,我们使用的是极坐标而不是笛卡尔坐标,因此有必要使用 angle() 和 radius() 函数。当我们将 path 元素附加到标记时,我们调用行生成器来设置其 d 属性。在样式中,我们给它一个半透明的填充属性。
<script>
import { lineRadial, curveCatmullRomClosed } from "d3-shape";
export let drawings;
export let monthScale;
export let radialScale;
const lineGenerator=lineRadial() #A
.angle((d)=> monthScale(d.month)) #A
.radius((d)=> radialScale(d.drawings.length)) #A
.curve(curveCatmullRomClosed); #A
</script>
<path d={lineGenerator(drawings)} /> #B
<style lang="scss">
path {
fill: rgba($secondary, 0.25);
pointer-events: none;
}
</style>
图14.16显示了1885年的面积图。
我们将可视化梵高作品的最后一部分是他每个月写的信的数量。因为您现在拥有所有必需的知识,所以请自己试一试!
您可以自己完成此项目,也可以按照以下说明进行操作:
1. 在 Grid.svelte 中加载字母数据集。此数据集包含每月写的信件总数。
2. 通过道具将当前年份对应的字母传递给 GidItem.svelte。
3. 在 GidItem.svelte 中,导入字母组件。将其添加到标记中,并将字母数据和比例作为道具传递。
4. 在 Letters.svelte 中,为每个月附加一行,然后根据相关字母的数量并使用三角函数设置行的端点。
如果您在任何时候遇到困难或想将您的解决方案与我们的解决方案进行比较,您可以在附录 D 的 D.14 节和本章代码文件的文件夹 14.5.5-Radial_bar_chart / 末尾找到它。但是,像往常一样,我们鼓励您尝试自己完成它。您的解决方案可能与我们的略有不同,没关系!
为了完成可视化的静态版本,我们注释掉之前用于查看网格布局的矩形和圆形并添加时间线。由于时间轴与 D3 没有太大关系,因此我们不会解释代码,但您可以在附录 D 的清单 D.14.4 和本章代码文件的文件夹 14.5.4 中找到它。您也可以将其视为自己构建的挑战!带有时间轴的完整静态布局如图 14.7 所示。
现在我们的静态项目已经准备就绪,必须退后一步,考虑未来的用户可能想要如何探索它。他们将寻找哪些其他信息?他们会问哪些问题?我们可以通过互动来回答这些问题吗?以下是用户可能会提出的三个问题示例:
我们可以通过简单的交互来回答这些问题:前两个带有工具提示,最后一个带有交叉突出显示。由于本章已经很长了,并且此类交互与 D3 无关(在框架中,我们倾向于避免使用 D3 的事件侦听器,因为我们不希望 D3 与 DOM 交互),因此我们不会详细介绍如何实现它们。本节的主要重点是为您提供一个示例,说明如何规划对项目有意义的交互。您可以在在线托管项目 (https://d3js-in-action-third-edition.github.io/van_gogh_work/) 上使用这些交互,并在本章代码文件的文件夹 14.6 中找到代码。下图也显示了它们的实际效果。
我们的项目到此结束!我们希望它能激发您对可视化的创意。如果您想更深入地了解将 D3 与 Svelte 相结合以实现交互式数据可视化,我们强烈推荐 Connor Rothschild 的 Svelte 更好的数据可视化课程 (https://www.newline.co/courses/better-data-visualizations-with-svelte)。
篇文章就来介绍下如何使用 vue3 + ts + svg + ECharts 实现一个如下所示的双十一数据大屏页面:
执行命令 npm create vue@latest 创建基于 Vite 构建的 vue3 项目,功能选择如下:
我选择使用 pnpm 安装项目依赖:pnpm i,各安装包的版本号可见于下图:
在 vite.config.ts 中添加配置,以便在项目启动时能自动打开浏览器:
typescript
复制代码
export default defineConfig({ // ... server: { open: true } })
现在,就可以通过 pnpm dev 启动新建的项目了。
大屏适配的方案有很多,比如 rem、vw 和 flex 布局等,我选择使用缩放(scale)的方式来适配大屏,因为该方案使用起来比较简单,也不用考虑第三方库的单位等问题。
假设设计稿的尺寸为 1920 * 1080px,为了保证效果,在大屏中放大时应该保持宽高比 designRatio 不变,designRatio 为 1920 / 1080 ≈ 1.78。放大的倍数 scaleRatio,可以分为以下 2 种情况计算:
具体代码我封装成了一个 hook:
// 屏幕适配,src\hooks\useScreenAdapt.ts
import _ from 'lodash'
import { onMounted, onUnmounted } from 'vue'
export default function useScreenAdapt(dWidth: number=1920, dHeight: number=1080) {
// 节流
const throttleAdjustZoom=_.throttle(()=> {
AdjustZoom()
}, 1000)
onMounted(()=> {
AdjustZoom()
// 响应式
window.addEventListener('resize', throttleAdjustZoom)
})
// 释放资源
onUnmounted(()=> {
window.removeEventListener('resize', throttleAdjustZoom)
})
function AdjustZoom() {
// 设计稿尺寸及宽高比
const designWidth=dWidth
const designHeight=dHeight
const designRatio=designWidth / designHeight // 1.78
// 当前屏幕的尺寸及宽高比
const deviceWidth=document.documentElement.clientWidth
const devicHeight=document.documentElement.clientHeight
const deviceRatio=deviceWidth / devicHeight
// 计算缩放比
let scaleRatio=1
// 如果当前屏幕的宽高比大于设计稿的,则以高度比作为缩放比
if (deviceRatio > designRatio) {
scaleRatio=devicHeight / designHeight
} else {
// 否则以宽度比作为缩放比
scaleRatio=deviceWidth / designWidth
}
document.body.style.transform=`scale(${scaleRatio}) translateX(-50%)`
}
}
最后是给 body 添加了 transform 属性,为了实现居中效果,还需要给 body 添加上相应样式:
/* \src\assets\base.css */
* {
box-sizing: border-box;
}
body {
position: relative;
margin: 0;
width: 1920px;
height: 1080px;
transform-origin: left top;
left: 50%;
background-color: black;
}
为避免改变屏幕尺寸时过于频繁触发 AdjustZoom,我借助 lodash 的 throttle 方法做了个节流,这就需要安装 lodash:pnpm add lodash。因为用到了 ts,如果直接引入使用 lodash 会遇到如下报错:
我们需要引用它的声明文件,才能获得对应的代码补全、接口提示等功能。提示里已经告诉了我们解决办法,就是去安装 @types/lodash:pnpm add -D @types/lodash,之后就能在 ts 文件中正常使用 lodash 了。
页面头部使用的就是一张 svg,样式中给 #top 添加绝对定位 position: absolute; ,目的在于开启一个单独的渲染层,以减少之后添加动画造成的回流损耗:
<template>
<main class="main-bg">
<div id="top"></div>
</main>
</template>
<style scoped>
#top {
position: absolute;
width: 100%;
height: 183px;
background-size: cover;
background-image: url(@/assets/imgs/top_bg.svg);
}
</style>
作为背景引入的 top_bg.svg 是我使用 Illustrator 绘制后导出的,绘制时注意做好图层的命名:
因为图层的名称会影响到导出的 svg 文件中元素的 id 名称。另外导出的 svg 文件中也可能存在一些中文命名或一些不必要的代码,我们可以自行修改:
使用 Illustrator 绘制的都是静态图形,现在我们以其中一个圆球为例,添加上平移的动画以及高斯模糊的滤镜:
<!-- top_bg.svg 部分代码 -->
<?xml version="1.0" encoding="UTF-8"?>
<svg id="top-bg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1920 183">
<defs>
<style>
#circle-1 {
opacity: 0;
transform: translate(800px, -18px) scale(0.5);
animation: circle-1-ani 1.8s ease-out forwards infinite;
}
@keyframes circle-1-ani {
90%,
100% {
opacity: 0.95;
transform: translate(600px, 80px) scale(1);
}
}
</style>
<filter id="blurMe">
<feGaussianBlur stdDeviation="2" />
</filter>
</defs>
<circle id="circle-1" class="cls-1" r="12.96" filter="url(#blurMe)" />
</svg>
动画使用 css 定义,可以直接写在 <defs> 里的 <style> 中。一旦用到 transform,那么圆的坐标系就会移动到圆的中心点,所以我将原本 <circle> 中的用于定义圆心坐标的 cx 和 cy 属性删除了,通过在 #circle-1 中直接使用 transform: translate(800px, -18px); 来定位圆的初始位置:
滤镜定义在 <defs> 里的 <filter> 中,使用的是高斯模糊 <feGaussianBlur>, stdDeviation 用于指定钟形(bell-curve),可以理解为模糊程度。在圆形 <circle> 上通过 filter 属性,传入滤镜的 id 应用滤镜。
首先是安装 ECharts:pnpm add echarts。在 npm 的仓库搜索 echarts 可以看到其带有如下所示的 ts 标志:
说明它的库文件中已经包含了 .d.ts 文件:
所以不需要像上面使用 lodash 那样再去额外安装声明文件了。
接着就可以封装 echarts 组件了。组件中只需要提供一个展示图表的 dom 容器 <div>,然后在 onMounted(确保可以获取到 dom 容器) 中创建一个 ECharts 实例 myChart,最后通过 myChart.setOption(option) 传入从父组件获取的图表实例的配置项以及数据 option:
<!-- src\components\BaseEChart.vue -->
<template>
<div ref="mainRef" :style="{ width: width, height: height }"></div>
</template>
<script lang="ts" setup>
import * as echarts from 'echarts'
import { onMounted, onUnmounted, ref } from 'vue'
interface IProps {
width?: string
height?: string
chartOption: echarts.EChartsOption
}
const props=withDefaults(defineProps<IProps>(), {
width: '100%',
height: '100%'
})
const mainRef=ref(null)
let myChart: echarts.ECharts | null=null
onMounted(()=> {
myChart=echarts.init(mainRef.value, 'dark', { renderer: 'svg' })
const option=props.chartOption
myChart.setOption(option)
})
onUnmounted(()=> {
// 销毁 echart 实例,释放资源
myChart?.dispose()
})
</script>
以左上角的“人均消费金额排名”柱状图为例,代码如下:
<!-- src\views\HomeView.vue -->
<template>
<main class="main-bg">
<div id="left-top">
<div class="title">人均消费金额排名</div>
<div class="sub-title">Ranking of per capita consumption amount</div>
<BaseEChart :chartOption="amountRankOption" />
</div>
</main>
</template>
<script setup lang="ts">
import BaseEChart from '@/components/BaseEChart.vue'
import { amountRankOption } from './config/amount-rank-option'
</script>
<style scoped>
#left-top {
position: absolute;
top: 130px;
left: 20px;
width: 500px;
height: 320px;
}
</style>
在页面引入 BaseEChart 后,传入定义好的 amountRankOption 即可:
// 人均消费金额排名柱状图配置
import * as echarts from 'echarts'
type EChartsOption=echarts.EChartsOption
export const amountRankOption: EChartsOption={
grid: {
top: 20,
bottom: 50,
left: 40,
right: 40
},
xAxis: {
axisTick: {
show: false // 隐藏 x 坐标轴刻度
},
data: ['思明', '湖里', '集美', '同安', '海沧', '翔安']
},
yAxis: {
axisLabel: {
show: false // 隐藏 y 坐标轴刻度标签
},
splitLine: {
show: false // 隐藏平行于 x 轴的分隔线
}
},
series: [
{
type: 'bar',
data: [5, 20, 36, 10, 10, 20],
barWidth: 20 // 设置柱形的宽度
}
]
}
至于剩下的图表的实现,只是配置不同而已,如有兴趣可以去该项目的 git 仓库查看。
最后添加成交额的数字滚动动画,用到了 countup.js,需要先安装: pnpm add countup.js。
使用时,直接 new CountUp() 生成 countUp,第 1 个参数为要添加动画的 dom 的 id,第 2 个参数为动画结束时显示的数字,还可以传入第 3 个参数 options 实现一些配置,比如设置前缀,小数点等。然后通过 countUp.start() 即可实现动画效果:
<!-- src\components\Digital.vue -->
<template>
<div>
<span class="t1">成交额</span>
<span id="amount" class="t2">150</span>
<span class="t1">亿</span>
</div>
</template>
<script lang="ts" setup>
import { CountUp } from 'countup.js'
import { onMounted } from 'vue'
onMounted(()=> {
const countUp=new CountUp('amount', 150)
if (!countUp.error) {
countUp.start()
} else {
console.error(countUp.error)
}
})
</script>
原文链接:https://juejin.cn/post/7305434729527181322
)实验平台:正点原子开拓者FPGA 开发板
2)摘自《开拓者 Nios II开发指南》关注官方微信号公众号,获取更多资料:正点原子
3)全套实验源码+手册+视频下载地址:http://www.openedv.com/docs/index.html
第十四uC/GUI显示线/点实验
我们在使用 Nios II 的时候会移植 uC/GUI 来制作精美的 UI,所谓 UI 就是 User Interface 的
缩写、GUI 就是 Graphical User Interface 的缩写,即图形用户接口。uC/GUI 是 Micrium 公司研
发的通用的嵌入式用户图像界面软件,可以给任何使用图像 LCD 的应用程序提供单独于处理
器和 LCD 控制器之外的有效的图形用户接口,能够应用于单一任务环境,也能够应用于多任
务环境中。本章我们将向大家介绍如何在 Qsys 中移植 uC/GUI,并以 RGB 接口的 4.3 寸、
480*272 分辨率的 LCD 屏幕为例实现基本的打点画线功能,本章包括以下几个部分:
14.1 简介
14.2 实验任务
14.3 硬件设计
14.4 软件设计
14.5 下载验证
简介
当前主流的小型嵌入式 GUI 主要有:emWin(uC/GUI)、TouchGFX、Embedded Wizard
GUI、uGFX 和 MicroChip GUI。当然,还有其它的 GUI,以上所列的 GUI 基本上都是收费的,
但由于 ST 公司购买了 emWin 的版权,得到了定制版的 emWin,然后改了名字叫 StemWin,
所以当用户在 STM32 芯片上使用 emWin 软件库时,是不需要向 emWin 或 ST 公司付费的。也
正因为 STM32 在工商业的大范围使用,使得 emWin 的使用场合更广、学习资料也更多。另外
uC/GUI 和 emWin 还是有区别的。uC/GUI 的核心代码并不是 Micrium 公司开发的,而是 Segger
公司为 Micrium 公司定制的图形软件库,当然也是基于 Segger 公司的 emWin 图形软件库开发
的。在以前较早的版本程序中 uC/GUI 的源代码是开源的(可以在网上能够找到),但是新版
本的程序 emWin 和 uC/GUI 只对用户提供库文件,是不开源的。这里为了方便对 GUI 感兴趣
的读者可以查看 uC/GUI 的底层,我们采用开源的 uC/GUI3.90 版本进行移植。那么 uC/GUI 有
什么特点呢?
μC/GUI 是一种用于嵌入式应用的图形支持软件,一种用于为任何使用一个图形 LCD 的
应用提供一个高效率的,与处理器和 LCD 控制器无关的图形用户界面。它适合于单一任务和
多任务环境,专用的操作系统或者任何商业的实时操作系统(RTOS)。μC/GUI 以 C 源代码形
式提供。它可以适用于任何尺寸的物理和虚拟显示,任何 LCD 控制器和 CPU。
μC/GUI 很适合大多数的使用黑色/白色和彩色 LCD 的应用程序。它有一个很好的颜色管
理器,允许它处理灰阶。μC/GUI 也提供一个可扩展的 2D 图形库和一个视窗管理器,在使用
一个最小的 RAM 时能支持显示窗口。它的架构基于模块化设计,由不同的模块中的不同层组
成,主要包括:液晶驱动模块,内存设备模块,窗口系统模块,窗口控件模块,反锯齿模块和
触摸屏及外围模块。其主要特性包括丰富图形库,多窗口、多任务机制,窗口管理及丰富窗口
控件类(按钮、检验框、单/多行编辑框、列表框、进度条、菜单等),多字符集和多字体支持,
多种常见图像文件支持,鼠标、触摸屏支持,灵活自由配制等。另外 μC/GUI 可以在嵌入式系
统上运行也可以裸机运行。
μC/GUI 对内存的需求如下:
小的系统(没有视窗管理器)
? RAM:100 字节
? 堆栈:500 字节
? ROM:10~25KB(取决于使用的功能)
大的系统(包括视窗管理器和控件)
? RAM:2~6KB(取决于所需窗口的数量)
? 堆栈:1200 字节
? ROM:30~60KB(取决于使用的功能)
注意,如果应用程序使用许多字体的话,ROM 的需求将增加。以上所有的数值都是粗略
的估计,根据实际的应用有所区别。
另外我们需要说明的一点是对于 μC/GUI 而言屏幕坐标如下:
图 14.1.1 屏幕坐标
显示平面由二维坐标 X 轴和 Y 轴表示,即值(X,Y)。水平刻度被称作 X 轴,而垂直刻度被
称作 Y 轴。在程序中需要用到 X 和 Y 坐标时,X 坐标总在前面。显示屏(或者一个窗口)的
左上角为一默认的坐标(0,0)。正的 X 值方向总是向右;正的 Y 值方向总是向下。上图说明
该坐标系和 X 轴和 Y 轴的方向。另外所有传递到一个 API 函数的坐标总是以像素(屏幕由能
够被单独控制的许多点组成,这些点被称作像素)为单位所指定。大部分 μC/GUI 在它的 API
中向用户程序提供的文本和绘图函数能够在任何指定像素上写或绘制。
实验任务
本章我们首先将 μC/GUI 移植到 Nios II 上运行,然后实现基本的打点画线功能。
硬件设计
本章实验工程可基于《lcd_all_Colorbar》实验上搭建,所以这里我们直接在该实验工程
上进行移植效果显示。硬件设计部分不变,只需修改软件设计部分。
软件设计
我们打开软件工程后,关闭原先的工程,新建一个工程,命名为 qsys_gui,然后将原先工
程的源代码文件添加进来(APP 目录),并将之前的 main.c 替换现在的 hello_world.c。现在我
们开始移植 uC/GUI。
在开始移植 uC/GUI 之前,我们建议大家最好先浏览一下《uC/GUI 中文手册》,该手册可
以在网上下载,也可在我们提供的软件资料中找到,《uC/GUI 中文手册》里面详细的介绍了
uC/GUI 的所有 API 函数及相关例程,并提供了配置说明。通过阅读《uC/GUI 中文手册》,我
们可以进一步了解 uC/GUI,加快移植的速度,减少移植的弯路。下面我们就开始进行移植。
第一步:添加需要的功能文件。
首先我们新建一个名为 uCGUI 的文件夹,用来存放我们需要的 uCGUI 源码,新建好文件
夹以后,我们将光盘中的 uCGUI3.90 源码复制出来并解压,解压完成以后,我们可以看到该源
码中有三个文件夹分别为:Sample 文件夹、Start 文件夹和 Tool 文件夹。首先,我们将 Start 文
件夹中的 Config 文件夹复制到 uCGUI 中,然后我们将 Start 文件夹下的 GUI 文件夹中的所有
文件夹都复制到 uCGUI 中,最后我们再将 Sample 文件夹下的 GUI_X 和 GUIDemo 这两个文
件夹复制到 uCGUI 中,至此我们就完成了移植第一步,最终我们 uCGUI 文件夹中的内容,如
下图所示。
图 14.4.1 uCGUI文件夹中的内容
上图各个文件夹的内容如下:
图 14.4.2 文件夹内容详解
其中 AntiAlias、JPEG、Mendev、Widget、WM 和 GUIDemo 为可选项,前四项可以依据
项目的需要而增删,GUIDemo 是 uC/GUI 自带的 Demo,如果不需要演示该 Demo,则可以不
添加。
第二步:修改相应的配置文件。
首先我们修改 Config 文件夹下的 GUIConf.h 文件,该文件修改后代码如下所示。
1 #ifndef GUICONF_H
2 #define GUICONF_H
3
4 #define GUI_OS (0) /* 支持多任务处理 */
5 #define GUI_SUPPORT_TOUCH (0) /* 支持触摸 */
6 #define GUI_SUPPORT_UNICODE (1) /* 支持 Unicode */
7
8 #define GUI_DEFAULT_FONT &GUI_Font6x8 /* GUI 默认字体 */
9 #define GUI_ALLOC_SIZE 12500 /* 动态内存的大小*/
10 //#define GUI_ALLOC_SIZE 1024*1024
11
12 #define GUI_WINSUPPORT 1 /* 支持窗口管理 */
13 #define GUI_SUPPORT_MEMDEV 1 /* 支持内存设备 */
14 #define GUI_SUPPORT_AA 1 /* 支持抗锯齿显示 */
15
16 #endif /* Avoid multiple inclusion */
该文件是GUI的基本属性配置文件,它有一些开关可以配置,比如是否支持系统(GUI_OS),
是否支持触摸(GUI_SUPPORT_TOUCH)等。如果我们需要支持系统,可将相应的值设为 1,
如果不需要就设为 0,此处我们不需要系统,所以将其设置为 0,其余以次类推。动态内存大
小 GUI_ALLOC_SIZE 可根据需求设置,这里我们设置为 12500,修改好该文件后,我们修改
Config 文件夹下的 GUITouchConf.h 文件,即 GUI 的触摸配置文件,因为在 GUIConf.h 文件中
我们将触摸的宏设置为 0,即不使用触摸功能,所以无需配置该文件,但还是可以看一下该文
件的内容,如下所示。
1 #ifndef GUITOUCH_CONF_H
2 #define GUITOUCH_CONF_H
3
4
5 #define GUI_TOUCH_AD_LEFT 3750
6 #define GUI_TOUCH_AD_RIGHT 300
7 #define GUI_TOUCH_AD_TOP 420
8 #define GUI_TOUCH_AD_BOTTOM 3850
9 #define GUI_TOUCH_SWAP_XY 0
10 #define GUI_TOUCH_MIRROR_X 0
11 #define GUI_TOUCH_MIRROR_Y 1
12
13 #endif /* GUITOUCH_CONF_H */
该文件用来配置触摸屏的一些参数,可根据实际需求来配置。接下来我们修改 Config 文
件夹下的 LCDConf.h 文件,该文件修改后代码如下所示。
1 #ifndef LCDCONF_H
2 #define LCDCONF_H
3
4 /*********************************************************************
5 *
6 * General configuration of LCD
7 *
8 **********************************************************************
9 */
10
11 #define LCD_XSIZE (272) /* 配置 TFT 的水平分辨率 */
12 #define LCD_YSIZE (480) /* 配置 TFT 的垂直分辨率 */
13
14 #define LCD_BITSPERPIXEL (16) /* 每个像素的位数 */
15
16 #define LCD_CONTROLLER (666) /* TFT 控制器的名称 */
17 #define LCD_FIXEDPALETTE (565) /* 调色板格式 */
18 #define LCD_SWAP_RB (1) /* 红蓝反色交换 */
19 // #define LCD_SWAP_XY (1)
20 #define LCD_INIT_CONTROLLER() LCD_L0_Init(); /* TFT 初始化函数 */
21
22 #endif /* LCDCONF_H */
该文件用来设置 TFT LCD 相关的参数,比如 TFT LCD 的分辨率、像素位数等,另外还可
以配置 TFT LCD 的寄存器(若有)和 TFT LCD 初始化入口等,这个文件与硬件直接相关,一
般是根据使用的 TFT LCD 来配置。
第三步:与硬件底层对接
TFT LCD 的对接:uC/GUI 自带了很多驱动,支持很多屏幕,由于我们使用的 4.3 寸 RGB
TFT LCD 屏幕,uC/GUI 自带的驱动中并没有该屏幕的驱动,所以这里先将 uCGUI/LCDDriver
目录下的文件先全部删除,然后添加修改好的屏幕驱动文件 LCD_driver 文件,如下图所示:
图 14.4.3 与硬件底层对接文件
对该文件我们简单的介绍下如果需要修改需要注意的事项。首先 TFT 控制器的名称需要
对应。下图 53 行的 LCD_CONTROLLER 应与我们上面修改的 LCDConf.h 里的#define
LCD_CONTROLLER 相对应。
图 14.4.4 修改参数
其次对于不同的屏幕需要相应修改下面的函数。
图 14.4.5 修改函数
另外如果需要触摸支持的话,还需要触摸的对接,因为这里我们不使用触摸,就不做介绍。
第四步:添加到工程
现在,我们进行最后一步,将 uC/GUI 添加到工程中,实验一下可行性。添加的方法很简
单,将 uCGUI 文件夹复制到该工程目录下,即 qsys/software/qsys_gui 文件夹下,然后我们在
Eclipse 软件工程中刷新该工程(在左边的工程栏按快捷键 F5,或右键点击应用工程文件夹
qsys_gui 后点击 fresh),当然了,我们也可以直接将我们的 uCGUI 文件夹粘贴至我们 Eclipse
软件工程中的结构下。添加完成后,如下图:
图 14.4.6 添加uC/GUI
此时我们还不能使用 uCGUI,还需要将该文件夹的路径添加到我们的工程中。添加方法如
下:我们右键点击应用工程文件夹 qsys_gui,在弹出的菜单栏中点击【Properties】菜单,弹出
属性页面如下图,点击 Nios IIApplication Properties 下的 Nios IIApplication Paths,在 Applicatuin
include directories 栏下点击 Add…按钮,将工程下的 uCGUI文件目录下的子目录除了 GUIDemo
外一个个添加进来(也可只添加我们需要的功能目录)。
图 14.4.7 添加子目录
添加完成后,点击“OK”按钮即可。现在我们 ucgui 的移植基本完成。试一下基本的打点
画线功能。
我们修改 qsys_gui.c 的代码如下:
1 #include <stdio.h>
2 #include "system.h"
3 #include "io.h"
4 #include "alt_types.h"
5 #include "altera_avalon_pio_regs.h"
6 #include "sys/alt_irq.h"
7 #include "unistd.h"
8 #include <string.h>
9 #include "mculcd.h"
10 #include "GUI.h"
11
12 _lcd_gui lcdgui;
13 extern _lcd_dev lcddev; //管理 LCD 重要参数
14
15 //SDRAM 显存的地址
16 alt_u16 *ram =(alt_u16 *)(SDRAM_BASE + SDRAM_SPAN - 2049000);
17
18 int main()
19 {
20 printf("Hello from NiosII!\n");
21
22 MY_LCD_Init(); //LCD 初始化
23 GUI_Init(); //uC/GUI 初始化
24
25 lcdgui.width =lcddev.height;
26 lcdgui.height =lcddev.width;
27
28 GUI_SetBkColor(GUI_VERYLIGHTCYAN); //设置 GUI 背景色
29 GUI_Clear(); //GUI 清屏
30
31 GUI_SetPenSize(10); //设置点的大小
32 GUI_SetColor(GUI_RED); //设置 GUI 前景色
33 GUI_DrawPoint(lcdgui.width/2,lcdgui.height/2); //画点
34 GUI_DrawLine(0,lcdgui.height/2 + 11,lcdgui.width,lcdgui.height/2 + 11); //画线
35
36 alt_dcache_flush_all();
37
38 return 0;
39 }
这里我们先设置了 GUI 背景色,然后清屏,此时清屏会以当前的背景色清屏。使用的带
GUI 的函数都可从《uC/GUI 中文手册》中找到,这里我们就不再做详细的介绍。该代码实现的
功能是在屏幕的正中间画了一个点,然后在点的下面画了一条横线。
软件部分就介绍到这里,接下来我们进行下载验证。
下载验证
讲完了软件工程,接下来我们就将该实验下载至我们的开拓者开发板进行验证。
首先我们将 4.3 寸的 ATK-4.3’RGBLCD 与开发板上的 RGB LCD 接口连接。再将下载器
一端连电脑,另一端与开发板上对应端口连接,最后连接电源线并打开电源开关。
我们在 Quartus II 软件中将 lcd_all_colorbar.sof 文件下载至我们的开拓者开发板,下载完
成后,我们还需要在 Nios II SBT for Eclipse 软件中将 qsys_gui.elf 文件下载至我们的开拓者开
发板,qsys_gui.elf 下载完成以后,我们的 C 程序将会执行在我们的开拓者开发板上。显示的
效果如下图所示。
图 14.5.1 实验结果图
至此,我们的 uC/GUI 移植和实现打点画线实验就完成了。
*请认真填写需求信息,我们会在24小时内与您取得联系。