制作响应式、无 JavaScript 图表的新技术
网络上有无数用于生成图表的库。每个库的服务领域略有不同,但它们都有一个共同点:都需要 JavaScript。
当然,这很有道理——你的图表通常依赖于必须使用 JS 通过网络获取的数据,或者渲染到<canvas>
元素中。但这并不理想。并非每个人都有 JS,而且无论如何,依赖它意味着你会在页面上留下一个图表形状的空洞,直到它加载完成,而只有当你的所有数据可视化都隐藏在页面首屏下方时,你才能真正避免这种情况。
另一个更微妙的问题是,流体图表(那些自适应其容器宽度的图表)在调整大小时必须重新绘制,以避免潜在的崩溃。这意味着开发人员的工作量会更大(尤其是在使用像D3这样的低级库的情况下),浏览器的工作量也必然会更大。
对于最近的《纽约时报》文章,我想看看是否有可能创建无需 JS 即可运行的 SVG 图表。
嗯,确实如此。我还没见过其他地方使用过同样的技术组合,所以想把整个过程写下来。我还创建了一个名为 Pancake 的实验性 Svelte 组件库,让这些技术更容易使用。
问题
创建 SVG 折线图(我们稍后会介绍其他图表类型)其实相当简单。假设我们有如下一系列数据……
const data = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 4 },
{ x: 3, y: 9 },
{ x: 4, y: 16 },
{ x: 5, y: 25 },
{ x: 6, y: 36 },
{ x: 7, y: 49 },
{ x: 8, y: 64 },
{ x: 9, y: 81 },
{ x: 10, y: 100 }
];
...以及一个 300px x 100px 的图表。如果我们将这些x
值乘以 30,再y
从 100 中减去这些值,我们将得到填充空间的坐标:
<polyline points="
0,0
30,99
60,96
90,91
120,84
150,75
180,64
210,51
240,36
270,19
300,0
"></polyline>
当然,通常情况下,你会使用缩放函数,而不是手动计算坐标:
function scale(domain, range) {
const m = (range[1] - range[0]) / (domain[1] - domain[0]);
return num => range[0] + m * (num - domain[0]);
}
const x = scale([0, Math.max(...data.map(d => d.x))], [0, 300]);
const y = scale([0, Math.max(...data.map(d => d.y))], [100, 0]);
const points = data.map(d => `${x(d.x)},${y(d.y)}`).join(' ');
const chart = `
<svg width="300" height="100">
<polyline points="${points}"></polyline>
</svg>
`;
添加一些轴和一些样式,我们就得到了一个图表:
该逻辑可以全部存在于 Node.js 脚本中,这意味着可以轻松创建此图表而无需任何客户端 JS。
但它无法适应其容器的大小——它永远是一个 300px x 100px 的图表。在大多数网站上,这是一个问题。
解决方案(第一部分)
SVG 有一个属性viewBox
,它定义了一个独立于元素本身大小的坐标系<svg>
。通常情况下,无论元素的宽高比如何,viewBox 的宽高比都会保持不变<svg>
,但我们可以使用 禁用此功能preserveAspectRatio="none"
。
我们可以选择一个简单的坐标系,像这样......
<svg viewBox="0 0 100 100" preserveAspectRatio="none">
……并将我们的数据投影进去。现在,我们的图表可以流畅地适应其环境:
但它显然在两个重要方面存在缺陷。首先,文本缩放比例非常糟糕,在某些情况下甚至难以辨认。其次,线条笔画也随着线条本身被拉伸,看起来很糟糕。
第二个问题很简单,只需一个鲜为人知的 CSS 属性即可解决 ——vector-effect: non-scaling-stroke
应用于每个元素:
但据我所知,第一个问题无法在 SVG 中解决。
解决方案(第二部分)
我们可以使用 HTML 元素来代替 SVG 元素作为坐标轴,并通过 CSS 进行定位。由于我们使用的是基于百分比的坐标系,因此很容易将 HTML 层和 SVG 层粘合在一起。
使用 HTML 重新创建上述轴非常简单:
<!-- x axis -->
<div class="x axis" style="top: 100%; width: 100%; border-top: 1px solid black;">
<span style="left: 0">0</span>
<span style="left: 20%">2</span>
<span style="left: 40%">4</span>
<span style="left: 60%">6</span>
<span style="left: 80%">8</span>
<span style="left: 100%">10</span>
</div>
<!-- y axis -->
<div class="y axis" style="height: 100%; border-left: 1px solid black;">
<span style="top: 100%">0</span>
<span style="top: 50%">50</span>
<span style="top: 0%">100</span>
</div>
<style>
.axis {
position: absolute;
}
.axis span {
position: absolute;
line-height: 1;
}
.x.axis span {
top: 0.5em;
transform: translate(-50%,0);
}
.y.axis span {
left: -0.5em;
transform: translate(-100%,-50%);
}
</style>
我们的图表不再混乱:
使用 HTML 元素的另一个好处是它们会自动捕捉到最近的像素,这意味着您不会遇到 SVG 元素容易出现的“模糊”效果。
打包
这解决了问题,但需要大量的手动操作,因此有了Pancake。使用 Pancake 后,上面的图表看起来会像这样:
<script>
import * as Pancake from '@sveltejs/pancake';
const points = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 4 },
{ x: 3, y: 9 },
{ x: 4, y: 16 },
{ x: 5, y: 25 },
{ x: 6, y: 36 },
{ x: 7, y: 49 },
{ x: 8, y: 64 },
{ x: 9, y: 81 },
{ x: 10, y: 100 }
];
</script>
<div class="chart">
<Pancake.Chart x1={0} x2={10} y1={0} y2={100}>
<Pancake.Box x2={10} y2={100}>
<div class="axes"></div>
</Pancake.Box>
<Pancake.Grid vertical count={5} let:value>
<span class="x label">{value}</span>
</Pancake.Grid>
<Pancake.Grid horizontal count={3} let:value>
<span class="y label">{value}</span>
</Pancake.Grid>
<Pancake.Svg>
<Pancake.SvgLine data={points} let:d>
<path class="data" {d}/>
</Pancake.SvgLine>
</Pancake.Svg>
</Pancake.Chart>
</div>
<style>
.chart {
height: 100%;
padding: 3em 2em 2em 3em;
box-sizing: border-box;
}
.axes {
width: 100%;
height: 100%;
border-left: 1px solid black;
border-bottom: 1px solid black;
}
.y.label {
position: absolute;
left: -2.5em;
width: 2em;
text-align: right;
bottom: -0.5em;
}
.x.label {
position: absolute;
width: 4em;
left: -2em;
bottom: -22px;
font-family: sans-serif;
text-align: center;
}
path.data {
stroke: red;
stroke-linejoin: round;
stroke-linecap: round;
stroke-width: 2px;
fill: none;
}
</style>
由于我们使用了 Svelte,因此此图表可以在构建时使用 Node.js 轻松渲染,也可以使用客户端 JS 注入到 DOM 中。对于具有一定交互性的图表(例如Pancake 主页上的大型示例图表),您可能需要同时执行这两种操作:使用 HTML 提供基本图表,然后通过填充初始 DOM 逐步增强其交互性。如果没有像 Svelte 这样的组件框架,这很难做到。
请注意,Pancake 实际上并没有创建构成图表的<span>
和节点。相反,这些组件主要是逻辑性的——你只需要添加标记,这意味着你可以对图表元素的外观进行细粒度的控制。<path>
进一步
除了简单的折线图,我们还能做更多的事情:
散点图尤其有趣。因为我们不能使用<circle>
元素——它们会像之前的线条和文本元素那样拉伸——所以我们必须稍微发挥一下创造力。该<Pancake.Scatterplot>
组件会生成一条半径为零的不连续圆弧路径。通过使用描边宽度渲染该路径,我们可以让它看起来像是在绘制圆形。
因为我们使用的是 Svelte 组件,所以我们可以轻松地在图表中引入动效,就像这个小倍数示例一样。我们还可以用最少的麻烦添加诸如声明式过渡之类的功能。
Pancake 图表中的交互功能也可以通过声明的方式处理。例如,我们可以创建一个四叉树(大量借鉴 D3 的思想),用来找到距离鼠标最近的点:
<Pancake.SvgScatterplot data={points} let:d>
<path class="data" {d}/>
</Pancake.SvgScatterplot>
<Pancake.Quadtree data={points} let:closest>
{#if closest}
<Pancake.SvgPoint x={closest.x} y={closest.y} let:d>
<path class="highlight" {d}/>
</Pancake.SvgPoint>
{/if}
</Pancake.Quadtree>
在《纽约时报》,我们正在使用一种非常类似的技术来创建无需 JavaScript 的地图,以追踪新冠病毒疫情。还有一些工作要做,但这项工作最终可能会被纳入 Pancake 项目。
未来,该库可能会添加对画布层(2D 和 WebGL)渲染的支持。使用 JS 的图表<canvas>
将严格依赖于 JS,但当数据量超过 SVG 能够高效渲染的范围时,这种做法是必要的。
注意事项
这仍然处于实验阶段;它还没有像现有的图表库那样经过实战检验。
它专注于管理二维图表的坐标系统。这对于折线图、条形图、散点图、堆积面积图等等来说已经足够了,但如果你需要制作饼图,你就得另寻他处了。
目前还没有文档,但主页上有一些示例可以借鉴。随着我们遇到更多实际问题,API 可能会发生变化。
致谢
“Pancake” 这个名字源于图表是通过层层叠加而构建的。我非常感谢Michael Keller创作了Layer Cake,Pancake 的很多灵感都来源于此,我也从中借鉴了一些上面链接的示例图表。Michael 也报道了上面链接的故事,这给了我创作 Pancake 的最初动力。
我还要感谢D3和Observable 的创始人Mike Bostock,他分享了深刻的见解、示例和代码,使这类项目得以实现。Pancake 主页上的几个示例都是从D3 示例页面中毫不掩饰地抄袭而来的,而 D3 示例页面对于任何想要测试新图表库的人来说都是一座金矿。
文章来源:https://dev.to/richharris/a-new-technique-for-making-responsive-javascript-free-charts-gmp