制作响应式、无JavaScript图表的新技术的教程

122 阅读7分钟

网络上有无数个生成图表的库。每一个库的服务范围都略有不同,但所有的库都有一个共同点:它们需要JavaScript。

这当然是有道理的--你的图表往往取决于必须用JS在网络上获取的数据,或者将被渲染成一个<canvas> 元素。但这并不理想。不是每个人都有JS,而且在任何情况下,依赖JS意味着你会在页面上留下一个图表形状的洞,直到它加载为止,只有当你的所有数据显示都被隐藏在折叠下面时,你才能真正摆脱这种情况。

另一个更微妙的问题是,流体图--那些适应其容器宽度的图表--必须在调整大小时重新绘制,以避免潜在的破坏。这可能意味着开发者要做更多的工作(特别是如果开发者使用的是像D3这样的低级库),当然也会给浏览器带来更多的工作。

在《纽约时报》最近的一篇文章中,我想看看是否有可能创建不使用JS也能工作的SVG图表。

CyberTipline reports vs funding

嗯,确实如此。我没有在其他地方看到过同样的技术组合,所以我想我应该把这个过程写出来。我还创建了一个名为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乘100px的图表。如果我们把x 的值乘以30,然后从100中减去y 的值,我们就会得到填补空间的坐标。

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

抛开一些坐标轴和一些样式,我们就有了一个图表

Simple line chart

这些逻辑都可以存在于Node.js脚本中,这意味着不需要任何客户端JS就可以轻松创建这个图表。

但它不会适应其容器的大小--它将永远是一个300px x 100px的图表。在大多数网站上,这是个问题。

解决办法(第一部分)

SVG有一个叫做viewBox 的属性,它定义了一个独立于<svg> 元素本身大小的坐标系统。通常情况下,不管<svg> 元素的长宽比如何,viewBox的长宽比都会被保留下来,但是我们可以用preserveAspectRatio="none"

我们可以选择一个简单的坐标系,像这样...

<svg viewBox="0 0 100 100" preserveAspectRatio="none">

...并将我们的数据投射到其中。现在,我们的图表可以流畅地适应它的环境

Fluid-but-broken charts

但它显然在两个重要方面有缺陷。首先,文本的缩放比例非常可怕,在某些情况下甚至无法辨认。其次,线条的笔画与线条本身一起被拉长,这看起来很可怕。

其中第二个问题很简单,可以通过一个鲜为人知的CSS属性--vector-effect: non-scaling-stroke --应用于每个元素来解决。

Fluid-and-slightly-less-broken charts

但是,据我所知,第一个问题在SVG中是无法解决的。

解决方案(第二部分)

我们可以不使用SVG元素来做轴,而是使用HTML元素,并用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>

我们的图表不再有问题了

Non-borked fluid line charts

使用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> 节点。相反,组件主要是逻辑性的--你带来了标记,意味着你对图表元素的外观有精细的控制。

更进一步

我们可以做的比简单的线形图多得多。

Different Pancake chart types

散点图是特别有趣的。因为我们不能使用<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>

在纽约时报,我们正在使用一种非常类似的技术来创建跟踪冠状病毒爆发的无JS地图。还有一些工作要做,但很可能这些工作最终会被折叠到Pancake中。

在未来,该库可能会增加对画布层渲染的支持(包括2D和WebGL)。使用<canvas> 的图表将对JS产生硬性依赖,但在你有更多的数据无法用SVG渲染的情况下,这是有必要的。

注意事项

这仍然是一个实验性的东西;它还没有像现有的图表库那样经过实战检验。

它的重点是管理二维图表的坐标系统。这对线图、柱状图、散点图、堆积面积图等来说已经足够了,但如果你需要制作饼图,你就得去找别的地方。

目前,没有任何文档,但主页上有你可以借鉴的例子。随着我们遇到更多的实际问题,API有可能会改变。

鸣谢

煎饼 "这个名字来自于图表是由层层叠加而成的。我非常感谢Michael Keller创造了Layer Cake,Pancake从它那里获得了很多灵感,我也从那里剽窃了上面链接的一些图表例子。迈克尔还报道了上面链接的故事,使我有理由首先创建Pancake。

我也要感谢D3ObservableMike Bostock,他分享的见解、例子和代码使这样的项目成为可能。Pancake主页上的几个例子是无耻地从D3例子页面上复制的,对于任何想要测试新图表库的人来说,这都是一座金矿。