d3 使用 SVG 轴和 canvas 图表缩放和拖动

d3 zoom and drag with SVG axes and canvas chart

我有一张包含很多点的图表。这就是为什么我使用 canvas 来画线。对于 x 轴和 y 轴,我想使用 SVG,因为它更清晰并且使用 canvas 绘制文本不是很快。

这是代码 (TypeScript)

import { min, max } from "d3-array";
import { scaleLinear, ScaleLinear } from "d3-scale";
import { select, event, Selection } from "d3-selection";
import { line, Line } from "d3-shape";
import { ZoomBehavior, zoom } from "d3-zoom";
import { axisBottom, Axis, axisLeft } from "d3-axis";

interface Margin {
  left: number;
  right: number;
  top: number;
  bottom: number;
}

interface Config {
  margin: Margin;
  target: HTMLCanvasElement;
  svg: SVGSVGElement;
}

export default class ScopeChart {
  private canvas: Selection<HTMLCanvasElement, unknown, null, undefined>;
  private svg: Selection<SVGGElement, unknown, null, undefined>;
  private xAxis: Axis<number>;
  private xAxisGroup: Selection<SVGGElement, unknown, null, undefined>;
  private yAxis: Axis<number>;
  private yAxisGroup: Selection<SVGGElement, unknown, null, undefined>;
  private context: CanvasRenderingContext2D;
  private raw: number[];
  private filtered: number[];
  private xScale: ScaleLinear<number, number>;
  private yScale: ScaleLinear<number, number>;
  private line: Line<number>;

  public constructor(config: Config) {
    this.raw = [];
    this.filtered = [];

    const behavior = zoom() as ZoomBehavior<SVGGElement, unknown>;

    const width = 640;
    const height = 480;

    const w = width - config.margin.left - config.margin.right;
    const h = height - config.margin.top - config.margin.bottom;

    this.canvas = select(config.target)
      .attr("width", w)
      .attr("height", h)
      .style(
        "transform",
        `translate(${config.margin.left}px, ${config.margin.top}px)`
      );

    this.svg = select(config.svg)
      .attr("width", width)
      .attr("height", height)
      .append("g")
      .attr(
        "transform",
        `translate(${config.margin.left}, ${config.margin.top})`
      );

    this.svg
      .append("rect")
      .attr("width", w)
      .attr("height", h)
      .style("fill", "none")
      .style("pointer-events", "all")
      .call(behavior);

    behavior
      // set min to 1 to prevent zooming out of data
      .scaleExtent([1, Infinity])
      // prevent dragging data out of view
      .translateExtent([[0, 0], [width, height]])
      .on("zoom", this.zoom);

    this.context = (this.canvas.node() as HTMLCanvasElement).getContext(
      "2d"
    ) as CanvasRenderingContext2D;

    this.xScale = scaleLinear().range([0, w]);

    this.xAxis = axisBottom(this.xScale) as Axis<number>;
    this.xAxisGroup = this.svg
      .append("g")
      .attr("class", "x axis")
      .attr("transform", `translate(0, ${h})`)
      .call(this.xAxis);

    this.yScale = scaleLinear().range([h, 0]);

    this.yAxis = axisLeft(this.yScale) as Axis<number>;
    this.yAxisGroup = this.svg
      .append("g")
      .attr("class", "y axis")
      .call(this.yAxis);

    this.line = line<number>()
      .x((_, i): number => this.xScale(i))
      .y((d): number => this.yScale(d))
      .context(this.context);
  }

  private drawRaw(context: CanvasRenderingContext2D): void {
    context.beginPath();
    this.line(this.raw);

    context.lineWidth = 1;
    context.strokeStyle = "steelblue";
    context.stroke();
  }

  private drawFiltered(context: CanvasRenderingContext2D): void {
    context.beginPath();
    this.line(this.filtered);

    context.lineWidth = 1;
    context.strokeStyle = "orange";
    context.stroke();
  }

  private clear(context: CanvasRenderingContext2D): void {
    const width = this.canvas.property("width");
    const height = this.canvas.property("height");

    context.clearRect(0, 0, width, height);
  }

  public render(raw: number[], filtered: number[]): void {
    this.raw = raw;
    this.filtered = filtered;

    this.xScale.domain([0, raw.length - 1]);
    this.yScale.domain([min(raw) as number, max(raw) as number]);

    this.clear(this.context);
    this.drawRaw(this.context);
    this.drawFiltered(this.context);

    this.xAxisGroup.call(this.xAxis);
    this.yAxisGroup.call(this.yAxis);
  }

  public zoom = (): void => {
    const newXScale = event.transform.rescaleX(this.xScale);
    const newYScale = event.transform.rescaleY(this.yScale);

    this.line.x((_, i): number => newXScale(i));
    this.line.y((d): number => newYScale(d));

    this.clear(this.context);
    this.drawRaw(this.context);
    this.drawFiltered(this.context);

    this.xAxisGroup.call(this.xAxis.scale(newXScale));
    this.yAxisGroup.call(this.yAxis.scale(newYScale));
  };
}

这是现场示例

https://codesandbox.io/s/1pprq

问题是translateExtent。我想在放大我的可用数据时限制拖动,即 x 轴上的 [0, 20000] 和 y 轴上的 [-1.2, 1.2]

不知何故,我目前能够进一步放大。放大并一直拖到底部时,您可以看到效果。您会看到最低值和 x 轴之间存在间隙。

我认为这与使用canvassvg有关。 svgcanvas 之上,ZoomBehaviorsvg 之上。不知何故,缩放未正确转换为 canvas.

我想将 svg 放在最前面,因为我需要更多的界面元素,这些元素会添加到 svg

有什么想法吗?谢谢!

如果我理解正确:

您 运行 遇到的问题是您的翻译范围不正确

behavior
  // set min to 1 to prevent zooming out of data
  .scaleExtent([1, Infinity])
  // prevent dragging data out of view
  .translateExtent([[0, 0], [width, height]])
  .on("zoom", this.zoom);

在上面,widthheight指的是SVG的宽度和高度,而不是canvas。此外,通常不会明确指定缩放范围,但如果未使用 zoom.extent() 指定缩放范围,则缩放范围默认为调用它的容器的尺寸。

如果您的平移范围与您的缩放范围大小相等 - 默认情况下容器(SVG)的范围 - 它是,您可以在该容器的坐标 space 内的任何地方缩放和平移,但不要超出它的坐标。因此,当缩放比例为 1 时,我们无法平移任何地方,因为我们会平移超出平移范围的定义。

注意:这在逻辑上意味着平移范围必须包含且不小于缩放范围。

但是,在这种情况下,如果我们放大,我们可以平移并保持在平移范围内。

我们可以看到,如果您放大,则无法向上平移超出预期限制。这是因为 canvas 的顶部在 y==0,这是翻译范围的边界。

正如您所注意到的,如果您放大,您可以向下平移超出预期的限制。 canvas 的底部是 h,它小于平移范围限制 height,因此当我们放大时,我们可以随着 hheight 每次缩放都会增加(如上所述,当 k==1 时无法平移)。

我们可以尝试更改翻译范围以反映 canvas 的范围。但是,由于 canvas 小于 SVG,因此这将不起作用,因为平移范围将小于缩放范围。如上所述,Mike 指出 here:"The problem is that the translateExtent you’ve specified is smaller than the zoom extent. So there’s no way to satisfy the requested constraint."

我们可以修改 translateExtent 和缩放的范围,但是:

behavior
  // set min to 1 to prevent zooming out of data
  .scaleExtent([1, Infinity])
  // set the zoom extent to the canvas size:
  .extent([[0,0],[w,h]])
  // prevent dragging data out of view
  .translateExtent([[0, 0], [w, h]])
  .on("zoom", this.zoom);

上面创建了一个缩放行为,将 canvas 限制在其原始范围内 - 如果我们在 canvas 上调用缩放并且不希望能够提供相同的参数平移到它之外(除非我们可以依靠默认缩放范围来提供适当的值,而不是手动指定缩放范围)。

这是更新后的 sandbox