Interaction and Pan/Zoom
While Counterpoint does not provide built-in rendering capabilities, it does provide helper classes to make it easier to use Canvas and WebGL for rendering while still supporting interaction.
Retrieving Marks by Position
For many interactive applications, you’ll need to detect what mark is under the
user’s cursor based on the mark coordinates. You may also
need to enumerate the set of marks that are within a certain distance of the
user’s cursor. Counterpoint supports both tasks using an efficient hashing-based
algorithm implemented in the PositionMap
class.
In the example below, as you hover your mouse, you can see that the hitTest
returns at most one mark that contains the mouse location. The marksNear
function
can return multiple marks that are within 50 pixels of the mouse.
hitTest: none. marksNear: none.
To use hit-testing, first define a hit-test function for each mark. This determines whether a location is contained within the mark. For example, if we have circular marks, we could use a Euclidean distance function:
renderGroup.configure({
...,
hitTest: (mark, location) => {
let x = mark.attr('x');
let y = mark.attr('y');
let r = mark.attr('radius');
return Math.sqrt(Math.pow(x - location[0], 2.0) + Math.pow(y - location[1], 2.0)) <= r;
}
});
Then, we create a position map and add the render group to it:
let positionMap = new PositionMap().add(renderGroup);
When we want to query the position map, we can call the hitTest
function, with
an array of coordinates, e.g.:
function handleMouseMove(event) {
let location = [
event.pageX - canvas.getBoundingClientRect().left,
event.pageY - canvas.getBoundingClientRect().top,
];
let hit = positionMap.hitTest(location);
if (hit) {
// do something...
}
}
To simply get the marks within a given radius of a location, the logic is very similar, and a hit-test function is not required:
let marksInRadius = positionMap.marksNear(location, radius);
IMPORTANT: When using a Position Map, the internal memory of where points are
located is not automatically updated. Call the invalidate
method to notify
the position map that it needs to be recomputed:
// some code to modify point locations...
positionMap.invalidate();
PositionMap
Options
Option | Description |
---|---|
coordinateAttributes: string[] |
The names of the attributes to use for coordinates from each mark |
marksPerBin: number |
The approximate average number of marks to place in each bin. This is used to determine the bin size. If the number of marks will be very large, it is recommended to set this to a higher number to prevent a very sparse hash map. |
transformCoordinates: boolean |
Whether or not to run the transform function on each coordinate. If set to false , this can allow the position map to run in untransformed coordinates and thus be invariant to pan and zoom interactions, if the transform function performs pan/zoom scaling. |
maximumHitTestDistance: number |
The default maximum distance to search when hit-testing. Set this to the largest distance from mark coordinates that would be expected to result in a match. |
Pan and Zoom
Counterpoint offers pan and zoom transforms in a Scales
class, which uses
Counterpoint attributes under the hood so it can respond reactively to other
attributes. These scales are interoperable with D3’s zoom framework.
Try zooming and panning the scatterplot below to see how it works. You can also click the buttons to zoom to or follow specific points as you animate the plot. (See the code on GitHub.)
Selected indexes: none
To initialize the Scales
, you provide the X and Y domains and ranges. Domains
represent the extent of values in your mark coordiantes, while ranges represent
the extent of values they should be mapped to on the screen. For example, to
display values ranging from -1 to 1 in a plot that is 600 x 600 pixels, we can use:
let scales = (new Scales()
.xDomain([-1, 1])
.yDomain([-1, 1])
.xRange([0, 600])
.yRange([0, 600])
);
Then, you can pass the scales.xScale
and scales.yScale
properties as-is to
the transform
options of your marks’ coordinate attributes:
let mark = new Mark(id, {
x: {
value: ...,
transform: scales.xScale
},
y: {
value: ...,
transform: scales.yScale
}
});
Importantly, you must add the scales
to your Ticker so that the view will be
redrawn when the scales change.
Now, you can simply implement zooming and panning using gestures (or use D3-zoom),
and update the scales
object as needed. Below we provide example code for
implementing basic zoom and pan with native JavaScript event handlers and with
D3-zoom:
Native JavaScript (supports scroll wheel and click+drag only)
let lastMousePos: [number, number] | null = null;
function onMousedown(e: MouseEvent) {
lastMousePos = [
e.clientX - canvas.getBoundingClientRect().left,
e.clientY - canvas.getBoundingClientRect().top,
];
}
function onMousemove(e: MouseEvent) {
if (lastMousePos != null) {
let newMousePos: [number, number] = [
e.clientX - canvas.getBoundingClientRect().left,
e.clientY - canvas.getBoundingClientRect().top,
];
scales.translateBy(
newMousePos[0] - lastMousePos[0],
newMousePos[1] - lastMousePos[1]
);
lastMousePos = newMousePos;
e.preventDefault();
}
}
function onMouseup(e: MouseEvent) {
lastMousePos = null;
}
function onMouseWheel(e: WheelEvent) {
let ds = -0.01 * e.deltaY;
let rect = canvas.getBoundingClientRect();
let mouseX = e.clientX - rect.left;
let mouseY = e.clientY - rect.top;
scales.scaleBy(ds, [mouseX, mouseY]);
e.preventDefault();
}
D3 Zoom
let zoom = d3
.zoom()
.scaleExtent([0.1, 10])
.on('zoom', (e) => {
// important to make sure the source event exists, filtering out our
// programmatic changes
if (e.sourceEvent != null) {
// tell the scales the zoom transform has changed
scales.transform(e.transform);
}
});
d3.select(canvas).call(zoom);
scales.onUpdate(() => {
// When the scales update, we also need to let the d3 zoom object know that
// the zoom transform has changed
let sel = d3.select(canvas);
let currentT = d3.zoomTransform(canvas);
let t = scales.transform();
if (t.k != currentT.k || t.x != currentT.x || t.y != currentT.y) {
sel.call(zoom.transform, new d3.ZoomTransform(t.k, t.x, t.y));
}
});
Reactive Zoom Behavior
As shown in the demo above, the Scales
class can update dynamically in response
to marks that you provide. There are two main types of updates you can perform:
- Zoom Once (
Scales.zoomTo
): Update the transform to focus on a given set of marks in their current locations. If these marks move later, the scales will not change. - Follow (
Scales.follow
/Scales.unfollow
): Update the transform to remain focused on a given set of marks, even if they change locations.
With either of these updates, you can specify how to compute the desired zoom
transform by passing an instance of MarkFollower
to the zoomTo
or follow
methods. There are two easy ways to define a MarkFollower
:
- Center on a Point (
centerOn
): This global helper function takes a single mark as input, and it computes a box in which the given mark is centered. It also provides options to set the padding, etc. For example:scales.follow(centerOn(myMark, { padding: 50 }));
- Contain a set of Marks (
markBox
): This function takes an array of marks as input, and computes a box that contains all of the marks. For example:scales.zoomTo(markBox(myFavoriteMarks, { padding: 50 }));
TIP: Custom Coordinate Names
If your marks use names other than ‘x’ and ‘y’ to represent their coordinates, you can specify which attributes should be used to compute the mark box using the
xAttr
andyAttr
options.