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:

  1. 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.
  2. 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:

  1. 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 }));
    
  2. 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 and yAttr options.

results matching ""

    No results matching ""