Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate ~20 min· topic 1 of 7

Topic 8.1

Pointer Events on Charts

In one line

Pointer events give one set of events for mouse, finger and pen. This topic shows how to turn a pointer position into chart coordinates, why thin lines are hard to hit, and why hover cannot be the only way to reach a number.

Think of it like this

A shop counter bell. It rings the same way whether a finger, a stick or a pen presses it. You do not need one bell per kind of hand. Pointer events are that single bell: one kind of event for a mouse, a finger or a pen.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

pointerType
The kind of device: mouse, touch or pen.
d3.pointer
A D3 function that turns an event's client position into user units of a given element.
hit area
The part of the screen where a shape receives pointer events.
touch-action
A CSS property that tells the browser which touch gestures it may handle itself, such as vertical scrolling.
pointercancel
An event that says the browser took the gesture over (for example to scroll). No pointerup follows.

Step by step

01Send one move as a mouse and as a finger

The first example draws an SVG whose viewBox is 400 by 100 units but is shown 200 pixels wide. It listens to pointermove and logs three coordinates for the same point of the chart.

Two events are sent: one with pointerType: "mouse", one with "touch". The handler is the same, and the numbers are the same.

02Read the three numbers

clientX - left and offsetX are in pixels, so they are about half of 300. Only d3.pointer returns 300, the position in the chart's own units, which is what x.invert(300) needs to find a date.

the right callwhole filejs
const [ux, uy] = d3.pointer(event, svg.node()); // chart units, whatever the CSS size

03Test what is under the pointer

The second example does not wait for a real mouse. document.elementFromPoint(x, y) asks the browser which element would receive an event at that screen point. It is the same hit test the browser uses, so it is a good way to check a hit area.

04Widen the target, not the line

A second path with the same shape, stroke: transparent and stroke-width: 20, sits on top. It is invisible, but transparent still counts as painted, so it receives events within 10 units of the line. stroke: none would not: nothing is painted, so nothing is hit.

Try it yourself

  1. 1

    Make the target bigger

    In the second example, change stroke-width", 20 to "stroke-width", 30. The 12-unit test point is now inside the hit path (it reaches 15 units each side), so it prints hit.

  2. 2

    Use the real chart size

    In the first example, change width="200" in the HTML to width="400". The on-screen size equals the viewBox, so clientX - left, offsetX and d3.pointer all agree at 300. The three numbers only disagree when the chart is scaled, which is every responsive chart.

Code & diagrams

One move as a mouse and as a finger: three coordinates New tab

The page sends its own events so the output is the same every time. Hover the real chart in the editor and the handler runs with real events.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

on screen: 200 x 50 pixels, but the viewBox is 400 x 100 units
mouse | clientX - left: 150 | offsetX: 150 | d3.pointer: 300 50
touch | clientX - left: 150 | offsetX: 150 | d3.pointer: 300 50
Why the thin line is hard to hit, and the hit path that fixes it New tab

Failure that prints no error: a hit area that silently catches nothing. elementFromPoint is how you check it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

thin line only, 0 units below the line -> line
thin line only, 4 units below the line -> the background
thin line only, 12 units below the line -> the background
with the hit path, 0 units below the line -> hit
with the hit path, 4 units below the line -> hit
with the hit path, 12 units below the line -> the background
hit path with stroke none, 4 units below -> the background
... plus pointer-events: stroke, 4 units below -> hit
pointer-events: none on a transparent path -> the background

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use offsetX instead of d3.pointer

In the first example, replace d3.pointer(event, svg) by [event.offsetX, event.offsetY].

terminal
$ Console
── what you'll see ──
on screen: 200 x 50 pixels, but the viewBox is 400 x 100 units
mouse | clientX - left: 150 | offsetX: 150 | d3.pointer: 150 25
touch | clientX - left: 150 | offsetX: 150 | d3.pointer: 150 25

Break #2

Remove the paint from the hit path

In the second example, change the hit path's stroke from "transparent" to "none".

terminal
$ Console
── what you'll see ──
with the hit path, 0 units below the line -> line
with the hit path, 4 units below the line -> the background
with the hit path, 12 units below the line -> the background
hit path with stroke none, 4 units below -> the background

Myth vs fact

Myth

Mouse events are enough, phones fake them.

Fact

Phones send compatibility mouse events only after a tap, and not for drags or hover. Pointer events are the same code path for everything.

Myth

event.offsetX is the chart's x.

Fact

It is in CSS pixels, relative to the element under the pointer (a bar, not the SVG). Use d3.pointer with the SVG.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    d3.pointer follows sourceEvent chains first (d3-selection's sourceEvent.js unwraps events inside brush and zoom handlers), so the same call works in your own listeners and inside D3 behaviours. If the node is an SVG element it uses getScreenCTM().inverse(); for HTML elements it uses getBoundingClientRect() minus the border.

  • ▸

    touch-action is the intersection of the element's value and every ancestor's up to the scrolling box. pan-y on the SVG does not help if a child says none: d3.brush sets touch-action: none on its group when the device reports a touch screen (measured in Chromium with touch enabled: the group's computed value is none, with no touch screen it is auto). The build (Topic 8.7) sets it back to pan-y on that group.

  • ▸

    element.setPointerCapture(event.pointerId) sends every later event of that pointer to the element, even when the pointer leaves it. A hand-written slider or drag handle needs it, or the drag stops at the edge. d3.brush and d3.zoom do their own tracking with mouse and touch events, so you do not call it for them.

Remember this

  1. 1

    The problem this solves: browsers used to send different events for a mouse (mousemove) and for a finger (touchstart, touchmove). A chart that listened only to the mouse did nothing on a phone. Pointer events (pointerdown, pointermove, pointerup) cover all inputs with the same code.

  2. 2

    Pointer position is not chart position. clientX counts screen pixels, but your scales work in the chart's own units (the viewBox, Topic 1.4). d3.pointer(event, svg) converts: it inverts the element's getScreenCTM() (the matrix from chart units to the screen) and returns [x, y] in chart units. event.offsetX does not: it is in CSS pixels, relative to whichever element is under the pointer. In the first example offsetX says 150 where the chart point is 300.

  3. 3

    Only painted parts get events. By default (pointer-events: visiblePainted) a shape receives events only where it is painted. A line chart's path has fill: none and a stroke 1.5 units wide, so the pointer must be within about 1 unit of the line. At 4 units away the event goes to whatever is behind. Two cures: a second, wider path with stroke: transparent as a hit area (the second example), or one big transparent rectangle over the plot that catches every move and finds the nearest point itself (Topic 8.3).

  4. 4

    Hover does not exist on a touch screen. A mouse can hover, and so can a pen held just above the glass, but a finger is either on the glass or not. There is no "hovering above the chart" with a finger. A tooltip shown on pointerenter appears when the finger lands and disappears when it lifts, so the reader never sees it. Anything that appears only on hover needs a second way in: a tap that keeps it open (Topic 8.2) and keyboard focus.

  5. 5

    The browser may take the gesture away. When a finger starts to drag, the browser usually scrolls the page and sends pointercancel instead of more pointermove events. The CSS property touch-action says which gestures it may take: pan-y leaves vertical scrolling to the browser and sends sideways drags to your code; none takes everything and can trap a phone user on a chart that fills the screen. Treat pointercancel like pointerup, or a highlight stays stuck on.

Explain it without notes

01

Why use pointer events instead of mouse events plus touch events?

02

Why is event.offsetX the wrong number for a tooltip position in a responsive SVG chart?

03

A user says the tooltip on the line chart "almost never shows". What do you check first?

Practice

01

Make a chart with a 640-unit viewBox shown at 320 pixels. Log d3.pointer for a pointer at the pixel (160, 40) of the SVG.

02

Write isNear(event, svg, ux, tolerance): true when the pointer is within tolerance chart units of the vertical line x = ux.

Trade-offs

  • ↔

    Wide invisible hit paths are simple and precise, but one per mark gets expensive with many marks. One transparent overlay plus a nearest-point search (Topic 8.3) scales to thousands of points.

Done when you can

  • I can say what pointerType tells me and why one handler is enough for mouse, touch and pen.

  • I can turn a pointer event into chart units with d3.pointer, and say why offsetX is wrong.

  • I can test a hit area with elementFromPoint and widen it with a transparent stroke.

  • I can say why hover cannot be the only way to reach a number, and what touch-action and pointercancel are for.