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

我们正在制作什么
在没有任何交互的情况下,创建此设计相当简单。 一种方法是堆叠元素(例如 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。 我们可以使用基本的 createElementNS
、setAttribute
和 appendChild
函数来完成所有这些操作。
请注意,我们使用的是 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
值,则它看起来像这样

我们什么也看不到。 这是因为填充黑色的是变得不可见的部分。 我们使用 white
和 black
填充来控制蒙版的可见性。 虚线作为视觉辅助,用于参考不可见区域的尺寸。
灰色填充
现在让我们使用介于白色和黑色之间的东西,比如gray

它既不完全不透明也不完全实心;它是透明的。因此,现在我们知道可以通过使用不同于white
和black
的值来控制这里的“可见度”,这是一个值得记住的好技巧。
最后一点
以下是我们迄今为止涵盖和了解的关于蒙版的内容
<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 即可。
非常酷!!实际应用是什么?