Grammar of Graphics at Scale — Synthesizing Declarative Charts with High-Performance LOD Engines

a second perspective on letsplot optimization & the xy research
Perspective 2: Statistical Charts vs. Raw Scatter at Scale
Insight from LetsPlot Creator: "Для scatter с такими обьемами нужен high-performance движок определенно. Если замахиваться на такое, то надо будет добавить в арсенал. С другой стороны, в exploratory data analysis скаттер это только один тул. Множество других графиков (гистограмма например), показывают результат той или иной статистической ф-ии. Тут ggplot хорош."

Translation & Context

"For scatter with such volumes, a high-performance engine is definitely needed. If we aim for that, we will need to add it to the arsenal. On the other hand, in exploratory data analysis, scatter is only one tool. Many other charts (for example, histogram) show the result of one or another statistical function. Here ggplot is good."

This is a profoundly accurate observation. It highlights the fundamental distinction between raw data geoms (like geom_point) and statistical geoms (like geom_histogram, geom_density, geom_boxplot). Let's explore how this distinction informs the architecture for high-performance charting.

1. The GGPlot Paradigm: Strengths & Bottlenecks

Why ggplot is Good for Statistical Charts

Charts like histograms, density plots, boxplots, and violin plots are inherently statistical aggregations. They take raw data and apply a statistical function (binning, quantiles, kernel density estimation) before rendering. The output is already a reduced dataset:

Because these geoms are already statistical transformations, they are naturally LOD-friendly. The data is reduced to a manageable size (often O(screen pixels) or O(bins)) before it ever reaches the rendering layer. ggplot's strength lies in this declarative mapping: data + geom_histogram() implies "apply the histogram stat, then render the bars."

The Scatter Plot Bottleneck

Scatter plots are different. geom_point(x, y) is a raw data geom. It implies a 1:1 mapping: one mark per row. At 100k points, this is fine. At 10M or 100M points, this creates an O(N) rendering cliff:

The creator is right: for scatter with such volumes, a high-performance engine is definitely needed. But the question is: how do we maintain the declarative Grammar of Graphics spirit while solving the rendering bottleneck?

2. The Solution: "Transparent" Render Tiers

Extending the Statistical Concept to Scatter

The insight from the xy library is to treat viewport-driven density aggregation as a dynamic statistical function. Just as geom_histogram() applies a binning stat, a high-performance scatter engine applies a density surface stat when the visible point count exceeds a threshold.

This is implemented via a Render Tier system that sits between the declarative spec and the WebGL renderer:

Tier Name Representation Trigger Condition
0 Direct every visible mark, exact points visible_count <= 200,000
1 Shape-preserving reduction per-pixel-column aggregate (M4 for lines) Lines/areas >10k points
2 Density / aggregate surface mean-point-color texture (512x384 grid) visible_count > 200,000
3 Out-of-core tiles Tier-2 pyramid (4→1 exact sums) Zoom-out beyond Tier-2 resolution

Key Design Principle: API Stability

The user still declares xy.scatter(x, y, color=col, density=True) or geom_point(). The engine internally decides whether to render Tier-0 points, Tier-2 density surface, or Tier-3 pyramid tiles based on the viewport's visible_count. This preserves the declarative Grammar of Graphics API while achieving 100M-point performance.

3. Architecture Recommendations for LetsPlot

Merging Grammar of Graphics with Native Aggregation Kernels

  1. Keep the declarative API intact: geom_point(), geom_histogram(), etc. should remain the user-facing syntax.
  2. For geom_point with N > 200k, automatically switch to a "density surface" geom internally: But keep the color/alpha semantics using mean-color compositing (alpha-weighted mean of resolved colors in linear light).
  3. Implement aggregation in a native core (Rust/Kotlin/Scala): Avoid Python/JS O(N) bottlenecks on zoom/pan. The 2D binning with mean-color compositing should happen in a native kernel (like XY's Rust bin_2d_mean_color).
  4. Pre-compute multi-resolution pyramids for static datasets: For datasets that don't change, build 4→1 count pyramids at load time so zoom-out queries are O(visible cells), not O(N).
  5. Use offset-encoded f32 geometry & uniform-only pan/zoom: For the Tier-0 (direct) path, store geometry as relative f32 coordinates (v - offset) * scale and update only two vec2 uniforms per mark in the WebGL shader.
Validating the Direction: The LetsPlot creator's insight is correct: ggplot is good for statistical charts because they are already aggregated. But for scatter at scale, you must extend the "statistical function" concept to include viewport-driven density aggregation. This doesn't break the Grammar of Graphics; it enhances it by making the "stat" layer aware of the viewport's resolution and the data's volume.
4. Technical Deep Dive: Mean-Color Compositing

Preserving Aesthetics in Aggregation

When switching from Tier-0 (points) to Tier-2 (density surface), a critical challenge is preserving the user's color and alpha aesthetics. The xy library solves this with physical alpha compositing and alpha-weighted mean color in linear light: