如何使用蒙版创建嵌套正方形的动画图表

Avatar of Mészáros Róbert
Mészáros Róbert

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

我们有很多众所周知的图表类型:条形图、圆环图、折线图、饼图,等等。 所有流行的图表库都支持这些。 然后有一些图表类型甚至没有名称。 请查看这个构思出来的图表,它使用堆叠(嵌套)正方形来帮助可视化相对大小,或不同值如何相互比较

我们正在制作什么

在没有任何交互的情况下,创建此设计相当简单。 一种方法是堆叠元素(例如 SVG <rect> 元素,甚至 HTML div),使其大小依次减小,所有元素的左下角都与同一点接触。

但是,一旦我们引入一些交互性,事情就会变得更加棘手。 以下是它应该如何工作:当我们将鼠标悬停在其中一个形状上时,我们希望其他形状淡出并移开。

我们将使用矩形和蒙版创建这些不规则形状——文字上带有 <rect><mask> 元素的 <svg>。 如果您完全不熟悉蒙版,那么您来对地方了。 这是一篇入门级文章。 如果您经验更丰富,那么也许这种剪切效果是一个您可以随身携带的技巧。

现在,在我们开始之前,您可能想知道是否可以使用自定义形状的 SVG 的更好替代方案。 这绝对有可能! 但是,使用 <path> 绘制形状可能令人生畏,甚至会变得混乱。 因此,我们使用“更简单”的元素来获得相同的形状和效果。

例如,以下是如何使用 <path> 表示最大的蓝色形状。

<svg viewBox="0 0 320 320" width="320" height="320">
  <path d="M320 0H0V56H264V320H320V0Z" fill="#264653"/>
</svg>

如果 0H0V56… 对您来说毫无意义,请查看 “SVG” path 语法:图解指南” 以全面了解语法。

图表的 basics

给定如下数据集

type DataSetEntry = {
  label: string;
  value: number;
};

type DataSet = DataSetEntry[];

const rawDataSet: DataSet = [
  { label: 'Bad', value: 1231 },
  { label: 'Beginning', value: 6321 },
  { label: 'Developing', value: 10028 },
  { label: 'Accomplished', value: 12123 },
  { label: 'Exemplary', value: 2120 }
];

…我们希望最终得到如下 SVG

<svg viewBox="0 0 320 320" width="320" height="320">
  <rect width="320" height="320" y="0" fill="..."></rect>
  <rect width="264" height="264" y="56" fill="..."></rect>
  <rect width="167" height="167" y="153" fill="..."></rect>
  <rect width="56" height="56" y="264" fill="..."></rect>
  <rect width="32" height="32" y="288" fill="..."></rect>
</svg>

确定最大值

稍后我们会明白为什么我们需要最大值。 我们可以使用 Math.max() 来获取它。 它接受任意数量的参数并返回集合中的最大值。

const dataSetHighestValue: number = Math.max(
  ...rawDataSet.map((entry: DataSetEntry) => entry.value)
);

由于我们有一个小的数据集,我们可以直接看出我们将得到 12123

计算矩形的尺寸

如果我们查看设计,表示最大值(12123)的矩形覆盖了图表的整个区域。

我们任意选择 320 作为 SVG 尺寸。 由于我们的矩形是正方形,因此宽度和高度相等。 我们如何使 12123 等于 320? 其他不太“特殊”的值呢? 6321 矩形有多大?

换句话说,我们如何将一个范围内的数字([0, 12123])映射到另一个范围([0, 320])? 或者,用 更数学术语 来说,我们如何将变量缩放到 [a, b] 的区间?

出于我们的目的,我们将像这样实现该函数

const remapValue = (
  value: number,
  fromMin: number,
  fromMax: number,
  toMin: number,
  toMax: number
): number => {
  return ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin) + toMin;
};

remapValue(1231, 0, 12123, 0, 320); // 32
remapValue(6321, 0, 12123, 0, 320); // 167
remapValue(12123, 0, 12123, 0, 320); // 320

由于我们在代码中将值映射到相同的范围,因此我们可以创建一个包装函数,而不是一遍又一遍地传递最小值和最大值

const valueRemapper = (
  fromMin: number,
  fromMax: number,
  toMin: number,
  toMax: number
) => {
  return (value: number): number => {
    return remapValue(value, fromMin, fromMax, toMin, toMax);
  };
};

const remapDataSetValueToSvgDimension = valueRemapper(
  0,
  dataSetHighestValue,
  0,
  svgDimension
);

我们可以像这样使用它

remapDataSetValueToSvgDimension(1231); // 32
remapDataSetValueToSvgDimension(6321); // 167
remapDataSetValueToSvgDimension(12123); // 320

创建和插入 DOM 元素

剩下的与 DOM 操作有关。 我们必须创建 <svg> 和五个 <rect> 元素,设置它们的属性,并将它们附加到 DOM。 我们可以使用基本的 createElementNSsetAttributeappendChild 函数来完成所有这些操作。

请注意,我们使用的是 createElementNS 而不是更常见的 createElement。 这是因为我们正在使用 SVG。 HTML 和 SVG 元素具有不同的规范,因此它们属于不同的命名空间 URI。 恰好 createElement 方便地使用了 HTML 命名空间! 因此,要创建 SVG,我们必须这样冗长地写

document.createElementNS('http://www.w3.org/2000/svg', 'svg') as SVGSVGElement;

当然,我们可以创建另一个辅助函数

const createSvgNSElement = (element: string): SVGElement => {
  return document.createElementNS('http://www.w3.org/2000/svg', element);
};

当我们将矩形附加到 DOM 时,我们必须注意它们的顺序。 否则,我们将不得不显式地指定 z-index。 第一个矩形必须是最大的,最后一个矩形必须是最小的。 最好在循环之前对数据进行排序。

const data = rawDataSet.sort(
  (a: DataSetEntry, b: DataSetEntry) => b.value - a.value
);

data.forEach((d: DataSetEntry, index: number) => {
  const rect: SVGRectElement = createSvgNSElement('rect') as SVGRectElement;
  const rectDimension: number = remapDataSetValueToSvgDimension(d.value);

  rect.setAttribute('width', `${rectDimension}`);
  rect.setAttribute('height', `${rectDimension}`);
  rect.setAttribute('y', `${svgDimension - rectDimension}`);

  svg.appendChild(rect);
});

坐标系从左上角开始;那就是 [0, 0] 的位置。 我们将始终从左侧绘制矩形。 控制水平位置的 x 属性默认为 0,因此我们不必设置它。 y 属性控制垂直位置。

为了给人一种视觉印象,即所有矩形都起源于同一个点,该点与它们的左下角相连,我们必须将矩形向下推。 推多少? 矩形未填充的精确量。 并且该值是图表尺寸与特定矩形尺寸之间的差值。 如果我们将所有部分放在一起,我们将得到以下内容

我们已经使用 CSS 将动画代码添加到此演示中。

剪切矩形

我们必须将我们的矩形变成不规则形状,这些形状有点像数字七或旋转 180 度的字母 L。

如果我们关注“缺失的部分”,那么我们可以看到它们是我们已经在使用的相同矩形的剪切。

我们想要隐藏这些剪切。 这样我们最终就会得到我们想要的 L 形状。

蒙版 101

蒙版是您定义并在以后应用于元素的内容。 通常,mask 内联在其所属的 <svg> 元素中。 并且,通常,它应该具有唯一的 id,因为我们必须引用它才能将蒙版应用于元素。

<svg>
  <mask id="...">
    <!-- ... -->
  </mask>
</svg>

<mask> 标签中,我们放置充当实际蒙版的形状。 我们还将 mask 属性应用于元素。

<svg>
  <mask id="myCleverlyNamedMask">
    <!-- ... -->
  </mask>
  <rect mask="url(#myCleverlyNamedMask)"></rect>
</svg>

这不是定义或应用蒙版的唯一方法,但对于此演示来说,这是最直接的方法。 在编写任何生成蒙版的代码之前,让我们进行一些实验。

我们说我们想覆盖与现有矩形大小匹配的剪切区域。 如果我们取最大的元素并将前一个矩形作为蒙版应用,我们将得到以下代码

<svg viewBox="0 0 320 320" width="320" height="320">
  <mask id="theMask">
    <rect width="264" height="264" y="56" fill=""></rect>
  </mask>
  <rect width="320" height="320" y="0" fill="#264653" mask="url(#theMask)"></rect>
</svg>

蒙版内的元素需要一个 fill 值。 那应该是什么? 根据我们选择的 fill 值(颜色),我们将看到完全不同的结果。

白色填充

如果我们对 fill 使用 white 值,那么我们将得到以下结果

现在,我们的矩形与蒙版矩形具有相同的尺寸。 这不是我们想要的。

黑色填充

如果我们改为使用 black 值,则它看起来像这样

我们什么也看不到。 这是因为填充黑色的是变得不可见的部分。 我们使用 whiteblack 填充来控制蒙版的可见性。 虚线作为视觉辅助,用于参考不可见区域的尺寸。

灰色填充

现在让我们使用介于白色和黑色之间的东西,比如gray

它既不完全不透明也不完全实心;它是透明的。因此,现在我们知道可以通过使用不同于whiteblack的值来控制这里的“可见度”,这是一个值得记住的好技巧。

最后一点

以下是我们迄今为止涵盖和了解的关于蒙版的内容

  • <mask>内的元素控制着蒙版区域的尺寸。
  • 我们可以使蒙版区域的内容可见、不可见或透明。

我们只对蒙版使用了一种形状,但与任何通用 HTML 标签一样,我们可以在其中嵌套任意数量的子元素。事实上,实现我们想要的效果的技巧是使用两个 SVG <rect>元素。我们必须将它们一个叠加在另一个上面。

<svg viewBox="0 0 320 320" width="320" height="320">
  <mask id="maskW320">
    <rect width="320" height="320" y="0" fill="???"></rect>
    <rect width="264" height="264" y="56" fill="???"></rect>
  </mask>
  <rect width="320" height="320" y="0" fill="#264653" mask="url(#maskW320)"></rect>
</svg>

我们的一个蒙版矩形填充了white;另一个填充了black。即使我们知道规则,让我们也尝试一下各种可能性。

<mask id="maskW320">
  <rect width="320" height="320" y="0" fill="black"></rect>
  <rect width="264" height="264" y="56" fill="white"></rect>
</mask>

<mask>是最大元素的尺寸,而最大元素填充了black。这意味着该区域下的所有内容都不可见。而较小矩形下的所有内容都是可见的。

现在让我们翻转一下,将black矩形放在上面

<mask id="maskW320">
  <rect width="320" height="320" y="0" fill="white"></rect>
  <rect width="264" height="264" y="56" fill="black"></rect>
</mask>

这就是我们想要的!

填充白色最大矩形下的所有内容都是可见的,但是较小的黑色矩形位于其顶部(在 z 轴上更靠近我们),遮蔽了那一部分。

生成蒙版

现在我们知道该做什么了,我们可以相对轻松地创建蒙版。这类似于我们最初生成彩色矩形的方式——我们创建一个辅助循环,在其中创建mask和两个rect

这次,我们不是将rect直接附加到 SVG,而是将其附加到mask

data.forEach((d: DataSetEntry, index: number) => {
  const mask: SVGMaskElement = createSvgNSElement('mask') as SVGMaskElement;

  const rectDimension: number = remapDataSetValueToSvgDimension(d.value);
  const rect: SVGRectElement = createSvgNSElement('rect') as SVGRectElement;

  rect.setAttribute('width', `${rectDimension}`);
  // ...setting the rest of the attributes...

  mask.setAttribute('id', `maskW${rectDimension.toFixed()}`);

  mask.appendChild(rect);

  // ...creating and setting the attributes for the smaller rectangle...

  svg.appendChild(mask);
});

data.forEach((d: DataSetEntry, index: number) => {
    // ...our code to generate the colored rectangles...
});

我们可以使用索引作为蒙版的 ID,但至少对我来说,这似乎是一个更易读的选项。

mask.setAttribute('id', `maskW${rectDimension.toFixed()}`); // maskW320, masW240, ...

至于在蒙版中添加较小的矩形,我们可以轻松访问所需的值,因为我们之前已将矩形值从高到低排序。这意味着循环中的下一个元素是较小的矩形,即我们应该引用的那个。我们可以通过其索引来做到这一点。

// ...previous part where we created the mask and the rectangle...

const smallerRectIndex = index + 1;

// there's no next one when we are on the smallest
if (data[smallerRectIndex] !== undefined) {
  const smallerRectDimension: number = remapDataSetValueToSvgDimension(
    data[smallerRectIndex].value
  );
  const smallerRect: SVGRectElement = createSvgNSElement(
    'rect'
  ) as SVGRectElement;

  // ...setting the rectangle attributes...

  mask.appendChild(smallerRect);
}

svg.appendChild(mask);

剩下的就是将mask属性添加到我们原始循环中的彩色矩形。它应该与我们选择的格式匹配。

rect.setAttribute('mask', `url(#maskW${rectDimension.toFixed()})`); // maskW320, maskW240, ...

最终结果

我们完成了!我们成功地制作了一个由嵌套正方形组成的图表。它甚至可以在鼠标悬停时分开。而这一切只需要使用<mask>元素绘制每个正方形的裁剪区域的 SVG 即可。