折线图、条形图和饼图是仪表板的基本要素,也是任何数据可视化工具包的基本组成部分。 当然,您可以使用 SVG 或 JavaScript 图表库(如 Chart.js)或像 D3 这样复杂的工具来创建这些图表,但是如果您不想将另一个库加载到您已经性能堪忧的网站中怎么办?
有很多文章介绍如何创建仅使用 CSS 的 条形图、柱状图和 饼图,但是如果您只需要一个基本的折线图,那么您就运气不佳了。 虽然 CSS 可以使用边框等“绘制线条”,但没有明确的方法可以在 X 和 Y 坐标平面上从一个点绘制到另一个点。
确实有办法! 如果您只需要一个简单的折线图,则无需加载大型 JavaScript 库,甚至无需使用 SVG。 您只需使用 CSS 和 HTML 中的几个自定义属性即可创建所需的一切。 但是,需要提醒您一下。 这确实涉及一些 三角学 知识。 如果这并没有吓跑您,那么卷起袖子,让我们开始吧!
以下是我们将要达到的目标
让我们从基线开始
如果您正在手工创建折线图(即在图表纸上实际绘制线条),则会先创建点,然后连接这些点以形成线条。 如果您像这样分解流程,则可以使用 CSS 重新创建任何基本的折线图。
假设我们有一个数据数组,用于在 X 和 Y 坐标系上显示点,其中一周中的日期位于 X 轴上,数值表示 Y 轴上的点。
[
{ value: 25, dimension: "Monday" },
{ value: 60, dimension: "Tuesday" },
{ value: 45, dimension: "Wednesday" },
{ value: 50, dimension: "Thursday" },
{ value: 40, dimension: "Friday" }
]
让我们创建一个无序列表来保存我们的数据点,并对其应用一些样式。 这是我们的 HTML
<figure class="css-chart" style="--widget-size: 200px;">
<ul class="line-chart">
<li>
<div class="data-point" data-value="25"></div>
</li>
<li>
<div class="data-point" data-value="60"></div>
</li>
<li>
<div class="data-point" data-value="45"></div>
</li>
<li>
<div class="data-point" data-value="50"></div>
</li>
<li>
<div class="data-point" data-value="40"></div>
</li>
</ul>
</figure>
这里需要注意几点。 首先,我们将所有内容都包装在一个 <figure>
元素中,这是一种很好的语义 HTML 方法,表示这是自包含的内容,还提供了使用 <figcaption>
的可选优势,如果我们需要的话。 其次,请注意,我们正在将值存储在一个名为 data-value
的 数据属性 中,该属性包含在无序列表中列表项内部的自己的 div 中。 为什么我们使用单独的 div 而不是将类和属性放在列表项本身上? 在我们开始绘制线条时,这将对我们有所帮助。
最后,请注意,我们在父 <figure>
元素上有一个内联 自定义属性,我们将其称为 --widget-size
。 我们将在 CSS 中使用它,CSS 将如下所示
/* The parent element */
.css-chart {
/* The chart borders */
border-bottom: 1px solid;
border-left: 1px solid;
/* The height, which is initially defined in the HTML */
height: var(--widget-size);
/* A little breathing room should there be others items around the chart */
margin: 1em;
/* Remove any padding so we have as much space to work with inside the element */
padding: 0;
position: relative;
/* The chart width, as defined in the HTML */
width: var(--widget-size);
}
/* The unordered list holding the data points, no list styling and no spacing */
.line-chart {
list-style: none;
margin: 0;
padding: 0;
}
/* Each point on the chart, each a 12px circle with a light border */
.data-point {
background-color: white;
border: 2px solid lightblue;
border-radius: 50%;
height: 12px;
position: absolute;
width: 12px;
}
上面的 HTML 和 CSS 将为我们提供这个不太令人兴奋的起点
渲染数据点
这看起来还不是很多。 我们需要一种方法来在即将成为图表的 X 和 Y 坐标上绘制每个数据点。 在我们的 CSS 中,我们已将 .data-point
类设置为使用绝对定位,并在其父级 .css-chart
容器上设置了固定的宽度和高度,并使用自定义属性。 我们可以用它来计算我们的 X 和 Y 位置。
我们的自定义属性将图表高度设置为 200px,并且在我们的值数组中,最大值为 60。 如果我们将该数据点设置为图表 Y 轴上的最高点(200px),那么我们可以使用数据集中任何值与 60 的比率,并将其乘以 200 以获得所有点的 Y 坐标。 因此,我们最大的值 60 将具有一个可以这样计算的 Y 值
(60 / 60) * 200 = 200px
而我们最小的值 25 将以相同的方式计算出 Y 值
(25 / 60) * 200 = 83.33333333333334px
获取每个数据点的 Y 坐标更容易。 如果我们在图表上均匀地间隔点,那么我们可以将图表的宽度(200px)除以数据数组中的值数(5)得到 40px。 这意味着第一个值将具有 40px 的 X 坐标(以便为左侧轴留出边距,如果我们想要的话),最后一个值将具有 200px 的 X 坐标。
您刚刚被数学“攻击”了!🤓
现在,让我们向列表项中的每个 div 添加内联样式。 我们新的 HTML 变成了这样,其中内联样式包含每个点的计算位置。
<figure class="css-chart">
<ul class="line-chart">
<li>
<div class="data-point" data-value="25" style="bottom: 83.33333333333334px; left: 40px;"></div>
</li>
<li>
<div class="data-point" data-value="60" style="bottom: 200px; left: 80px;"></div>
</li>
<li>
<div class="data-point" data-value="45" style="bottom: 150px; left: 120px;"></div>
</li>
<li>
<div class="data-point" data-value="50" style="bottom: 166.66666666666669pxpx; left: 160px;"></div>
</li>
<li>
<div class="data-point" data-value="40" style="bottom: 133.33333333333331px; left: 200px;"></div>
</li>
</ul>
</figure>
嘿,这看起来好多了! 但是即使您可以看到接下来会发生什么,您也无法真正称之为折线图。 没问题。 我们只需要使用更多的数学知识来完成我们的“连点成线”游戏。 再次查看我们渲染的数据点的图片。 您能看到连接它们的三角形吗? 如果没有,也许下一张图片会有所帮助
为什么这很重要? 嘘,答案即将揭晓。
渲染线段
现在看到三角形了吗? 而且它们不仅仅是任何普通的三角形。 它们是我们(出于我们的目的)最好的三角形,因为它们是直角三角形! 当我们之前计算数据点的 Y 坐标时,我们也在计算直角三角形的一条边的长度(即,如果您将其视为阶梯,则为“水平距离”)。 如果我们计算从一个点到下一个点的 X 坐标的差,这将告诉我们直角三角形另一边的长度(即阶梯的“垂直距离”)。 借助这两条信息,我们可以计算出神奇的斜边的长度,事实证明,这正是我们需要在屏幕上绘制的内容,以便连接我们的点并制作一个真正的折线图。
例如,让我们以图表上的第二个和第三个点为例。
<!-- ... -->
<li>
<div class="data-point" data-value="60" style="bottom: 200px; left: 80px;"></div>
</li>
<li>
<div class="data-point" data-value="45" style="bottom: 150px; left: 120px;"></div>
</li>
<!-- ... -->
第二个数据点的 Y 值为 200,第三个数据点的 Y 值为 150,因此连接它们的三角形的对边长度为 200 减去 150,或 50。 它有一个 40 像素长的邻边(我们在每个点之间设置的间距)。
这意味着斜边的长度是 50 的平方加上 40 的平方的平方根,或 64.03124237432849。
让我们在图表中每个列表项内部再创建一个 div,它将作为从该点绘制的三角形的斜边。 然后,我们将在新的 div 上设置一个内联自定义属性,其中包含该斜边的长度。
<!-- ... -->
<li>
<div class="data-point" data-value="60"></div>
<div class="line-segment" style="--hypotenuse: 64.03124237432849;"></div>
</li>
<!-- ... -->
顺便说一句,我们的线段需要知道它们正确的 X 和 Y 坐标,因此让我们从 .data-point
元素中删除内联样式,而是在其父级(<li>
元素)中添加 CSS 自定义属性。 让我们创造性地将这些属性称为 --x
和 --y
。 我们的数据点不需要知道斜边(线段的长度),因此我们可以直接向 .line-segment
添加斜边长度的 CSS 自定义属性。 因此,现在我们的 HTML 将如下所示
<!-- ... -->
<li style="--y: 200px; --x: 80px">
<div class="data-point" data-value="60"></div>
<div class="line-segment" style="--hypotenuse: 64.03124237432849;"></div>
</li>
<!-- ... -->
我们需要更新我们的 CSS 以使用这些新的自定义属性定位数据点,并为我们添加到标记中的新 .line-segment
div 设置样式
.data-point {
/* Same as before */
bottom: var(--y);
left: var(--x);
}
.line-segment {
background-color: blue;
bottom: var(--y);
height: 3px;
left: var(--x);
position: absolute;
width: calc(var(--hypotenuse) * 1px);
}
好吧,我们现在有了线段,但这根本不是我们想要的。 为了获得一个功能性的折线图,我们需要应用一个变换。 但首先,让我们修复几个问题。
首先,我们的线段与数据点的底部对齐,但我们希望线段的原点是数据点圆的中心。 我们可以通过对 .data-point
样式进行快速 CSS 更改来解决此问题。 我们需要调整它们的 X 和 Y 位置以考虑数据点及其边框的大小以及线段的宽度。
.data-point {
/* ... */
/* The data points have a radius of 8px and the line segment has a width of 3px,
so we split the difference to center the data points on the line segment origins */
bottom: calc(var(--y) - 6.5px);
left: calc(var(--x) - 9.5px);
}
其次,我们的线段渲染在数据点的顶部而不是其后面。 我们可以通过在 HTML 中首先放置线段来解决此问题
<!-- ... -->
<li style="--y: 200px; --x: 80px">
<div class="line-segment" style="--hypotenuse: 64.03124237432849;"></div>
<div class="data-point" data-value="60"></div>
</li>
<!-- ... -->
应用转换,太棒了
我们快完成了。 我们只需要进行最后一步数学运算。 具体来说,我们需要找到与直角三角形对边相对的角度的度数,然后将我们的线段旋转相同的度数。
我们如何做到这一点? 三角学! 您可能还记得记住正弦、余弦和正切如何计算的小助记符
- SOH(正弦 = 对边除以斜边)
- CAH(余弦 = 邻边除以斜边)
- TOA(正切 = 对边除以邻边)
您可以使用其中的任何一个,因为我们知道直角三角形所有三条边的长度。 我选择了正弦,所以这让我们得到了以下等式
sin(x) = Opposite / Hypotenuse
这个方程的答案将告诉我们如何旋转每一段线段,使其连接到下一个数据点。我们可以使用 JavaScript 中的 Math.asin(Opposite / Hypotenuse)
快速实现这一点。不过它会以弧度给出答案,因此我们需要将结果乘以 (180 / Math.PI)
。
以我们之前提到的第二个数据点为例,我们已经计算出对边长度为 50,斜边长度为 64.03124237432849,所以我们可以将方程改写成这样
sin(x) = 50 / 64.03124237432849 = 51.34019174590991
这就是我们正在寻找的角度!我们需要为每个数据点求解这个方程,然后将值作为 CSS 自定义属性传递给我们的 .line-segment
元素。这样会得到如下所示的 HTML 代码
<!-- ... -->
<li style="--y: 200px; --x: 80px">
<div class="data-point" data-value="60"></div>
<div class="line-segment" style="--hypotenuse: 64.03124237432849; --angle: 51.34019174590991;"></div>
</li>
<!-- ... -->
这里我们可以在 CSS 中应用这些属性
.line-segment {
/* ... */
transform: rotate(calc(var(--angle) * 1deg));
width: calc(var(--hypotenuse) * 1px);
}
现在当我们渲染它时,我们就有了我们的线段了!
等等,什么?我们的线段到处都是。现在怎么办?哦,对了。默认情况下,transform: rotate()
围绕变换元素的中心旋转。我们希望旋转从左下角开始,从当前数据点向下一个数据点倾斜。这意味着我们需要在 .line-segment
类上设置另一个 CSS 属性。
.line-segment {
/* ... */
transform: rotate(calc(var(--angle) * 1deg));
transform-origin: left bottom;
width: calc(var(--hypotenuse) * 1px);
}
现在,当我们渲染它时,我们终于得到了我们一直在等待的纯 CSS 线形图。
重要提示:当您计算对边(“上升”)的值时,请确保将其计算为“当前数据点的 Y 位置”减去“下一个数据点的 Y 位置”。当下一个数据点比当前数据点值更大(在图表上更高)时,这将导致负值,从而导致负旋转。这就是我们确保线条向上倾斜的方式。
何时使用这种图表
这种方法非常适合简单的静态网站或使用服务器端生成内容的动态网站。当然,它也可以用于具有客户端动态生成内容的网站,但那样你又回到了在客户端运行 JavaScript 的情况。这篇文章顶部的 CodePen 展示了这种线形图的客户端动态生成的示例。
CSS 的 calc()
函数非常有用,但它无法为我们计算正弦、余弦和正切。这意味着您必须手动计算值,或者编写一个快速函数(客户端或服务器端)来为我们的 CSS 自定义属性生成所需的值(X、Y、斜边和角度)。
我知道你们中的一些人读到这里会觉得,如果需要脚本计算值,那就不算纯 CSS 了——这很公平。关键是所有图表渲染都在 CSS 中完成。连接数据点和线的操作都是通过 HTML 元素和 CSS 完成的,即使在没有启用 JavaScript 的静态渲染环境中也能完美运行。也许更重要的是,无需下载另一个臃肿的库来在页面上渲染一个简单的线形图。
潜在改进
与任何事物一样,我们总能做一些事情来更上一层楼。在这种情况下,我认为可以通过三个方面改进这种方法。
响应式
我概述的方法使用固定大小的图表尺寸,这恰恰是我们不希望在响应式设计中看到的。如果我们可以在客户端运行 JavaScript,就可以解决此限制。与其硬编码图表大小,我们可以设置一个 CSS 自定义属性(还记得我们的 --widget-size
属性吗?),将所有计算都基于它,并在容器或窗口初始显示或调整大小时使用某种形式的容器查询或窗口调整大小监听器更新该属性。
工具提示
我们可以向 .data-point 添加一个 ::before
伪元素,以在悬停在数据点上时在一个工具提示中显示它包含的 data-value
信息。这是一种不错的补充,有助于将我们的简单图表变成一个完整的产品。
轴线
注意图表轴是未标记的?我们可以在轴上分布表示最高值、零以及它们之间任意数量点的标签。
边距
为了使本文尽可能简单,我尽量保持数字简单,但在现实世界中,您可能希望在图表中包含一些边距,以便数据点不会与容器的极端边缘重叠。这可以简单地从 y 坐标范围内减去数据点的宽度。对于 X 坐标,您也可以在将图表总宽度分成相等区域之前,从图表总宽度中减去数据点的宽度。
就是这样!我们刚刚深入了解了一种在 CSS 中绘制图表的方法,而且我们甚至不需要库或其他第三方依赖项来使其工作。💥
我喜欢这个,我只是在角度上遇到了困难。
我太笨了,无法弄清楚那个数学问题。
嗨,Jonathan!感谢你查看这个。不要觉得自己笨!我试图解释如何在不深入细节的情况下进行数学计算,但这可能使它难以理解。以下是基本思路。三角形底边的长度是两点之间的水平距离。三角形远(对)边的长度是两个相邻点之间的差值(因此,如果一个点在 150px 处,下一个点在 100px 处,则三角形的那条边的长度为 50)。您想要在两点之间绘制的线段的长度是该三角形的斜边,因此它是其他两条边的平方和的平方根。一旦您得到了斜边的长度,您只需要确定要绘制的角度。为此,您可以在浏览器控制台中使用 JavaScript。公式将是 Math.asin(Opposite / Hypotenuse) * (180 / Math.PI)。这将为您提供要旋转线段的度数。希望这有帮助!
这太棒了!我喜欢“这里有工具,现在把它推到极限”的体验,这就是 Web 开发。很棒的文章
嗨,Marshall,感谢你的帖子。
我添加了工具提示,它甚至比 ::before 伪元素更简单,您只需给 data-point div 添加一个 title 即可。
我还向 values-array 添加了“键”,它只是 x 轴上的标签,在我的例子中是值所属的月份。
这是我对您代码的更改
1) 用值和键填充 values-array
const chartValues = [{value: 25, key: “2020-03”},{value: 60, key: “2020-04”},{value: 45, key: “2020-05”},{value: 50, key: “2020-06”},{value: 40, key: “2020-07”}]
2) 在 formatLineChartData 中,像处理值一样将键从原始 values 数组复制到 cssValues
currentValue.value = values[i].value;
currentValue.key = values[i].key;
3) 在 createListItem 中,输出键和值作为 data-point 的 title
希望这对某些人有所帮助;)
干杯
Sascha
这是一篇很棒的文章;正是我需要的。
你能解释一下如何在窗口调整大小时更改 <div> 属性并刷新线条和圆圈的绘制吗?我尝试在调整大小事件中调用 render(),它可以正确绘制新的线条/圆圈,但旧的线条/圆圈仍然在屏幕上。