Component | Line: Add `markerSpacing` to thin out line pattern markers by 50rayn · Pull Request #931 · f5/unovis · GitHub
Skip to content

Component | Line: Add markerSpacing to thin out line pattern markers - #931

Open
50rayn wants to merge 4 commits into
f5:mainfrom
50rayn:perf/line-marker-density
Open

50rayn wants to merge 4 commits into
f5:mainfrom
50rayn:perf/line-marker-density

Conversation

@50rayn

@50rayn 50rayn commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Adds an opt-in markerSpacing option to Line, for lines that carry an SVG marker via the pattern accessor.

  • Dev examples
  • Wrappers
  • Gallery example — n/a
  • Docs

Problem

A marker attached through pattern is applied as the CSS marker property 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 markerSpacing set, the markers move off the line path onto a separate, invisible path that carries only the vertices that should get one, roughly one every markerSpacing pixels:

<VisLine data={data} x={x} y={y} markerSpacing={40} />

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 — line shape — 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 curveType places 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, and getPointAtLength itself 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.ts covering the thinning maths, the width-derived count, and the isolated-point case.

- 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
@50rayn

50rayn commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

@50rayn

50rayn commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author
2026-09-24.at.21.47.45-converted.2.mp4

Same remount of 200 charts under a 4x CPU throttle, at two render budgets.

With 0 ms, every container renders in one frame — the page is unresponsive until they all land. With the default 8 ms, the work spreads across frames, and the charts fill in progressively. Total blocking time drops from 3,976 ms to 1,691 ms.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@50rayn thanks for the contribution!
one small issue:
Screenshot 2026-10-16 at 2 34 32 PM
Screenshot 2026-10-16 at 2 34 58 PM

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants