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.
const [ux, uy] = d3.pointer(event, svg.node()); // chart units, whatever the CSS size03Test 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
Make the target bigger
In the second example, change
stroke-width", 20to"stroke-width", 30. The 12-unit test point is now inside the hit path (it reaches 15 units each side), so it printshit. - 2
Use the real chart size
In the first example, change
width="200"in the HTML towidth="400". The on-screen size equals the viewBox, soclientX - left,offsetXandd3.pointerall agree at 300. The three numbers only disagree when the chart is scaled, which is every responsive chart.
Code & diagrams
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
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 50Failure that prints no error: a hit area that silently catches nothing. elementFromPoint is how you check it.
Preview
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 backgroundBreak 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].
Break #2
Remove the paint from the hit path
In the second example, change the hit path's stroke from "transparent" to "none".
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.pointerfollowssourceEventchains first (d3-selection'ssourceEvent.jsunwraps 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 usesgetScreenCTM().inverse(); for HTML elements it usesgetBoundingClientRect()minus the border. - ▸
touch-actionis the intersection of the element's value and every ancestor's up to the scrolling box.pan-yon the SVG does not help if a child saysnone:d3.brushsetstouch-action: noneon its group when the device reports a touch screen (measured in Chromium with touch enabled: the group's computed value isnone, with no touch screen it isauto). The build (Topic 8.7) sets it back topan-yon 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.brushandd3.zoomdo their own tracking with mouse and touch events, so you do not call it for them.
Remember this
- 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
Pointer position is not chart position.
clientXcounts screen pixels, but your scales work in the chart's own units (theviewBox, Topic 1.4).d3.pointer(event, svg)converts: it inverts the element'sgetScreenCTM()(the matrix from chart units to the screen) and returns[x, y]in chart units.event.offsetXdoes 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
Only painted parts get events. By default (
pointer-events: visiblePainted) a shape receives events only where it is painted. A line chart's path hasfill: noneand 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 withstroke: transparentas 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
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
pointerenterappears 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
The browser may take the gesture away. When a finger starts to drag, the browser usually scrolls the page and sends
pointercancelinstead of morepointermoveevents. The CSS propertytouch-actionsays which gestures it may take:pan-yleaves vertical scrolling to the browser and sends sideways drags to your code;nonetakes everything and can trap a phone user on a chart that fills the screen. Treatpointercancellikepointerup, or a highlight stays stuck on.
Explain it without notes
Why use pointer events instead of mouse events plus touch events?
Why is event.offsetX the wrong number for a tooltip position in a responsive SVG chart?
A user says the tooltip on the line chart "almost never shows". What do you check first?
Practice
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.
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.