整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

CSS 中 SMACSS

MACSS(Sentence Modular And Compound Structure)是一种将 CSS 代码分解为相互关联的独立模块的方法。它可以提高 CSS 代码的可重用性、可维护性和可读性。

SMACSS 的优势

* 可重用性:使用模块可以提高 CSS 代码的可重用性。

* 可维护性:结构化代码简化了维护。

* 可读性:清晰的模块结构易于阅读和理解。

SMACSS 的核心概念

1. 句法 (Sentence)

* 完整的 CSS 语句。

* 应该表示一个可重用的独立功能或外观。

2. 模块 (Module)

* 组织 CSS 规则的组。

* 每个模块应表示界面设计中的特定概念或功能。

3. 组合 (Compound)

* 简单的 CSS 选择器,包含多个元素。

* 应用于将多个元素组合成更复杂的结构。

工具和集成

* 各种工具可用于使用 SMACSS。

* 许多 IDE 和 CSS 管理工具集成了 SMACSS 功能。

使用 SMACSS 的优点

* 提高 CSS 代码的可重用性。

* 提高 CSS 代码的可维护性。

* 提高 CSS 代码的可读性。

结论

SMACSS 是一种强大的 CSS 组织方法,可以提高 CSS 代码的可重用性、可维护性和可读性。它是现代 web 开发中使用最广泛的组织方法之一。

章涵盖

  • 探索定量和定性数据的可视化渠道。
  • 将复杂的项目分解为小的 UI 组件。
  • 组合不同的 D3 方法以创建自定义可视化效果。
  • 创建响应式 SVG 网格。
  • 在径向布局上定位视觉元素。

在本书的前三部分中,我们一直在应用各种 D3 技术来开发众所周知的可视化布局,如条形图、流图、直方图、地图等。但是,如果您选择 D3 作为数据可视化工具,那么您很有可能还希望构建复杂且不寻常的可视化。若要创建独特的项目,需要了解 D3 可以使用的不同方法和布局。与其说是详细了解每种方法,不如说是掌握 D3 背后的哲学,并知道在需要时在哪里查找信息。附录 C 中,我们映射了所有 D3 模块及其内容,可以为您提供帮助。创建自定义布局所需的另一项技能是将想法和几何分解为代码的能力,我们将在本章的项目中执行此操作。

该项目将带您了解创建完全自定义可视化的幕后情况,从草图创意到将项目分解为组件,再到将视觉元素渲染到径向布局上。我们将建造的项目探索了文森特梵高在他生命的最后十年中产生的艺术遗产。您可以在 https://d3js-in-action-third-edition.github.io/van_gogh_work/ 找到已完成的项目。

我们将遵循一个六步过程来使这个项目栩栩如生。虽然这不是一成不变的,但这大致是任何数据可视化项目都可以遵循的方法。

  1. 收集数据。
  2. 探索数据。
  3. 草绘布局。
  4. 构建项目框架。
  5. 创建可视化效果。
  6. 规划有意义的交互。

14.1 收集数据

收集和清理数据是任何数据可视化项目中最关键的一步。如果幸运的话,我们得到了现成的数据集,可以直接开始可视化,就像本书以前的项目一样。但通常情况下,我们需要从不同来源收集数据,对其进行分析,清理数据并对其进行格式化。数据收集和操作可能需要大量的时间。它需要耐心和勤奋。在本节中,我们将讨论为本章项目准备数据所经历的不同步骤。

但在我们寻找数据之前,让我们花点时间定义我们想要可视化的信息类型。这个项目的灵感来自Frederica Fragapane的数据可视化研讨会,在此期间,我们使用文森特梵高写给他兄弟西奥的信的数据集。我们对梵高的丰富文学遗产感到震惊,并认为将其与他的著名绘画和素描相结合以深入了解他的整个艺术遗产会很有趣。

所以,我们知道我们想收集有关梵高的绘画、素描和信件的数据。理想情况下,我们希望及时放置这些作品,以可视化他艺术作品的起伏。经过几次谷歌搜索,我们找到了以下资源:

  • 文森特梵高的作品列表:维基百科页面列出了梵高创作的每幅画,以及他随信件一起寄出的草图,按媒介和时期分组。
  • 文森特梵高的绘画列表:维基百科页面列出了梵高创作的每幅画,除了他的信件草图。
  • vangoghletters.org:一个专门介绍梵高信件的网站,按时期分组。
  • 文森特·梵高:一个关于梵高生平的维基百科页面,可以帮助我们更好地了解塑造他艺术作品的事件。

通过探索这些资源,我们还注意到,我们可以根据梵高居住的城市将他的生活分解为几个阶段。例如,他于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技能对于从网页中提取任何信息是多么有价值。

事例 14.1 从梵高的画作中提取绘画信息的脚本 维基百科页面

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)。

14.2 探索数据

在第3章中,我们定义了两类主要数据:定量定性,如图14.1所示。定量数据由数字信息组成,例如股票市场行为价值的起伏或教室里的学生人数。定量数据可以是离散的,由无法细分的整数组成,也可以是连续的,其中数字在细分为较小的单位时仍然有意义。另一方面,定性数据是非数字信息,例如国家列表或星巴克咖啡订单的可用尺寸(矮、高、大、通风等)。定性数据可以是名义数据(值没有特定顺序)或顺序(顺序很重要)。

图14.1 我们可以将数据分为两大类:定量(数字信息)和定性(非数字信息)。定量数据是离散的或连续的,而定性数据是名义的或有序的。


由于我们不会使用相同的通道来可视化不同的数据类型,因此编写一个可用于项目的变量列表并按数据类型组织它们通常很有帮助。此步骤可以帮助我们识别可以使用的不同视觉通道或编码数据的方法。图14.2说明了定量数据通常通过位置(如散点图)、长度(如条形图)、面积(如我们的罗密欧与朱丽叶项目中节点的大小)(见第12章)、角度(如饼图)或连续色标进行可视化。另一方面,定性数据通常使用分类色阶图案符号连接(如网络图)或分层数据的外壳(如圆形包)进行翻译。这样的列表只能是不完整的,因为只要有一点创造力,我们就可以设计出可视化数据的新方法。但它提供了我们可以使用的主要视觉编码的概述。

图 14.2 定量数据通常使用位置、长度、面积、角度和连续色标可视化,而定性数据使用分类色标、图案、符号、连接和外壳。


在这一点上,一个有用的练习包括列出数据集中包含的不同数据属性,识别定量和定性数据,并集思广益我们希望如何可视化主要属性。在图 14.3 中,我们列出了该项目的四个数据集(梵高的画作列表、他的绘画列表、他每月写的信数量以及他职业生涯中居住的城市的时间线),并确定数据属性是定量的(蓝点)还是定性的(红点)。基于这些信息,我们可以开始考虑要创建的可视化。

图 14.3 在数据可视化开始时,列出我们必须识别为定量(蓝点)和定性数据(红点)的所有数据属性会很有帮助。然后,我们可以开始集思广益,讨论如何可视化这些数据属性。


在这个项目中,我们希望在时间轴上展示梵高的艺术作品(绘画、素描和信件),以探索每种表达方式的使用与艺术家在荷兰和法国的移动如何演变之间的相关性。我们希望更多地关注绘画,并允许用户单独探索它们。如果圆圈代表每幅画,我们可以使用圆圈的颜色来传达绘画的主题(肖像、静物、风景等),使用它们的大小作为作品的尺寸,并用圆圈的边框突出显示介质(油画、水彩或印刷品),如图 14.4 所示。这些圆圈将定位在某种时间轴上。

每月制作的图纸和字母数量可以通过条形图或面积图的长度作为次要信息添加。最后,我们知道我们需要一些可点击的时间线来选择和突出梵高在他生命不同时期的作品。

图 14.4 在探索了数据属性并确定了它们的类型之后,我们决定用一个位于时间轴上的圆圈来表示每幅画。圆圈的颜色、大小和边框将传达绘画的主题、尺寸和媒介。我们将通过条形图的长度和面积图可视化绘图和字母的数量,面积图也位于时间轴上。


14.3 绘制布局草图

一旦选择了视觉通道,我们就可以开始绘制项目的布局。我们已经确定每幅画将由一个圆圈表示并定位在时间轴上。水平轴或垂直轴可以工作,尽管它对于屏幕来说可能太大。一个有趣的解决方法可能是径向时间轴。与其有一个很难适应移动屏幕的大圆圈,不如使用小倍数方法。小型序列图是一系列可视化效果,使用相同的比例和轴,但表示数据的不同方面。通过这种方法,我们可以每年有一个轮子,允许我们将它们定位到一个网格中,如图 14.5 所示。在桌面上,我们将在左侧显示可点击的时间线,在右侧以三列网格形式布置小型序列可视化。在平板电脑上,网格将减少到两列,而在移动设备上,我们将使用没有时间轴功能的单列网格。

图 14.5 小型多个可视化是共享相同比例和轴的一系列图表。它们通常布置成网格,使其易于适应不同的屏幕尺寸。


每个小倍数将可视化一整年,月份沿圆周分布。对于每个月,图纸的数量将由面积图和条形长度的字母数量表示。代表一个月内绘画的圆圈将聚集在一起,如图 14.6 所示。

图 14.6 月份将在每个小倍数中以圆形模式排列。每个月图纸的数量将通过面积图显示,而字母的数量将由条形的长度表示。一个月内创作的画作将分组在一起。


下一步是创建调色板并选择字体。我们需要为八种不同的绘画主题提供一个分类的调色板:自画像、肖像、农民生活、室内场景、静物、风景、城市景观建筑等,以及字母和素描的另一种颜色。创建任何调色板时,请考虑要在项目中安装的氛围。例如,在这里,我们想使用一种欢快的调色板,灵感来自梵高生命中最后几年的画作中的色调。我们通过从绘画中提取金色并使用 coolors.co 生成匹配的颜色,从图 14.7 创建了分类调色板。对于分类调色板来说,八种颜色已经很多了,因此我们不得不对某些类别使用类似的色调。例如,我们为肖像(#c16e70)选择了旧玫瑰色,为自画像选择了相同颜色的较亮版本(#f7a3a6)。您还可以在 adobe.color.com 和 colorhunt.co 上找到调色板的灵感。

图 14.7 对于这个项目,我们需要为绘画主题使用八种颜色的分类调色板,并为字母和图画提供额外的颜色。通过调整亮度,我们在文本和背景的另外两种变体中拒绝了最后一种颜色。


对于字体,我们发现 font.google.com 是免费网络字体的绝佳资源。通常,您希望每个项目最多坚持两个字体系列,一个用于标题,一个用于文本正文。一个简单的谷歌搜索将为谷歌字体组合提供很多想法。对于这个项目,我们选择了“Libre Baskerville Bold”作为标题,一种与19世纪相呼应的衬线字体,文本和标签为“Source Sans Pro”,一种无衬线字体,对用户界面具有出色的可读性。

14.4 构建项目骨架

一旦我们知道我们想要构建什么,我们必须决定我们要使用的基础设施。因为这个项目比我们在本书前面创建的项目更复杂,所以我们将转向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/ 文件夹中找到我们将处理的所有文件。

  • App.svelte 是我们项目的根组件,并调用创建页面布局的子组件。
  • data/ 文件夹包含我们将用于此项目的四个数据集:drawings.json 、letters.json 、paintings.json 和 timeline.json 。
  • global_styles/ 包含四个 SCSS 文件,然后加载到 styles.scss 中。使用 SCSS 而不是 CSS 允许我们声明样式变量(例如 $text:#160E13 ),我们可以在整个项目中重用这些变量,使其更易于维护。
  • layout/ 包含项目布局的主要组件,如标题和图例。
  • chart_component/ 包含与特定可视化组件(如绘画或字母)相关的组件。
  • UI/ 用于界面组件,如工具提示。
  • utils/ 包含实用程序函数和常量。

14.4.1 响应式SVG容器的另一种方法

从第一章开始,我们采用了一种简单而有效的方法来使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.8 SVG 容器的宽度与屏幕宽度和页面的响应式布局成正比。在大于 748px 的屏幕上,SVG 分布在 12 列弹性框网格中的 12 列上,而在较小的屏幕上,它采用所有 <12> 列。


在示例 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 中,而是直接将其添加到组件的标记中。

清单 14.2 动态更新 SVG 容器的宽度 (Grid.svelte)

<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 网格时会很有帮助。

示例 14.3 动态更新 SVG 容器的高度 (Grid.svelte)

<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>

14.4.2 创建响应式 SVG 网格

在最后一个列表中,我们使用变量 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

等等...

对于垂直平移,我们将索引四舍五入除以列数,以了解我们在哪一行,然后将结果乘以网格元素的高度。然后,我们在组中附加一个矩形元素,将其尺寸设置为网格项的宽度和高度,并为其提供蓝色笔触。我们添加此矩形以确保网格按预期工作,并在屏幕宽度更改时正确调整大小,但我们不会将其保留在最终可视化效果中。

示例 14.4 向 SVG 容器添加响应式网格 (Grid.svelte)

{#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 所示。

图 14.9 SVG 网格在大于 900 像素的屏幕上有三列,在 600 到 900 像素之间的屏幕上有两列,在较小的屏幕上有一列。


响应式 SVG 网格

14.5 创建径向可视化

准备好项目骨架后,我们可以开始利用 D3 来创建梵高作品的可视化!在本节中,我们将构建我们的小的多重可视化,从轴和标签开始,继续绘画,最后是绘图和字母。

14.5.1 添加径向轴

我们的小型多重可视化的主干可以简化为背景圆圈和年份标签。但在实施这些元素之前,我们需要定义它们的确切位置。图 14.10 显示了在定位圆圈和年份标签之前需要考虑的不同参数的草图。我们已经计算了每个小倍数的宽度(smWidth)和高度(smHeight)。为了确保可视化之间有足够的空间并为月份标签留出空间,我们可以定义要在每个圆圈周围应用的填充,比如说 60px。根据这个值和网格元素的宽度,我们可以计算背景圆的半径。

图 14.10 我们已经计算了网格元素的宽度和高度。如果我们在背景圆圈周围设置一个固定的填充值,我们可以计算它们的半径。


我们将开始在 Grid.svelte 的子组件中构建可视化,名为 GridItem.svelte 。在清单 14.5 中,我们首先将此组件导入到 Grid.svelte 中。然后,我们将 GridItem 附加到每个块中,这将从年份数组中生成每年的 GridItem。我们将 smWidth 、smHeight 和当前年份作为道具传递给这个子组件。

清单 14.5 导入 GridItem 组件 (Grid.svelte)

<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 .它用作年份标签的参考点,然后只需将其垂直平移到网格项的底部。第二组元素垂直平移到背景圆的中心。当我们开始向可视化追加其他形状以表示绘画、素描和字母时,此策略将特别方便。

示例 14.6 将背景圆圈和年份标签添加到可视化中 (GridItem.svelte)

<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 = 半径 * 余量θ

图 14.11 我们想在圆圈内为每个月画一个轴。这些轴的起点是可视化坐标系的原点,而端点的位置可以通过基本三角法确定。


为了绘制月份轴,我们继续在 网格项目.svelte .我们首先声明一个点刻度,该刻度将月份数组作为输入(该数组在文件 /utils/months.js 中可用)并返回相应的角度。我们希望 12 月显示在 360 点钟位置,对应于零角度。我们知道,一个完整的圆覆盖 2° 或 2π 弧度。因为一年有十二个月,所以我们将刻度中的最后一个角度设置为 2π - 12π/<> 弧度,或一个完整的圆减去十二分之一的圆。

在标记中,我们使用每个块为每个月附加一个行元素。每条线的起点是 (0, 0) ,而它的端点是用刚才讨论的三角函数计算的。

示例 14.7 添加月份轴 (GridItem.svelte)

<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 中找到该函数。它采用弧度的角度作为输入,并返回相同的弧度角度。

清单 14.8 添加月份标签 (GridItem.svelte)

<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.5.2 在圆周上应用力布局

准备好轴后,我们可以开始绘制可视化效果了。我们将首先用一个圆圈来表示梵高的每幅画。这些圆圈将围绕相应月份轴的端点按年份分组,然后按创建月份分组。如第14.3节所述,圆圈的颜色将基于绘画的主题及其在媒介上的边框。最后,我们将圆的面积设置为与相关图稿的尺寸成比例。图 14.12 显示了我们所追求的效果。为了在避免重叠的同时生成圆簇,我们将使用 D3 的力布局。

图14.12 梵高创作的每幅画都将用一个圆圈表示。这些圆圈将定位在与其创建年份相对应的小倍数可视化上,聚集在月轴的顶端。我们将使用 D3 的力布局来创建这些集群。


在进一步进入代码之前,让我们花点时间思考一下组件的体系结构,并制定最佳前进方向的战略。我们的小倍数可视化由三层组件组成,如图 14.13 所示。第一个由 Grid.svelte 组件持有,该组件负责将 SVG 容器添加到标记中,并将年份分解为类似网格的布局。该组件“知道”我们将生成可视化的所有年份。

第二层由 GridItem.svelte 处理。此组件仅“感知”单个年份的数据,并显示其相应的年份标签和月份轴。最后,还有组件 绘画.svelte , 图纸.svelte 和 字母.svelte 。我们还没有处理这些文件,但它们包含在 chart_components/ 文件夹中。顾名思义,这些组件负责可视化一年中产生的绘画、素描和信件。因为它们将从GridItem.svelte调用,所以它们也将知道一年的数据。

图 14.13 我们的小型多重可视化涉及三层组件。第一个(Grid.svelte)负责整体可视化及其响应网格。第二个 (GridItem.svelte) 保存每个小的多重可视化效果(对应于一年),并显示月份轴。最后一个(Paintings.svelte,Drawings.svelte和Letters.svelte)负责与绘画,绘图和字母相关的可视化元素。



考虑到这种分层架构,我们看到加载整个绘画数据集的最佳位置是 Grid.svelte ,因为该组件监督可视化的整体性,并且在应用程序中仅加载一次。然后,该组件会将每年对应的绘画作为道具传递给GridItem.svelte,然后将它们传递给Paintings.svelte。

基于这个逻辑,在清单 14.9 中,我们回到 Grid.svelte 并导入绘画数据集。由于我们稍后希望根据绘画的尺寸调整代表绘画的圆圈的大小,因此我们计算这些作品的面积,并使用此信息查找数据集中最大的绘画尺寸。请注意,数据集中有一些绘画的尺寸不可用。在这种情况下,我们将相应圆的半径设置为 3px。

要将绘画的面积(以cm 2为单位)缩放为屏幕上的圆形区域(以px2为单位),我们可以使用线性比例。我们称此比例为绘画AreaScale,并使用圆的面积公式找到范围覆盖的最大面积:

a = πr2

最后,我们将显示绘画所需的数据和函数传递给 GridItem .请注意我们如何过滤绘画数据集以仅传递与当前年份对应的绘画。

示例 14.9 加载绘画数据集并为绘画区域创建比例 (Grid.svelte)

<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 所示。

示例 14.10 导入绘画组件并再次传递道具 (GridItem.svelte)

<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/π)

14.11 为每幅画附加一个圆圈(Paintings.svelte)

<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 的力布局解决这个问题。

图 14.14 在这个阶段,绘画的圆圈在其月轴的顶端重叠。我们将通过 D3 的力布局来解决这个问题。



为了在每个月轴的尖端创建节点集群,我们将使用 D3 的力布局。这种布局有点复杂,所以如果你需要更深入的介绍,我们建议阅读第12章。在示例 14.12 中,我们使用 forceSimulation() 方法初始化一个新的力模拟,我们将绘画数组传递给该方法。我们还声明了一个空节点数组,在每次报价后,我们使用模拟的节点进行更新。然后,我们遍历此节点数组而不是绘画,以将圆圈附加到标记中。

我们计算施加在反应块($ )内节点的力,以便在相关变量发生变化时触发重新计算。在这个块内,定位力(forceX和forceY)将节点推向其月轴的尖端,而碰撞力(forceCollide)确保节点之间没有重叠。

我们还降低了 alpha(模拟的“温度”)并提高了 alpha 衰减率,以帮助模拟更快地收敛。这种调整需要反复试验的方法才能找到正确的设置。

最后,我们使用仿真添加到节点的 x 和 y 属性来设置相应圆的 cx 和 cy 属性。

14.12 使用力布局计算每幅画的位置(Paintings.svelte)

<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 中,我们声明了一个序数尺度,它将主题作为输入并返回相应的圆圈。然后,我们所要做的就是通过调用此刻度来设置圆圈的填充属性。

14.13 根据圆圈对应的绘画主题设置圆圈的颜色(Paintings.svelte)

<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 中的可视化效果。

图14.15 梵高在1887年间创作的画作的可视化。


14.5.3 绘制径向面积图

我们的下一步是绘制一个面积图,可视化梵高每年完成的绘画数量。在第 4 章中,我们学习了如何使用 D3 的形状生成器来计算折线图和面积图路径元素的 d 属性。在这里,我们将使用与形状生成器 lineRadial() 类似的策略,该策略在 d3 形状模块中可用。

与上一节一样,我们希望考虑用于渲染可视化的三层 Svelte 组件。我们将在 Grid.svelte 中加载整个图纸数据集,并计算一个月的最大图纸数量。我们还将重新组织数据集以每年拆分信息,如清单 14.14 所示。我们将此信息传递给 GridItem.svelte,并初始化一个刻度,负责计算与许多绘图对应的沿月轴的径向位置(参见示例 14.15),并将所有这些信息传递给 Drawings.svelte,后者将绘制面积图。

清单 14.14 输入图形并重新组织数据 (Grid.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}

示例 14.15 初始化一个刻度,负责沿月轴定位绘图数量 (GridItem.svelte)

<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 属性。在样式中,我们给它一个半透明的填充属性。

在14.16 绘制径向面积图

<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年的面积图。

图 14.16 D3的径向线生成器用于绘制梵高每年创作的图纸数量的径向面积图。


14.5.4 绘制径向条形图

我们将可视化梵高作品的最后一部分是他每个月写的信的数量。因为您现在拥有所有必需的知识,所以请自己试一试!

练习:用径向条形图可视化梵高每月写的字母数量

您可以自己完成此项目,也可以按照以下说明进行操作:

1. 在 Grid.svelte 中加载字母数据集。此数据集包含每月写的信件总数。

2. 通过道具将当前年份对应的字母传递给 GidItem.svelte。

3. 在 GidItem.svelte 中,导入字母组件。将其添加到标记中,并将字母数据和比例作为道具传递。

4. 在 Letters.svelte 中,为每个月附加一行,然后根据相关字母的数量并使用三角函数设置行的端点。

梵高在1885年间写的信数量。



如果您在任何时候遇到困难或想将您的解决方案与我们的解决方案进行比较,您可以在附录 D 的 D.14 节和本章代码文件的文件夹 14.5.5-Radial_bar_chart / 末尾找到它。但是,像往常一样,我们鼓励您尝试自己完成它。您的解决方案可能与我们的略有不同,没关系!

为了完成可视化的静态版本,我们注释掉之前用于查看网格布局的矩形和圆形并添加时间线。由于时间轴与 D3 没有太大关系,因此我们不会解释代码,但您可以在附录 D 的清单 D.14.4 和本章代码文件的文件夹 14.5.4 中找到它。您也可以将其视为自己构建的挑战!带有时间轴的完整静态布局如图 14.7 所示。

图 14.17 完成的静态布局包括梵高在 1881 年至 1890 年间居住的时间线,以及他每年可视化的艺术作品。


14.6 规划有意义的交互

现在我们的静态项目已经准备就绪,必须退后一步,考虑未来的用户可能想要如何探索它。他们将寻找哪些其他信息?他们会问哪些问题?我们可以通过互动来回答这些问题吗?以下是用户可能会提出的三个问题示例:

  • 每个圆圈代表哪幅画?我们能看到吗?
  • 我怎么知道 1885 年 <> 月制作了多少图纸和信件?目前,我可以用图例估计值,但看到数字会更好。
  • 我怎样才能将时间轴中显示的梵高居住的城市与他的艺术作品联系起来?

我们可以通过简单的交互来回答这些问题:前两个带有工具提示,最后一个带有交叉突出显示。由于本章已经很长了,并且此类交互与 D3 无关(在框架中,我们倾向于避免使用 D3 的事件侦听器,因为我们不希望 D3 与 DOM 交互),因此我们不会详细介绍如何实现它们。本节的主要重点是为您提供一个示例,说明如何规划对项目有意义的交互。您可以在在线托管项目 (https://d3js-in-action-third-edition.github.io/van_gogh_work/) 上使用这些交互,并在本章代码文件的文件夹 14.6 中找到代码。下图也显示了它们的实际效果。

图 14.18 当鼠标位于绘画的圆圈上并显示此绘画的细节时触发的工具提示。



图 14.19 当鼠标位于可视化效果上并显示每个月绘画、素描和字母的数量时触发的工具提示。


图 14.20 在时间轴上选择某个时间段时,可视化中仅显示该时间段内创建的绘画。


我们的项目到此结束!我们希望它能激发您对可视化的创意。如果您想更深入地了解将 D3 与 Svelte 相结合以实现交互式数据可视化,我们强烈推荐 Connor Rothschild 的 Svelte 更好的数据可视化课程 (https://www.newline.co/courses/better-data-visualizations-with-svelte)。

14.7 小结

  • D3的主要卖点之一是它如何使我们能够创建创新的可视化。
  • 在处理可视化项目时,我们倾向于遵循以下步骤:收集数据、清理和探索数据、绘制可视化布局、构建项目骨架、实现可视化元素以及添加交互。
  • 我们可以使用我们的JavaScript技能从网页中提取数据。
  • 在探索数据时,列出我们可以使用的定量和定性数据属性很有帮助,因为我们使用不同的渠道来可视化它们。
  • 为了创建创新的数据可视化,我们需要将所需的视觉通道分解为构建块。了解哪些 D3 模块包含实现这些块所需的方法非常有用。
  • 创建径向可视化时,我们在极坐标系中工作。我们可以使用基本的三角函数来计算不同可视化元素的位置。
  • 若要规划有意义的交互,请问问自己用户在浏览可视化时想要回答哪些问题。

lamp函数是CSS函数 min() max()的完美结合。

在研究CSS clamp()之前,我们先看一下这两个函数CSS min() 和 CSS max()。 理解它们将有助于让 CSS clamp() 更容易理解。

CSS min() 和 CSS max()

CSS min() 允许您将逗号分隔表达式列表中的最小值设置为 CSS 属性值。CSS max() 将从逗号分隔的表达式列表中设置最大的值作为 CSS 属性值。

min() 和 max() 都可以在允许 <length>、<frequency>、<angle>、<time>、<percentage>、<number> 或 <integer> 的任何地方使用。

举个例子:

width: min(50vw, 700px);

这意味着div的宽度最大为 700 像素,但是,如果 div 在视口宽度的 50% 处更小(50vw = 50 视图宽度),它将采用两个值中的较小值。因此,如果视口为 1300px 宽,则 <div> 最终将是 650px 宽(转换为 50vw),但是,如果视口是 1600px 宽,则 <div> 将只有 700px 宽,因为那是两个可用选项之间的较小值。max的例子也一样,只不过是取不同情况下的最大值。

虽然 CSS min() 和 CSS max() 在使响应式 CSS 设计更容易方面取得了长足的进步,但开发人员想要更多,我们希望能够在同一个 CSS 函数中定义下限和上限。 于是,CSS clamp() 诞生了。

CSS Clamp()

CSS clamp() 结合了 CSS min() 和 CSS max() 的优点。 CSS clamp() 本质上是在上限和下限之间设置一个值。 clamp() 允许在定义的最小值和最大值之间的值范围内选择中间值。它采用三个参数:最小值、首选值和最大值。

如果将 clamp() 分解为 CSS min() 和 CSS max() 函数,它会是这样的:clamp(MIN, VAL, MAX) 解析为 max(MIN, min(VAL, MAX))。这就是开发人员过去必须做的事情才能在 CSS 中进行扩展。

虽然它很容易解释,但 MIN 值是最小值。这是允许值范围内的下限。如果首选值小于此值,将使用 MIN 值。

首选 VAL 是表达式,只要结果介于 MIN 和 MAX 值之间,就会使用其值。

MAX 值是最大(最正)表达式值,如果首选值大于此上限,则使用该值。

比如下面的代码,当75%的值小于350px,就使用350px,当75%的值大于800px,就使用800px,介于之间就使用75%。

width: clamp(350px, 75%, 800px);

字体的例子如下:

font-size: clamp(1.5rem, 5vw, 4rem);

总结:

如果您需要让文本在移动设备上变得更小,但又不是难以阅读的小字体,clamp() 可以处理。当用户在大型桌面显示器上时,希望放大该图像, clamp() 也同样适用。从现在开始,每当我需要在上限和下限内响应时, 都可以使用CSS clamp()来尝试。