Reputation: 16395
I've got a chart with lots of points. That's why I'm using canvas
to draw the lines. For the x and y axes I'd like to use SVG since it's sharper and drawing text with canvas
isn't super fast.
Here is the code (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));
};
}
And here is the live example
https://codesandbox.io/s/1pprq
The problem is translateExtent
. I'd like to restrict dragging when zoomed in to my available data, i.e. [0, 20000]
on the x axis and [-1.2, 1.2]
on the y axis.
Somehow I'm currently able to zoom in further. You can see the effect when zooming in and dragging all the way to the bottom. You will see a gap between the lowest value and the x axis.
I think it has something to do with using canvas
and svg
. The svg
is on top of the canvas
and the ZoomBehavior
is on the svg
. Somehow the zoom isn't properly translated to the canvas
.
I'd like to keep the svg
on top because I need more interface elements later one which are added to the svg
.
Any ideas? Thank you!
Upvotes: 2
Views: 1143
Reputation: 38161
If I understand the question correctly:
The issue you are running into is that your translate extent is not correct
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);
In the above, width
and height
refer to the width and height of the SVG, not the canvas. Also, zoom extent is not often specified explicitly, but if zoom extent is not specified with zoom.extent()
, the zoom extent defaults to the dimensions of the container it was called on.
If your translate extent is equal in size to your zoom extent - by default the extent of the container (the SVG) - which it is, you can zoom and pan anywhere within that container's coordinate space, but not to coordinates beyond it. Consequently, when zoom scale is 1, we cannot pan anywhere as we would by definition pan beyond the translate extent.
Note: This logically means translate extent must contain and not be smaller than the zoom extent.
But, in this scenario, if we zoom in, we can pan and remain within the translate extent.
We can see if you zoom in you cannot pan up beyond the intended limits. This is because the top of the canvas is at y==0
, this is the bounds of the translate extent.
As you note if you zoom in you can pan down beyond the intended limits. The bottom of the canvas is h
, which is smaller than height
which is the translate extent limit, so as we zoom in, we can pan further and further down as the gap between h
and height
increases each time we zoom (and as noted above, cannot be panned when k==1
).
We could try to change the translate extent to reflect the bounds of the canvas. But, as the canvas is smaller than the SVG this won't work as the translate extent would be smaller than the zoom extent. As noted above and noted here by Mike: "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."
We can modify the translateExtent and the zoom's extent, however:
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);
The above creates a zoom behavior that constrains the canvas to its original extent - we would be providing the same parameters if we were calling the zoom on the canvas and wanted to not be able to pan beyond it (except we could rely on the default zoom extent to provide the appropriate values rather than specifying the zoom extent manually).
Here's an updated sandbox.
Upvotes: 2