Repository navigation
Conversation
- The browser repaints a pattern marker on every point, which halves the frame rate on long lines - Markers move to their own path, roughly one per `markerSpacing` px of the chart's width - Kept on the line below one point per pixel, where a smoothing curve would show the gap - Points with no neighbours always keep theirs: `line - gap - point - gap - line` has no segment to draw
2026-09-24.at.21.47.45-converted.2.mp4Same remount of 200 charts under a 4x CPU throttle, at two render budgets. With |
There was a problem hiding this comment.
When there is exactly one defined point and the chart width is ≤ 1 (e.g. a container with width: 0 before layout/ResizeObserver fires, or a 1px sparkline), count = clamp(Math.floor(width/spacing), 2, defined.length) evaluates to clamp(0, 2, 1) = 1. Then step = (defined.length - 1) / (count - 1) = 0 / 0 = NaN, so defined[Math.round(i * step)] is defined[NaN] → undefined, which gets added to the points Set. The final .sort((a, b) => a.x - b.x) then throws TypeError: Cannot read properties of undefined (reading 'x') because a/b is undefined. This happens inside a d3-selection.each() callback in _render, so it will break rendering for the whole chart, not just this line.
Fix: Guard the degenerate case before computing step, e.g.:
const count = clamp(Math.floor(width / spacing), 2, defined.length)
if (count < 2) return [...points].sort((a, b) => a.x - b.x).reduce((path, p) => `${path}M${p.x},${p.y}`, '') || null
const step = (defined.length - 1) / (count - 1)
There was a problem hiding this comment.
@50rayn thanks for the contribution!
one small issue:



Adds an opt-in
markerSpacingoption toLine, for lines that carry an SVG marker via the pattern accessor.Problem
A marker attached through
patternis applied as the CSSmarkerproperty on the line path, so the browser paints one marker instance per path vertex. That cost scales with the data, not with the chart: a line with 9,000 points paints 9,000 markers into a few hundred pixels, and the browser repaints all of them every frame. Several such lines on one chart drop the frame rate to the high teens, and most of those markers land on top of each other anyway.Approach
With
markerSpacingset, the markers move off the line path onto a separate, invisible path that carries only the vertices that should get one, roughly one everymarkerSpacingpixels:The count is derived from the x scale's pixel range rather than from the data, so it adapts to the container — the same chart draws ~8 markers at 400 px wide and ~33 at 1400 px, instead of 9,000 at both. On a chart of several dense lines this took the in-chart frame rate from 17 to 51 fps.
Two behaviours worth calling out, both of which cost correctness if skipped:
Isolated points always survive the thinning. A point with no defined neighbour on either side — the
line — gap — point — gap — lineshape — has no line segment to sit on, so its marker is the only thing that renders it. Thinning it away would be silent data loss, so those are kept unconditionally, on top of the evenly spaced ones.The option is ignored below one point per pixel. With sparse data the markers stay on the line itself, because a smoothing
curveTypeplaces the rendered curve away from the raw vertices and the separate path would visibly disagree with the line.An earlier version spaced markers by path length via
getPointAtLength. That was dropped: measured path length turned out to be essentially width-independent (21,425 px at 400 px wide vs 21,567 px at 1500 px), so it produced the same marker count at every size, andgetPointAtLengthitself cost 135 ms–1,134 ms on these paths.Notes
Default is
undefined, so nothing changes unless the option is set.Tests: 8 specs in
marker.spec.tscovering the thinning maths, the width-derived count, and the isolated-point case.