From 7e9809c49b4368162ca8b1aba78859fe81db8fc8 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:19 +0000 Subject: [PATCH 1/4] Color density surfaces by mean point color; count becomes alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tier-2 density map colormapped the binned COUNT, ignoring the points' own colors — a colored scatter's aggregate view matched neither its marks nor its legend, and every density<->points zoom transition visibly recolored the chart. The surface now wears the data's colors (LOD doc §2): each cell carries the alpha-weighted mean of its binned points' resolved colors — one law for continuous, categorical, and direct-RGBA channels — averaged in linear light through an integer-only pipeline (checked-in sRGB<->linear u16 tables, u64 sums; bitwise deterministic across thread counts and platforms). The log-tone-mapped count drives only the alpha channel: more points, deeper color; fewer, lighter. Constant-color traces keep the compact count-only wire and a client tint (the mean of a constant is the constant). - Rust core: bin_2d_mean_color kernel, with a fused cell accumulator that fans out under a points-per-cell gate (<=4 workers, exact integer merge — output identical to the serial scan); pyramid mean-color planes (build_color/compose_color; count grids bit-identical to the count-only compose; colored pyramids refuse appends and are invalidated + rebuilt lazily). C ABI v40. - Python: channels.resolve_bin_colors maps every channel mode onto the kernel color source; initial emit + density_view (exact and pyramid paths) ship the RGBA plane as density.rgba with color_agg: "mean"; color is no longer listed in dropped_channels; the windowed-exact spatial tier is gated to colorless traces (its index is position-only, §27 — the upsampled colored-pyramid grid stays instead); SVG/PNG exporters follow the same color law; continuous-channel density scatters render their colorbar again. - Client: DENSITY_FS mean-color branch over a premultiplied RGBA8 texture (count tone curve baked at upload so bilinear filtering weights color by coverage); exposure easing re-encodes color grids; the standalone to_html re-bin worker aggregates the sample's colors under the same law; the density-tier gradient legend swatch is removed (count is alpha, so a gradient would claim color == density; a named density trace falls through to the plain marker swatch, matching the static exporters). - Handoff (§5): intensity-only. Hue is continuous by construction, so drilled points arrive in native colors, enter at their cell's count-alpha, and ease to native opacity; density_colormap left the points wire, and density_val (still u8 on the live wire, #221) now weights alpha instead of indexing a colormap LUT. The aggregate backdrop retires once a drill settles inside its window (T10 amended: a mean-color wash under exact marks reads as data) and eases back fast when the view leaves the window, a refinement goes pending, or the drill dies — zoom-outs never blank and interleaved replies never flash. - Spec: LOD doc §2 rules rewritten around the shipped mean-color law, pyramid §4 color planes and refused appends, T3/T10 amended, Phase 1/3 progress; dossier §5 Tier-2, F5 verdict, and constants table; wire protocol records density.rgba/color_agg, the u8 unit-scalar encodings, and the slimmer points message; chart-kind contract covers the aggregate kernel pairing and the legend rule; rust-engine inventory row. - Tests: kernel-vs-NumPy oracle suite (tests/test_density_mean_color, including a serial parallel-merge oracle and exporter pixel checks), wire and colorbar assertions, render-smoke probes for the mean-color surface (meancolor) and backdrop retirement (dretire). The pyramid color test asserts the direction the #153 area-weighted compose keeps invariant (nonzero count implies lit, unlit implies zero count) rather than exact mask equality, which boundary-bin count slivers can legitimately break. --- CHANGELOG.md | 25 ++ js/src/00_header.ts | 8 +- js/src/40_gl.ts | 36 ++- js/src/45_lod.ts | 169 +++++++--- js/src/46_worker.ts | 54 +++- js/src/50_chartview.ts | 46 ++- js/src/54_kernel.ts | 59 +++- python/xy/_native.py | 212 ++++++++++++- python/xy/_payload.py | 23 +- python/xy/_raster.py | 28 +- python/xy/_svg.py | 14 +- python/xy/_trace.py | 3 + python/xy/channels.py | 82 +++++ python/xy/components.py | 9 +- python/xy/config.py | 11 +- python/xy/interaction.py | 98 ++++-- python/xy/kernels.py | 6 + python/xy/marks.py | 21 +- scripts/abi_smoke.py | 190 +++++++++++ scripts/render_smoke_nonumpy.py | 74 ++++- spec/api/chart-kind-contract.md | 13 +- spec/api/chart-roadmap.md | 9 +- spec/design-dossier.md | 59 +++- spec/design/lod-architecture.md | 248 ++++++++++----- spec/design/renderer-architecture.md | 2 +- spec/design/rust-engine.md | 36 ++- spec/design/wire-protocol.md | 45 ++- src/kernels.rs | 429 +++++++++++++++++++++++++ src/lib.rs | 176 ++++++++++- src/tiles.rs | 454 +++++++++++++++++++++++++-- tests/test_declarative_colorbar.py | 11 +- tests/test_density_mean_color.py | 326 +++++++++++++++++++ tests/test_scatter.py | 50 ++- 33 files changed, 2712 insertions(+), 314 deletions(-) create mode 100644 tests/test_density_mean_color.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8fa687..d41b9a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,31 @@ in the README). `memory_report`), and `Chart.figure()` remains as an advanced escape hatch to the internal engine object. +### Changed +- **Density surfaces now wear the data's own colors (LOD doc §2).** A Tier-2 + scatter's aggregated view colors each cell with the alpha-weighted **mean of + its binned points' resolved colors** (continuous colormap, categorical + palette, and direct-RGBA channels alike; averaged in linear light through a + deterministic integer pipeline), while the binned **count now drives only + the alpha channel** — more points, deeper color; fewer points, lighter. + Previously the count itself was colormapped, so a colored scatter's density + view matched neither its points nor its legend and every density⇄points + zoom transition recolored the chart. The wire ships a per-cell RGBA plane + (`density.rgba`, recorded as `color_agg: "mean"`); constant-color traces + keep the compact count-only grid and a client-side tint. The count pyramid + gained matching mean-color planes (`xy_pyramid_build_color` / + `xy_pyramid_compose_color`; colored pyramids refuse in-place appends and + rebuild lazily, and their base scan fans out ≤4 workers so a 100M-point + build lands in about a quarter of the time), the SVG/PNG exporters and the + standalone `to_html` re-bin worker follow the same law, and the drill + handoff is now intensity-only — drilled points arrive in their native + colors (`density_colormap` left the points wire), and the aggregate + backdrop retires once a drill settles inside its window (T10), returning + the moment the view leaves it or a refinement goes pending. The color + channel is no longer listed in `dropped_channels` at Tier 2, and a + continuous-channel density scatter renders its colorbar again. C ABI v40 + (`xy_bin_2d_mean_color` + pyramid color entry points). + ### Added - **Export format parity and a unified export API (ENG-10447).** `to_image(format=...)` and extension-inferred, atomic `write_image(path)` diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 16b50f8f..b67da7d3 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -11,9 +11,11 @@ * - per-point size: constant or continuous (mapped to a px range) * - GPU picking → exact-row hover tooltip (§7/§17 Tier-0 hover; exact values * come from the kernel's f64 canonical store, §16) - * - Tier-2 density surface for massive scatter (§5): a kernel-binned count grid - * uploaded as a log-normalized R8 texture and colormapped at composite time, - * re-binned on zoom via a kernel round-trip (stale grid stays drawn until + * - Tier-2 density surface for massive scatter (§5): a kernel-binned count + * grid whose log-normalized count drives the ALPHA channel; channel-bearing + * traces add a per-cell mean point-color plane (premultiplied RGBA8 + * texture), constant-color traces tint a 1-byte count texture (LOD doc §2). + * Re-binned on zoom via a kernel round-trip (stale grid stays drawn until * then, §17) * * Dependency-free: this file is the whole client. DOM is used only for chrome — diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 602d620d..782e7f16 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -219,7 +219,7 @@ float xyMarkerSdf(vec2 d, int shape) { export const POINT_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; -uniform sampler2D u_dlut; uniform float u_dblend; +uniform float u_dblend; uniform int u_symbol; uniform vec4 u_ptStroke; uniform float u_ptStrokeWidth; uniform int u_ptStrokeFace; uniform int u_strokeMode; uniform float u_strokeOpacity; uniform int u_selActive; uniform vec4 u_selColor; uniform vec4 u_unselColor; @@ -248,12 +248,6 @@ void main() { if (shapeCov <= 0.001) discard; vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); vec3 rgb = paint.rgb; - // Drill handoff (§5): near the density boundary, paint by local density with - // the density ramp; ease into native colors as the zoom deepens (u_dblend->0). - if (u_dblend > 0.001) { - vec3 drgb = texture(u_dlut, vec2(clamp(v_dval, 0.0, 1.0), 0.5)).rgb; - rgb = mix(rgb, drgb, u_dblend); - } // §34 selected/unselected recolor: when a selection is active, tint each point // toward its state color (.a is the mix weight; 0 = keep native color). if (u_selActive == 1) { @@ -262,6 +256,15 @@ void main() { } float intrinsicAlpha = paint.a; float fillAlpha = (v_style.y >= 0.0 ? v_style.y : intrinsicAlpha) * v_style.x * u_opacity; + // Drill handoff (§5): the density surface already wears the mean point + // color (LOD doc §2), so hue never jumps at the texture->marks swap — only + // intensity hands off. Near the boundary each mark enters at its cell's + // count-alpha (v_dval through the texture's own tone curve) and eases to + // native opacity as the zoom deepens (u_dblend -> 0). + if (u_dblend > 0.001) { + float dalpha = clamp(v_dval * 1.35, 0.0, 1.0); + fillAlpha *= mix(1.0, dalpha, u_dblend); + } vec4 px = vec4(rgb * fillAlpha, fillAlpha); // premultiplied fill // Uniform (u_ptStroke) and per-item (v_stroke) stroke paint ship straight // alpha and go through the same artist-alpha/opacity stack, so a scalar @@ -391,18 +394,35 @@ void main() { // Density grids are binned uniformly in scale coordinates (§28), so // u_gridRange arrives as *scale coordinates* of the grid's raw x/y ranges and // the fragment's uv is a straight affine map of v_coord — no inverse needed. +// +// Color law (LOD doc §2): the surface wears the DATA's colors, count drives +// only the alpha. Mean-color grids (u_meanColor) sample a premultiplied RGBA8 +// texture whose rgb is the per-cell mean point color and whose alpha carries +// the log count tone curve (baked at upload so bilinear filtering weights +// color by coverage — no dark skirts at occupied/empty seams). Constant-color +// traces keep the 1-byte count texture and tint with u_color; the LUT branch +// remains as the fallback for count-only grids that ship neither (hand-built +// or legacy specs). export const DENSITY_FS = `#version 300 es precision highp float; uniform sampler2D u_grid; uniform sampler2D u_lut; uniform vec4 u_gridRange; // coord(gx0),coord(gx1),coord(gy0),coord(gy1) uniform float u_opacity; uniform vec4 u_color; uniform int u_constantColor; +uniform int u_meanColor; in vec2 v_coord; out vec4 outColor; void main() { vec2 uv = vec2((v_coord.x - u_gridRange.x) / (u_gridRange.y - u_gridRange.x), (v_coord.y - u_gridRange.z) / (u_gridRange.w - u_gridRange.z)); if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; - float t = texture(u_grid, uv).r; + vec4 s = texture(u_grid, uv); + if (u_meanColor == 1) { + float alpha = s.a * u_opacity; + if (alpha <= 0.004) discard; + outColor = vec4(s.rgb * u_opacity, alpha); + return; + } + float t = s.r; if (t <= 0.0) discard; vec4 paint = u_constantColor == 1 ? u_color diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index f9372d9c..f2475fd2 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -50,23 +50,57 @@ export function lodCopyGrid(f32) { return f32.slice ? f32.slice() : new Float32Array(f32); } -// Log tone-mapped grid upload (R8): stable perception across renormalization, -// and the u_max swings between rebins compress logarithmically (§5/§F6). -export function lodWriteGridTexture(gl, tex, f32, w, h, maxVal, filter) { - const data = new Uint8Array(f32.length); +// Log tone-mapped grid upload: stable perception across renormalization, and +// the u_max swings between rebins compress logarithmically (§5/§F6). +// +// Count-only grids upload as R8 (the shader tints/LUTs them). Mean-color +// grids (LOD doc §2) pass the straight-alpha RGBA plane shipped by the +// kernel: rgb = per-cell mean point color, a = mean point alpha. The texture +// bakes the count tone curve into the alpha and stores rgb PREMULTIPLIED — +// in sRGB space, exactly like the mark shaders' outputs — so bilinear +// filtering weights color by coverage instead of dragging occupied cells +// toward transparent-black neighbors. Exposure easing re-calls this per +// norm step; rgb is re-premultiplied against the eased alpha each time. +// `filter` picks the sampling the reply asked for (spatial-exact grids ship +// "nearest"); it applies to both texture layouts. +export function lodWriteGridTexture(gl, tex, f32, w, h, maxVal, rgba = null, filter = "linear") { const denom = Math.log1p(Math.max(0, maxVal || 0)); - if (denom > 0) { - for (let i = 0; i < f32.length; i++) { - const c = f32[i]; - if (c > 0 && Number.isFinite(c)) { - data[i] = Math.max(1, Math.min(255, Math.round(255 * Math.log1p(c) / denom))); + let data; + if (rgba) { + data = new Uint8Array(f32.length * 4); + if (denom > 0) { + for (let i = 0; i < f32.length; i++) { + const c = f32[i]; + if (!(c > 0) || !Number.isFinite(c)) continue; + const t = Math.min(1, Math.log1p(c) / denom); + const shaped = Math.min(1, t * 1.35) * (rgba[i * 4 + 3] / 255); + if (shaped <= 0) continue; + const a = Math.max(1, Math.round(255 * shaped)); + data[i * 4] = Math.round(rgba[i * 4] * a / 255); + data[i * 4 + 1] = Math.round(rgba[i * 4 + 1] * a / 255); + data[i * 4 + 2] = Math.round(rgba[i * 4 + 2] * a / 255); + data[i * 4 + 3] = a; + } + } + } else { + data = new Uint8Array(f32.length); + if (denom > 0) { + for (let i = 0; i < f32.length; i++) { + const c = f32[i]; + if (c > 0 && Number.isFinite(c)) { + data[i] = Math.max(1, Math.min(255, Math.round(255 * Math.log1p(c) / denom))); + } } } } gl.bindTexture(gl.TEXTURE_2D, tex); const align = gl.getParameter(gl.UNPACK_ALIGNMENT); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); + if (rgba) { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); + } else { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); + } gl.pixelStorei(gl.UNPACK_ALIGNMENT, align); // "nearest" for a full-screen-resolution grid (exact deep-zoom detail — crisp, // no interpolation bleed); "linear" (default) smooths an upsampled aggregate. @@ -106,7 +140,8 @@ function lodStartNormAnim(view, g, start, target) { g.density.normMax = target; g.densityNormMax = target; lodWriteGridTexture( - view.gl, g.density.tex, g.density.grid, g.density.w, g.density.h, target, g.density.filter + view.gl, g.density.tex, g.density.grid, g.density.w, g.density.h, target, + g.density.rgba, g.density.filter, ); return; } @@ -130,7 +165,7 @@ function lodStepNorm(view, g) { if (rel > 0.004 || t >= 1) { d.normMax = norm; g.densityNormMax = norm; - lodWriteGridTexture(view.gl, d.tex, d.grid, d.w, d.h, norm, d.filter); + lodWriteGridTexture(view.gl, d.tex, d.grid, d.w, d.h, norm, d.rgba, d.filter); } if (t < 1) { view.draw(); @@ -452,10 +487,11 @@ export function lodApplyDrill(view, g, upd, buffers) { gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); } view._pointMarkStyle(d, d.trace); - // Color-continuous handoff (§5): per-point local log-density + a blend - // weight. Fresh at the boundary (blend≈1) the marks wear the aggregate's - // colormap, so the texture->marks swap doesn't recolor the chart; deeper - // zooms ship smaller blends and the native colors ease in. + // Intensity-continuous handoff (§5): per-point local log-density + a blend + // weight. The density surface already wears the mean point color (LOD doc + // §2), so hue is continuous by construction; fresh at the boundary + // (blend≈1) each mark enters at its cell's count-alpha and deeper zooms + // ship smaller blends, easing marks to native opacity. if (upd.density_val && upd.density_val.buf !== undefined) { const dvalValues = upd.density_val.dtype === "u8" ? view._asU8(buffers[upd.density_val.buf]) @@ -464,15 +500,14 @@ export function lodApplyDrill(view, g, upd, buffers) { view._tagChannelBuf(d.dBuf, dvalValues, true); gl.bindBuffer(gl.ARRAY_BUFFER, d.dBuf); gl.bufferData(gl.ARRAY_BUFFER, dvalValues, gl.STATIC_DRAW); - d.dlut = view._lut(upd.density_colormap || "viridis"); const first = d.lodBlend === undefined; d.lodBlend = Math.min(1, upd.lod_blend ?? 0); // The kernel's blend weight assumes level-by-level zooms; a fast zoom // skips levels and the first marks land with a mostly-native weight — - // a visible recolor at the texture→marks swap. The BOUNDARY is the - // transition itself: fresh marks appear wearing the aggregate's colormap + // a visible intensity pop at the texture→marks swap. The BOUNDARY is the + // transition itself: fresh marks appear at the aggregate's count-alpha // (blend 1) and the tween eases them to the kernel's weight, so the swap - // never recolors regardless of how many levels the zoom skipped (§5). + // never pops regardless of how many levels the zoom skipped (§5). d._lodBlendNative = d.lodBlend; if (fresh) d.lodBlendShown = 1; else if (first) d.lodBlendShown = d.lodBlend; // no tween-from-zero on refresh @@ -550,6 +585,8 @@ export function lodDropDrill(view, g) { g._drillShownAlpha = null; g._drillDying = false; g._drillDiedInsideWin = false; + g._drillBackdropShown = 1; // next drill enters over a full backdrop (T10) + g._drillBackdropTick = 0; view._hoverId = -1; // drilled indices are dead; don't reuse a cached row view._lastRow = null; // The freed drill may have been the only pickable geometry (a density-only @@ -617,8 +654,8 @@ function lodDrillShownAlpha(view, g) { // Switch to the entry (fade-in) clock, seeded so it starts at the shown alpha. function lodEnterDrillContinuous(view, g) { - // A revived/held drill eases back to its native colors (the exit ramp - // below may have retargeted the blend at the aggregate's colormap). + // A revived/held drill eases back to its native intensity (the exit ramp + // below may have retargeted the blend at the aggregate's count-alpha). if (g.drill && g.drill.dBuf && g.drill._lodBlendNative !== undefined) { g.drill.lodBlend = g.drill._lodBlendNative; } @@ -631,10 +668,11 @@ function lodEnterDrillContinuous(view, g) { // Switch to the exit (fade-out) clock, seeded the same way. function lodBeginDrillExitContinuous(view, g) { - // Exit recolor (§5): dying/exiting marks converge to the aggregate's - // colormap as they fade, so they melt INTO the texture instead of a - // differently-colored cluster blinking out over it. The blend tween - // (τ=90ms) does the easing; revives restore the native weight. + // Exit re-intensity (§5): dying/exiting marks converge to the aggregate's + // local count-alpha as they fade, so they melt INTO the texture instead of + // a differently-weighted cluster blinking out over it (hue already matches + // — the texture wears the mean point color). The blend tween (τ=90ms) does + // the easing; revives restore the native weight. if (g.drill && g.drill.dBuf) g.drill.lodBlend = 1; if (g._drillExitFadeStart != null) return; // already exiting — keep its clock const alpha = lodDrillShownAlpha(view, g); @@ -654,6 +692,9 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { const grid = d.enc === "log-u8" ? lodDecodeLogU8(buffers[d.buf], d.max) : lodCopyGrid(view._asF32(buffers[d.buf])); + // Mean point color plane (LOD doc §2), copied because exposure easing + // re-reads it on every norm step, after the wire buffer may be gone. + const rgba = d.rgba !== undefined ? new Uint8Array(view._asU8(buffers[d.rgba])) : null; const normStart = lodNormMax(g, d.max); const normMax = view._prefersReducedMotion() ? d.max : normStart; g.densityNormMax = normMax; @@ -664,8 +705,10 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap, color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color, xRange: d.x_range, yRange: d.y_range, - grid, filter, - tex: view._uploadGrid(grid, d.w, d.h, normMax, filter), + grid, + rgba, + filter, + tex: view._uploadGrid(grid, d.w, d.h, normMax, rgba, filter), lut: g.density.lut, }; // Exact scans include a view-specific sample and replace the overlay. @@ -711,17 +754,40 @@ function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { view._drawDensity(g, density, opacityScale); } -// The tier's frame: the aggregate texture is the CONTINUOUS BACKDROP at every -// state (T10) — marks draw over it while the view sits inside a live drilled -// window, fade over it during transitions (drill-in entry fade, dying drill -// exit fade, stale-while-revalidate hold), and it stands alone otherwise. -// Never blank, never a hard cut (§5 smooth transitions): the background of a -// drilled frame and a density frame is the same texture, so every drill -// transition is a marks-layer fade, not a full-frame swap. (Previously marks -// "owned the frame" once their entry fade completed — the backdrop flipped to -// the blank chart background, and interleaved density/points replies during a -// continuous zoom flashed green-texture ⇄ points-on-blank, the live-drilldown -// flicker.) +// The drilled frame's backdrop opacity, eased continuously (T10). The +// aggregate texture stays painted through every transition — entry fade, +// hold, exit fade, revive — and retires once a drill is settled inside its +// window: the marks are exact for that window, and a mean-color wash under +// exact points reads as data. Retirement eases out gently after the entry +// fade lands and eases back fast when the drill exits, holds, or dies, so +// zoom-outs never blank (T1) and interleaved replies never flash. Time-based +// exponential decay, same shape as the lod_blend tween; reduced motion snaps. +function lodDrillBackdropScale(view, g, target) { + let shown = g._drillBackdropShown; + if (shown === undefined || shown === null) shown = 1; + if (Math.abs(shown - target) <= 0.005 || view._prefersReducedMotion()) { + g._drillBackdropShown = target; + g._drillBackdropTick = 0; + return target; + } + const now = view._now(); + const dt = g._drillBackdropTick ? Math.min(100, now - g._drillBackdropTick) : 16; + g._drillBackdropTick = now; + const tau = target > shown ? 45 : 80; + shown += (target - shown) * (1 - Math.exp(-dt / tau)); + g._drillBackdropShown = shown; + view.draw(); // keep the retire/restore animating on settled views + return shown; +} + +// The tier's frame: the aggregate texture is the continuous backdrop through +// every transitional drill state (T10) — marks fade over it entering, held, +// dying, and exiting — and it stands alone otherwise, so a representation +// change is a marks-layer fade over a stable frame, never a blank or a hard +// cut (§5). Once a drill is settled inside its window the backdrop retires +// (lodDrillBackdropScale above): the marks are exact, and the §28 aggregate +// context returns the instant the view leaves the window or a refinement +// goes pending. export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { lodStepNorm(view, g); const d = g.drill; @@ -753,7 +819,12 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { g._drillExitFadeStart = null; const fade = lodFade(view, g._drillFadeStart); g._drillShownAlpha = fade; - if (density && density.tex) lodDrawDensityWithFade(view, g, density); + // Settled (entry fade landed): the marks are exact — retire the backdrop. + // Refreshes inside a settled drill keep it retired (no per-reply flash). + const backdrop = lodDrillBackdropScale(view, g, fade >= 1 ? 0 : 1); + if (density && density.tex && backdrop > 0.004) { + lodDrawDensityWithFade(view, g, density, backdrop); + } if (fade < 1) { drawMarks(fade); view.draw(); @@ -764,11 +835,12 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { } else if (density && density.tex) { if (lodHoldPendingDrill(view, g, d)) { // Held marks continue their own entry fade over the backdrop — a hold - // engaging mid-fade must not snap. + // engaging mid-fade must not snap. The backdrop eases back in if the + // settled drill had retired it (the view has left the exact window). lodEnterDrillContinuous(view, g); const fade = lodFade(view, g._drillFadeStart); g._drillShownAlpha = fade; - lodDrawDensityWithFade(view, g, density); + lodDrawDensityWithFade(view, g, density, lodDrillBackdropScale(view, g, 1)); if (fade < 1) { drawMarks(fade); } else { @@ -794,7 +866,9 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { const exitFade = exitingDrill ? lodDrillExitFade(view, g) : 1; if (d) g._drillShownAlpha = exitingDrill && exitFade < 1 ? 1 - exitFade : 0; if (exitingDrill && exitFade < 1) { - lodDrawDensityWithFade(view, g, density); + // A retired backdrop (settled drill) eases back in under the exiting + // marks — the fast restore keeps the frame from ever reading blank. + lodDrawDensityWithFade(view, g, density, lodDrillBackdropScale(view, g, 1)); drawMarks(1 - exitFade); view.draw(); } else { @@ -809,7 +883,14 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { if (g.drill && g._drillEverInside && lodDrillOutgrown(view, g, d)) { lodDropDrill(view, g); } - lodDrawDensityWithFade(view, g, density); + // Ease toward full while a (retired) drill lingers; without one the + // aggregate owns the frame outright. + const backdrop = g.drill ? lodDrillBackdropScale(view, g, 1) : 1; + if (!g.drill) { + g._drillBackdropShown = 1; + g._drillBackdropTick = 0; + } + lodDrawDensityWithFade(view, g, density, backdrop); view._drawDensitySample(g, x0, x1, y0, y1); } } else if (d) { diff --git a/js/src/46_worker.ts b/js/src/46_worker.ts index a72c2375..a412b2b3 100644 --- a/js/src/46_worker.ts +++ b/js/src/46_worker.ts @@ -9,38 +9,82 @@ // same LOD plumbing as a kernel density_update and recorded as a reduction // badge ("zoom re-binned from sample") — never silent. // +// Channel-bearing traces also init the worker with the sample's resolved +// straight-alpha RGBA8 point colors; each rebin then returns a mean-color +// plane alongside the counts (LOD doc §2): per cell, the alpha-weighted mean +// point color averaged in linear light — the same law as the kernel's +// bin_2d_mean_color — so a standalone zoom keeps the surface wearing the +// data's own colors while count keeps driving only the alpha. +// // The worker script travels inside the bundle and boots from a Blob URL (the // standalone CSP allows worker-src blob:). Environments without workers (or a // stricter CSP) fall back to the old stretched-overview behavior. // --------------------------------------------------------------------------- const XY_REBIN_WORKER_SRC = ` +// sRGB byte -> linear-light (0..1); built once, mirrors the kernel's table. +const LIN = new Float64Array(256); +for (let i = 0; i < 256; i++) { + const c = i / 255; + LIN[i] = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} +const SRGB = (v) => { + const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; + return Math.max(0, Math.min(255, Math.round(c * 255))); +}; const DATA = new Map(); self.onmessage = (e) => { const m = e.data; if (m.type === "init") { - DATA.set(m.trace, { x: new Float64Array(m.x), y: new Float64Array(m.y) }); + DATA.set(m.trace, { + x: new Float64Array(m.x), + y: new Float64Array(m.y), + rgba: m.rgba ? new Uint8Array(m.rgba) : null, + }); return; } const d = DATA.get(m.trace); if (!d) return; const w = m.w, h = m.h; const grid = new Float32Array(w * h); + const sums = d.rgba ? new Float64Array(w * h * 4) : null; // aR, aG, aB, sum(a) const sx = w / ((m.x1 - m.x0) || 1); const sy = h / ((m.y1 - m.y0) || 1); let max = 0; - const X = d.x, Y = d.y, n = X.length; + const X = d.x, Y = d.y, C = d.rgba, n = X.length; for (let i = 0; i < n; i++) { const cx = (X[i] - m.x0) * sx; const cy = (Y[i] - m.y0) * sy; if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue; - const v = ++grid[(cy | 0) * w + (cx | 0)]; + const cell = (cy | 0) * w + (cx | 0); + const v = ++grid[cell]; if (v > max) max = v; + if (sums) { + const a = C[i * 4 + 3]; + sums[cell * 4] += a * LIN[C[i * 4]]; + sums[cell * 4 + 1] += a * LIN[C[i * 4 + 1]]; + sums[cell * 4 + 2] += a * LIN[C[i * 4 + 2]]; + sums[cell * 4 + 3] += a; + } + } + let rgba = null; + if (sums) { + rgba = new Uint8Array(w * h * 4); + for (let cell = 0; cell < w * h; cell++) { + const count = grid[cell]; + const weight = sums[cell * 4 + 3]; + if (!(count > 0) || !(weight > 0)) continue; + rgba[cell * 4] = SRGB(sums[cell * 4] / weight); + rgba[cell * 4 + 1] = SRGB(sums[cell * 4 + 1] / weight); + rgba[cell * 4 + 2] = SRGB(sums[cell * 4 + 2] / weight); + rgba[cell * 4 + 3] = Math.min(255, Math.round(weight / count)); + } } self.postMessage( { type: "grid", seq: m.seq, trace: m.trace, w, h, max, - x0: m.x0, x1: m.x1, y0: m.y0, y1: m.y1, grid: grid.buffer }, - [grid.buffer] + x0: m.x0, x1: m.x1, y0: m.y0, y1: m.y1, grid: grid.buffer, + rgba: rgba ? rgba.buffer : null }, + rgba ? [grid.buffer, rgba.buffer] : [grid.buffer] ); }; `; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 21d89f30..eeac5ca3 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1721,9 +1721,12 @@ export class ChartView { const items = []; if (s.show_legend !== false) { for (const t of s.traces) { - if (t.tier === "density") { - items.push({ swatch: "gradient", cmap: t.density.colormap, name: t.name || "density" }); - } else if (t.color && t.color.mode === "categorical") { + // A density-tier surface encodes count as alpha and wears the mean + // point color (LOD doc §2), so it gets no colormap gradient swatch — + // a gradient would claim color == density. A named density trace + // falls through to the plain marker swatch below, matching the + // static SVG/raster exporters. + if (t.color && t.color.mode === "categorical") { t.color.categories.forEach((cat, i) => items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} })); } else if (t.color && t.color.mode === "continuous") { @@ -2095,14 +2098,22 @@ export class ChartView { const meta = this.spec.columns[d.buf]; const raw = this._columnView(buffer, meta); const grid = d.enc === "log-u8" ? lodDecodeLogU8(raw, d.max) : raw; + // Mean point color plane (LOD doc §2), copied because exposure + // re-encodes outlive the payload buffer; absent for constant-color + // traces, which tint the count texture instead. + const rgba = d.rgba !== undefined + ? new Uint8Array(this._columnView(buffer, this.spec.columns[d.rgba])) + : null; g.densityNormMax = d.max; const filter = d.filter || "linear"; g.density = { w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap, color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null, xRange: d.x_range, yRange: d.y_range, - grid: lodCopyGrid(grid), filter, - tex: this._uploadGrid(grid, d.w, d.h, d.max, filter), + grid: lodCopyGrid(grid), + rgba, + filter, + tex: this._uploadGrid(grid, d.w, d.h, d.max, rgba, filter), lut: this._lut(d.colormap), }; g.sampleOverlay = this._buildDensitySample(t, d.sample, buffer); @@ -2849,10 +2860,10 @@ export class ChartView { return tex; } - _uploadGrid(f32, w, h, maxVal, filter) { + _uploadGrid(f32, w, h, maxVal, rgba = null, filter = "linear") { const gl = this.gl; const tex = gl.createTexture(); - lodWriteGridTexture(gl, tex, f32, w, h, maxVal, filter); + lodWriteGridTexture(gl, tex, f32, w, h, maxVal, rgba, filter); return tex; } @@ -3245,10 +3256,12 @@ export class ChartView { gl.bindTexture(gl.TEXTURE_2D, g.lut); gl.uniform1i(u("u_lut"), 0); } - // Drill handoff (§5): blend from the density ramp toward native colors. - // The shown weight eases toward the kernel's target so successive drill - // updates recolor smoothly instead of stepping. Time-based decay (τ=90ms) - // — a per-frame factor would converge 2.4× faster on a 144Hz display. + // Drill handoff (§5): blend from the aggregate's local count-alpha toward + // native opacity (hue already matches — the texture wears the mean point + // color, LOD doc §2). The shown weight eases toward the kernel's target + // so successive drill updates re-weight smoothly instead of stepping. + // Time-based decay (τ=90ms) — a per-frame factor would converge 2.4× + // faster on a 144Hz display. const blendTarget = g.lodBlend ?? 0; let blend = g.lodBlendShown ?? blendTarget; if (Math.abs(blend - blendTarget) > 0.005 && !this._prefersReducedMotion()) { @@ -3263,12 +3276,7 @@ export class ChartView { g._blendTick = 0; } gl.uniform1f(u("u_dblend"), blend); - const blendOn = blend > 0.001 && g.dBuf && g.dlut; - if (blendOn) { - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, g.dlut); - } - gl.uniform1i(u("u_dlut"), 1); // sampler must always point at a valid unit + const blendOn = blend > 0.001 && g.dBuf; this._bindVao( g, @@ -3442,6 +3450,10 @@ export class ChartView { this._axisCoord(yAxis, d.yRange[0]), this._axisCoord(yAxis, d.yRange[1]), ); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale); + // Mean-color grids carry their colors in the texture (LOD doc §2); + // count-only grids tint with the constant trace color or, failing that, + // fall back to the LUT ramp (hand-built/legacy specs). + gl.uniform1i(u("u_meanColor"), d.rgba ? 1 : 0); const constant = d.color; gl.uniform1i(u("u_constantColor"), constant ? 1 : 0); gl.uniform4f(u("u_color"), ...(constant || [1, 1, 1, 1])); diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 8de436be..12b3d8db 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -1,4 +1,6 @@ import { payloadBuffers } from "./00_header"; +import { buildLutData } from "./10_colormaps"; +import { parseColor } from "./20_theme"; import { lodApplyDensityUpdate, lodApplyDrill, lodDropDrill, lodRememberDensity } from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -123,7 +125,7 @@ Object.assign(ChartView.prototype, { const hd = g._homeDensity; this._applySampleRebinGrid(g, { ...hd, - tex: this._uploadGrid(hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.filter), + tex: this._uploadGrid(hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.rgba, hd.filter), }, false); } return; @@ -139,7 +141,9 @@ Object.assign(ChartView.prototype, { this._rebinInit = new Set(); } if (!this._rebinInit.has(g.trace.id)) { - // Decode the offset-encoded sample once (f64, §16); the worker keeps it. + // Decode the offset-encoded sample once (f64, §16); the worker keeps it, + // along with the sample's resolved point colors so re-binned grids keep + // the mean-color surface (LOD doc §2). const cpu = g.sampleOverlay._cpu; const n = Math.min(cpu.x.length, cpu.y.length); const xs = new Float64Array(n); @@ -148,9 +152,13 @@ Object.assign(ChartView.prototype, { xs[i] = this._decodeValue(cpu.x, cpu.xMeta, i); ys[i] = this._decodeValue(cpu.y, cpu.yMeta, i); } + const rgba = this._sampleBinColors(g, n); this._rebinWorker.postMessage( - { type: "init", trace: g.trace.id, x: xs.buffer, y: ys.buffer }, - [xs.buffer, ys.buffer] + { + type: "init", trace: g.trace.id, x: xs.buffer, y: ys.buffer, + rgba: rgba ? rgba.buffer : null, + }, + rgba ? [xs.buffer, ys.buffer, rgba.buffer] : [xs.buffer, ys.buffer] ); this._rebinInit.add(g.trace.id); } @@ -163,17 +171,58 @@ Object.assign(ChartView.prototype, { }); }, + // Straight-alpha RGBA8 per retained-sample point, resolved from the + // overlay's shipped channel exactly as the point shader draws it — the + // worker's mean-color source (LOD doc §2). Constant-color traces return + // null: their count-only grid is tinted by the draw path instead. + _sampleBinColors(g, n) { + const overlay = g.sampleOverlay; + const cpu = overlay && overlay._cpu; + const spec = overlay && overlay.trace && overlay.trace.color; + if (!cpu || !spec || spec.mode === "constant" || spec.mode === "match_fill") return null; + if (spec.mode === "direct_rgba" && cpu.rgba) { + return new Uint8Array(cpu.rgba.subarray(0, n * 4)); + } + if (!cpu.color) return null; + const out = new Uint8Array(n * 4); + if (spec.mode === "continuous") { + const lut = buildLutData(spec.colormap); + for (let i = 0; i < n; i++) { + const at = Math.round(Math.max(0, Math.min(1, cpu.color[i])) * 255) * 4; + out[i * 4] = lut[at]; + out[i * 4 + 1] = lut[at + 1]; + out[i * 4 + 2] = lut[at + 2]; + out[i * 4 + 3] = 255; + } + return out; + } + if (spec.mode === "categorical" && Array.isArray(spec.palette) && spec.palette.length) { + const palette = spec.palette.map((c) => parseColor(this.root, c, [0, 0, 0, 1])); + for (let i = 0; i < n; i++) { + const p = palette[Math.round(cpu.color[i]) % palette.length]; + out[i * 4] = Math.round(p[0] * 255); + out[i * 4 + 1] = Math.round(p[1] * 255); + out[i * 4 + 2] = Math.round(p[2] * 255); + out[i * 4 + 3] = Math.round(p[3] * 255); + } + return out; + } + return null; + }, + _onRebinResult(msg) { if (this._destroyed || this._glLost || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; const g = this.gpuTraces.find((t) => t.trace.id === msg.trace && t.tier === "density"); if (!g) return; const grid = new Float32Array(msg.grid); + const rgba = msg.rgba ? new Uint8Array(msg.rgba) : null; this._applySampleRebinGrid(g, { w: msg.w, h: msg.h, max: msg.max, normMax: msg.max, colormap: g.density.colormap, xRange: [msg.x0, msg.x1], yRange: [msg.y0, msg.y1], grid, - tex: this._uploadGrid(grid, msg.w, msg.h, msg.max || 1), + rgba, + tex: this._uploadGrid(grid, msg.w, msg.h, msg.max || 1, rgba), lut: g.density.lut, }, true); }, diff --git a/python/xy/_native.py b/python/xy/_native.py index 09d80d8b..e0298da5 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 39 +ABI_VERSION = 40 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. @@ -452,6 +452,23 @@ def _load() -> ctypes.CDLL: ctypes.c_void_p, ctypes.c_void_p, ] + lib.xy_bin_2d_mean_color.restype = ctypes.c_int32 + lib.xy_bin_2d_mean_color.argtypes = [ + ctypes.c_void_p, # x + ctypes.c_void_p, # y + ctypes.c_size_t, # len + ctypes.c_void_p, # idx (or NULL) + ctypes.c_void_p, # rgba (or NULL) + ctypes.c_void_p, # lut (with idx) + ctypes.c_size_t, # lut_len + ctypes.c_double, # x0 + ctypes.c_double, # x1 + ctypes.c_double, # y0 + ctypes.c_double, # y1 + ctypes.c_size_t, # w + ctypes.c_size_t, # h + ctypes.c_void_p, # out rgba8 + ] lib.xy_bin_2d_sample_range.restype = ctypes.c_size_t lib.xy_bin_2d_sample_range.argtypes = [ ctypes.c_void_p, # x @@ -622,6 +639,34 @@ def _load() -> ctypes.CDLL: ctypes.c_size_t, # max_upsample ctypes.c_void_p, ] + lib.xy_pyramid_build_color.restype = ctypes.c_uint64 + lib.xy_pyramid_build_color.argtypes = [ + ctypes.c_void_p, # x + ctypes.c_void_p, # y + ctypes.c_size_t, # len + ctypes.c_void_p, # idx (or NULL) + ctypes.c_void_p, # rgba (or NULL) + ctypes.c_void_p, # lut (with idx) + ctypes.c_size_t, # lut_len + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_uint32, + ] + lib.xy_pyramid_compose_color.restype = ctypes.c_int32 + lib.xy_pyramid_compose_color.argtypes = [ + ctypes.c_uint64, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_size_t, + ctypes.c_size_t, + ctypes.c_size_t, # max_upsample + ctypes.c_void_p, # out counts f32 + ctypes.c_void_p, # out rgba8 + ] lib.xy_pyramid_free.restype = ctypes.c_int32 lib.xy_pyramid_free.argtypes = [ctypes.c_uint64] lib.xy_local_log_density.restype = ctypes.c_int32 @@ -2045,6 +2090,85 @@ def bin_2d_f32( return out +def _color_source_args( + n: int, + idx: "npt.NDArray[np.uint8] | None", + rgba: "npt.NDArray[np.uint8] | None", + lut: "npt.NDArray[np.uint8] | None", +) -> tuple: + """Validate and marshal a mean-color source: exactly one of `idx` + (per-point LUT index + `lut` of 1..=256 RGBA8 rows) or `rgba` (per-point + straight-alpha RGBA8). Returns the four C arguments plus the arrays kept + alive through the call.""" + if (idx is None) == (rgba is None): + raise ValueError("exactly one of idx or rgba must be provided") + if idx is not None: + idx = np.ascontiguousarray(idx, dtype=np.uint8) + if idx.shape != (n,): + raise ValueError(f"idx must be 1-D length {n}, got shape {idx.shape}") + if lut is None: + raise ValueError("idx colors need a lut") + lut = np.ascontiguousarray(lut, dtype=np.uint8) + if lut.ndim != 2 or lut.shape[1] != 4 or not 1 <= lut.shape[0] <= 256: + raise ValueError(f"lut must be (1..=256, 4) u8, got shape {lut.shape}") + return idx.ctypes.data, None, lut.ctypes.data, lut.shape[0], (idx, lut) + rgba = np.ascontiguousarray(rgba, dtype=np.uint8) + if rgba.shape not in {(n, 4), (n * 4,)}: + raise ValueError(f"rgba must be ({n}, 4) u8, got shape {rgba.shape}") + return None, rgba.ctypes.data, None, 0, (rgba,) + + +def bin_2d_mean_color( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + x0: float, + x1: float, + y0: float, + y1: float, + w: int, + h: int, + *, + idx: "npt.NDArray[np.uint8] | None" = None, + rgba: "npt.NDArray[np.uint8] | None" = None, + lut: "npt.NDArray[np.uint8] | None" = None, +) -> npt.NDArray[np.uint8]: + """Mean-color companion grid to `bin_2d` (§5 Tier 2, LOD doc §2): (h, w, 4) + straight-alpha RGBA8, row 0 = bottom. Each occupied cell carries the + alpha-weighted mean of its points' resolved colors (averaged in linear + light) and the plain mean of their straight alpha; cell membership is + bit-identical to `bin_2d`.""" + w = _bounded_positive_int(w, "w") + h = _bounded_positive_int(h, "h") + x0, x1 = _finite_increasing(x0, x1, "x range") + y0, y1 = _finite_increasing(y0, y1, "y range") + x = _as_f64(x, "x") + y = _as_f64(y, "y") + if len(x) != len(y): + raise ValueError("x and y must have equal length") + idx_ptr, rgba_ptr, lut_ptr, lut_len, _keepalive = _color_source_args(len(x), idx, rgba, lut) + out = np.zeros((h, w, 4), dtype=np.uint8) + if len(x): + ok = _lib.xy_bin_2d_mean_color( + _ptr_f64(x), + _ptr_f64(y), + len(x), + idx_ptr, + rgba_ptr, + lut_ptr, + lut_len, + x0, + x1, + y0, + y1, + w, + h, + out.ctypes.data, + ) + if not ok: + raise ValueError("invalid bin_2d_mean_color arguments") + return out + + def bin_2d_indices( x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], @@ -2614,6 +2738,52 @@ def pyramid_build( ) +def pyramid_build_color( + x: "npt.NDArray[np.float64]", + y: "npt.NDArray[np.float64]", + x0: float, + x1: float, + y0: float, + y1: float, + base_dim: int, + *, + idx: "npt.NDArray[np.uint8] | None" = None, + rgba: "npt.NDArray[np.uint8] | None" = None, + lut: "npt.NDArray[np.uint8] | None" = None, +) -> int: + """Build a pyramid with mean-color planes (LOD doc §2/§4.1) so zoomed-out + density views of a channel-bearing trace keep the mean point color without + an O(N) rescan. Returns a handle, 0 on failure. Color source as in + `bin_2d_mean_color`. Colored pyramids refuse `pyramid_append` — callers + invalidate and lazily rebuild instead.""" + base_dim = _pyramid_base_dim(base_dim) + x0, x1 = _finite_increasing(x0, x1, "x range") + y0, y1 = _finite_increasing(y0, y1, "y range") + x = _as_f64(x, "x") + y = _as_f64(y, "y") + if len(x) != len(y): + raise ValueError("x and y must have equal length") + if len(x) == 0: + return 0 + idx_ptr, rgba_ptr, lut_ptr, lut_len, _keepalive = _color_source_args(len(x), idx, rgba, lut) + return int( + _lib.xy_pyramid_build_color( + x.ctypes.data, + y.ctypes.data, + len(x), + idx_ptr, + rgba_ptr, + lut_ptr, + lut_len, + x0, + x1, + y0, + y1, + base_dim, + ) + ) + + def pyramid_append( handle: int, x: "npt.NDArray[np.float64]", @@ -2695,6 +2865,46 @@ def pyramid_compose( return out, int(level) +def pyramid_compose_color( + handle: int, + lo_x: float, + hi_x: float, + lo_y: float, + hi_y: float, + w: int, + h: int, + max_upsample: int = 2, +) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint8], int] | None: + """(counts f32 [h*w], mean-color rgba8 [h, w, 4], level) from a colored + pyramid, or None when the window outresolves it beyond ``max_upsample`` + or the pyramid carries no color planes (caller falls back to an exact + re-bin, §28). Counts are bit-identical to `pyramid_compose` with the + same ``max_upsample``.""" + handle = _pyramid_handle(handle) + lo_x, hi_x = _finite_increasing(lo_x, hi_x, "x range") + lo_y, hi_y = _finite_increasing(lo_y, hi_y, "y range") + w = _bounded_positive_int(w, "w") + h = _bounded_positive_int(h, "h") + max_upsample = _positive_int(max_upsample, "max_upsample") + out = np.zeros(w * h, dtype=np.float32) + out_rgba = np.zeros((h, w, 4), dtype=np.uint8) + level = _lib.xy_pyramid_compose_color( + ctypes.c_uint64(handle), + lo_x, + hi_x, + lo_y, + hi_y, + w, + h, + max_upsample, + out.ctypes.data, + out_rgba.ctypes.data, + ) + if level < 0: + return None + return out, out_rgba, int(level) + + def pyramid_free(handle: int) -> bool: return _lib.xy_pyramid_free(ctypes.c_uint64(_pyramid_handle(handle))) == 1 diff --git a/python/xy/_payload.py b/python/xy/_payload.py index a7f20279..a992b0ef 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -1051,16 +1051,18 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d grid, sel = kernels.bin_2d_indices(bx, by, bx0, bx1, by0, by1, w, h) visible = int(len(sel)) encoded_grid, gmax = kernels.density_log_u8(grid) - # Honor the user's colormap for the density ramp even though the per-point - # color *data* can't survive count-aggregation (needs the §5-F5 algebra). - # Constant channels carry it too — colormap= without color data means - # exactly this ramp. Categorical has no ramp, so it keeps the default. + # The density surface wears the data's own colors (LOD doc §2): count + # is the alpha channel, and per-point color channels aggregate to a + # per-cell mean shipped as an RGBA plane below. `colormap` stays on + # the wire only for the client's count-only LUT fallback (hand-built + # specs); no shipped path colormaps counts. cmap = ( t.color_ch.colormap if (t.color_ch and t.color_ch.mode in ("constant", "continuous")) else channels.DEFAULT_COLORMAP ) dropped_channels = list(t.per_item_channel_names()) + bin_colors = channels.resolve_bin_colors(t.color_ch, None, DEFAULT_PALETTE) density = { "buf": pw.ship_u8(encoded_grid), "w": w, @@ -1070,9 +1072,18 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d "colormap": cmap, "x_range": list(xr), "y_range": list(yr), - "channels_dropped": bool(dropped_channels), # compatibility boolean - "dropped_channels": dropped_channels, # complete, actionable list (§28) } + if bin_colors is not None: + # Mean point color per cell, straight-alpha RGBA8: the color the + # points themselves would downsample to (averaged in linear + # light). The channel is aggregated, recorded via `color_agg`, + # and therefore leaves the dropped list. + rgba_grid = kernels.bin_2d_mean_color(bx, by, bx0, bx1, by0, by1, w, h, **bin_colors) + density["rgba"] = pw.ship_u8(rgba_grid.reshape(-1)) + density["color_agg"] = "mean" + dropped_channels.remove("color") + density["channels_dropped"] = bool(dropped_channels) # compatibility boolean + density["dropped_channels"] = dropped_channels # complete, actionable list (§28) if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None: density["color"] = t.color_ch.constant if oversized: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index d075af94..ce325ec8 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -37,6 +37,7 @@ _column, _corner_radii, _css, + _density_column, _heatmap_rgba_grid, _legend_layout, _lut, @@ -1890,6 +1891,31 @@ def _emit_grid( elif g.get("enc") == "log-u8": w, h = int(g["w"]), int(g["h"]) meta = cols[g["buf"]] + xr, yr = g["x_range"], g["y_range"] + dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy) + if g.get("rgba") is not None: + # Mean point color per cell (LOD doc §2): rgb from the shipped + # plane; count drives only the alpha ramp, scaled by the cell's + # mean point alpha — the same law as _svg._density_image and the + # client's DENSITY_FS. Precomposed here and emitted as a plain + # image op; the count→LUT density op cannot express per-cell color. + rgba_meta = cols[g["rgba"]] + mean = np.frombuffer( + blob, dtype=np.uint8, count=rgba_meta["len"], offset=rgba_meta["byte_offset"] + ).reshape(h, w, 4) + counts = _density_column(blob, meta, g).reshape(h, w) + gmax = float(g.get("max") or 1.0) or 1.0 + tnorm = np.clip(counts / gmax, 0.0, 1.0) + alpha = ( + np.clip(tnorm * 1.35, 0, 1) + * 255 + * _fill_opacity(style, 0.85) + * (mean[..., 3].astype(np.float64) / 255.0) + ).astype(np.uint8) + alpha[tnorm <= 0] = 0 + rgba = np.ascontiguousarray(np.dstack([mean[..., :3], alpha])[::-1]) + cmd.image(dx, dy, dw, dh, w, h, rgba.tobytes(), nearest=False) + return paint_alpha = 1.0 if g.get("color") is not None: red, green, blue, alpha = _parse_color(g["color"]) @@ -1897,8 +1923,6 @@ def _emit_grid( paint_alpha = alpha / 255.0 else: stops = np.asarray(_colormap_stops(g.get("colormap", "viridis")), dtype=np.uint8) - xr, yr = g["x_range"], g["y_range"] - dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy) cmd.density_image( dx, dy, diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3421267c..f26707e3 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2577,8 +2577,18 @@ def _density_image( grid = _density_column(blob, cols[d["buf"]], d).reshape(h, w) gmax = float(d.get("max") or 1.0) or 1.0 tnorm = np.clip(grid / gmax, 0.0, 1.0) - paint_alpha = 1.0 - if d.get("color") is not None: + paint_alpha: float | np.ndarray = 1.0 + if d.get("rgba") is not None: + # Mean point color per cell (LOD doc §2): rgb from the shipped plane, + # count drives only the alpha ramp, scaled by the cell's mean point + # alpha — the same law as the client's DENSITY_FS. + meta = cols[d["rgba"]] + mean = np.frombuffer( + blob, dtype=np.uint8, count=meta["len"], offset=meta["byte_offset"] + ).reshape(h, w, 4) + rgb = mean[..., :3] + paint_alpha = mean[..., 3].astype(np.float64) / 255.0 + elif d.get("color") is not None: red, green, blue, alpha8 = _paint_rgba8(d["color"]) rgb = np.empty((h, w, 3), dtype=np.uint8) rgb[:] = (red, green, blue) diff --git a/python/xy/_trace.py b/python/xy/_trace.py index 7d1d94a5..73629a7d 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -74,7 +74,10 @@ class Trace: # Count-pyramid cache (§5 Tier 3), managed by `interaction.py`: None = # never tried, 0 = tried and not applicable, otherwise the native handle. # The finalizer frees the native side when the trace is collected. + # `_pyr_colored` records whether the handle carries mean-color planes + # (LOD doc §2) — compose must ask for them, and the memory report counts them. _pyr_handle: Optional[int] = field(default=None, init=False, repr=False, compare=False) + _pyr_colored: bool = field(default=False, init=False, repr=False, compare=False) _pyr_finalizer: Optional[Any] = field(default=None, init=False, repr=False, compare=False) _pyr_base_dim: int = field(default=0, init=False, repr=False, compare=False) # Optional Tier-3 spatial index (xy._spatial.SpatialIndex) for O(window) diff --git a/python/xy/channels.py b/python/xy/channels.py index bf2bb9b7..d5417853 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -523,6 +523,88 @@ def quantize_unit_u8(values: npt.NDArray[np.float64], domain: tuple[float, float return np.rint(np.clip(unit, 0.0, 1.0) * 255.0).astype(np.uint8) +def colormap_lut_rgba8(colormap: str) -> npt.NDArray[np.uint8]: + """The client's 256-texel colormap LUT as (256, 4) straight-alpha RGBA8. + + Built from the same stop tables the SVG exporter mirrors from + `js/src/10_colormaps.ts`, so a value binned through this LUT wears the + byte-identical color its drawn point does.""" + from . import _svg # deferred: channels is core, _svg owns the stop tables + + lut = np.empty((256, 4), dtype=np.uint8) + lut[:, :3] = _svg._lut(colormap, np.linspace(0.0, 1.0, 256)) + lut[:, 3] = 255 + return lut + + +def palette_rgba8(palette: list[str], n_categories: int) -> npt.NDArray[np.uint8]: + """Categorical palette colors as straight-alpha RGBA8 LUT rows. + + One row per category up to 256; beyond that callers fold codes modulo the + base palette instead (`resolve_bin_colors`), which is the same repeat rule + `ship_color_channel` applies.""" + rows = min(n_categories, MAX_CATEGORIES) + lut = np.empty((max(rows, 1), 4), dtype=np.uint8) + for i in range(lut.shape[0]): + status, rgba = kernels.css_check(kernels.CSS_COLOR, str(palette[i % len(palette)])) + if status != 1 or rgba is None: + rgba = (0.0, 0.0, 0.0, 1.0) + lut[i] = [round(c * 255) for c in rgba] + return lut + + +def bins_mean_color(cc: Optional[ColorChannel]) -> bool: + """Whether this channel aggregates to a mean-color density plane at + Tier 2 (LOD doc §2) instead of being dropped. Cheap predicate — no + arrays are touched — for warning/spec sites; `resolve_bin_colors` is + gated on exactly this.""" + return cc is not None and cc.mode in ("continuous", "categorical", "direct_rgba") + + +def resolve_bin_colors(cc: Optional[ColorChannel], sel: Any, palette: list[str]) -> Optional[dict]: + """Kernel color source for mean-color density binning (LOD doc §2). + + Returns `kernels.bin_2d_mean_color`-style kwargs — ``{"idx", "lut"}`` for + palette/colormap channels, ``{"rgba"}`` for direct RGBA — resolved to the + straight-alpha RGBA8 each point *draws* with, so the aggregated surface + and the drawn marks share one color story. Constant channels return + ``None``: their mean is the constant, so the count-only grid plus the + client-side tint reproduces it exactly with no per-cell color plane. + """ + if not bins_mean_color(cc): + return None + assert cc is not None + if cc.mode == "direct_rgba": + rgba = cc.rgba + if rgba is None: + raise ValueError("direct RGBA color channel missing values") + values = rgba if sel is None else rgba[sel] + return {"rgba": np.rint(np.clip(values, 0.0, 1.0) * 255.0).astype(np.uint8)} + if cc.mode == "continuous": + values = cc.values + domain = cc.domain + if values is None or domain is None: + raise ValueError("continuous color channel missing values or domain") + vals = values if sel is None else values[sel] + # Same normalization the wire ships, quantized to the nearest of the + # client's 256 LUT texels. + unit = normalize_to_unit(vals, domain) + idx = np.rint(np.asarray(unit, dtype=np.float64) * 255.0).astype(np.uint8) + return {"idx": idx, "lut": colormap_lut_rgba8(cc.colormap)} + code_values = cc.codes + categories = cc.categories + if code_values is None or categories is None: + raise ValueError("categorical color channel missing codes or categories") + codes = code_values if sel is None else code_values[sel] + if codes.dtype == np.uint8: + return {"idx": codes, "lut": palette_rgba8(palette, len(categories))} + # >256 categories ship wide codes; palette colors repeat every + # len(palette) categories, so folding the codes onto the base palette + # bins each point with exactly the color it draws with. + folded = (codes % len(palette)).astype(np.uint8) + return {"idx": folded, "lut": palette_rgba8(palette, len(palette))} + + def ship_channels( trace: Any, sel: Any, diff --git a/python/xy/components.py b/python/xy/components.py index ccff2a9e..65578cb2 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -3833,11 +3833,10 @@ def _declarative_colorbar_options(mark: Mark, traces: list[Any]) -> Optional[dic channel = trace.color_ch if channel is None or channel.mode != "continuous": continue - # A density-tier scatter colors aggregate counts; its original - # per-row color channel is explicitly dropped, so advertising that - # channel's domain beside the density ramp would be misleading. - if trace.kind == "scatter" and trace.use_density(): - continue + # A density-tier scatter wears the channel's own colors — each + # cell shows the mean of its points' colormapped values (LOD doc + # §2) — so the channel's domain⇄colormap colorbar is truthful in + # both representations and renders as for a direct scatter. domain = channel.domain colormap = channel.colormap if domain is None or colormap is None: diff --git a/python/xy/config.py b/python/xy/config.py index b6abdef2..60dd4310 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -23,13 +23,16 @@ # Scatter above this many points switches to Tier-2 density aggregation (§5): # instead of shipping/drawing every point (fill-rate + the ~1 GB single-alloc -# cliff, §5 F3), the kernel bins the viewport into a density grid and the client -# colormaps it. Screen-bounded transport and VRAM regardless of point count. +# cliff, §5 F3), the kernel bins the viewport into a density grid the client +# draws with the trace's own colors — count drives only the alpha (LOD doc +# §2). Screen-bounded transport and VRAM regardless of point count. SCATTER_DENSITY_THRESHOLD = 200_000 # Absolute direct-draw ceiling; above this, density is forced even if the user -# asked for per-point channels (they can't survive count-aggregation without the -# §5-F5 aggregation algebra — we warn and drop them, never silently mislead). +# asked for per-point channels. The color channel survives as the surface's +# per-cell mean point color (LOD doc §2); the rest (size, stroke, styles) have +# no honest per-cell aggregate yet (§5 F5) — we warn and drop them, never +# silently mislead. DIRECT_SOFT_CEILING = 2_000_000 # Stable-key matching retains a browser-side identity table for only bounded diff --git a/python/xy/interaction.py b/python/xy/interaction.py index fd9c3826..114d2616 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -380,7 +380,13 @@ def _pyramid_base_dim_for(t: Trace) -> int: def _ensure_pyramid(t: Trace) -> int | None: """Lazily build the trace's count pyramid (§5 Tier 3). Cached on the trace; 0 is remembered as "tried and not applicable" so we never rebuild. - Only worth the memory for genuinely large traces.""" + Only worth the memory for genuinely large traces. + + Channel-bearing traces build mean-color planes alongside the counts + (LOD doc §2) so pyramid-served zoom-outs keep the mean point color; those + pyramids refuse native appends and are invalidated + lazily rebuilt + instead (the appended rows' colors and a possibly moved channel domain + both require a rescan).""" handle = getattr(t, "_pyr_handle", None) if handle is not None: return handle or None @@ -398,9 +404,16 @@ def _ensure_pyramid(t: Trace) -> int | None: x1 += (x1 - x0) * 1e-9 y1 += (y1 - y0) * 1e-9 base_dim = _pyramid_base_dim_for(t) - handle = kernels.pyramid_build(t.x.values, t.y.values, x0, x1, y0, y1, base_dim) + bin_colors = channels.resolve_bin_colors(t.color_ch, None, DEFAULT_PALETTE) + if bin_colors is not None: + handle = kernels.pyramid_build_color( + t.x.values, t.y.values, x0, x1, y0, y1, base_dim, **bin_colors + ) + else: + handle = kernels.pyramid_build(t.x.values, t.y.values, x0, x1, y0, y1, base_dim) t._pyr_handle = handle t._pyr_base_dim = base_dim + t._pyr_colored = bool(handle) and bin_colors is not None if handle: # §27: the pyramid is native-side memory owned by this trace. Tie its # lifetime to the Trace object so a discarded Figure (the notebook @@ -425,12 +438,14 @@ def _free_pyramid(t: Trace) -> None: t._pyr_handle = None -def _pyramid_resident_bytes(base_dim: int = PYRAMID_BASE_DIM) -> int: - """Exact native bytes of one count pyramid: u32 levels from base_dim² - halving per side down to 1² (mirrors tiles.rs level construction).""" +def _pyramid_resident_bytes(base_dim: int = PYRAMID_BASE_DIM, *, colored: bool = False) -> int: + """Exact native bytes of one pyramid: u32 count levels from base_dim² + halving per side down to 1² (mirrors tiles.rs level construction), plus + the [u16; 4] mean-color planes when the trace bins colors.""" + per_cell = 4 + (8 if colored else 0) total, dim = 0, base_dim while True: - total += dim * dim * 4 + total += dim * dim * per_cell if dim == 1: return total dim >>= 1 @@ -438,9 +453,13 @@ def _pyramid_resident_bytes(base_dim: int = PYRAMID_BASE_DIM) -> int: def pyramid_report_bytes(fig: Any) -> int: """Memory-report line (design dossier §27): native bytes held by live - trace pyramids, at each trace's actual (possibly adaptive) base dim.""" + trace pyramids, at each trace's actual (possibly adaptive) base dim, + including the mean-color planes of colored pyramids.""" return sum( - _pyramid_resident_bytes(getattr(t, "_pyr_base_dim", None) or PYRAMID_BASE_DIM) + _pyramid_resident_bytes( + getattr(t, "_pyr_base_dim", None) or PYRAMID_BASE_DIM, + colored=bool(getattr(t, "_pyr_colored", False)), + ) for t in fig.traces if getattr(t, "_pyr_handle", 0) ) @@ -538,9 +557,9 @@ def _density_sample_update( def _quantize_dval(dval: np.ndarray) -> np.ndarray: """Quantize per-point local log-density ([0,1] by construction) to u8. - The value is only a LUT coordinate for the density→points color handoff - (§5) — 256 levels exceed what the crossfade can show, at a quarter of the - f32 wire bytes (§29).""" + The value only weights the density→points intensity handoff (§5) — 256 + levels exceed what the crossfade can show, at a quarter of the f32 wire + bytes (§29).""" return np.rint(np.clip(dval, 0.0, 1.0) * 255.0).astype(np.uint8) @@ -608,7 +627,6 @@ def _ship_index_points( "size": size_spec, "density_val": {"buf": dval_buf, "dtype": "u8"}, "lod_blend": lod_blend, - "density_colormap": channels.DEFAULT_COLORMAP, "drill_seq": drill_seq, "style": dict(t.style), } @@ -646,6 +664,10 @@ def density_view( # budget we fall through to the exact scan that drilling needs anyway. binning = "exact" grid = None + # Mean point color plane (LOD doc §2): channel-bearing traces ship it + # alongside the counts wherever a grid is produced below. + rgba_grid: np.ndarray | None = None + bin_colors = channels.resolve_bin_colors(t.color_ch, None, DEFAULT_PALETTE) # Texture sampling for the density grid: "nearest" when the grid is at full # screen resolution (exact deep-zoom detail — crisp, no interpolation bleed), # "linear" when it is upsampled from a coarser tier (smooth aggregate). @@ -677,9 +699,19 @@ def density_view( False, aggregate_reduction="pyramid-count", ) - res = kernels.pyramid_compose( - pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample - ) + # Colored pyramids compose the mean-color plane with the counts + # (same level, same max_upsample); both refusals (outresolved + # window, missing planes) fall through to the paths below. + if getattr(t, "_pyr_colored", False): + res_color = kernels.pyramid_compose_color( + pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample + ) + res = (res_color[0], res_color[2]) if res_color is not None else None + rgba_grid = res_color[1] if res_color is not None else None + else: + res = kernels.pyramid_compose( + pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample + ) if res is not None: grid, level = res visible = plan.visible @@ -690,6 +722,10 @@ def density_view( # (upsampled), re-bin it *exactly* from just its in-window points — as long # as that count is affordable to read. This is what turns a blocky deep zoom # into real detail (streets), and it gets cheaper the deeper you go. + # The derived index is position-only (§27): it can neither ship channels + # with a points drill nor bin a mean-color plane (LOD doc §2), so a + # color-channelled trace skips this tier and keeps its upsampled + # colored-pyramid grid — blurry but truthful, recorded via `binning`. sidx = getattr(t, "_spatial_index", None) # `window_count` is a cheap (offsets-only, no point reads) upper bound — # whole overlapping cells, which at tight zoom overshoots the true in-window @@ -697,6 +733,7 @@ def density_view( # are affordable, gather **once** and decide by the *actual* in-window count. if ( sidx is not None + and bin_colors is None and (grid is None or binning.endswith("-upsampled")) and sidx.window_count(lo_x, hi_x, lo_y, hi_y) <= SPATIAL_EXACT_MAX_POINTS ): @@ -739,9 +776,16 @@ def density_view( visible = plan.visible w, h = plan.grid_w, plan.grid_h grid = kernels.bin_2d(xv, yv, lo_x, hi_x, lo_y, hi_y, w, h) + if bin_colors is not None: + # This branch is already the O(N) correctness net; the mean-color + # pass (LOD doc §2) rides the same full-column scan cost. + rgba_grid = kernels.bin_2d_mean_color( + xv, yv, lo_x, hi_x, lo_y, hi_y, w, h, **bin_colors + ) binning = "bin2d-oversized" lod.exit_drill(t) if grid is None: + rgba_grid = None sel = kernels.range_indices(xv, yv, lo_x, hi_x, lo_y, hi_y) plan = lod.plan_view_lod(request, len(sel), SCATTER_DENSITY_THRESHOLD, t.drill_mode) visible = plan.visible @@ -753,6 +797,10 @@ def density_view( bx, (bx0, bx1) = fig._binning_coords(t.x_axis, xv, (lo_x, hi_x)) by, (by0, by1) = fig._binning_coords(t.y_axis, yv, (lo_y, hi_y)) grid = kernels.bin_2d(bx, by, bx0, bx1, by0, by1, w, h) + if bin_colors is not None: + # Mean point color per cell (LOD doc §2): same window, same + # binning space, occupied cells match the count grid exactly. + rgba_grid = kernels.bin_2d_mean_color(bx, by, bx0, bx1, by0, by1, w, h, **bin_colors) else: plan = lod.plan_view_lod( request, @@ -782,6 +830,9 @@ def density_view( "x_range": [lo_x, hi_x], "y_range": [lo_y, hi_y], } + if rgba_grid is not None: + density["rgba"] = writer.add_u8(np.ascontiguousarray(rgba_grid).reshape(-1)) + density["color_agg"] = "mean" if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None: density["color"] = t.color_ch.constant if sample is not None: @@ -822,8 +873,10 @@ def _drill_points( ship in the direct-scatter wire shape, normalized over their *global* domain so colors/sizes stay stable across views; offsets re-center on the window midpoint (§16); each point carries its local log-density plus a - `lod_blend` weight (visible/budget) so the density→points handoff is - color-continuous instead of a palette jump (§5).""" + `lod_blend` weight (visible/budget) so the density→points handoff stays + continuous (§5). The surface wears the mean point color (LOD doc §2), so + the handoff is intensity-only: fresh marks enter at their cell's + count-alpha and ease to native opacity, with hue continuous throughout.""" xs, ys = t.x.values[sel], t.y.values[sel] writer = lod.BufferWriter() x_ref, y_ref = lod.add_window_xy( @@ -853,13 +906,11 @@ def _drill_points( _quantize_dval(lod.local_log_density(dx, dy, d_x0, d_x1, d_y0, d_y1, gw, gh)) ) drill_seq = lod.enter_drill(t, sel) - # 1.0 right at the boundary → density-colored points; →0 as zoom deepens. + # 1.0 right at the boundary → points enter at the density surface's + # local count-alpha; →0 as zoom deepens. The surface wears the mean point + # color (LOD doc §2), so marks arrive in their native colors and only + # intensity eases. lod_blend = float(min(1.0, visible / SCATTER_DENSITY_THRESHOLD)) - cmap = ( - t.color_ch.colormap - if (t.color_ch and t.color_ch.mode == "continuous") - else channels.DEFAULT_COLORMAP - ) trace_update = { "id": t.id, "mode": "points", @@ -878,7 +929,6 @@ def _drill_points( "size": size_spec, "density_val": {"buf": dval_buf, "dtype": "u8"}, "lod_blend": lod_blend, - "density_colormap": cmap, "drill_seq": drill_seq, "style": dict(t.style), } diff --git a/python/xy/kernels.py b/python/xy/kernels.py index 56b11664..e53cedf3 100644 --- a/python/xy/kernels.py +++ b/python/xy/kernels.py @@ -51,6 +51,7 @@ bin_2d = _impl.bin_2d bin_2d_f32 = _impl.bin_2d_f32 bin_2d_indices = _impl.bin_2d_indices +bin_2d_mean_color = _impl.bin_2d_mean_color bin_2d_sample_range = _impl.bin_2d_sample_range bin_2d_stratified_sample_range_u8_counted = _impl.bin_2d_stratified_sample_range_u8_counted histogram_uniform = _impl.histogram_uniform @@ -70,9 +71,11 @@ triangle_edges = _impl.triangle_edges local_log_density = _impl.local_log_density pyramid_build = _impl.pyramid_build +pyramid_build_color = _impl.pyramid_build_color pyramid_append = _impl.pyramid_append pyramid_count = _impl.pyramid_count pyramid_compose = _impl.pyramid_compose +pyramid_compose_color = _impl.pyramid_compose_color pyramid_free = _impl.pyramid_free polygon_triangles = _impl.polygon_triangles quad_mesh_triangles = _impl.quad_mesh_triangles @@ -94,6 +97,7 @@ "bin_2d", "bin_2d_f32", "bin_2d_indices", + "bin_2d_mean_color", "bin_2d_sample_range", "bin_2d_stratified_sample_range_u8_counted", "correlation", @@ -120,7 +124,9 @@ "polygon_triangles", "pyramid_append", "pyramid_build", + "pyramid_build_color", "pyramid_compose", + "pyramid_compose_color", "pyramid_count", "pyramid_free", "quad_mesh_triangles", diff --git a/python/xy/marks.py b/python/xy/marks.py index 336282c8..33463875 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -1453,14 +1453,29 @@ def scatter( force_density=density, ) - dropped_channels = trace.per_item_channel_names() + # The color channel survives aggregation as the density surface's + # per-cell mean point color (LOD doc §2); every other per-item + # channel is dropped at Tier 2 — allowed, never silent (§28). + color_aggregates = channels.bins_mean_color(trace.color_ch) + dropped_channels = tuple( + name + for name in trace.per_item_channel_names() + if not (color_aggregates and name == "color") + ) + mean_color_note = ( + " The color channel is kept as the surface's per-cell mean point color" + " (count drives the alpha)." + if color_aggregates + else "" + ) if density is None and dropped_channels and n > DIRECT_SOFT_CEILING: warnings.warn( f"scatter has {n:,} points with per-point styles — above the " f"direct ceiling ({DIRECT_SOFT_CEILING:,}). Falling back to a " f"density surface; dropped channels: {', '.join(dropped_channels)} " "(aggregating arbitrary instance styles needs the §5-F5 aggregation algebra, not yet " - "implemented). Pass density=False to keep direct draw at your risk.", + f"implemented).{mean_color_note} " + "Pass density=False to keep direct draw at your risk.", RuntimeWarning, stacklevel=2, ) @@ -1469,7 +1484,7 @@ def scatter( warnings.warn( f"scatter has {n:,} points above the soft ceiling " f"({DIRECT_SOFT_CEILING:,}); using a density surface for the " - "initial render.", + f"initial render.{mean_color_note}", RuntimeWarning, stacklevel=2, ) diff --git a/scripts/abi_smoke.py b/scripts/abi_smoke.py index 42613ab3..66768ac3 100644 --- a/scripts/abi_smoke.py +++ b/scripts/abi_smoke.py @@ -1393,6 +1393,196 @@ def ok(cond: bool, msg: str) -> None: ok(lib.xy_pyramid_free(ctypes.c_uint64(handle)) == 1, "pyramid free") ok(lib.xy_pyramid_free(ctypes.c_uint64(handle)) == 0, "double free is an error code") + # Mean-color density (LOD doc §2): per-cell mean point color + count-only + # alpha. One red and one blue point per side of a 2x1 grid, then both in + # one cell: pure cells keep exact colors, the mixed cell averages in + # linear light (255,0,0)+(0,0,255) -> (188,0,188). + ZZ = ctypes.c_size_t + DD = ctypes.c_double + lib.xy_bin_2d_mean_color.restype = ctypes.c_int32 + lib.xy_bin_2d_mean_color.argtypes = [ + F64P, + F64P, + ZZ, + U8P, + U8P, + U8P, + ZZ, + DD, + DD, + DD, + DD, + ZZ, + ZZ, + U8P, + ] + mc_x = array("d", [0.25, 0.75]) + mc_y = array("d", [0.5, 0.5]) + mc_idx = array("B", [0, 1]) + mc_lut = array("B", [255, 0, 0, 255, 0, 0, 255, 255]) + mc_out = array("B", bytes(2 * 1 * 4)) + ok( + lib.xy_bin_2d_mean_color( + _ptr(mc_x, ctypes.c_double), + _ptr(mc_y, ctypes.c_double), + 2, + _ptr(mc_idx, ctypes.c_uint8), + U8P(), + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 1.0, + 0.0, + 1.0, + 2, + 1, + _ptr(mc_out, ctypes.c_uint8), + ) + == 1, + "bin_2d_mean_color ok flag", + ) + ok(list(mc_out[0:4]) == [255, 0, 0, 255], "mean color pure red cell") + ok(list(mc_out[4:8]) == [0, 0, 255, 255], "mean color pure blue cell") + mc_y_one = array("d", [0.5, 0.5]) + mc_x_one = array("d", [0.5, 0.5]) + mc_one = array("B", bytes(4)) + ok( + lib.xy_bin_2d_mean_color( + _ptr(mc_x_one, ctypes.c_double), + _ptr(mc_y_one, ctypes.c_double), + 2, + _ptr(mc_idx, ctypes.c_uint8), + U8P(), + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + _ptr(mc_one, ctypes.c_uint8), + ) + == 1 + and list(mc_one) == [188, 0, 188, 255], + "mean color mixed cell averages in linear light", + ) + ok( + lib.xy_bin_2d_mean_color( + _ptr(mc_x_one, ctypes.c_double), + _ptr(mc_y_one, ctypes.c_double), + 2, + _ptr(mc_idx, ctypes.c_uint8), + _ptr(mc_lut, ctypes.c_uint8), # both sources set: invalid + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + _ptr(mc_one, ctypes.c_uint8), + ) + == 0, + "mean color rejects ambiguous color source", + ) + + # Colored pyramid: same counts as the plain build, mean-color plane on + # compose, appends refused (colors unknown; caller rebuilds lazily). + lib.xy_pyramid_build_color.restype = ctypes.c_uint64 + lib.xy_pyramid_build_color.argtypes = [ + F64P, + F64P, + ZZ, + U8P, + U8P, + U8P, + ZZ, + DD, + DD, + DD, + DD, + ctypes.c_uint32, + ] + lib.xy_pyramid_compose_color.restype = ctypes.c_int32 + lib.xy_pyramid_compose_color.argtypes = [ + ctypes.c_uint64, + DD, + DD, + DD, + DD, + ZZ, + ZZ, + ZZ, # max_upsample + F32P, + U8P, + ] + pc_idx = array("B", [1 if px[i] >= 4.0 else 0 for i in range(n_p)]) + chandle = lib.xy_pyramid_build_color( + _ptr(px, ctypes.c_double), + _ptr(py, ctypes.c_double), + n_p, + _ptr(pc_idx, ctypes.c_uint8), + U8P(), + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 8.0, + 0.0, + 8.0, + 8, + ) + ok(chandle != 0, "colored pyramid build returns a handle") + cgrid = array("f", bytes(4 * 8 * 8)) + crgba = array("B", bytes(8 * 8 * 4)) + ok( + lib.xy_pyramid_compose_color( + ctypes.c_uint64(chandle), + 0.0, + 8.0, + 0.0, + 8.0, + 8, + 8, + 2, + _ptr(cgrid, ctypes.c_float), + _ptr(crgba, ctypes.c_uint8), + ) + == 0, + "colored compose full window uses level 0", + ) + ok(sum(cgrid) == float(n_p), "colored compose conserves the count") + left_ok = all( + list(crgba[(r * 8 + c) * 4 : (r * 8 + c) * 4 + 4]) == [255, 0, 0, 255] + for r in range(8) + for c in range(4) + ) + right_ok = all( + list(crgba[(r * 8 + c) * 4 : (r * 8 + c) * 4 + 4]) == [0, 0, 255, 255] + for r in range(8) + for c in range(4, 8) + ) + ok(left_ok and right_ok, "colored compose keeps per-side colors exact") + ok( + lib.xy_pyramid_append( + ctypes.c_uint64(chandle), + _ptr(append_x, ctypes.c_double), + _ptr(append_y, ctypes.c_double), + len(append_x), + ) + == 0, + "colored pyramid refuses appends (rebuilds lazily)", + ) + ok( + lib.xy_pyramid_compose( + ctypes.c_uint64(chandle), 0.0, 8.0, 0.0, 8.0, 8, 8, 2, _ptr(grid_p, ctypes.c_float) + ) + == 0, + "count-only compose still serves a colored pyramid", + ) + ok(lib.xy_pyramid_free(ctypes.c_uint64(chandle)) == 1, "colored pyramid free") + # rasterize: caller-owned RGBA8 framebuffer; empty command buffer clears to # transparent, a malformed op is rejected, and a null out is refused. null_u8 = U8P() diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index c83035ca..5222d819 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -560,7 +560,7 @@ def main() -> None: x_range:[5000,5010],y_range:[5000,5010], x:{{buf:0,len:n3,offset:5005,scale:1}},y:{{buf:1,len:n3,offset:5005,scale:1}}, color:{{mode:"continuous",colormap:"viridis",buf:2}},size:{{mode:"constant",size:8}}, - density_val:{{buf:3}},lod_blend:0.85,density_colormap:"magma"}}]}}, + density_val:{{buf:3}},lod_blend:0.85}}]}}, [xs3.buffer,ys3.buffer,cs3.buffer,ds3.buffer]); const pendingDensity0=v._drawDensity; const pendingPoints0=v._drawPoints; @@ -576,15 +576,16 @@ def main() -> None: x_range:[5000,5010],y_range:[5000,5010], x:{{buf:0,len:n3,offset:5005,scale:1}},y:{{buf:1,len:n3,offset:5005,scale:1}}, color:{{mode:"continuous",colormap:"viridis",buf:2}},size:{{mode:"constant",size:8}}, - density_val:{{buf:3}},lod_blend:0.85,density_colormap:"magma",drill_seq:5}}]}}, + density_val:{{buf:3}},lod_blend:0.85,drill_seq:5}}]}}, [xs3.buffer,ys3.buffer,cs3.buffer,ds3.buffer]); const drilled=(gd.drill && gd.drill.n===n3 && gd.drill.colorMode===1 && v._viewInside(gd.drill.win)===true)?1:0; - // Color-continuous handoff: the drill carries local density + blend - // weight. Fresh marks ARRIVE wearing the aggregate's colormap (shown - // blend seeds at 1 — the texture->marks swap must not recolor even when - // a fast zoom skipped levels) and ease toward the kernel's native weight. - const dblend=(gd.drill && gd.drill.dBuf && gd.drill.dlut + // Intensity-continuous handoff: the drill carries local density + blend + // weight. Fresh marks ARRIVE at the aggregate's count-alpha (shown blend + // seeds at 1 — the texture->marks swap must not pop even when a fast + // zoom skipped levels) and ease toward the kernel's native weight. Hue + // needs no handoff: the surface wears the mean point color (LOD doc §2). + const dblend=(gd.drill && gd.drill.dBuf && Math.abs(gd.drill.lodBlend-0.85)<1e-6 && gd.drill.lodBlendShown===1)?1:0; // Staff-review invariants: subset version stored, stale hover cache @@ -631,7 +632,7 @@ def main() -> None: x_range:[5000,5010],y_range:[5000,5010], x:{{buf:0,len:n3,offset:5005,scale:1}},y:{{buf:1,len:n3,offset:5005,scale:1}}, color:{{mode:"continuous",colormap:"viridis",buf:2}},size:{{mode:"constant",size:8}}, - density_val:{{buf:3}},lod_blend:0.7,density_colormap:"magma",drill_seq:6}}]}}, + density_val:{{buf:3}},lod_blend:0.7,drill_seq:6}}]}}, [xs3.buffer,ys3.buffer,cs3.buffer,ds3.buffer]); const refresh=(gd.drill && gd.drill.seq===6 && gd._drillFadeStart===null && gd._drillDying!==true)?1:0; @@ -640,6 +641,22 @@ def main() -> None: v._drawNow(); const hit3=v._pickAt(v.plot.w/2, v.plot.h/2); const dpick=(hit3 && hit3.trace===gd.trace.id)?1:0; + // T10 retirement: settled inside the drilled window the aggregate + // backdrop eases out and STOPS drawing (the marks are exact; a mean-color + // wash under them reads as data). Mid-ease it still draws; fully retired + // it must not. The hold/exit probes below then draw it again (restore). + let retireDraws=0; + const retire0=v._drawDensity; + v._drawDensity=function(gg,dd,op){{if(gg===gd)retireDraws++;return retire0.call(this,gg,dd,op);}}; + gd._drillBackdropShown=0.5; gd._drillBackdropTick=0; // mid-retire: still painted + v._drawNow(); + const retireMid=retireDraws; + gd._drillBackdropShown=0; gd._drillBackdropTick=0; // retired: skipped + retireDraws=0; + v._drawNow(); + const retireDone=retireDraws; + v._drawDensity=retire0; + const dretire=(retireMid>0 && retireDone===0 && gd._drillBackdropShown===0)?1:0; // Tiny drill-to-drill zoom-outs keep the resident drilled marks for the // short wait when the pending target is still under direct budget — over // the aggregate backdrop (T10): the texture never leaves the frame, so @@ -1116,7 +1133,34 @@ def main() -> None: const gLn=vSm.gpuTraces[0], gAr=vSm.gpuTraces[1]; const msmooth=(gLn.n===65 && gLn._cpu.x.length===5 && gAr.n===65 && gAr._cpu.base.length===5)?1:0; vSm.destroy();holderSm.remove(); - const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}}`; + // Mean-color density (LOD doc §2): the surface must wear the shipped + // per-cell mean point colors while count drives only the alpha. Two + // cells, equal counts: left mean-red, right mean-blue — the drawn pixels + // must follow the rgba plane, not any count colormap. + const mcBuf=new ArrayBuffer(64); const mcCols=[]; let mcOff=0; + const mcu8=(vals)=>{{new Uint8Array(mcBuf,mcOff,vals.length).set(vals); + mcCols.push({{byte_offset:mcOff,len:vals.length,dtype:"u8"}}); + mcOff+=vals.length+(4-(vals.length%4))%4; return mcCols.length-1;}}; + const mcSpec={{protocol:{PROTOCOL_VERSION},width:200,height:160,title:"",backend:"none", + show_legend:false,show_modebar:false, + x_axis:{{kind:"linear",label:"",range:[0,2]}}, + y_axis:{{kind:"linear",label:"",range:[0,1]}}, + traces:[{{id:0,kind:"scatter",name:"mc",tier:"density",n_points:20, + style:{{opacity:1.0}}, + density:{{buf:mcu8([255,255]),rgba:mcu8([255,0,0,255, 0,0,255,255]),color_agg:"mean", + w:2,h:1,max:10.0,enc:"log-u8",colormap:"viridis", + x_range:[0,2],y_range:[0,1],channels_dropped:false,dropped_channels:[]}}}}], + columns:mcCols}}; + const holderMc=document.createElement("div");document.body.appendChild(holderMc); + const vMc=xy.renderStandalone(holderMc,mcSpec,mcBuf); + vMc._drawNow(); + const gMc=vMc.gl,WM=gMc.drawingBufferWidth,HM=gMc.drawingBufferHeight; + const lpx=new Uint8Array(4), rpx=new Uint8Array(4); + gMc.readPixels(Math.round(WM*0.35),Math.round(HM*0.5),1,1,gMc.RGBA,gMc.UNSIGNED_BYTE,lpx); + gMc.readPixels(Math.round(WM*0.8),Math.round(HM*0.5),1,1,gMc.RGBA,gMc.UNSIGNED_BYTE,rpx); + const meancolor=(lpx[0]>60 && lpx[0]>lpx[2]*3 && rpx[2]>60 && rpx[2]>rpx[0]*3)?1:0; + vMc.destroy();holderMc.remove(); + const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}} meancolor=${{meancolor}} dretire=${{dretire}}`; const baseWithStyle=`${{base}} vstyle=${{vstyle}}`; // Responsive: 100%-by-100% chart in a 400x300 container tracks its parent; // growing the container must fire the ResizeObserver and re-render bigger. @@ -1333,6 +1377,8 @@ def main() -> None: refresh = int(re.search(r"refresh=(\d+)", title).group(1)) dying = int(re.search(r"dying=(\d+)", title).group(1)) density_lit = int(re.search(r"densityLit=(\d+)", title).group(1)) + meancolor = int(re.search(r"meancolor=(\d+)", title).group(1)) + dretire = int(re.search(r"dretire=(\d+)", title).group(1)) dpick = int(re.search(r"dpick=(\d+)", title).group(1)) hold = int(re.search(r"hold=(\d+)", title).group(1)) zoomout = int(re.search(r"zoomout=(\d+)", title).group(1)) @@ -1509,6 +1555,16 @@ def main() -> None: ) if qwire != 1: raise SystemExit("log-u8 density decode failed (quantized wire)") + if meancolor != 1: + raise SystemExit( + "mean-color density failed (surface must wear the per-cell mean " + "point colors, count as alpha — LOD doc §2)" + ) + if dretire != 1: + raise SystemExit( + "settled drill kept its aggregate backdrop painted (T10: the " + "backdrop retires once the marks are exact for the window)" + ) if stream != 1: raise SystemExit( "streaming append failed (trace rebuild or follow policy: refit/hold/slide)" diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 5dce7618..6f38f018 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -182,8 +182,9 @@ benchmark tracks this as part of the core 2D payload budget. decision with hysteresis, drilled-subset versioning (`drill_seq`), window encoding, screen-derived grid shaping, entry/exit fades, the density-source cache, and eased normalization. An *aggregating* kind supplies its own - aggregate kernel (density uses `bin_2d`; a 1D histogram supplies 1D binning) - and reuses this framework around it. + aggregate kernel (density uses `bin_2d` for counts plus `bin_2d_mean_color` + for the per-cell mean point color, LOD doc §2; a 1D histogram supplies 1D + binning) and reuses this framework around it. - **Transport**: data-less JSON spec + one binary blob; no JSON numbers (§29). - **Ticks / axes / time axis / autorange**: keyed on axis kind, not mark kind. @@ -206,9 +207,13 @@ usually wrong). Each has an explicit trigger: - **Picking** (`_renderPick`, `_pickAt`): point-geometry only today (`pointPick`). *Trigger: first pickable non-point mark (bar/candle)* — add a `pick` step to `MARK_KINDS` and give the mark its own ID-pass geometry. -- **Legend** (`_buildLegend`): keyed on *channel modes* (density / categorical / +- **Legend** (`_buildLegend`): keyed on *channel modes* (categorical / continuous / named-series), not mark kinds — a colored bar inherits swatches - for free. *Trigger: a mark needing a swatch that isn't channel-shaped.* + for free. A density-tier surface gets no gradient swatch of its own: count is + encoded as alpha, not color (LOD doc §2), so a colormap gradient would read as + "color == density" and mislead; a named density trace falls through to the + plain named-series swatch, matching the static exporters. *Trigger: a mark + needing a swatch that isn't channel-shaped.* - **Decimation** (`interaction.decimate_view`): line and area-like marks use the shared M4 path on first payload; errorbar/stem segments reduce to a pixel-derived cap at emit time. Contour is NOT pixel-bounded: its segment diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index 6a2e80b4..25f5e51d 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -292,9 +292,12 @@ and drill updates emit the same wire shape. step, O(visible tiles) per frame after a one-pass build (~1.33× cost). - *Progressive refinement* (§17) — bin a 1-in-k sample first so a coarse density appears <100 ms, refine over subsequent frames. -- *Per-bin channel aggregation* (§5-F5) — mean/max color per density cell, so - a zoomed-out colored scatter keeps *some* channel signal instead of - `channels_dropped`. +- *Per-bin channel aggregation* (§5-F5) — **color shipped.** Density cells + carry the alpha-weighted mean of their points' resolved colors with count as + the alpha channel (LOD doc §2) — continuous, categorical, and direct-RGBA + channels alike, at every tier including the pyramid — recorded as + `color_agg: "mean"` instead of `channels_dropped`. Non-color aggregates + (mean/max of a value channel read as data, size) remain open. ## Cross-Cutting: Styling & Theming diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 96a42539..271cbeaa 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -48,8 +48,10 @@ Every claim in this dossier is **mode-scoped and testable** — no universal num **Outstanding work (F4–F12), the honest to-do list:** - **F4** — per-trace f32 offsets (a single viewport origin can't serve traces at wildly different coordinate magnitudes). *Augments §4/§16.* -- **F5** — aggregation algebra for color-by-category / mean / non-count reductions - (Tier-2 currently assumes a scalar density). *Augments §5.* +- **F5** — aggregation algebra for per-point color: **shipped as mean point color** + (Tier-2 surfaces wear the per-cell alpha-weighted mean of the resolved point + colors, count drives only the alpha — LOD doc §2). Non-color reductions + (mean/max of arbitrary value channels as *data*, size) remain unmodeled. *Augments §5.* - **F6** — per-*view* colormap normalization domain (else zoom flickers brightness). *Augments §5.* - **F7** — streaming into the pyramid: `+=`/`-=` across levels; min/max tiles need @@ -282,7 +284,10 @@ the visible x-range on zoom. Turns 100M points into ~a few thousand drawn vertic with no visible difference. **Tier 2 — multiresolution aggregation (datashader-style, but tiled):** for massive -scatter and heatmaps, don't draw points — draw a colormapped **density texture**. +scatter and heatmaps, don't draw points — draw a **density texture** that wears the +data's own colors: per cell, the alpha-weighted mean of the resolved point colors, +with the log-tone-mapped count as the alpha channel (LOD doc §2). Constant-color +traces reduce to a count texture tinted with the constant. The naive version ("bin all points into a screen-sized texture") is *not* "O(points) once": the bin grid depends on the viewport, so every pan/zoom would re-bin the whole @@ -317,8 +322,11 @@ served upsampled from the finest level — an O(N) rescan of a 100 GB+ mmap is n interactive, and past 2³²−1 rows the per-row index kernels would overflow u32 anyway. Those traces also get a finer finest level (adaptive `~sqrt(N/target)`, capped `PYRAMID_MAX_DIM` = 16384²) so the upsampled floor stays as sharp as memory allows. -Level and mode are recorded per update (`binning: "pyramid-L[-upsampled]"`). Only the -`count` plane exists; the per-channel `sum` / `argmax+purity` planes above are not built. +Level and mode are recorded per update (`binning: "pyramid-L[-upsampled]"`). +Channel-bearing traces build mean-color planes alongside the counts +(`xy_pyramid_build_color` / `_compose_color`, LOD doc §2/§4.1 — same level, same +`max_upsample`, both compose regimes; colored pyramids refuse `_append` and rebuild +lazily), so pyramid-served views wear the data's own colors at any zoom. *Windowed-exact tier for out-of-core scatter (`python/xy/_spatial.py`):* the pyramid's upsampled floor is blocky at metro/city zoom (its finest cell is kilometres wide over a @@ -342,6 +350,11 @@ tier keyed on the *actual* in-window count: and uploaded **nearest-neighbour** — no coarser grid stretched over the viewport, so no upscale blur and no pixelation. The upsampled pyramid keeps `linear` (smooth aggregate); the choice rides the wire as `density.filter`. +- The whole windowed-exact tier is gated to traces **without a color channel**: the + position-only index cannot bin the LOD doc §2 mean-color plane, and a count-only + surface the shader colormaps is exactly what §2 retired. A color-channelled trace + keeps its upsampled *colored* pyramid grid instead — blurry beats mis-colored (§28, + recorded per update by the `binning` value). The sorted columns are a derived **f32** cache (§27: canonical stays f64, every derived buffer is rebuildable). *Pending:* tiling proper (per-tile build, fetch, and pan-time reuse), so @@ -397,7 +410,7 @@ re-exports several of them as a historic import path and is not listed for those | `PROTOCOL_VERSION` | `3` | Wire-spec version stamped on every payload; the client refuses a mismatch loudly (§33). | `_payload.py` | | `DECIMATION_THRESHOLD` | `10_000` | Line/area traces with more points than this ship M4-decimated (Tier 1); at or below, raw columns go over the wire. Also gates re-decimation on the interaction path. | `_payload.py`, `interaction.py` | | `SCATTER_DENSITY_THRESHOLD` | `200_000` | Tier-0 → Tier-2 count budget for a scatter with **no** per-point channel (`Trace.use_density()`), and the visible-count budget for view-LOD planning and drill decisions. | `_trace.py`, `interaction.py`; mirrored client-side as `LOD_DIRECT_POINT_BUDGET` in `js/src/45_lod.ts` | -| `DIRECT_SOFT_CEILING` | `2_000_000` | Tier-0 → Tier-2 count budget for a scatter that **does** carry a per-point color or size channel; above it density is forced and the channels are warned about, never silently dropped (§5 F5). | `_trace.py`, `marks.py` | +| `DIRECT_SOFT_CEILING` | `2_000_000` | Tier-0 → Tier-2 count budget for a scatter that **does** carry a per-point color or size channel; above it density is forced and warned about — the color channel aggregates to the surface's per-cell mean point color (LOD doc §2), every other per-item channel is dropped and named, never silently (§5 F5). | `_trace.py`, `marks.py` | | `DENSITY_GRID` | `(512, 384)` | Default density-grid cell dimensions for the initial spec, before the client requests a viewport-matched size via `density_view`. | `_payload.py` | | `MAX_SCREEN_DIM` | `4096` | Upper clamp on any browser-supplied pixel dimension, so untrusted widget/comm input cannot inflate decimation buckets or density grids. | `lod.py`, `_native.py` | | `MAX_CONTOUR_WORK` | `4_000_000` | Ceiling on contour `cells × levels`; a request over it raises instead of allocating an unbounded segment buffer. | `marks.py`, `_native.py` | @@ -452,7 +465,9 @@ F3, still pending (above). - **WebGPU primary, WebGL2 fallback.** One ``, everything is GPU primitives. - **Instanced draws** for markers/bars/lines — one draw call for millions of marks. -- **Density textures** for aggregated tiers (§5, Tier 2). +- **Density textures** for aggregated tiers (§5, Tier 2) — mean point color per + cell, count as alpha (LOD doc §2); premultiplied RGBA8 upload for + channel-bearing traces, tinted R8 count texture for constant-color ones. - **DOM/SVG only for chrome** — axis tick labels, legend, title, tooltip. Little of it, and it stays crisp/accessible/selectable. - **Retained scene graph**, spec-diff → buffer-diff. Pan/zoom is a view-matrix uniform @@ -987,7 +1002,8 @@ means uniform in the axis's scale coordinates, not in raw data. Concretely: rule "cells are uniform in scale coordinates" (wire-protocol.md §3). Without this, clusters at x=1 and x=1000 share one cell on a symlog axis despite being a third of the screen apart. The drill handoff's per-point - `local_log_density` bins in the same space so its colors match the grid's. + `local_log_density` bins in the same space so its count-alpha matches the + grid's (hue already matches — the grid wears the mean point color, LOD doc §2). - **The raw-space tile pyramid** (Tier 3) cannot compose a scale-coordinate grid, so traces on a nonlinear axis skip pyramid build/compose and always take the exact scan (`binning: "exact"`). Cost: the O(visible) rescan the @@ -1693,7 +1709,7 @@ haven't measured. | F2 | No filtering / selection / linked-brushing model; a static pyramid is stale under any filter | **Critical** | confirmed | | F3 | GPU ceilings mis-modeled — fill-rate & the 1 GB single-allocation cap bound Tier 0, not point count | **Major** | confirmed | | F4 | A single viewport offset can't serve multiple traces at different coordinate magnitudes | **Major** | plausible | -| F5 | Tier-2 assumes a scalar density — color-by-category / mean / non-count aggregation unmodeled | **Major** | confirmed | +| F5 | Tier-2 assumed a scalar density — **resolved for color** (mean-point-color surfaces, LOD doc §2); non-color value aggregations (mean/min/max-as-data, size) remain unmodeled | **Major → residual Moderate** | confirmed, largely addressed | | F6 | Colormap normalization domain across pyramid levels is unspecified → brightness flicker on zoom | **Major** | plausible | | F7 | Streaming into the pyramid is under-reconciled with multi-level structure + eviction | Moderate | confirmed | | F8 | "One core, logically identical" collides with the per-backend implementation matrix | Moderate | confirmed | @@ -1776,14 +1792,23 @@ viewport origin; a single origin leaves the far trace with catastrophic f32 erro the shared view transform stays f64 on CPU and composes with each trace's offset at upload. Modest bookkeeping, but the doc doesn't have it and multi-trace is the norm. -### F5 — Tier-2 assumes a scalar density; categorical/aggregation algebra is missing. [Major] -**Failure scenario.** `scatter(x, y, color=category)` with 12 categories over 50M points. -One density texture can't carry per-category counts; you need N per-category planes -(imMens's ∏-bins problem) or a per-bin categorical mode/argmax. `color=mean(value)` needs -2-channel (sum,count) accumulation; std needs more. The doc's Tier-2 is single-scalar. -**Fix.** Specify the **aggregation algebra** — count / sum / mean(2-ch) / min / max / -**categorical-by (N capped planes + "other")** — mirroring datashader's `ds.by`/`ds.mean`, -with its memory cost (N× the tile, feeding F9) and a category cap that logs truncation. +### F5 — Tier-2 assumed a scalar density; the color algebra now ships. [Major → residual Moderate] +**Original failure scenario.** `scatter(x, y, color=category)` with 12 categories over +50M points: one count texture couldn't carry the colors, so the surface wore a count +colormap that matched neither the points nor the legend — a jarring recolor at every +density⇄points transition. +**Shipped fix (LOD doc §2).** The Tier-2 surface carries the **per-cell alpha-weighted +mean of the resolved point colors** (one law for continuous, categorical, and +direct-RGBA channels; linear-light integer pipeline, deterministic), with the +log-tone-mapped count as the alpha channel. The pyramid grew matching mean-color +planes (`build_color`/`compose_color`); the drill handoff became intensity-only +because hue is continuous by construction. `color_agg: "mean"` records the transform. +**Residual gap.** Aggregating *values as data* — per-cell mean/min/max of a channel +readable from hover/legend, per-category count planes for filtered queries +(imMens's ∏-bins), std, etc. — is still unmodeled; size channels still drop. Those +need the fuller algebra (count / sum / mean(2-ch) / min / max / categorical-by with +capped planes) mirroring datashader's `ds.by`/`ds.mean`, with its memory cost +(N× the tile, feeding F9) and a category cap that logs truncation. ### F6 — Colormap normalization across pyramid levels is unspecified → zoom flicker. [Major] **Failure scenario.** Color maps bin-count→hue. Coarse tiles have higher counts than fine diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 6031d75c..2e4d9d64 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -28,7 +28,7 @@ pixel-area or overdraw term exists in `python/xy/` or `js/src/` today. |---|---|---|---|---| | 0 | Direct | every visible mark, exact | O(visible) verts | shipped (all kinds) | | 1 | Shape-preserving reduction | per-pixel-column aggregate that IS the mark's meaning (M4 for lines; OHLC-bucket for candles when finance returns; max-bin for bars) | O(px) verts | shipped (line) | -| 2 | Density / aggregate surface | colormapped count (+channel) texture | O(screen) texels | shipped (scatter) | +| 2 | Density / aggregate surface | mean-point-color texture, count as alpha (§2) | O(screen) texels | shipped (scatter) | | 3 | Out-of-core tiles | Tier-2 pyramid where not all tiles are resident | O(visible tiles) | **this doc** | **Invariant L1 (recorded reduction):** every update carries `tier`, `mode`, @@ -44,7 +44,7 @@ real marks with real channels — never a sample, never an aggregate. | Kind | Tier 0 | Tier 1 | Tier 2 | zoom-in recovery | |---|---|---|---|---| -| scatter | points | — (unordered decimation lies, §28) | density grid (+ channel aggregates, §4 below) | drill-in ships exact visible points + channels (shipped) | +| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact visible points + channels (shipped) | | line/area | polyline | M4 per column (extrema-exact) | — (M4 already screen-bounded) | re-decimate window (shipped) | | heatmap | cell rects | — | mip-style tile reduction of the user grid (max/mean recorded) | finer pyramid level, then exact cells | | histogram/bar | rects | re-bin at viewport resolution | — (bins are already aggregates) | re-bin visible window (finer bins = more truth, never less) | @@ -93,38 +93,71 @@ metadata, or encoded-buffer assembly. ## 2. Zoomed-out truthfulness with channels (the "no visual lying" rules) Aggregating positions is easy; aggregating **color/size/category** without -lying is the hard part (§5-F5). Rules, per channel mode: - -1. **Count is always channel 0.** The density surface's luminance/alpha - encodes count (log tone-mapped, shipped). Any additional channel rides on - top of count, never instead of it — otherwise sparse-but-extreme cells - look as important as dense ones. -2. **Continuous channel → per-cell MEAN, displayed only with count ≥ floor.** - Ship a second grid `sum(channel)`; client computes mean = sum/count in the - shader. Cells under a count floor (default 1) render count-only — a mean - of one point is exact, a mean of zero is a lie. - *Rejected:* per-cell max (overweights outliers silently). Max is offered as - an explicit `agg="max"` the user must opt into, and the legend then says - "max per cell" (recorded, §28). -3. **Categorical channel → per-cell MAJORITY + purity.** Ship two grids: - `argmax(category)` (palette index) and `purity = max_count/count`. - The shader desaturates toward the count ramp as purity → 1/k, so a - 50/50 cell doesn't masquerade as solidly category A. This is the honest - version of datashader's `count_cat` composite. - Categories >256 already warn at ingest (LUT width); majority-of-256 is - fine because the palette itself caps there. +lying is the hard part (§5-F5). The governing principle, shipped: **the +density surface wears the data's own colors; count drives only the alpha.** +A cell's color is what the eye would see if every one of its points were +drawn sub-pixel — the physically downsampled image — so the aggregate view +and the point view are two magnifications of the same picture, not two +different charts. More points → deeper (more opaque) color; fewer points → +lighter; the color itself always comes from the represented points. + +1. **Count is always channel 0, and it is the ALPHA channel.** The count + grid ships log-tone-mapped (`buf` + `max`, log-u8) and drives opacity + exclusively. Color rides on top of count, never instead of it — + otherwise sparse-but-extreme cells look as important as dense ones. No + shipped path colormaps counts anymore (the client keeps a count→LUT + fallback solely for hand-built/legacy count-only specs). +2. **Per-point colors → per-cell alpha-weighted MEAN of the *resolved* + colors, averaged in linear light.** One law for every channel mode — + continuous (colormapped value), categorical (palette color), and + `direct_rgba` alike: each point contributes the exact sRGB color it draws + with, weighted by its straight alpha, summed in linear light (integer + pipeline: checked-in sRGB⇄linear-u16 tables, u64 sums — bitwise + deterministic across thread counts and platforms), then quantized back to + sRGB. Ships as a `w*h*4` straight-alpha RGBA8 plane (`rgba`, mean color + + mean point alpha) with `color_agg: "mean"` recorded; displayed alpha = + count tone-curve × mean point alpha. A cell of one point shows that + point's exact color; an all-invisible (alpha-0) cell never invents one. + Constant-color traces ship no plane — the mean of a constant IS the + constant, so the wire keeps the 1-byte count grid plus `color` and the + client tints (bit-equivalent, 4× cheaper). Cost, recorded: a + channel-bearing trace pays one extra O(visible) color pass per exact + re-bin (the count grid and sample selection stay fused as before; plain + scatters are byte-identical to the pre-color pipeline), +4 B/cell on the + wire, and the §4 pyramid's color planes at +8 B/cell. + *Why mean-of-colors and not colormap(mean value):* the mean color is + representation-agnostic (categories have no mean value), matches the + drilled points' downsample exactly (the anti-jarring contract, T3), and + never claims a data value that isn't there — a mixed red/blue cell reads + as the purple *blend* the points themselves produce, not as a fictitious + mid-scale value. The cost, recorded: a mixed cell's color can sit off the + palette/colormap gamut; the sample overlay and drill-in disambiguate. + *Rejected:* per-cell max (overweights outliers silently); majority+purity + (hid minority categories entirely and desaturated toward a count ramp + that no longer exists). Linear-light averaging is deliberate — sRGB-space + byte averaging darkens mixes (red+blue → 128-purple instead of the + physically correct 188-purple). +3. **Categorical channels beyond the palette fold first.** Codes wider than + the 256-entry LUT bin with `code % len(palette)` — exactly the repeat + rule the point shader applies — so every point bins with the color it + draws with, at any cardinality. 4. **Size channel → drop at Tier 2, always say so.** Size has no honest - per-cell aggregate that isn't just another continuous channel; we already - ship `channels_dropped: true`. Keep that; render the legend badge from it. -5. **The drill handoff already solves re-entry:** freshly drilled points wear - the density ramp by local log-density (`lod_blend`), easing to native - channels as the window shrinks — the same rules make the *outbound* - transition honest because both endpoints display the same statistics. + per-cell aggregate that isn't just another continuous channel; we ship + `channels_dropped: true` + `dropped_channels` naming it. Color is *not* + listed there when it aggregates — `color_agg: "mean"` records the + transform instead (aggregated ≠ dropped). +5. **The drill handoff is intensity-only now:** hue is continuous by + construction (both sides show the points' colors), so freshly drilled + points arrive in their native colors and only their opacity hands off — + entering at the cell's count-alpha (`density_val` through the same tone + curve as the texture) and easing to native opacity as `lod_blend` → 0. + The same rule runs outbound: exiting marks re-target the aggregate's + count-alpha and melt into a surface that already matches their hue. **Invariant L4 (badge):** any active reduction that drops or transforms a -channel renders a visible "aggregated: mean/majority/…" badge sourced from -the spec, not from client guesswork. (Client work item; spec already carries -the facts.) +channel renders a visible "aggregated: mean/…" badge sourced from the spec +(`color_agg`, `dropped_channels`), not from client guesswork. (Client work +item; spec already carries the facts.) --- @@ -166,16 +199,24 @@ pyramid replaces per-view scans with per-view *tile composition*. - **Data-space tiles**, power-of-two levels, 256×256 cells/tile. Level 0 covers the full x/y extent with 1 tile; level l has 4^l tiles. -- Each tile stores channel-aggregate grids per §2: `count`, and per channel - `sum` (continuous) or `argmax+purity` (categorical) — f32 planes. +- Each tile stores the channel aggregates per §2: `count` (u32), and for + channel-bearing traces a mean-color plane — per cell `[r, g, b, a]` as + linear-light u16 means plus mean straight alpha (u16 scale). Means, not + sums: 8 B/cell instead of 24+, at the cost of one re-rounding per level + (≤ 0.5 lsb of u16 per level — recorded, invisible). - **Build:** one pass over rows bins into the finest level L (L ≈ ceil(log4(N / target_points_per_cell / 256²))); levels L-1..0 are 4→1 - reductions (`count` sums; `sum` sums; majority re-argmaxes from summed - per-category counts — which requires keeping per-category counts at build - time for the top-k categories, k ≤ 8, "other" bucketed; recorded). + reductions (`count`: exact u64 sums saturating to u32; color: exact + weighted means, weight = child count × child mean alpha — the same + alpha-weighted average the flat kernel computes over raw points). Total cost ≈ 1.33 × one full pass; total size ≈ 1.33 × finest level. - **Rust owns this** (`tiles.rs`, see rust-engine doc): build_pyramid(), tile fetch by (level, tx, ty), append-aware rebuild of dirty tiles. + Colored pyramids refuse native appends — the batch's colors are unknown to + the count-only append path and an append can move a continuous channel's + domain, silently re-coloring every already-binned point — so the caller + invalidates and the next density view rebuilds lazily (recorded; count-only + pyramids keep O(rows·levels) increments). ### 4.2 Serving a viewport @@ -199,16 +240,22 @@ reply = compose(tiles) → one grid ≤ (screen px) — done kernel-side today, ### 4.3 Truthfulness across levels -Reductions are exact (sums of sums; majority from kept per-category counts), -so any pyramid level shows the same statistics the raw pass would — modulo -the "other" bucket for tail categories, which the spec records -(`categories_bucketed: k`). Level choice is recorded per update -(`pyramid_level`). No level ever extrapolates. +Count reductions are exact (sums of sums), so any pyramid level shows the +same counts the raw pass would. Color reductions are exact weighted means of +the stored child means, re-quantized to u16 once per level (≤ 0.5 lsb of +linear-u16 per level; over the 11 levels of the default base that stays +under one displayable sRGB step — recorded here, invisible in practice). +Level choice is recorded per update (`binning: "pyramid-L"`). No level +ever extrapolates. ### 4.4 Memory & residency - Finest level for 100M points at 16 pts/cell ≈ 6.25M cells ≈ 25 MB (count - f32) + 25 MB/channel. ×1.33 pyramid ≈ 33-66 MB kernel-side. Fine. + u32) + 50 MB mean-color plane ([u16; 4]/cell). ×1.33 pyramid ≈ 33-100 MB + kernel-side. Fine. (Shipped shape: 2048² default base ⇒ 22 MB counts + + 45 MB color per colored trace, `pyramid_report_bytes`; huge/out-of-core + traces build adaptively finer bases — Phase-3 item 7 — and the color plane + scales with them.) - 1B points: ~330-660 MB — still kernel-side RAM, but now Tier 3 applies: tiles are chunked to disk (Arrow/Parquet row groups per tile, dossier §32), LRU-resident under a byte budget, and *only* the ≤ ~12 visible tiles are @@ -264,16 +311,19 @@ invariants so future kinds don't regress them: the aggregate→marks transition only — restarting per refresh reads as flashing; exit fade with the "dying" state so buffers outlive the fade). - **T3 — color-continuous:** the two sides of a transition display the same - statistic at the boundary (lod_blend density-ramp handoff). The kernel's - blend weight (`visible/budget`) is only ≈1 when the swap happens at the - budget boundary — a fast zoom skips levels and lands marks with a - mostly-native weight, a visible recolor at the swap (live-drilldown field - capture). The BOUNDARY is therefore the transition itself, client-side: - fresh marks arrive with the shown blend seeded at 1 (wearing the - aggregate's colormap) and ease to the kernel's native weight, and - dying/exiting marks re-target blend 1 so they melt into the texture as - they fade (`lodApplyDrill`, `lodBeginDrillExitContinuous`; revives restore - the native weight via `lodEnterDrillContinuous`). + statistic at the boundary. Hue is continuous *by construction* — the + aggregate surface wears the mean point color (§2), so marks and texture + agree on color at every zoom — and intensity hands off via the lod_blend + count-alpha ramp. The kernel's blend weight (`visible/budget`) is only ≈1 + when the swap happens at the budget boundary — a fast zoom skips levels + and lands marks with a mostly-native weight, a visible intensity pop at + the swap (live-drilldown field capture). The BOUNDARY is therefore the + transition itself, client-side: fresh marks arrive with the shown blend + seeded at 1 (entering at the aggregate's local count-alpha) and ease to + the kernel's native weight, and dying/exiting marks re-target blend 1 so + they melt into the texture as they fade (`lodApplyDrill`, + `lodBeginDrillExitContinuous`; revives restore the native weight via + `lodEnterDrillContinuous`). - **T4 — normalization is eased, never stepped** (exposure-style normMax). - **T5 — stale replies die:** seq on view updates, drill_seq on subsets, pending-view hold for prefetched drills. @@ -334,19 +384,25 @@ invariants so future kinds don't regress them: latches. Overlays die with their evicted cache entry (except the home/init overlay, the standalone re-bin worker's CPU-side source), and the "sampled n of N" badge reports the overlay actually drawn. -- **T10 — the aggregate backdrop is continuous:** the density texture draws - under the marks in EVERY drill state — entering, settled inside, held, - exiting — never only until the entry fade completes. The background of a - drilled frame and a density frame is the same texture, so every drill - transition is a marks-layer fade over a stable context, not a full-frame - swap. (Previously marks "owned the frame" once their entry fade finished: - the backdrop flipped to the blank chart background, and interleaved - density/points replies during a continuous zoom flashed - green-texture ⇄ points-on-blank — the live-drilldown flicker. It also - kept the aggregate context visible while drilled, which is the §28 hybrid - intent.) `lodDrawDensityTier` routes every branch's backdrop through - `lodDrawDensityWithFade`, so cached-window crossfades stay continuous - while drilled too. +- **T10 — the aggregate backdrop is continuous through transitions, and + retires when the drill settles:** the density texture draws under the + marks in every TRANSITIONAL drill state — entering, held, dying, exiting — + so every representation change is a marks-layer fade over a stable + context, never a full-frame swap or a blank. (Previously marks "owned the + frame" once their entry fade finished: the backdrop flipped to the blank + chart background, and interleaved density/points replies during a + continuous zoom flashed green-texture ⇄ points-on-blank — the + live-drilldown flicker.) Once a drill is SETTLED inside its window — + entry fade landed, no exit/hold/death in flight — the backdrop eases out + (`lodDrillBackdropScale`): the marks are exact for that window, so the + aggregate adds no information the marks don't already carry, and leaving + it painted washes exact points with mean color that reads as data + (field-reported against the mean-color surface). It eases back FAST the + moment the view leaves the window, a refinement goes pending, or the + drill dies — so zoom-outs still never blank (T1) and per-reply refreshes + inside a settled drill never re-flash it. `lodDrawDensityTier` routes + every branch's backdrop through `lodDrawDensityWithFade`, so + cached-window crossfades stay continuous while drilled too. - **T11 — an exited drill is a bounded revive cache:** a drill whose entry completed and whose exit fade has finished is retained so a rapid zoom back into its window hands the exact marks back with no kernel round-trip @@ -369,14 +425,27 @@ contract entry before it lands. ## 6. Implementation plan -**Phase 1 — channel-truthful density (kernel+client, ~1 wk)** -1. `bin_2d_channels` kernel: count + sum(channel) [+ top-k category counts] - in one pass (native Rust core). -2. Wire mean/majority+purity grids through `density_view` and the initial - emit; extend DENSITY_FS to blend channel color over the count ramp with - the purity desaturation; count-floor uniform. -3. Legend/badge rendering from spec facts; smoke probes: mean-cell color - correctness, purity desaturation, badge presence. +**Phase 1 — channel-truthful density (kernel+client)** +1. **Done:** `bin_2d_mean_color` kernel — per-cell count + alpha-weighted + linear-light color sums in one pass, straight-alpha RGBA8 mean-color grid + out (native Rust core; integer tables + integer sums, deterministic for + any thread count and across platforms). Color source is either per-point + LUT indices + a ≤256-entry RGBA8 LUT (continuous quantizes to the + client's 256 texels; categorical passes codes, wide codes fold modulo the + palette) or per-point straight RGBA8 (`direct_rgba`) — + `channels.resolve_bin_colors` maps every channel mode onto one of the two. +2. **Done:** mean-color planes wired through the initial emit + (`_payload._density_trace_spec`), `density_view` (exact and pyramid + paths), the static SVG/PNG exporters, and the standalone re-bin worker; + DENSITY_FS gained the `u_meanColor` branch (premultiplied RGBA8 texture, + count tone-curve baked into alpha at upload so bilinear filtering weights + color by coverage); the drill handoff became intensity-only (§2 rule 5). + The colorbar for a continuous channel now stays on density traces — the + surface really shows the channel's colors. +3. Legend/badge rendering from spec facts (`color_agg`, `dropped_channels`) + remains a client work item; smoke probes for mean-cell color correctness + ship (`render_smoke_nonumpy.py` `meancolor`, `abi_smoke.py` mean-color + checks, `tests/test_density_mean_color.py` NumPy oracle). **Phase 2 — deterministic sampling utilities** 4. **Done:** `lod.sample_keep_mask(row_ids, level)` SplitMix64 sampler + @@ -395,16 +464,24 @@ contract entry before it lands. by the coverage fade when nothing covers the view. **Phase 3 — pyramid (build + serve shipped; client cache and bench gate open)** -6. **Done (count plane only):** `src/tiles.rs` builds a square count pyramid - over the trace's full data bounds — finest level is `PYRAMID_BASE_DIM`² - (2048², `python/xy/config.py`), each coarser level an exact 4→1 u64 sum - saturating to u32, so every level conserves total count. C ABI is - `xy_pyramid_build` / `xy_pyramid_append` / `xy_pyramid_count` / - `xy_pyramid_compose` / `xy_pyramid_free` (no per-tile fetch entry point; - composition happens kernel-side). Handles are slab indices behind a mutex, - cached per trace and built lazily by `interaction._ensure_pyramid` on the - first density view at ≥ `PYRAMID_MIN_POINTS` (2,000,000), released by a - weakref finalizer. Channel planes (§2, §4.1) are not built yet. +6. **Done (count + mean-color planes):** `src/tiles.rs` builds a square count + pyramid over the trace's full data bounds — finest level is + `PYRAMID_BASE_DIM`² (2048², `python/xy/config.py`), each coarser level an + exact 4→1 u64 sum saturating to u32, so every level conserves total count. + Channel-bearing traces build the §4.1 mean-color planes alongside + (`xy_pyramid_build_color`, one fused scan — fan-out gated by the + points-per-cell ratio and capped at 4 workers, ~170 MB transient + accumulator each; +8 B/cell stored, ~45 MB/colored trace at the default + base, reported by `pyramid_report_bytes`) and serve them with + `xy_pyramid_compose_color`, + whose count grid is bit-identical to `xy_pyramid_compose`. C ABI is + `xy_pyramid_build` / `xy_pyramid_build_color` / `xy_pyramid_append` / + `xy_pyramid_count` / `xy_pyramid_compose` / `xy_pyramid_compose_color` / + `xy_pyramid_free` (no per-tile fetch entry point; composition happens + kernel-side). Handles are slab indices behind a mutex, cached per trace + and built lazily by `interaction._ensure_pyramid` on the first density + view at ≥ `PYRAMID_MIN_POINTS` (2,000,000), released by a weakref + finalizer; colored pyramids refuse appends and rebuild lazily (§4.1). 7. **Done:** `density_view` estimates the window with `pyramid_count` and serves it with `pyramid_compose` when that estimate sits safely above the drill threshold; `compose` picks the coarsest level that still meets the @@ -434,6 +511,11 @@ contract entry before it lands. nearest-neighbour filtering (`binning: "spatial-exact"`, `filter: "nearest"`) — no interpolation blur. Gated by an offsets-only `window_count` upper bound (`SPATIAL_EXACT_MAX_POINTS`); wider windows keep the instant upsampled pyramid. + Color-channelled traces skip this tier entirely: the index is position-only + (§27), so it can neither ship channels with a points drill nor bin the §2 + mean-color plane — the upsampled *colored* pyramid grid stays (blurry but + truthful) rather than flipping to a count-only surface (recorded, dossier + §28 table). 8. Client: tile-keyed cache replaces window-keyed `densityCache` (same eviction, same crossfades). Still pending — `js/src/45_lod.ts` keys the cache by density window, and no client code reads the served level. diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 6c3fa426..7e65e36a 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -29,7 +29,7 @@ relative mass, not as a budget (see §3 on why a line count failed as a metric). | `30_ticks.ts` | 224 | CPU-side tick generation in f64 for linear, log, category and time axes, plus every axis/colorbar label formatter (automatic and `format=`-driven). Specified in §6. | | `40_gl.ts` | 829 | WebGL2 primitives: shader compile/link, `makeProgram` with its per-program uniform-location memo (R1), the fixed `ATTR_SLOTS` attribute-slot table bound at link time, and the shader inventory itself. The only module that is GPU-API-specific by design (§4). | | `45_lod.ts` | 567 | View-dependent level-of-detail orchestration, deliberately chart-agnostic: tier selection, drill enter/exit hysteresis (`LOD_DRILL_EXIT_FACTOR`), cross-tier fades, and the retained tier caches. Calls back into `view._draw*` rather than drawing itself, which is the seam tests intercept. | -| `46_worker.ts` | 59 | The standalone density re-bin worker: a worker source string carried inside the bundle and booted from a Blob URL. Re-bins the retained sample off the main thread so kernel-less (`to_html`) density charts refine on zoom instead of stretching the overview texture; absence of workers falls back to stretching. | +| `46_worker.ts` | 103 | The standalone density re-bin worker: a worker source string carried inside the bundle and booted from a Blob URL. Re-bins the retained sample off the main thread — counts plus, for channel-bearing traces, the per-cell mean point color (same linear-light law as the kernel, LOD doc §2) — so kernel-less (`to_html`) density charts refine on zoom instead of stretching the overview texture; absence of workers falls back to stretching. | | `50_chartview.ts` | 4175 | The `ChartView` class: the four drawing surfaces, scale/view state, chrome (background, grid, axes, legend, colorbar), GL buffer and VAO management (R2), and pick orchestration. Modules 51–54 extend this same class. | | `51_annotations.ts` | 591 | The 2D overlay canvas above the marks canvas: annotation markers, arrows, shape fills, and collision-nudged labels. Separates canvas shape style keys from label CSS so annotation styling never leaks into the DOM label. | | `52_tooltip.ts` | 321 | Hit → source row → tooltip DOM. Anchors the tooltip at the picked point's data coordinates and reprojects it every draw ([interaction.md](../api/interaction.md) §7). Renders the local f32-decoded row immediately, then replaces it with the kernel's exact f64 row when that reply arrives (sequence- and `drill_seq`-guarded); composes text nodes, never HTML. | diff --git a/spec/design/rust-engine.md b/spec/design/rust-engine.md index a8a0f394..b8a2e3b5 100644 --- a/spec/design/rust-engine.md +++ b/spec/design/rust-engine.md @@ -25,7 +25,7 @@ clear ImportError when it can't load, with no pure-Python fallback. | Concern | Today | Verdict | |---|---|---| -| zone maps, encode_f32, m4, bin_2d, min_max, histogram_uniform, normalize_f32, range/validity indices, local_log_density | Rust (ABI v36) | correct — new equal-length x/y columns use a paired zone-map call with bit-identical per-column reductions; full-domain density first paint fuses binning with uniform or counted-u8 overlay sampling while retaining exact standalone outputs; mesh/rectangle validity scans consume only columns not already proven finite by zone metadata | +| zone maps, encode_f32, m4, bin_2d, bin_2d_mean_color, min_max, histogram_uniform, normalize_f32, range/validity indices, local_log_density | Rust (ABI v40) | correct — new equal-length x/y columns use a paired zone-map call with bit-identical per-column reductions; full-domain density first paint fuses binning with uniform or counted-u8 overlay sampling while retaining exact standalone outputs; mean-color binning (LOD doc §2) is an integer-only pipeline (checked-in sRGB⇄linear-u16 tables, alpha-weighted u64 sums) so grids are bitwise deterministic across thread counts and platforms; mesh/rectangle validity scans consume only columns not already proven finite by zone metadata | | fixed-width string/bytes/bool factorization | Rust (ABI v36) | correct — compact palettes use a bounded L1-resident codebook with full-record collision checks and emit exact counts; U1 uses a direct Unicode-scalar table with endian support; ≥512k rows probe a prefix then encode disjoint chunks in parallel, merging late labels by canonical first-row order before any retry; Python sees only unique labels and retains display-label ordering policy | | static display-list raster, row-banded polyline/point/segment paint, batched fill+stroke triangle meshes, affine scatter projection plus typed color/size resolution, density/heatmap colormap and sampling | Rust (ABI v36) | correct — commands borrow f32/u8 payload or canonical spans synchronously; compact stratified sampling reuses factorization counts; batched/banded output is byte-identical | | signal processing: `xy_rfft`, `xy_welch_spectra`, `xy_spectrogram` | Rust (ABI v36) | correct — O(N) transforms over sample columns; window/segment policy stays in Python | @@ -101,8 +101,11 @@ src/ # 15,423 lines shipped, 8 modules # rest of SVG scene construction stays in Python. simd.rs (448) # AVX2 twins of eligible kernels, runtime-dispatched (§3.4). # The one module besides lib.rs allowed `unsafe`. - tiles.rs (481) # pyramid build/compose/incremental append. Owns tile memory; - # handles are opaque u64 ids passed over the ABI (§3.3). + tiles.rs (1050) # pyramid build/compose/incremental append, plus the + # mean-color planes for channel-bearing traces + # (build_color/compose_color, LOD doc §2/§4.1; colored + # pyramids refuse appends and rebuild lazily). Owns tile + # memory; handles are opaque u64 ids over the ABI (§3.3). stream.rs # (plan) Rust-owned canonical append buffers. stats.rs # (plan) quantiles/box/violin/factorize. ``` @@ -213,18 +216,35 @@ Arrays-in/arrays-out stops working when Rust must own long-lived state uint64_t xy_pyramid_build(const double* x, const double* y, size_t len, double x0, double x1, double y0, double y1, uint32_t base_dim); -/* append: 1 applied; 0 on stale/busy handle, bad args, or a point outside - the pyramid's original domain (never partially mutates) */ +/* build with mean-color planes (LOD doc §2). The color source is exactly + one of idx (per-point LUT index, with lut as 1..=256 RGBA8 rows) or + rgba (per-point straight-alpha RGBA8); the other pointer is NULL */ +uint64_t xy_pyramid_build_color(const double* x, const double* y, size_t len, + const uint8_t* idx, const uint8_t* rgba, + const uint8_t* lut, size_t lut_len, + double x0, double x1, double y0, double y1, + uint32_t base_dim); +/* append: 1 applied; 0 on stale/busy handle, bad args, a point outside + the pyramid's original domain, or a colored pyramid (its colors are + unknown to this entry point — caller invalidates and rebuilds lazily). + Never partially mutates. */ int32_t xy_pyramid_append(uint64_t handle, const double* x, const double* y, size_t len); /* count over a window: 1 ok, 0 on stale handle/bad args */ int32_t xy_pyramid_count(uint64_t handle, double lo_x, double hi_x, double lo_y, double hi_y, double* out_count); /* compose window into a w×h grid: level used (>=0), -1 stale/bad args, - -2 window outresolves the pyramid (caller re-bins exactly and discloses) */ + -2 window outresolves the pyramid past max_upsample (caller re-bins + exactly and discloses) */ int32_t xy_pyramid_compose(uint64_t handle, double lo_x, double hi_x, - double lo_y, double hi_y, - size_t w, size_t h, float* out); + double lo_y, double hi_y, size_t w, size_t h, + size_t max_upsample, float* out); +/* compose plus the mean-color plane: counts bit-identical to + xy_pyramid_compose; -2 also for a pyramid built without color planes */ +int32_t xy_pyramid_compose_color(uint64_t handle, double lo_x, double hi_x, + double lo_y, double hi_y, size_t w, size_t h, + size_t max_upsample, + float* out, uint8_t* out_rgba); /* free: 1 if it existed, 0 for stale/unknown */ int32_t xy_pyramid_free(uint64_t handle); ``` diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index b7171e7a..fd2a4c1e 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -121,28 +121,39 @@ message unless `msg.seq === this.seq`. states which representation this view resolved to: - `mode: "density"` — `{id, mode, tier, visible, reduction, binning, density}`. - `density` is `{buf, w, h, max, enc: "log-u8", x_range, y_range}` plus - optional `color` (a constant-channel color) and `sample` (the retained - point-sample overlay). `binning` is `"exact"` or `"pyramid-L"`. - `x_range`/`y_range` are raw data endpoints, but the grid's cells are - **uniform in the axis's scale coordinates** (identical to raw on a linear - axis): on a log/symlog axis the kernel bins transformed values so every - cell covers the same strip of screen, and renderers interpolate cell edges - between the *transformed* endpoints (dossier §28). The raw-space tile - pyramid cannot compose such a grid, so nonlinear-axis traces always report - `binning: "exact"`. + `density` is `{buf, w, h, max, enc: "log-u8", x_range, y_range}` plus, for + channel-bearing traces, `rgba` (a `w*h*4` straight-alpha RGBA8 plane: each + occupied cell's alpha-weighted **mean point color**, averaged in linear + light, plus the cell's mean point alpha) with `color_agg: "mean"` recording + the aggregation (LOD doc §2); constant-color traces ship `color` (the + constant) instead and no color plane — the mean of a constant IS the + constant, so the client tints the count texture. Count always rides `buf` + as log-u8 and drives only the drawn **alpha**; renderers must never + colormap counts when either color source is present. Optional `sample` is + the retained point-sample overlay. `binning` is `"exact"` or + `"pyramid-L"`. `x_range`/`y_range` are raw data endpoints, but the + grid's cells are **uniform in the axis's scale coordinates** (identical to + raw on a linear axis): on a log/symlog axis the kernel bins transformed + values so every cell covers the same strip of screen, and renderers + interpolate cell edges between the *transformed* endpoints (dossier §28). + The raw-space tile pyramid cannot compose such a grid, so nonlinear-axis + traces always report `binning: "exact"`. - `mode: "points"` — the deep-zoom drill: `{id, mode, tier: "direct", visible, reduction: "none", x_range, y_range, - x, y, color, size, density_val, lod_blend, density_colormap, drill_seq, - style}`. `x_range`/`y_range` are the window these points cover; the client - falls back to the density overview the moment the view leaves it. + x, y, color, size, density_val, lod_blend, drill_seq, style}`. + `x_range`/`y_range` are the window these points cover; the client falls + back to the density overview the moment the view leaves it. `density_val` + (per-point local log-density) and `lod_blend` drive the intensity-only + handoff: hue is continuous by construction because the aggregate surface + wears the mean point color, so no `density_colormap` field rides this + message (clients ignore one if present). Channel encodings on this (and the sampled-overlay) live wire: `x`/`y` are offset-encoded f32 (position precision is load-bearing, dossier §16), but - every unit-scalar channel — continuous `color`, continuous `size`, - `density_val` — ships as **u8 LUT coordinates** with a `dtype: "u8"` - marker (absent means f32; the client accepts both), and categorical color - ships u8 codes. The quantization is safe precisely because these values are + every unit-scalar channel — continuous `color` and `size` (LUT/ramp + coordinates), `density_val` (the handoff weight) — ships quantized to + **u8** with a `dtype: "u8"` marker (absent means f32; the client accepts + both), and categorical color ships u8 codes. The quantization is safe precisely because these values are never read back into displayed numbers on a live path: hover/pick answers come from the kernel's canonical columns (`pick_result` above). The *build* payload keeps continuous channels as unit f32 — the client retains those diff --git a/src/kernels.rs b/src/kernels.rs index bf7e5648..aeaabdb1 100644 --- a/src/kernels.rs +++ b/src/kernels.rs @@ -3314,6 +3314,267 @@ fn bin_2d_impl( }); } +/// sRGB byte → linear-light u16 (IEC 61966-2-1, scaled to 0..=65535, +/// round-half-even). A checked-in literal keeps the mean-color pipeline +/// below integer-only end to end (no runtime `powf`/libm dependency), so it +/// is bitwise deterministic for any thread count and across platforms. +/// Strictly increasing, so the inverse is an exact search. +pub(crate) const SRGB_TO_LINEAR_U16: [u16; 256] = [ + 0, 20, 40, 60, 80, 99, 119, 139, + 159, 179, 199, 219, 241, 264, 288, 313, + 340, 367, 396, 427, 458, 491, 526, 562, + 599, 637, 677, 718, 761, 805, 851, 898, + 947, 997, 1048, 1101, 1156, 1212, 1270, 1330, + 1391, 1453, 1517, 1583, 1651, 1720, 1790, 1863, + 1937, 2013, 2090, 2170, 2250, 2333, 2418, 2504, + 2592, 2681, 2773, 2866, 2961, 3058, 3157, 3258, + 3360, 3464, 3570, 3678, 3788, 3900, 4014, 4129, + 4247, 4366, 4488, 4611, 4736, 4864, 4993, 5124, + 5257, 5392, 5530, 5669, 5810, 5953, 6099, 6246, + 6395, 6547, 6700, 6856, 7014, 7174, 7335, 7500, + 7666, 7834, 8004, 8177, 8352, 8528, 8708, 8889, + 9072, 9258, 9445, 9635, 9828, 10022, 10219, 10417, + 10619, 10822, 11028, 11235, 11446, 11658, 11873, 12090, + 12309, 12530, 12754, 12980, 13209, 13440, 13673, 13909, + 14146, 14387, 14629, 14874, 15122, 15371, 15623, 15878, + 16135, 16394, 16656, 16920, 17187, 17456, 17727, 18001, + 18277, 18556, 18837, 19121, 19407, 19696, 19987, 20281, + 20577, 20876, 21177, 21481, 21787, 22096, 22407, 22721, + 23038, 23357, 23678, 24002, 24329, 24658, 24990, 25325, + 25662, 26001, 26344, 26688, 27036, 27386, 27739, 28094, + 28452, 28813, 29176, 29542, 29911, 30282, 30656, 31033, + 31412, 31794, 32179, 32567, 32957, 33350, 33745, 34143, + 34544, 34948, 35355, 35764, 36176, 36591, 37008, 37429, + 37852, 38278, 38706, 39138, 39572, 40009, 40449, 40891, + 41337, 41785, 42236, 42690, 43147, 43606, 44069, 44534, + 45002, 45473, 45947, 46423, 46903, 47385, 47871, 48359, + 48850, 49344, 49841, 50341, 50844, 51349, 51858, 52369, + 52884, 53401, 53921, 54445, 54971, 55500, 56032, 56567, + 57105, 57646, 58190, 58737, 59287, 59840, 60396, 60955, + 61517, 62082, 62650, 63221, 63795, 64372, 64952, 65535, +]; + +/// Nearest sRGB byte for a linear-light u16 — the exact inverse of the table +/// above (integer search over a strictly increasing sequence; ties round to +/// the darker byte). +pub(crate) fn linear_u16_to_srgb_u8(linear: u16) -> u8 { + let above = SRGB_TO_LINEAR_U16.partition_point(|&v| v <= linear); + if above == 0 { + return 0; // unreachable: table[0] == 0 ≤ every u16 + } + if above >= 256 { + return 255; + } + let below_value = SRGB_TO_LINEAR_U16[above - 1]; + let above_value = SRGB_TO_LINEAR_U16[above]; + if linear - below_value <= above_value - linear { + (above - 1) as u8 + } else { + above as u8 + } +} + +/// Per-point straight-alpha RGBA8 source for mean-color binning. +pub enum BinColorSource<'a> { + /// One LUT index per point plus an RGBA8 palette/colormap table + /// (1..=256 entries). Indices wrap modulo the table length — the + /// palette's own repeat rule, so out-of-palette categorical codes bin + /// with exactly the color they draw with. + Indexed { idx: &'a [u8], lut: &'a [[u8; 4]] }, + /// Straight-alpha RGBA8, 4 bytes per point (the `direct_rgba` channel). + Rgba(&'a [u8]), +} + +/// One cell of the mean-color accumulator: exact integer sums, so the +/// parallel merge is order-independent (bitwise deterministic for any thread +/// count, like `bin_2d`). Color sums are alpha-weighted linear-light u16, so +/// a translucent point contributes proportionally and the mean is the +/// physically downsampled color of the cell's points. +#[derive(Clone, Copy, Default)] +pub(crate) struct MeanColorCell { + pub(crate) count: u32, + alpha: u64, + red: u64, + green: u64, + blue: u64, +} + +impl MeanColorCell { + /// Straight-alpha RGBA8: sRGB mean color + mean point alpha (integer + /// rounding, half away from zero — deterministic). + fn rgba8(&self) -> [u8; 4] { + if self.count == 0 || self.alpha == 0 { + return [0, 0, 0, 0]; + } + let mean = |sum: u64| ((sum + self.alpha / 2) / self.alpha).min(u16::MAX as u64) as u16; + let alpha = + ((self.alpha + u64::from(self.count) / 2) / u64::from(self.count)).min(255) as u8; + [ + linear_u16_to_srgb_u8(mean(self.red)), + linear_u16_to_srgb_u8(mean(self.green)), + linear_u16_to_srgb_u8(mean(self.blue)), + alpha, + ] + } + + /// Storage form for pyramid color planes: linear-light u16 mean color + /// plus the mean straight alpha rescaled to 0..=65535. + pub(crate) fn mean_u16x4(&self) -> [u16; 4] { + if self.count == 0 || self.alpha == 0 { + return [0, 0, 0, 0]; + } + let mean = |sum: u64| ((sum + self.alpha / 2) / self.alpha).min(u16::MAX as u64) as u16; + let alpha = ((self.alpha * 257 + u64::from(self.count) / 2) / u64::from(self.count)) + .min(u16::MAX as u64) as u16; + [mean(self.red), mean(self.green), mean(self.blue), alpha] + } +} + +/// Fused count+color accumulation over a full grid: the shared core of +/// `bin_2d_mean_color` and the pyramid build's base pass +/// (`tiles::build_color`). Returns the raw accumulator cells so callers keep +/// exact counts alongside the color means. +/// +/// Fan-out is gated by the points-per-cell ratio (like `bin_2d`) and capped +/// at 4: each worker's private accumulator is ~10× a count grid (40 B/cell), +/// so the cap bounds the transient to ~4 grids while still cutting the +/// pyramid's 100M-row base scan to a quarter. Integer sums merge +/// order-independently — bitwise deterministic for any thread count. +#[allow(clippy::too_many_arguments)] +pub(crate) fn bin_2d_mean_color_cells( + x: &[f64], + y: &[f64], + colors: &BinColorSource<'_>, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, +) -> Vec { + let n = x.len(); + let cells = w * h; + let threads = bin_2d_threads(n, cells).min(4); + if threads <= 1 || n < threads { + let mut grid = vec![MeanColorCell::default(); cells]; + bin_2d_mean_color_accumulate(x, y, colors, 0, x0, x1, y0, y1, w, h, &mut grid); + return grid; + } + let chunk = n.div_ceil(threads); + let grids: Vec> = std::thread::scope(|s| { + let handles: Vec<_> = (0..threads) + .map(|t| { + let lo = (t * chunk).min(n); + let hi = ((t + 1) * chunk).min(n); + let (xs, ys) = (&x[lo..hi], &y[lo..hi]); + s.spawn(move || { + let mut grid = vec![MeanColorCell::default(); cells]; + bin_2d_mean_color_accumulate(xs, ys, colors, lo, x0, x1, y0, y1, w, h, &mut grid); + grid + }) + }) + .collect(); + handles + .into_iter() + .map(|hd| hd.join().expect("bin_2d_mean_color worker panicked")) + .collect() + }); + let mut grid = vec![MeanColorCell::default(); cells]; + for part in &grids { + for (acc, cell) in grid.iter_mut().zip(part) { + acc.count = acc.count.saturating_add(cell.count); + acc.alpha += cell.alpha; + acc.red += cell.red; + acc.green += cell.green; + acc.blue += cell.blue; + } + } + grid +} + +/// Mean-color companion to `bin_2d` (§5 Tier 2, LOD doc §2): average the +/// *resolved point colors* of each cell so the density surface wears the data +/// set's own colors while count drives only the alpha channel. `out` is +/// `w*h*4` straight-alpha RGBA8, row 0 = bottom (same orientation as +/// `bin_2d`): rgb = alpha-weighted mean point color (averaged in linear +/// light, quantized back to sRGB), a = plain mean of the points' straight +/// alpha. Empty cells are fully zeroed; a cell whose points are all alpha-0 +/// keeps rgb 0 too (its display alpha is 0, so no color is ever invented). +/// In-window/NaN predicates and cell indexing are bit-identical to +/// `bin_2d_count_scalar`, so occupied cells match the count grid exactly. +#[allow(clippy::too_many_arguments)] // window (x0,x1,y0,y1) + grid (w,h) + io is irreducible +pub fn bin_2d_mean_color( + x: &[f64], + y: &[f64], + colors: &BinColorSource<'_>, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, + out: &mut [u8], +) { + assert_eq!(x.len(), y.len()); + assert_eq!(out.len(), w * h * 4); + assert!(w > 0 && h > 0 && x1_gt_x0(x0, x1) && x1_gt_x0(y0, y1)); + match colors { + BinColorSource::Indexed { idx, lut } => { + assert_eq!(idx.len(), x.len()); + assert!(!lut.is_empty() && lut.len() <= 256); + } + BinColorSource::Rgba(rgba) => assert_eq!(rgba.len(), x.len() * 4), + } + let grid = bin_2d_mean_color_cells(x, y, colors, x0, x1, y0, y1, w, h); + for (cell, quad) in grid.iter().zip(out.chunks_exact_mut(4)) { + quad.copy_from_slice(&cell.rgba8()); + } +} + +/// Shared scan used by the serial and parallel paths (per-point behavior is +/// identical by construction). `base` offsets the row index into the color +/// source so parallel chunks read their own colors. +#[allow(clippy::too_many_arguments)] +fn bin_2d_mean_color_accumulate( + x: &[f64], + y: &[f64], + colors: &BinColorSource<'_>, + base: usize, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, + grid: &mut [MeanColorCell], +) { + let sx = w as f64 / (x1 - x0); + let sy = h as f64 / (y1 - y0); + for i in 0..x.len() { + let xv = x[i]; + let yv = y[i]; + if !xv.is_finite() || !yv.is_finite() || xv < x0 || xv >= x1 || yv < y0 || yv >= y1 { + continue; + } + let cx = (((xv - x0) * sx) as usize).min(w - 1); + let cy = (((yv - y0) * sy) as usize).min(h - 1); + let [r, g, b, a] = match colors { + BinColorSource::Indexed { idx, lut } => lut[idx[base + i] as usize % lut.len()], + BinColorSource::Rgba(rgba) => { + let at = (base + i) * 4; + [rgba[at], rgba[at + 1], rgba[at + 2], rgba[at + 3]] + } + }; + let cell = &mut grid[cy * w + cx]; + let weight = u64::from(a); + cell.count = cell.count.saturating_add(1); + cell.alpha += weight; + cell.red += weight * u64::from(SRGB_TO_LINEAR_U16[r as usize]); + cell.green += weight * u64::from(SRGB_TO_LINEAR_U16[g as usize]); + cell.blue += weight * u64::from(SRGB_TO_LINEAR_U16[b as usize]); + } +} + /// Fused density scan (§5 Tier 2): one pass over `x`/`y` producing BOTH the /// screen-bounded count grid and the ascending in-window row indices, instead /// of `bin_2d` + `range_indices` each re-reading the full columns. The two @@ -5987,6 +6248,174 @@ mod tests { assert_eq!(par_threads_for(PAR_THRESHOLD, 1), 1); } + #[test] + fn srgb_linear_table_roundtrips_every_byte() { + for (byte, &linear) in SRGB_TO_LINEAR_U16.iter().enumerate() { + assert_eq!( + linear_u16_to_srgb_u8(linear), + byte as u8, + "table entry {byte} must invert exactly" + ); + } + assert_eq!(linear_u16_to_srgb_u8(0), 0); + assert_eq!(linear_u16_to_srgb_u8(u16::MAX), 255); + assert!( + SRGB_TO_LINEAR_U16.windows(2).all(|p| p[1] > p[0]), + "strictly increasing — the inverse search depends on it" + ); + } + + const MC_RED_BLUE: [[u8; 4]; 2] = [[255, 0, 0, 255], [0, 0, 255, 255]]; + + #[test] + fn bin_2d_mean_color_places_pure_and_mixed_cells() { + // 2×2 grid over the unit square: bottom-left one red point; + // bottom-right one red + one blue; NaN and out-of-window skipped. + let x = [0.1, 0.6, 0.9, f64::NAN, 2.0]; + let y = [0.1, 0.1, 0.1, 0.5, 0.5]; + let idx = [0u8, 0, 1, 0, 1]; + let colors = BinColorSource::Indexed { + idx: &idx, + lut: &MC_RED_BLUE, + }; + let mut out = vec![0u8; 2 * 2 * 4]; + bin_2d_mean_color(&x, &y, &colors, 0.0, 1.0, 0.0, 1.0, 2, 2, &mut out); + assert_eq!(&out[0..4], &[255, 0, 0, 255], "pure cell keeps exact color"); + // Half red + half blue averaged in linear light: 65535/2 → 32768, + // whose nearest sRGB byte is 188 — brighter than the sRGB-space + // average (128), the physically downsampled mix. + assert_eq!(&out[4..8], &[188, 0, 188, 255]); + assert_eq!(&out[8..16], &[0u8; 8], "empty cells stay fully zero"); + } + + #[test] + fn bin_2d_mean_color_weights_by_alpha() { + // A faint red (alpha 51 = 20%) and an opaque blue share a cell: the + // mean must lean blue 5:1, and the cell alpha is the plain mean. + let x = [0.5, 0.5]; + let y = [0.5, 0.5]; + let lut = [[255, 0, 0, 51], [0, 0, 255, 255]]; + let idx = [0u8, 1]; + let colors = BinColorSource::Indexed { + idx: &idx, + lut: &lut, + }; + let mut out = vec![0u8; 4]; + bin_2d_mean_color(&x, &y, &colors, 0.0, 1.0, 0.0, 1.0, 1, 1, &mut out); + let expected_red = linear_u16_to_srgb_u8(((51u64 * 65535 + 153) / 306) as u16); + let expected_blue = linear_u16_to_srgb_u8(((255u64 * 65535 + 153) / 306) as u16); + assert_eq!(out, vec![expected_red, 0, expected_blue, 153]); + assert!(out[2] > out[0], "opaque blue outweighs faint red"); + + // All-invisible cell: count > 0 but zero weight — never invent color. + let ghost_lut = [[255, 0, 0, 0]]; + let ghost_idx = [0u8, 0]; + let ghost = BinColorSource::Indexed { + idx: &ghost_idx, + lut: &ghost_lut, + }; + bin_2d_mean_color(&x, &y, &ghost, 0.0, 1.0, 0.0, 1.0, 1, 1, &mut out); + assert_eq!(out, vec![0, 0, 0, 0]); + } + + #[test] + fn bin_2d_mean_color_rgba_source_and_lut_wrap() { + let x = [0.5, 0.5]; + let y = [0.5, 0.5]; + let rgba: Vec = vec![255, 0, 0, 255, 0, 0, 255, 255]; + let mut direct = vec![0u8; 4]; + bin_2d_mean_color( + &x, + &y, + &BinColorSource::Rgba(&rgba), + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + &mut direct, + ); + assert_eq!(direct, vec![188, 0, 188, 255]); + // Indices wrap modulo the LUT length (the palette repeat rule): code 2 + // over a 2-entry LUT wears entry 0. + let idx = [2u8, 1]; + let mut wrapped = vec![0u8; 4]; + bin_2d_mean_color( + &x, + &y, + &BinColorSource::Indexed { + idx: &idx, + lut: &MC_RED_BLUE, + }, + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + &mut wrapped, + ); + assert_eq!(wrapped, direct); + } + + #[test] + fn bin_2d_mean_color_parallel_matches_serial_oracle() { + // Enough rows to fan out (PAR_THRESHOLD gate) over a small grid; the + // serial cell accumulator is the oracle, so the threaded integer + // merge must reproduce it bit-for-bit. + let n = PAR_THRESHOLD + 4096; + let mut x = Vec::with_capacity(n); + let mut y = Vec::with_capacity(n); + let mut idx = Vec::with_capacity(n); + let mut seed = 0x00C0FFEE_u64; + for _ in 0..n { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + x.push((seed % 1000) as f64 / 10.0); + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + y.push((seed % 1000) as f64 / 10.0); + idx.push((seed % 4) as u8); + } + let lut = [ + [255, 0, 0, 255], + [0, 0, 255, 255], + [0, 200, 80, 128], + [240, 240, 240, 30], + ]; + let colors = BinColorSource::Indexed { + idx: &idx, + lut: &lut, + }; + let (w, h) = (32, 32); + let mut out = vec![0u8; w * h * 4]; + bin_2d_mean_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, w, h, &mut out); + // The oracle accumulates serially by construction (direct call, one + // chunk) — `bin_2d_mean_color_cells` itself fans out at this size. + let mut cells = vec![MeanColorCell::default(); w * h]; + bin_2d_mean_color_accumulate(&x, &y, &colors, 0, 0.0, 100.0, 0.0, 100.0, w, h, &mut cells); + for (cell_index, cell) in cells.iter().enumerate() { + assert_eq!( + &out[cell_index * 4..cell_index * 4 + 4], + &cell.rgba8(), + "cell {cell_index}" + ); + } + // Occupancy must match bin_2d exactly (same predicates, same cells). + let mut counts = vec![0.0f32; w * h]; + bin_2d(&x, &y, 0.0, 100.0, 0.0, 100.0, w, h, &mut counts); + for (cell_index, count) in counts.iter().enumerate() { + assert_eq!( + *count > 0.0, + out[cell_index * 4 + 3] > 0, + "cell {cell_index} occupancy" + ); + } + } + #[test] fn bin_2d_density_hotspot() { // A cluster in one cell should dominate grid_max. diff --git a/src/lib.rs b/src/lib.rs index 0a91e9ed..db6c4985 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,7 +80,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 39; +pub const ABI_VERSION: u32 = 40; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] @@ -1686,6 +1686,90 @@ pub unsafe extern "C" fn xy_bin_2d_f32( }) } +/// Marshal the C-side color source for mean-color binning: exactly one of +/// `idx` (one LUT index per point, with `lut`/`lut_len` as 1..=256 RGBA8 +/// entries) or `rgba` (straight-alpha RGBA8, 4 bytes per point) must be +/// non-null. Returns `None` for any other shape. +/// +/// # Safety +/// Non-null pointers must address the documented lengths for `len` points. +unsafe fn color_source_from_raw<'a>( + len: usize, + idx: *const u8, + rgba: *const u8, + lut: *const u8, + lut_len: usize, +) -> Option> { + match (idx.is_null(), rgba.is_null()) { + (false, true) => { + if lut.is_null() || lut_len == 0 || lut_len > 256 { + return None; + } + Some(kernels::BinColorSource::Indexed { + idx: std::slice::from_raw_parts(idx, len), + lut: std::slice::from_raw_parts(lut as *const [u8; 4], lut_len), + }) + } + (true, false) => Some(kernels::BinColorSource::Rgba(std::slice::from_raw_parts( + rgba, + len * 4, + ))), + _ => None, + } +} + +/// Mean-color companion grid to `xy_bin_2d` (§5 Tier 2, LOD doc §2): fill +/// `out` (`w*h*4` straight-alpha RGBA8, row 0 = bottom) with each cell's +/// alpha-weighted mean point color (linear-light average, sRGB bytes out) +/// and mean point alpha. Cell membership is bit-identical to `xy_bin_2d`. +/// Returns 1 on success, 0 on invalid arguments. +/// +/// # Safety +/// `x`/`y` must point to `len` readable f64s; the color source pointers must +/// satisfy `color_source_from_raw`; `out` must address `w*h*4` writable bytes. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn xy_bin_2d_mean_color( + x: *const f64, + y: *const f64, + len: usize, + idx: *const u8, + rgba: *const u8, + lut: *const u8, + lut_len: usize, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, + out: *mut u8, +) -> i32 { + if w == 0 || h == 0 || !finite_gt(x0, x1) || !finite_gt(y0, y1) || out.is_null() { + return 0; + } + let Some(grid_len) = w.checked_mul(h).and_then(|n| n.checked_mul(4)) else { + return 0; + }; + let out = std::slice::from_raw_parts_mut(out, grid_len); + if len == 0 { + out.fill(0); + return 1; + } + if x.is_null() || y.is_null() { + return 0; + } + let Some(colors) = color_source_from_raw(len, idx, rgba, lut, lut_len) else { + return 0; + }; + let x = std::slice::from_raw_parts(x, len); + let y = std::slice::from_raw_parts(y, len); + ffi_guard(0, || { + kernels::bin_2d_mean_color(x, y, &colors, x0, x1, y0, y1, w, h, out); + 1 + }) +} + /// Native PNG rasterizer (dossier Phase 3). Paints a Python-built display list /// (`cmd[0..cmd_len]`, see `raster.rs`/`_raster.py`) into a caller-owned /// straight-alpha RGBA8 framebuffer `out` of `w*h*4` bytes. Returns 1 on @@ -2779,6 +2863,46 @@ pub unsafe extern "C" fn xy_pyramid_build( }) } +/// Build a pyramid with mean-color planes (LOD doc §2/§4.1) for a +/// channel-bearing trace. Same handle registry and geometry as +/// `xy_pyramid_build`; color source as in `xy_bin_2d_mean_color`. Returns the +/// handle, or 0 on invalid arguments. +/// +/// # Safety +/// `x`/`y` must point to `len` readable f64s; color source pointers must +/// satisfy `color_source_from_raw`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn xy_pyramid_build_color( + x: *const f64, + y: *const f64, + len: usize, + idx: *const u8, + rgba: *const u8, + lut: *const u8, + lut_len: usize, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + base_dim: u32, +) -> u64 { + if x.is_null() || y.is_null() || len == 0 { + return 0; + } + let Some(colors) = color_source_from_raw(len, idx, rgba, lut, lut_len) else { + return 0; + }; + let x = std::slice::from_raw_parts(x, len); + let y = std::slice::from_raw_parts(y, len); + ffi_guard(0, || { + match tiles::build_color(x, y, &colors, x0, x1, y0, y1, base_dim as usize) { + Some(p) => tiles::reg_insert(p), + None => 0, + } + }) +} + /// Increment a live pyramid from an appended point batch. Returns 1 when the /// update was applied, or 0 for a stale/busy handle, invalid pointers/lengths, /// or a finite point outside the pyramid's original domain. A rejected update @@ -2875,6 +2999,56 @@ pub unsafe extern "C" fn xy_pyramid_compose( }) } +/// `xy_pyramid_compose` plus the mean-color plane: fills `out` with the same +/// f32 counts (bit-identical) and `out_rgba` (`w*h*4`, straight-alpha RGBA8) +/// with the composed mean colors. Returns the level used (>= 0), -1 for bad +/// arguments/stale handle, or -2 when the window outresolves the pyramid OR +/// the pyramid carries no color planes — the caller re-bins exactly either way. +/// +/// # Safety +/// `out` must address `w*h` writable f32s and `out_rgba` `w*h*4` writable bytes. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn xy_pyramid_compose_color( + handle: u64, + lo_x: f64, + hi_x: f64, + lo_y: f64, + hi_y: f64, + w: usize, + h: usize, + max_upsample: usize, + out: *mut f32, + out_rgba: *mut u8, +) -> i32 { + if out.is_null() + || out_rgba.is_null() + || w == 0 + || h == 0 + || !finite_gt(lo_x, hi_x) + || !finite_gt(lo_y, hi_y) + { + return -1; + } + let Some(out_len) = w.checked_mul(h) else { + return -1; + }; + // Zero forgives a caller that forgot the knob; the count-only entry point + // applies the same floor. + let max_upsample = max_upsample.max(1); + let out = std::slice::from_raw_parts_mut(out, out_len); + let out_rgba = std::slice::from_raw_parts_mut(out_rgba, out_len * 4); + ffi_guard(-1, || { + match tiles::reg_with(handle, |p| { + tiles::compose_color(p, lo_x, hi_x, lo_y, hi_y, w, h, max_upsample, out, out_rgba) + }) { + Some(Some(level)) => level as i32, + Some(None) => -2, + None => -1, + } + }) +} + /// Release a pyramid. 1 if it existed, 0 for stale/unknown handles. /// # Safety /// No pointer arguments; safe for any handle value. diff --git a/src/tiles.rs b/src/tiles.rs index 2f4c7649..a0bc61b1 100644 --- a/src/tiles.rs +++ b/src/tiles.rs @@ -28,6 +28,13 @@ use crate::kernels; pub struct Pyramid { /// levels[0] = finest (dim²), levels[k] has dim >> k per side; last is 1². levels: Vec>, + /// Mean-color planes for channel-bearing traces (LOD doc §2): per cell + /// `[r, g, b, a]` — the alpha-weighted mean point color in linear-light + /// u16 plus the mean straight alpha scaled to 0..=65535. Cells store + /// means rather than sums to stay at 8 B/cell; each 4→1 reduction + /// re-rounds once, a ≤ 0.5-lsb-of-u16 error per level (recorded in the + /// LOD doc, §28). `None` for count-only pyramids. + color_levels: Option>>, dims: Vec, x0: f64, x1: f64, @@ -35,6 +42,12 @@ pub struct Pyramid { y1: f64, } +impl Pyramid { + pub fn has_color(&self) -> bool { + self.color_levels.is_some() + } +} + /// The production default upsample bound (callers pass their own via the ABI; /// see `xy_pyramid_compose`). Rendering source cells into an output grid finer /// than 2x reads as blur/blocks, so normal traces refuse past it and re-bin @@ -74,6 +87,79 @@ pub fn build( } Some(Pyramid { levels, + color_levels: None, + dims, + x0, + x1, + y0, + y1, + }) +} + +/// Build a pyramid that also carries mean-color planes, for channel-bearing +/// traces whose density surface wears the mean point color (LOD doc §2). +/// One fused scan accumulates counts and alpha-weighted linear-light color +/// sums together (exact integer sums merged order-independently, so the +/// result is bitwise identical for any fan-out); count levels are identical +/// to `build`'s. The scan fans out only when rows outnumber base cells +/// (points-per-cell gate, capped at 4 workers): each worker's accumulator is +/// 40 B/cell (~170 MB at the 2048² default), released before returning — +/// builds are one-time per trace and lazy. +#[allow(clippy::too_many_arguments)] +pub fn build_color( + x: &[f64], + y: &[f64], + colors: &kernels::BinColorSource<'_>, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + base_dim: usize, +) -> Option { + if x.len() != y.len() || base_dim < 2 || !base_dim.is_power_of_two() { + return None; + } + if !(x0.is_finite() && x1.is_finite() && y0.is_finite() && y1.is_finite() && x1 > x0 && y1 > y0) + { + return None; + } + match colors { + kernels::BinColorSource::Indexed { idx, lut } => { + if idx.len() != x.len() || lut.is_empty() || lut.len() > 256 { + return None; + } + } + kernels::BinColorSource::Rgba(rgba) => { + if rgba.len() != x.len() * 4 { + return None; + } + } + } + let base = kernels::bin_2d_mean_color_cells(x, y, colors, x0, x1, y0, y1, base_dim, base_dim); + let mut counts = Vec::with_capacity(base.len()); + let mut color = Vec::with_capacity(base.len()); + for cell in &base { + counts.push(cell.count); + color.push(cell.mean_u16x4()); + } + drop(base); + let mut levels = vec![counts]; + let mut color_levels = vec![color]; + let mut dims = vec![base_dim]; + let mut dim = base_dim; + while dim > 1 { + let prev = levels.last().expect("at least one level"); + let prev_color = color_levels.last().expect("at least one color level"); + let lvl = reduce_level(prev, dim); + let clvl = reduce_color_level(prev, prev_color, dim); + dim /= 2; + levels.push(lvl); + color_levels.push(clvl); + dims.push(dim); + } + Some(Pyramid { + levels, + color_levels: Some(color_levels), dims, x0, x1, @@ -92,6 +178,13 @@ pub fn append(p: &mut Pyramid, x: &[f64], y: &[f64]) -> bool { if x.len() != y.len() { return false; } + // A colored pyramid cannot be incremented without the batch's colors — + // and an append can also move a continuous channel's domain, silently + // re-coloring every already-binned point. Refuse; the caller invalidates + // and the next density view rebuilds lazily (LOD doc §4.1). + if p.color_levels.is_some() { + return false; + } for (&xv, &yv) in x.iter().zip(y) { if (xv.is_finite() && yv.is_finite()) && (xv < p.x0 || xv >= p.x1 || yv < p.y0 || yv >= p.y1) @@ -141,6 +234,50 @@ fn reduce_level(prev: &[u32], dim: usize) -> Vec { lvl } +/// 4→1 reduction of one color level. Each parent is the exact weighted mean +/// of its children — weight = child count × child mean alpha, the same +/// alpha-weighted average `bin_2d_mean_color` computes over raw points — then +/// re-rounded once to u16 (the ≤ 0.5-lsb-per-level error recorded on the +/// struct). u128 keeps the count×alpha×mean products exact even at saturated +/// child counts. +fn reduce_color_level(prev_counts: &[u32], prev_color: &[[u16; 4]], dim: usize) -> Vec<[u16; 4]> { + let next = dim / 2; + let mut lvl = vec![[0u16; 4]; next * next]; + for cy in 0..next { + for cx in 0..next { + let mut count: u64 = 0; + let mut weight: u128 = 0; + let mut sums = [0u128; 3]; + for (sy, sx) in [ + (2 * cy, 2 * cx), + (2 * cy, 2 * cx + 1), + (2 * cy + 1, 2 * cx), + (2 * cy + 1, 2 * cx + 1), + ] { + let c = u64::from(prev_counts[sy * dim + sx]); + if c == 0 { + continue; + } + let [r, g, b, a] = prev_color[sy * dim + sx]; + let w = u128::from(c) * u128::from(a); + count += c; + weight += w; + sums[0] += w * u128::from(r); + sums[1] += w * u128::from(g); + sums[2] += w * u128::from(b); + } + if count == 0 || weight == 0 { + continue; + } + let mean = |s: u128| ((s + weight / 2) / weight).min(u128::from(u16::MAX)) as u16; + let alpha = + ((weight + u128::from(count) / 2) / u128::from(count)).min(u128::from(u16::MAX)); + lvl[cy * next + cx] = [mean(sums[0]), mean(sums[1]), mean(sums[2]), alpha as u16]; + } + } + lvl +} + /// Cell-index range [lo, hi) of a level whose cell CENTERS fall inside the /// window along one axis. fn center_range(lo: f64, hi: f64, full_lo: f64, full_hi: f64, dim: usize) -> (usize, usize) { @@ -171,6 +308,45 @@ pub fn count(p: &Pyramid, lo_x: f64, hi_x: f64, lo_y: f64, hi_y: f64) -> f64 { total as f64 } +/// Pick the coarsest level that still meets the render resolution without +/// upsampling (fewest cells to walk, no blur). Only when even level 0 +/// cannot meet it, tolerate up to `max_upsample` before refusing — beyond +/// that the exact path must run. Callers over huge/out-of-core columns pass +/// a large `max_upsample` so the finest level is served (progressively +/// blurry) rather than triggering an O(N) rescan of the whole column (§28). +/// Shared by `compose` and `compose_color`, so the two grids of a colored +/// pyramid always come from the same level. +#[allow(clippy::too_many_arguments)] +fn choose_level( + p: &Pyramid, + lo_x: f64, + hi_x: f64, + lo_y: f64, + hi_y: f64, + w: usize, + h: usize, + max_upsample: usize, +) -> Option { + for level in (0..p.levels.len()).rev() { + let dim = p.dims[level]; + let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); + let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); + if cx1 - cx0 >= w && cy1 - cy0 >= h { + return Some(level); + } + } + let dim = p.dims[0]; + let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); + let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); + // Saturating multiply so a huge `max_upsample` can't overflow usize. + if (cx1 - cx0).saturating_mul(max_upsample) >= w + && (cy1 - cy0).saturating_mul(max_upsample) >= h + { + return Some(0); + } + None +} + /// Fill `out` (w×h, row-major, row 0 = bottom, same contract as bin_2d) for /// the window from the coarsest adequate level. Returns the level used, or /// None when even level 0 cannot meet the resolution (window too small). @@ -192,34 +368,7 @@ pub fn compose( if !(hi_x > lo_x && hi_y > lo_y) { return None; } - // Pick the coarsest level that still meets the render resolution without - // upsampling (fewest cells to walk, no blur). Only when even level 0 - // cannot meet it, tolerate up to `max_upsample` before refusing — beyond - // that the exact path must run. Callers over huge/out-of-core columns pass - // a large `max_upsample` so the finest level is served (progressively - // blurry) rather than triggering an O(N) rescan of the whole column (§28). - let mut chosen: Option = None; - for level in (0..p.levels.len()).rev() { - let dim = p.dims[level]; - let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); - let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); - if cx1 - cx0 >= w && cy1 - cy0 >= h { - chosen = Some(level); - break; - } - } - if chosen.is_none() { - let dim = p.dims[0]; - let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); - let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); - // Saturating multiply so a huge `max_upsample` can't overflow usize. - if (cx1 - cx0).saturating_mul(max_upsample) >= w - && (cy1 - cy0).saturating_mul(max_upsample) >= h - { - chosen = Some(0); - } - } - let level = chosen?; + let level = choose_level(p, lo_x, hi_x, lo_y, hi_y, w, h, max_upsample)?; let dim = p.dims[level]; let lvl = &p.levels[level]; for c in out.iter_mut() { @@ -355,6 +504,147 @@ fn axis_weights( v } +/// `compose` plus the mean-color plane: fills the identical f32 count grid +/// (delegates to `compose`, so counts are bit-identical to the count-only +/// entry point by construction) and a `w*h*4` straight-alpha RGBA8 grid whose +/// cells carry the weighted mean of the composed source cells' mean colors — +/// weight = overlap fraction × count × mean alpha, the same alpha-weighted +/// average `bin_2d_mean_color` computes over raw points. Both of `compose`'s +/// regimes apply: downsampling area-weights each source cell across the +/// output bins it overlaps (the color plane splits exactly like the counts, +/// matching the #153 area-weighted compose), and upsampling pulls the source +/// cell under each output pixel. f64 accumulation in a fixed traversal order +/// keeps the result deterministic. Returns `None` for windows the pyramid +/// cannot serve and for pyramids built without color planes; the caller +/// re-bins exactly either way. +#[allow(clippy::too_many_arguments)] +pub fn compose_color( + p: &Pyramid, + lo_x: f64, + hi_x: f64, + lo_y: f64, + hi_y: f64, + w: usize, + h: usize, + max_upsample: usize, + out: &mut [f32], + out_rgba: &mut [u8], +) -> Option { + let color_levels = p.color_levels.as_ref()?; + if out_rgba.len() != w.checked_mul(h)?.checked_mul(4)? { + return None; + } + let level = compose(p, lo_x, hi_x, lo_y, hi_y, w, h, max_upsample, out)?; + out_rgba.fill(0); + let dim = p.dims[level]; + let lvl = &p.levels[level]; + let clvl = &color_levels[level]; + let cell_x = (p.x1 - p.x0) / dim as f64; + let cell_y = (p.y1 - p.y0) / dim as f64; + let sx = w as f64 / (hi_x - lo_x); + let sy = h as f64 / (hi_y - lo_y); + let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); + let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); + let upsampling = (cx1 - cx0) < w || (cy1 - cy0) < h; + if upsampling { + // Pull, exactly like `compose`: each output pixel wears the mean + // color of the single source cell under it. + let inv_cell_x = 1.0 / cell_x; + let inv_cell_y = 1.0 / cell_y; + for (oy, quad_row) in out_rgba.chunks_exact_mut(w * 4).enumerate() { + let ydata = lo_y + (oy as f64 + 0.5) / sy; + let cy = ((ydata - p.y0) * inv_cell_y) as isize; + if cy < 0 || cy as usize >= dim { + continue; + } + let base = cy as usize * dim; + for (ox, quad) in quad_row.chunks_exact_mut(4).enumerate() { + let xdata = lo_x + (ox as f64 + 0.5) / sx; + let cx = ((xdata - p.x0) * inv_cell_x) as isize; + if cx < 0 || cx as usize >= dim { + continue; + } + let cell = base + cx as usize; + if lvl[cell] == 0 { + continue; + } + let [r, g, b, alpha] = clvl[cell]; + quad[0] = kernels::linear_u16_to_srgb_u8(r); + quad[1] = kernels::linear_u16_to_srgb_u8(g); + quad[2] = kernels::linear_u16_to_srgb_u8(b); + quad[3] = ((u32::from(alpha) + 128) / 257).min(255) as u8; + } + } + return Some(level); + } + if cx0 >= cx1 || cy0 >= cy1 { + return Some(level); + } + // Downsample / 1:1 — the same area-weighted splits as the count pass + // (`axis_weights`), accumulating per output bin: `weight_sum` carries + // fraction × count × mean-alpha (the color weights), `count_sum` carries + // fraction × count (the mean-alpha denominator). + let none = u32::MAX; + let xw = axis_weights(cx0, cx1, p.x0, cell_x, lo_x, sx, w); + let yw = axis_weights(cy0, cy1, p.y0, cell_y, lo_y, sy, h); + let mut count_sum = vec![0.0f64; w * h]; + let mut weight_sum = vec![0.0f64; w * h]; + let mut red = vec![0.0f64; w * h]; + let mut green = vec![0.0f64; w * h]; + let mut blue = vec![0.0f64; w * h]; + for (cy, &(by, wpy, nby, wny)) in (cy0..cy1).zip(yw.iter()) { + let row = &lvl[cy * dim + cx0..cy * dim + cx1]; + let crow = &clvl[cy * dim + cx0..cy * dim + cx1]; + let by = by as usize; + for ((&c, &[r, g, b, alpha]), &(bx, wpx, nbx, wnx)) in + row.iter().zip(crow.iter()).zip(xw.iter()) + { + if c == 0 { + continue; + } + let cf = f64::from(c); + let af = f64::from(alpha); + let (rf, gf, bf) = (f64::from(r), f64::from(g), f64::from(b)); + let bx = bx as usize; + let mut splat = |bin: usize, frac: f64| { + let effective = frac * cf; + let weight = effective * af; + count_sum[bin] += effective; + weight_sum[bin] += weight; + red[bin] += weight * rf; + green[bin] += weight * gf; + blue[bin] += weight * bf; + }; + splat(by * w + bx, f64::from(wpx) * f64::from(wpy)); + if nbx != none { + splat(by * w + nbx as usize, f64::from(wnx) * f64::from(wpy)); + } + if nby != none { + let nby = nby as usize; + splat(nby * w + bx, f64::from(wpx) * f64::from(wny)); + if nbx != none { + splat(nby * w + nbx as usize, f64::from(wnx) * f64::from(wny)); + } + } + } + } + for (bin, quad) in out_rgba.chunks_exact_mut(4).enumerate() { + let weight = weight_sum[bin]; + if !(count_sum[bin] > 0.0 && weight > 0.0) { + continue; + } + let mean = |s: f64| (s / weight).round().clamp(0.0, 65535.0) as u16; + // Stored alphas are u16-scaled (×257); the weighted mean over source + // cells comes back on the same scale, so /257 restores the byte. + let alpha_u16 = (weight / count_sum[bin]).round().clamp(0.0, 65535.0); + quad[0] = kernels::linear_u16_to_srgb_u8(mean(red[bin])); + quad[1] = kernels::linear_u16_to_srgb_u8(mean(green[bin])); + quad[2] = kernels::linear_u16_to_srgb_u8(mean(blue[bin])); + quad[3] = ((alpha_u16 as u32 + 128) / 257).min(255) as u8; + } + Some(level) +} + // -- handle registry (engine doc §3.3) --------------------------------------- // Pyramids are stored as `Arc` so lookups can clone the handle out and drop @@ -462,6 +752,114 @@ mod tests { ); } + /// Deterministic two-color source: points left of x=50 wear LUT entry 0, + /// the rest entry 1. + fn split_idx(x: &[f64]) -> Vec { + x.iter().map(|&v| u8::from(v >= 50.0)).collect() + } + + const RED_BLUE: [[u8; 4]; 2] = [[255, 0, 0, 255], [0, 0, 255, 255]]; + + #[test] + fn colored_build_keeps_count_levels_and_rejects_append() { + let (x, y) = cross(5000); + let idx = split_idx(&x); + let colors = kernels::BinColorSource::Indexed { + idx: &idx, + lut: &RED_BLUE, + }; + let plain = build(&x, &y, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + let mut colored = build_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + assert_eq!( + plain.levels, colored.levels, + "color planes must not perturb the count pyramid" + ); + assert!(colored.has_color() && !plain.has_color()); + let before = colored.levels.clone(); + assert!( + !append(&mut colored, &[50.0], &[50.0]), + "colored pyramids refuse increments (colors unknown, domain may shift)" + ); + assert_eq!(colored.levels, before); + } + + #[test] + fn compose_color_counts_match_compose_and_colors_match_kernel() { + let (x, y) = cross(6000); + let idx = split_idx(&x); + let colors = kernels::BinColorSource::Indexed { + idx: &idx, + lut: &RED_BLUE, + }; + let dim = 64; + let p = build_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, dim).unwrap(); + let mut counts = vec![0.0f32; dim * dim]; + let mut rgba = vec![0u8; dim * dim * 4]; + let level = + compose_color(&p, 0.0, 100.0, 0.0, 100.0, dim, dim, MAX_UPSAMPLE, &mut counts, &mut rgba) + .unwrap(); + assert_eq!(level, 0); + let mut plain = vec![0.0f32; dim * dim]; + assert_eq!( + compose(&p, 0.0, 100.0, 0.0, 100.0, dim, dim, MAX_UPSAMPLE, &mut plain), + Some(0) + ); + assert_eq!(counts, plain, "count grid is bit-identical to compose"); + let mut direct = vec![0u8; dim * dim * 4]; + kernels::bin_2d_mean_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, dim, dim, &mut direct); + assert_eq!( + rgba, direct, + "full-window level-0 compose reproduces the direct mean-color grid" + ); + } + + #[test] + fn compose_color_zoomed_out_levels_stay_pure_per_side() { + // All-red left half, all-blue right half: any pyramid level keeps + // each side's cells exactly pure, and mean alpha stays opaque. + let (x, y) = cross(8000); + let idx = split_idx(&x); + let colors = kernels::BinColorSource::Indexed { + idx: &idx, + lut: &RED_BLUE, + }; + let p = build_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + let (w, h) = (8, 8); + let mut counts = vec![0.0f32; w * h]; + let mut rgba = vec![0u8; w * h * 4]; + let level = + compose_color(&p, 0.0, 100.0, 0.0, 100.0, w, h, MAX_UPSAMPLE, &mut counts, &mut rgba) + .unwrap(); + assert!(level > 0, "an 8x8 render must come from a coarser level"); + for cy in 0..h { + for cx in 0..w { + let quad = &rgba[(cy * w + cx) * 4..(cy * w + cx) * 4 + 4]; + if counts[cy * w + cx] <= 0.0 { + assert_eq!(quad, [0, 0, 0, 0]); + continue; + } + let expect = if cx < w / 2 { RED_BLUE[0] } else { RED_BLUE[1] }; + assert_eq!( + quad, expect, + "pure-side cell at ({cx},{cy}) must keep its exact color" + ); + } + } + } + + #[test] + fn compose_color_without_planes_or_bad_shapes_refuses() { + let (x, y) = cross(4000); + let p = build(&x, &y, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + let mut counts = vec![0.0f32; 16]; + let mut rgba = vec![0u8; 64]; + assert_eq!( + compose_color(&p, 0.0, 100.0, 0.0, 100.0, 4, 4, MAX_UPSAMPLE, &mut counts, &mut rgba), + None, + "count-only pyramids refuse color composition" + ); + } + #[test] fn compose_full_window_matches_bin2d_exactly() { let (x, y) = cross(4000); diff --git a/tests/test_declarative_colorbar.py b/tests/test_declarative_colorbar.py index 03502f41..35f20c4c 100644 --- a/tests/test_declarative_colorbar.py +++ b/tests/test_declarative_colorbar.py @@ -102,7 +102,7 @@ def test_noncontinuous_scatter_does_not_invent_a_colorbar(color) -> None: assert "colorbar" not in spec -def test_density_scatter_does_not_label_the_dropped_per_row_color_channel() -> None: +def test_density_scatter_colorbar_labels_the_aggregated_color_channel() -> None: chart = xy.scatter_chart( xy.scatter( [0.0, 1.0, 2.0], @@ -116,9 +116,14 @@ def test_density_scatter_does_not_label_the_dropped_per_row_color_channel() -> N spec, _ = chart.figure().build_payload() + # The aggregated surface wears the channel's own colors — per-cell mean + # point color (LOD doc §2) — so the channel is not dropped and its + # domain⇄colormap legend stays truthful at every tier. assert spec["traces"][0]["tier"] == "density" - assert spec["traces"][0]["density"]["channels_dropped"] is True - assert "colorbar" not in spec + assert spec["traces"][0]["density"]["channels_dropped"] is False + assert spec["traces"][0]["density"]["color_agg"] == "mean" + assert spec["colorbar"]["domain"] == [10.0, 30.0] + assert spec["colorbar"]["colormap"] == "plasma" def test_hexbin_and_contour_colorbars_use_compiled_domains() -> None: diff --git a/tests/test_density_mean_color.py b/tests/test_density_mean_color.py new file mode 100644 index 00000000..75d813c3 --- /dev/null +++ b/tests/test_density_mean_color.py @@ -0,0 +1,326 @@ +"""Mean-color density surface (LOD doc §2): the aggregated view wears the +data's own colors — per-cell alpha-weighted mean of the resolved point +colors, averaged in linear light — while the binned count drives only the +alpha channel. These tests pin the kernel against a NumPy oracle (the LOD +doc's exit criterion), the wire shape across the initial emit / exact +density_view / pyramid paths, and the static exporters' color law. +""" + +from __future__ import annotations + +import numpy as np + +from xy import channels, kernels +from xy._figure import Figure +from xy.config import DEFAULT_PALETTE, PYRAMID_MIN_POINTS, SCATTER_DENSITY_THRESHOLD +from xy.interaction import _decode_log_u8 + +# sRGB <-> linear-light, float oracle (IEC 61966-2-1) — independent of the +# kernel's integer tables so the test checks the law, not the implementation. + + +def _srgb_to_linear(byte: np.ndarray) -> np.ndarray: + c = byte.astype(np.float64) / 255.0 + return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) + + +def _linear_to_srgb_u8(lin: np.ndarray) -> np.ndarray: + lin = np.clip(lin, 0.0, 1.0) + c = np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055) + return np.rint(c * 255.0).astype(np.uint8) + + +def _mean_color_oracle( + x: np.ndarray, + y: np.ndarray, + rgba: np.ndarray, + window: tuple[float, float, float, float], + w: int, + h: int, +) -> np.ndarray: + """NumPy reference for bin_2d_mean_color (straight-alpha RGBA8 out).""" + x0, x1, y0, y1 = window + out = np.zeros((h, w, 4), dtype=np.uint8) + keep = np.isfinite(x) & np.isfinite(y) & (x >= x0) & (x < x1) & (y >= y0) & (y < y1) + cx = np.minimum(((x[keep] - x0) * (w / (x1 - x0))).astype(np.int64), w - 1) + cy = np.minimum(((y[keep] - y0) * (h / (y1 - y0))).astype(np.int64), h - 1) + colors = rgba[keep] + lin = _srgb_to_linear(colors[:, :3]) + alpha = colors[:, 3].astype(np.float64) + for cell in np.unique(cy * w + cx): + rows = cy * w + cx == cell + weight = alpha[rows].sum() + count = int(rows.sum()) + if weight <= 0: + continue + mean_lin = (lin[rows] * alpha[rows, None]).sum(axis=0) / weight + out.reshape(-1, 4)[cell, :3] = _linear_to_srgb_u8(mean_lin) + # Round half up, like the kernel's integer (sum + count/2) / count — + # Python's round() is half-to-even and disagrees on exact halves. + out.reshape(-1, 4)[cell, 3] = min(255, int(np.floor(weight / count + 0.5))) + return out + + +def _decode_truecolor_png(data: bytes) -> tuple[int, int, np.ndarray]: + """Minimal decoder for xy's own truecolor PNGs (color type 6, + filter-0 scanlines, no interlace) — enough to assert exported pixels.""" + import struct + import zlib + + assert data[:8] == b"\x89PNG\r\n\x1a\n" + at = 8 + width = height = None + idat = b"" + while at < len(data): + (length,) = struct.unpack(">I", data[at : at + 4]) + kind = data[at + 4 : at + 8] + chunk = data[at + 8 : at + 8 + length] + at += 12 + length + if kind == b"IHDR": + width, height, depth, color_type, _c, _f, interlace = struct.unpack(">IIBBBBB", chunk) + assert (depth, color_type, interlace) == (8, 6, 0) + elif kind == b"IDAT": + idat += chunk + elif kind == b"IEND": + break + assert width and height + raw = zlib.decompress(idat) + stride = width * 4 + rows = [] + for row in range(height): + offset = row * (stride + 1) + assert raw[offset] == 0, "xy PNGs write filter-0 scanlines" + rows.append(np.frombuffer(raw, dtype=np.uint8, count=stride, offset=offset + 1)) + return width, height, np.stack(rows).reshape(height, width, 4) + + +def _payload_u8(spec, blob, ref) -> np.ndarray: + """Read a u8 column of a packed build_payload blob by column index.""" + meta = spec["columns"][ref] + return np.frombuffer(blob, dtype=np.uint8, count=meta["len"], offset=meta["byte_offset"]) + + +def test_mean_color_kernel_matches_numpy_oracle(): + rng = np.random.default_rng(11) + n = 20_000 + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + rgba = rng.integers(0, 256, size=(n, 4), dtype=np.uint8) + got = kernels.bin_2d_mean_color(x, y, 0.0, 100.0, 0.0, 100.0, 32, 24, rgba=rgba) + want = _mean_color_oracle(x, y, rgba, (0.0, 100.0, 0.0, 100.0), 32, 24) + # Independent float oracle vs the kernel's exact integer pipeline: alphas + # match to the byte; colors to 1 lsb (quantization at different stages). + assert np.array_equal(got[..., 3], want[..., 3]) + lit = want[..., 3] > 0 + diff = np.abs(got.astype(np.int16) - want.astype(np.int16))[lit] + assert diff.max() <= 1 + # Empty cells are fully zero — no invented color. + assert not got[~lit].any() + + +def test_payload_density_ships_mean_colors_for_categorical(): + # Two spatially separated categories: left red-ish cells must wear the + # first palette color exactly, right cells the second — the surface shows + # the data's colors, not a count colormap. + n = SCATTER_DENSITY_THRESHOLD + 10_000 + rng = np.random.default_rng(5) + x = np.concatenate([rng.uniform(0.0, 1.0, n // 2), rng.uniform(9.0, 10.0, n - n // 2)]) + y = rng.uniform(0.0, 1.0, n) + cats = np.where(x < 5.0, "left", "right") + # density=True: channel-bearing traces keep direct draw until the 2M + # ceiling, so force the aggregate view at test-friendly sizes. + fig = Figure().scatter(x, y, color=cats, density=True) + spec, blob = fig.build_payload() + tr = spec["traces"][0] + assert tr["tier"] == "density" + d = tr["density"] + assert d["color_agg"] == "mean" + assert d["channels_dropped"] is False and d["dropped_channels"] == [] + w, h = d["w"], d["h"] + rgba = _payload_u8(spec, blob, d["rgba"]).reshape(h, w, 4) + counts = _decode_log_u8(_payload_u8(spec, blob, d["buf"]).tobytes(), d["max"]).reshape(h, w) + palette = channels.palette_rgba8(DEFAULT_PALETTE, 2) + lit = rgba[..., 3] > 0 + assert lit.any() + assert np.array_equal(lit, counts > 0.5), "occupied cells match the count grid" + # "left" sorts before "right": palette rows 0 / 1. + left = rgba[:, : w // 2][lit[:, : w // 2]] + right = rgba[:, w // 2 :][lit[:, w // 2 :]] + assert (left == palette[0]).all() + assert (right == palette[1]).all() + + +def test_constant_color_density_keeps_count_only_wire(): + n = SCATTER_DENSITY_THRESHOLD + 10_000 + rng = np.random.default_rng(6) + fig = Figure().scatter(rng.uniform(0, 1, n), rng.uniform(0, 1, n), color="#ff0000") + spec, _ = fig.build_payload() + d = spec["traces"][0]["density"] + assert "rgba" not in d and "color_agg" not in d + assert d["color"] == "#ff0000" # client tints; mean of a constant IS the constant + + +def test_density_view_exact_path_ships_mean_colors(): + n = SCATTER_DENSITY_THRESHOLD * 3 + rng = np.random.default_rng(7) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + values = x.copy() # continuous channel correlated with position + fig = Figure().scatter(x, y, color=values, density=True) + upd, bufs = fig.density_view(0, 0.0, 100.0, 0.0, 100.0, 256, 192) + tr = upd["traces"][0] + assert tr["mode"] == "density" and tr["binning"] == "exact" + d = tr["density"] + assert d["color_agg"] == "mean" + rgba = np.frombuffer(bufs[d["rgba"]], dtype=np.uint8).reshape(d["h"], d["w"], 4) + lit = rgba[..., 3] > 0 + assert lit.any() + # Viridis runs dark-purple -> yellow: left columns must be bluer, right + # columns greener/yellower — the surface follows the channel, not count. + left_mean = rgba[:, : d["w"] // 4][lit[:, : d["w"] // 4]].mean(axis=0) + right_mean = rgba[:, 3 * d["w"] // 4 :][lit[:, 3 * d["w"] // 4 :]].mean(axis=0) + assert left_mean[2] > right_mean[2] # blue fades toward the right + assert right_mean[1] > left_mean[1] # green rises toward the right + + +def test_density_view_pyramid_path_ships_mean_colors(): + n = PYRAMID_MIN_POINTS + 50_000 + rng = np.random.default_rng(8) + x = np.concatenate([rng.uniform(0.0, 1.0, n // 2), rng.uniform(9.0, 10.0, n - n // 2)]) + y = rng.uniform(0.0, 1.0, n) + cats = np.where(x < 5.0, "left", "right") + fig = Figure().scatter(x, y, color=cats) + upd, bufs = fig.density_view(0, 0.0, 10.0, 0.0, 1.0, 128, 96) + tr = upd["traces"][0] + assert tr["binning"].startswith("pyramid-L"), "large trace must serve from the pyramid" + d = tr["density"] + assert d["color_agg"] == "mean" + rgba = np.frombuffer(bufs[d["rgba"]], dtype=np.uint8).reshape(d["h"], d["w"], 4) + palette = channels.palette_rgba8(DEFAULT_PALETTE, 2) + lit = rgba[..., 3] > 0 + assert lit.any() + left = rgba[:, : d["w"] // 2][lit[:, : d["w"] // 2]] + right = rgba[:, d["w"] // 2 :][lit[:, d["w"] // 2 :]] + assert (left == palette[0]).all() + assert (right == palette[1]).all() + # The area-weighted compose (#153) spreads a cluster-edge source cell + # across every output bin its extent overlaps, so boundary bins can carry + # a fractional sliver of count — lit in the color plane while the log-u8 + # count plane rounds the sliver to 0 or 1. The planes must still agree on + # where mass exists: any bin the count plane shows nonzero is lit, and an + # unlit bin never shows count. + enc = np.frombuffer(bufs[d["buf"]], dtype=np.uint8).reshape(d["h"], d["w"]) + assert lit[enc > 0].all() + assert (enc[~lit] == 0).all() + + +def test_colored_pyramid_append_invalidates_for_lazy_rebuild(): + n = PYRAMID_MIN_POINTS + 10_000 + rng = np.random.default_rng(9) + x = rng.uniform(0.0, 10.0, n) + y = rng.uniform(0.0, 1.0, n) + fig = Figure().scatter(x, y, color=x.copy()) + fig.density_view(0, 0.0, 10.0, 0.0, 1.0, 128, 96) # builds the colored pyramid + t = fig.traces[0] + assert getattr(t, "_pyr_handle", 0) + assert getattr(t, "_pyr_colored", False) is True + fig.append(0, [5.0], [0.5], color=[5.0]) + # The colored pyramid refuses native increments; the append must have + # invalidated it for a lazy rebuild rather than leaving stale colors. + assert getattr(t, "_pyr_handle", 0) in (None, 0) + + +def test_svg_export_density_uses_mean_colors(): + n = SCATTER_DENSITY_THRESHOLD + 10_000 + rng = np.random.default_rng(10) + x = np.concatenate([rng.uniform(0.0, 1.0, n // 2), rng.uniform(9.0, 10.0, n - n // 2)]) + y = rng.uniform(0.0, 1.0, n) + cats = np.where(x < 5.0, "left", "right") + fig = Figure().scatter(x, y, color=cats, density=True) + svg = fig.to_svg() + assert "data:image/png;base64," in svg + # The categorical palette must color the exported surface: decode the + # embedded density PNG (png_truecolor writes filter-0 scanlines) and + # check the two clusters' hues. + import base64 + import re + + payload = re.search(r"data:image/png;base64,([A-Za-z0-9+/=]+)", svg).group(1) + w, h, img = _decode_truecolor_png(base64.b64decode(payload)) + lit = img[..., 3] > 0 + palette = channels.palette_rgba8(DEFAULT_PALETTE, 2) + left = img[:, : w // 2][lit[:, : w // 2]] + right = img[:, w // 2 :][lit[:, w // 2 :]] + assert left.size and right.size + assert (left[:, :3] == palette[0, :3]).all() + assert (right[:, :3] == palette[1, :3]).all() + + +def test_resolve_bin_colors_modes(): + # constant -> None (count-only grid + client tint) + assert ( + channels.resolve_bin_colors( + channels.ColorChannel(mode="constant", constant="#123456"), None, DEFAULT_PALETTE + ) + is None + ) + # continuous -> 256-texel colormap LUT + quantized indices + cc = channels.resolve_color(np.array([0.0, 0.5, 1.0]), 3, default_constant="#000000") + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None and out["lut"].shape == (256, 4) + assert out["idx"].dtype == np.uint8 and list(out["idx"]) == [0, 128, 255] + # categorical -> palette rows, codes pass through + cc = channels.resolve_color(np.array(["a", "b", "a"]), 3, default_constant="#000000") + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None and out["lut"].shape[0] == 2 + assert list(out["idx"]) == [0, 1, 0] + # direct rgba -> packed straight-alpha bytes + cc = channels.resolve_color(np.array([[1.0, 0.0, 0.0, 0.5]] * 3), 3, default_constant="#000000") + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None and out["rgba"].shape == (3, 4) + assert list(out["rgba"][0]) == [255, 0, 0, 128] + + +def test_mean_color_weights_by_point_alpha(): + # A 20%-alpha red and an opaque blue in one cell: the mean must lean blue, + # and the cell's mean alpha rides the wire so display intensity follows. + x = np.array([0.5, 0.5]) + y = np.array([0.5, 0.5]) + rgba = np.array([[255, 0, 0, 51], [0, 0, 255, 255]], dtype=np.uint8) + grid = kernels.bin_2d_mean_color(x, y, 0.0, 1.0, 0.0, 1.0, 1, 1, rgba=rgba) + cell = grid[0, 0] + assert cell[2] > cell[0] > 0 + assert cell[3] == 153 # mean straight alpha: (51 + 255) / 2 + # An all-invisible cell must not invent color or intensity. + ghost = kernels.bin_2d_mean_color( + x, y, 0.0, 1.0, 0.0, 1.0, 1, 1, rgba=np.array([[255, 0, 0, 0]] * 2, dtype=np.uint8) + ) + assert list(ghost[0, 0]) == [0, 0, 0, 0] + + +def test_mean_color_mixed_cell_averages_in_linear_light(): + # Half red + half blue: linear-light averaging gives a brighter purple + # (188) than naive sRGB byte averaging (128) — the physically downsampled + # color of the cluster (LOD doc §2). + x = np.array([0.5, 0.5]) + y = np.array([0.5, 0.5]) + rgba = np.array([[255, 0, 0, 255], [0, 0, 255, 255]], dtype=np.uint8) + grid = kernels.bin_2d_mean_color(x, y, 0.0, 1.0, 0.0, 1.0, 1, 1, rgba=rgba) + assert list(grid[0, 0]) == [188, 0, 188, 255] + + +def test_wide_categorical_codes_fold_onto_palette(): + # >256 categories ship u32 codes; binned colors must still be exactly the + # palette color each point draws with (repeat rule, modulo palette). + n_cats = 300 + codes = np.arange(n_cats, dtype=np.uint32) + cc = channels.ColorChannel( + mode="categorical", + codes=codes, + categories=[f"c{i}" for i in range(n_cats)], + ) + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None + assert out["lut"].shape[0] == len(DEFAULT_PALETTE) + expected = (codes % len(DEFAULT_PALETTE)).astype(np.uint8) + assert np.array_equal(out["idx"], expected) diff --git a/tests/test_scatter.py b/tests/test_scatter.py index d25cb450..a004e7b3 100644 --- a/tests/test_scatter.py +++ b/tests/test_scatter.py @@ -740,15 +740,17 @@ def test_density_view_drills_to_points_when_window_fits(): row = fig.pick(0, 0) assert row is not None and 0.0 <= row["x"] <= 10.0 assert "color_value" in row - # Color-continuous handoff: per-point local log-density in [0,1], a blend - # weight = visible/budget, and the colormap the density surface uses — - # so freshly drilled points wear the density ramp (§5, never a palette jump). + # Intensity handoff (§5): per-point local log-density (u8, like every + # unit-scalar live channel) and a blend weight = visible/budget. Freshly + # drilled points enter at their cell's count-alpha and ease to native + # opacity; the surface wears the mean point color (LOD doc §2), so no + # density_colormap rides the points wire. assert tr["density_val"]["dtype"] == "u8" dbuf = np.frombuffer(bufs[tr["density_val"]["buf"]], dtype=np.uint8) assert len(dbuf) == inwin - assert dbuf.max() == 255 # the hottest cell hits the ramp top + assert dbuf.max() == 255 # the hottest cell reaches full handoff alpha assert tr["lod_blend"] == pytest.approx(inwin / SCATTER_DENSITY_THRESHOLD) - assert tr["density_colormap"] == "viridis" # continuous channel's colormap + assert "density_colormap" not in tr # Channels are normalized over the *global* domain after slicing (staff # review: slice-first must not change values — colors stay view-stable), # then quantized: every byte within half a step of the exact unit value. @@ -865,8 +867,9 @@ def test_drill_lod_blend_shrinks_as_zoom_deepens(): assert upd_wide["traces"][0]["mode"] == "points" assert upd_deep["traces"][0]["mode"] == "points" assert upd_deep["traces"][0]["lod_blend"] < upd_wide["traces"][0]["lod_blend"] - # constant-color scatter still gets the default density ramp for the handoff - assert upd_deep["traces"][0]["density_colormap"] == ch.DEFAULT_COLORMAP + # the handoff is intensity-only (hue is continuous by construction, LOD + # doc §2), so no colormap rides the points wire + assert "density_colormap" not in upd_deep["traces"][0] def test_density_view_returns_to_density_on_zoom_out(): @@ -905,17 +908,42 @@ def test_drill_hysteresis_holds_points_mode_near_boundary(): assert upd2["traces"][0]["mode"] == "density" # cold entry aggregates -def test_huge_scatter_with_channels_warns_and_drops(): +def test_huge_scatter_with_color_channel_warns_and_aggregates_mean_color(): from xy._figure import DIRECT_SOFT_CEILING n = DIRECT_SOFT_CEILING + 1 x = np.zeros(n) color = np.arange(n, dtype=np.float64) - with pytest.warns(RuntimeWarning, match="dropped"): + # Color survives aggregation as the surface's per-cell mean point color + # (LOD doc §2), and the warning says so. + with pytest.warns(RuntimeWarning, match="mean point color"): fig = Figure().scatter(x, x, color=color) spec, _ = fig.build_payload() - assert spec["traces"][0]["tier"] == "density" - assert spec["traces"][0]["density"]["channels_dropped"] is True + tr = spec["traces"][0] + assert tr["tier"] == "density" + assert tr["density"]["channels_dropped"] is False + assert tr["density"]["dropped_channels"] == [] + assert tr["density"]["color_agg"] == "mean" + assert "rgba" in tr["density"] + + +def test_huge_scatter_with_size_channel_warns_and_drops(): + from xy._figure import DIRECT_SOFT_CEILING + + n = DIRECT_SOFT_CEILING + 1 + x = np.zeros(n) + size = np.ones(n, dtype=np.float64) + size[0] = 2.0 + # Size has no honest per-cell aggregate (LOD doc §2 rule 4): dropped, + # recorded in `dropped_channels`, and warned about. + with pytest.warns(RuntimeWarning, match="dropped channels: size"): + fig = Figure().scatter(x, x, size=size) + spec, _ = fig.build_payload() + tr = spec["traces"][0] + assert tr["tier"] == "density" + assert tr["density"]["channels_dropped"] is True + assert tr["density"]["dropped_channels"] == ["size"] + assert "rgba" not in tr["density"] # -- pick / hover drill ------------------------------------------------------ From a6237479526c45ba67b63fc08c18f5697f2e1b76 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:34 +0000 Subject: [PATCH 2/4] Slim the fastapi live drilldown demo to a thin XYBF transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo predated the kernel pyramid and carried two extra density implementations to stay interactive at 100M points: a 6144^2 integral-image overview server-side and ~350 lines of page JS (browser local re-bins, request parking/single-flight/timeout-retry, per-client staleness maps, pending-status heuristics). Both were count-only, so once density surfaces started wearing the mean point color, mid-zoom windows flipped back to a count-colormapped green wash, flashing old<->new across zoom levels. The engine has since absorbed every job they existed to do: Figure.density_view serves wide windows from the mean-color pyramid in O(visible cells) with drill bookkeeping and recorded binning, and the render client debounces requests, seq-drops stale replies, and keeps the best cached texture drawn while a reply is in flight. The module is now the pattern it demonstrates: build one figure (pyramid warmed at startup), serve payload + bundled client as HTML, and wire ChartView to a comm object that POSTs messages to one endpoint dispatching density_view/pick under a lock. Page JS drops from ~400 lines to ~60 (fetch + status badge), and the HTML route reuses the shared figure instead of regenerating 100M rows per GET. Round-trips ship as XYBF binary frames (wire-protocol §7): the reply message is the frame's compact JSON metadata and each numeric buffer rides raw and 8-byte aligned, so the browser decodes one xy.decodeFrame and hands the kernel zero-copy views with no base64 step on either side (~33% smaller replies on the grids and point buffers that dominate a drill). First paint still embeds its blob as base64 — inline HTML has no binary channel (§6) — and malformed requests still return a JSON error the client rejects on res.ok before decoding. tests/test_drilldown_pan_alignment.py is removed with its subject: it guarded the browser-local re-bin's window-clamp bug, and engine-served grids always report exactly the window they binned, so that failure mode is unreachable. The example test now decodes the XYBF frame and asserts on its message and raw buffers. --- CHANGELOG.md | 8 + examples/fastapi/README.md | 6 +- examples/fastapi/live_drilldown.py | 705 ++++---------------------- tests/test_drilldown_pan_alignment.py | 144 ------ tests/test_example_apps.py | 11 +- 5 files changed, 117 insertions(+), 757 deletions(-) delete mode 100644 tests/test_drilldown_pan_alignment.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d41b9a4b..fad60130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,14 @@ in the README). channel is no longer listed in `dropped_channels` at Tier 2, and a continuous-channel density scatter renders its colorbar again. C ABI v40 (`xy_bin_2d_mean_color` + pyramid color entry points). +- **The FastAPI live-drilldown example is a thin transport over the engine.** + Every window is served through `Figure.density_view` (mean-color pyramid + warmed at startup); the demo's pre-pyramid density machinery — an + integral-image overview server-side and ~350 lines of page JS (local + re-bins, request parking, per-client staleness maps), all count-only — is + gone, and the page JS is a POST transport plus a status badge. Round-trip + replies ship as `XYBF` binary frames (wire-protocol §7) instead of + base64-in-JSON. ### Added - **Export format parity and a unified export API (ENG-10447).** diff --git a/examples/fastapi/README.md b/examples/fastapi/README.md index 5dcbe7e0..e9023568 100644 --- a/examples/fastapi/README.md +++ b/examples/fastapi/README.md @@ -14,7 +14,11 @@ Two integration surfaces: - **Server drilldown tier** — `GET /drilldown` serves a 100M-point scatter whose density surface refines into exact points on zoom, using `POST /api/xy/drilldown` (a Starlette endpoint in - [`live_drilldown.py`](live_drilldown.py)) for the view round-trips. + [`live_drilldown.py`](live_drilldown.py)) for the view round-trips. Each + reply is an `XYBF` binary frame — compact JSON metadata plus raw f32/u8 + buffers — decoded in the browser with the bundled `xy.decodeFrame`, so the + density grids and point buffers that dominate a drill never pay a base64 + encode/decode or its ~33% inflation. ## Run diff --git a/examples/fastapi/live_drilldown.py b/examples/fastapi/live_drilldown.py index 3e9922c9..79860bbd 100644 --- a/examples/fastapi/live_drilldown.py +++ b/examples/fastapi/live_drilldown.py @@ -1,3 +1,26 @@ +"""FastAPI live drilldown: a 100M-point scatter served by the engine's own LOD. + +The pattern this example demonstrates is deliberately small: + +- build one figure (`live_figure`), serve its payload plus the bundled render + client as a single HTML page; +- wire `ChartView` to a custom transport — a `comm` object whose `send` POSTs + the client's messages to one endpoint and feeds each reply — an `XYBF` + binary frame (`spec/design/wire-protocol.md` §7): small JSON metadata plus + raw f32/u8 buffers, decoded in the browser with `xy.decodeFrame` — back + through `onMessage`, with no base64 on either side; +- the endpoint dispatches `density_view` / `pick` straight to the figure, + under one lock. + +The LOD ladder itself lives in the engine: `Figure.density_view` picks the +tier per window — mean-color pyramid composition for wide views, exact +re-bins near the drill budget, exact points inside it (LOD doc §2/§4) — and +the render client debounces its requests, drops stale replies by `seq`, and +keeps the best cached density texture drawn until a fresh one lands (§17 +stale-while-revalidate), so the host app carries no aggregation or request +bookkeeping of its own. +""" + from __future__ import annotations import base64 @@ -5,22 +28,24 @@ import os import threading import warnings -from dataclasses import dataclass from functools import lru_cache from typing import Any, Union import numpy as np from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, Response import xy -# The live drilldown server probes the engine directly (traces, density_view, -# drill bookkeeping), so it works on the internal figure compiled from the -# public composition API via `Chart.figure()`. +# The live drilldown server probes the engine directly (density_view, pick), +# so it works on the internal figure compiled from the public composition API +# via `Chart.figure()`. from xy._figure import Figure -from xy.config import DRILL_EXIT_FACTOR, SCATTER_DENSITY_THRESHOLD -from xy.lod import grid_shape + +# `encode_frame` builds the XYBF binary transport frame (wire-protocol.md §7) +# the browser decodes with the bundled `xy.decodeFrame`; it is re-exported from +# the transport-neutral channel module, the same seam the Reflex adapter uses. +from xy.channel import encode_frame from xy.widget import bundled_js _DEFAULT_LIVE_POINTS = 100_000_000 @@ -52,12 +77,9 @@ def _live_points() -> int: # Point count for the drilldown demo; override with XY_LIVE_POINTS. LIVE_SCATTER_POINTS = _live_points() LIVE_DRILLDOWN_ROUTE = "/api/xy/drilldown" -DENSITY_OVERVIEW_BINS = 6144 -DENSITY_OVERVIEW_CHUNK = 1_000_000 -OVERVIEW_EXACT_FACTOR = 4.0 +# One figure serves every request; density_view mutates its drill bookkeeping, +# so requests (and payload builds) serialize. _FIGURE_LOCK = threading.Lock() -_DENSITY_SEQ_LOCK = threading.Lock() -_LATEST_DENSITY_SEQ: dict[str, int] = {} def _point_label(n: int) -> str: @@ -123,236 +145,55 @@ def colored_scatter_figure( @lru_cache(maxsize=1) def live_figure() -> Figure: - return live_store().figure - - -@dataclass(frozen=True) -class DensityOverview: - integral: np.ndarray - x_range: tuple[float, float] - y_range: tuple[float, float] - width: int - height: int - - @classmethod - def build(cls, fig: Figure, trace_id: int = 0) -> "DensityOverview": - t = fig.traces[trace_id] - x0, x1 = fig.x_range() - y0, y1 = fig.y_range() - w = h = DENSITY_OVERVIEW_BINS - grid = np.zeros((h, w), dtype=np.uint32) - flat_grid = grid.reshape(-1) - x_scale = w / (x1 - x0) - y_scale = h / (y1 - y0) - xv = t.x.values - yv = t.y.values - - for start in range(0, t.n_points, DENSITY_OVERVIEW_CHUNK): - end = min(start + DENSITY_OVERVIEW_CHUNK, t.n_points) - xs = xv[start:end] - ys = yv[start:end] - valid = np.isfinite(xs) & np.isfinite(ys) - valid &= (xs >= x0) & (xs < x1) & (ys >= y0) & (ys < y1) - if not np.any(valid): - continue - ix = ((xs[valid] - x0) * x_scale).astype(np.int64) - iy = ((ys[valid] - y0) * y_scale).astype(np.int64) - np.clip(ix, 0, w - 1, out=ix) - np.clip(iy, 0, h - 1, out=iy) - counts = np.bincount(iy * w + ix) - flat_grid[: len(counts)] += counts.astype(np.uint32, copy=False) - - summed = np.cumsum(grid, axis=0, dtype=np.uint32) - summed = np.cumsum(summed, axis=1, dtype=np.uint32) - integral = np.zeros((h + 1, w + 1), dtype=np.uint32) - integral[1:, 1:] = summed - return cls(integral=integral, x_range=(x0, x1), y_range=(y0, y1), width=w, height=h) - - def _edges( - self, lo: float, hi: float, domain: tuple[float, float], cells: int, bins: int - ) -> np.ndarray: - d0, d1 = domain - span = d1 - d0 - edges = (np.linspace(lo, hi, cells + 1) - d0) * (bins / span) - return np.clip(np.floor(edges).astype(np.int64), 0, bins) - - def _view_bounds(self, x0: float, x1: float, y0: float, y1: float) -> tuple[int, int, int, int]: - bx0, bx1 = self._edges(x0, x1, self.x_range, 1, self.width) - by0, by1 = self._edges(y0, y1, self.y_range, 1, self.height) - return int(bx0), int(bx1), int(by0), int(by1) - - def count(self, x0: float, x1: float, y0: float, y1: float) -> int: - bx0, bx1, by0, by1 = self._view_bounds(x0, x1, y0, y1) - if bx1 <= bx0 or by1 <= by0: - return 0 - ii = self.integral - return int(ii[by1, bx1]) - int(ii[by0, bx1]) - int(ii[by1, bx0]) + int(ii[by0, bx0]) - - def density( - self, - x0: float, - x1: float, - y0: float, - y1: float, - w: int, - h: int, - visible: int, - ) -> np.ndarray | None: - bx0, bx1, by0, by1 = self._view_bounds(x0, x1, y0, y1) - source_w = bx1 - bx0 - source_h = by1 - by0 - if source_w < 16 or source_h < 16: - return None - w, h = grid_shape(w, h, visible) - w = max(16, min(w, source_w)) - h = max(16, min(h, source_h)) - x_edges = self._edges(x0, x1, self.x_range, w, self.width) - y_edges = self._edges(y0, y1, self.y_range, h, self.height) - ii = self.integral - x_lo = x_edges[:-1] - x_hi = x_edges[1:] - y_lo = y_edges[:-1] - y_hi = y_edges[1:] - grid = ( - ii[y_hi[:, None], x_hi[None, :]].astype(np.int64) - - ii[y_lo[:, None], x_hi[None, :]].astype(np.int64) - - ii[y_hi[:, None], x_lo[None, :]].astype(np.int64) - + ii[y_lo[:, None], x_lo[None, :]].astype(np.int64) - ) - return grid.astype(np.float32, copy=False) - - -@dataclass(frozen=True) -class LiveStore: - figure: Figure - overview: DensityOverview - - -@lru_cache(maxsize=1) -def live_store() -> LiveStore: fig = colored_scatter_figure() - return LiveStore(figure=fig, overview=DensityOverview.build(fig)) + # Warm the kernel's mean-color pyramid (LOD doc §4) at startup so the + # first interactive zoom skips the one-time build; the reply itself is + # discarded. + x0, x1 = fig.x_range() + y0, y1 = fig.y_range() + fig.density_view(0, x0, x1, y0, y1, 512, 384) + return fig def _b64(buf: bytes) -> str: return base64.b64encode(buf).decode("ascii") -def _seq_value(raw: Any) -> int | None: - try: - return int(raw) - except (TypeError, ValueError): - return None - - -def _density_client_id(content: dict[str, Any]) -> str: - return str(content.get("client_id") or "default") - - -def _mark_latest_density(content: dict[str, Any]) -> tuple[str, int | None]: - client_id = _density_client_id(content) - seq = _seq_value(content.get("seq")) - if seq is None: - return client_id, None - with _DENSITY_SEQ_LOCK: - latest = _LATEST_DENSITY_SEQ.get(client_id, -1) - if seq > latest: - _LATEST_DENSITY_SEQ[client_id] = seq - return client_id, seq - - -def _density_is_stale(client_id: str, seq: int | None) -> bool: - if seq is None: - return False - with _DENSITY_SEQ_LOCK: - return seq < _LATEST_DENSITY_SEQ.get(client_id, -1) +# Round-trip replies travel as XYBF binary frames (wire-protocol.md §7): the +# reply message is the frame's compact JSON metadata and each numeric buffer +# rides raw and 8-byte aligned, so the browser decodes one `xy.decodeFrame` +# and hands the kernel zero-copy views. First paint still embeds its blob as +# base64 in the page below, because inline HTML has no binary channel +# (wire-protocol.md §6). +_FRAME_MEDIA_TYPE = "application/octet-stream" -def _response(message: dict[str, Any], buffers: list[bytes] | None = None) -> JSONResponse: - return JSONResponse( - { - "message": message, - "buffers": [_b64(buffer) for buffer in (buffers or [])], - } - ) +def _frame_response(message: dict[str, Any], buffers: list[bytes] | None = None) -> Response: + return Response(encode_frame(message, buffers or []), media_type=_FRAME_MEDIA_TYPE) -def _live_density_view( - store: LiveStore, trace_id: int, x0: float, x1: float, y0: float, y1: float, w: int, h: int -) -> tuple[dict[str, Any], list[bytes]]: - fig = store.figure - if trace_id != 0: - return fig.density_view(trace_id, x0, x1, y0, y1, w, h) - t = fig.traces[trace_id] - if not t.use_density(): - return {"traces": []}, [] - - lo_x, hi_x = min(x0, x1), max(x0, x1) - lo_y, hi_y = min(y0, y1), max(y0, y1) - budget = SCATTER_DENSITY_THRESHOLD * (DRILL_EXIT_FACTOR if t.drill_mode else 1.0) - visible = store.overview.count(lo_x, hi_x, lo_y, hi_y) - if visible <= budget * OVERVIEW_EXACT_FACTOR: - return fig.density_view(trace_id, lo_x, hi_x, lo_y, hi_y, w, h) - - grid = store.overview.density(lo_x, hi_x, lo_y, hi_y, w, h, visible) - if grid is None: - return fig.density_view(trace_id, lo_x, hi_x, lo_y, hi_y, w, h) - - if t.drill_mode: - t.drill_seq += 1 - t.drill_mode = False - t.shipped_sel = None - return ( - { - "traces": [ - { - "id": trace_id, - "mode": "density", - "visible": visible, - "density": { - "buf": 0, - "w": int(grid.shape[1]), - "h": int(grid.shape[0]), - "max": float(grid.max()) if grid.size else 0.0, - "x_range": [lo_x, hi_x], - "y_range": [lo_y, hi_y], - }, - } - ] - }, - [grid.reshape(-1).astype(np.float32, copy=False).tobytes()], - ) +async def drilldown_endpoint(request: Request) -> Response: + """Answer the render client's messages with the engine's own replies. - -async def drilldown_endpoint(request: Request) -> JSONResponse: + `Figure.density_view` owns the whole ladder (mean-color pyramid for wide + windows, exact re-bins near the budget, drill-in to real points, + hysteresis, recorded reductions), and the render client discards stale + replies by `seq`, so this endpoint is one lock and one dispatch. Each + reply ships as an XYBF binary frame (`_frame_response`); malformed + requests return a plain JSON error the client rejects on `res.ok` before + decoding. + """ try: content = await request.json() except json.JSONDecodeError: return JSONResponse({"error": "invalid json"}, status_code=400) kind = content.get("type") - density_client_id: str | None = None - density_seq: int | None = None - if kind == "density_view": - density_client_id, density_seq = _mark_latest_density(content) - with _FIGURE_LOCK: - store = live_store() - fig = store.figure + fig = live_figure() if kind == "density_view": try: - if _density_is_stale(density_client_id or "default", density_seq): - return _response( - { - "type": "density_update", - "seq": content.get("seq"), - "trace": content.get("trace"), - "stale": True, - "traces": [], - } - ) - update, buffers = _live_density_view( - store, + update, buffers = fig.density_view( int(content["trace"]), float(content["x0"]), float(content["x1"]), @@ -363,7 +204,7 @@ async def drilldown_endpoint(request: Request) -> JSONResponse: ) except (KeyError, ValueError, IndexError): return JSONResponse({"error": "bad density_view request"}, status_code=400) - return _response( + return _frame_response( { "type": "density_update", "seq": content.get("seq"), @@ -380,17 +221,18 @@ async def drilldown_endpoint(request: Request) -> JSONResponse: int(content.get("index", -1)), None if dseq is None else int(dseq), ) - return _response({"type": "pick_result", "seq": content.get("seq"), "row": row}) + return _frame_response({"type": "pick_result", "seq": content.get("seq"), "row": row}) if kind in {"select", "select_clear"}: - return _response({"type": "selection", "traces": [], "total": 0}) + return _frame_response({"type": "selection", "traces": [], "total": 0}) return JSONResponse({"error": f"unsupported message type {kind!r}"}, status_code=400) def live_drilldown_html() -> str: - fig = colored_scatter_figure() - spec, blob = fig.build_payload() + fig = live_figure() + with _FIGURE_LOCK: + spec, blob = fig.build_payload() spec_js = json.dumps(spec).replace(" str: diff --git a/tests/test_drilldown_pan_alignment.py b/tests/test_drilldown_pan_alignment.py deleted file mode 100644 index ecb0dbc3..00000000 --- a/tests/test_drilldown_pan_alignment.py +++ /dev/null @@ -1,144 +0,0 @@ -"""FastAPI drilldown: a pan past the data must not offset the density surface. - -The drilldown export re-bins its density overview *in the browser* on pan/zoom -(``localDensityUpdate`` in ``examples/fastapi/live_drilldown.py``) from a -fixed-extent integral image. A requested window can reach past the data domain; -the source-bin lookups clamp there, so the grid covers only the on-domain part -of the window. If the update still reports the *requested* window as the grid's -data range, the fixed-extent texture is stretched across the wider window and -the density slides off the point cloud (drilled points and the retained §28 -sample draw at true data coordinates). This drives the real client in headless -Chromium, pans well past the +x/+y data edge, and checks the reported density -range stays within the data domain and its mass stays aligned with the data. -""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path - -import pytest - -from conftest import run_browser_probe -from xy.export import find_chromium - -FASTAPI_DIR = Path(__file__).resolve().parents[1] / "examples" / "fastapi" - -_ANCHOR = "window.xyLiveDrilldown = view;" - -_PROBE = r""" -window.xyLiveDrilldown = view; -(async () => { - try { - view._drawNow(); view._raf = null; - const g = view.gpuTraces.find((t) => t.tier === "density"); - const ov = spec.traces.find((t) => t.kind === "scatter" && t.density).density; - const domX = ov.x_range, domY = ov.y_range; - const v0 = view.view0 || view.view; - const homeRange = { x: g.density.xRange.slice(), y: g.density.yRange.slice() }; - - // Density mass centroid in DATA coordinates, using the reported grid range. - const centroid = (d) => { - const { grid, w, h, xRange, yRange } = d; - let sx = 0, sy = 0, sw = 0; - for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { - const v = grid[y * w + x] || 0; if (v <= 0) continue; - sx += (xRange[0] + (x + 0.5) / w * (xRange[1] - xRange[0])) * v; - sy += (yRange[0] + (y + 0.5) / h * (yRange[1] - yRange[0])) * v; - sw += v; - } - return sw > 0 ? { x: sx / sw, y: sy / sw } : null; - }; - - // Pan well past the +x / +y data edge (span unchanged), so the requested - // window reaches beyond the domain and the source bins clamp. - const sx = v0.x1 - v0.x0, sy = v0.y1 - v0.y0; - const panned = { x0: v0.x0 + sx * 0.4, x1: v0.x1 + sx * 0.4, - y0: v0.y0 + sy * 0.4, y1: v0.y1 + sy * 0.4 }; - view.view = view._viewFrom(panned); - view._scheduleViewRequest(view.view, { delay: 0, seq: ++view.seq }); - await new Promise((r) => setTimeout(r, 60)); - view._drawNow(); view._raf = null; - - const c = centroid(g.density); - document.body.setAttribute("data-drill-pan", JSON.stringify({ - hasDensity: !!g, - domX, domY, - reqHiX: panned.x1, reqHiY: panned.y1, - homeRange, - pannedRange: { x: g.density.xRange.slice(), y: g.density.yRange.slice() }, - centroid: c, - })); - } catch (err) { - document.body.setAttribute("data-drill-pan-error", String((err && err.stack) || err)); - } -})(); -""" - - -def _drilldown_html() -> str: - # Enough points that a panned window still clears the browser-local re-bin - # budget (DIRECT_POINT_BUDGET * 4 = 800k visible); below it the pan falls to - # the server round-trip, which is unavailable under file:// and untested here. - os.environ["XY_LIVE_POINTS"] = "2000000" - sys.path.insert(0, str(FASTAPI_DIR)) - import importlib - - import live_drilldown - - importlib.reload(live_drilldown) - html = live_drilldown.live_drilldown_html() - assert _ANCHOR in html - return html - - -def test_drilldown_pan_past_data_keeps_density_aligned(tmp_path: Path) -> None: - chromium = find_chromium() - if chromium is None: - pytest.skip("Chromium unavailable") - # examples/fastapi/live_drilldown.py imports starlette at module load (the - # callback endpoint), but it is not a core test dependency. Skip cleanly - # where the fastapi example extra is not installed — same convention as - # tests/test_example_apps.py's importorskip for the fastapi app test. - pytest.importorskip("starlette") - - document = _drilldown_html().replace(_ANCHOR, _PROBE, 1) - result = run_browser_probe( - chromium, - document, - tmp_path / "drilldown_pan.html", - "data-drill-pan", - label="drilldown pan alignment probe", - ) - - assert result["hasDensity"] is True - dom_x = result["domX"] - dom_y = result["domY"] - px = result["pannedRange"]["x"] - py = result["pannedRange"]["y"] - - # The browser-local re-bin must have run (range changed from home), proving - # this exercised localDensityUpdate rather than silently no-op'ing. - assert px != result["homeRange"]["x"], "local density re-bin did not run on pan" - - # The reported grid range must stay within the data domain: the overview - # integral only holds data inside the domain, so a range reaching past it - # (toward the requested window edge) is exactly the stretch bug. - span_x = dom_x[1] - dom_x[0] - span_y = dom_y[1] - dom_y[0] - eps_x = span_x * 1e-6 - eps_y = span_y * 1e-6 - assert px[1] <= dom_x[1] + eps_x, (px, dom_x) - assert py[1] <= dom_y[1] + eps_y, (py, dom_y) - # And it was genuinely clamped to the domain, not left at the request. - assert px[1] < result["reqHiX"] - eps_x, (px, result["reqHiX"]) - assert py[1] < result["reqHiY"] - eps_y, (py, result["reqHiY"]) - - # The density mass stays aligned with the data (centered near the origin, - # not dragged toward the requested-but-empty window edge). The pre-fix - # stretch put the centroid past ~1.1 in x; the data centroid here is ~0.2. - c = result["centroid"] - assert c is not None - assert -1.0 < c["x"] < 1.0, c - assert -1.0 < c["y"] < 1.0, c diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index bb14d06e..09108b79 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -127,7 +127,16 @@ def test_fastapi_app_serves_live_charts_and_code() -> None: }, ) assert drill.status_code == 200 - assert "density_update" in drill.text + # The round-trip reply is an XYBF binary frame (no base64), decoded by the + # same seam the browser's xy.decodeFrame uses; density grids ride as raw + # buffers beside the compact JSON metadata. + assert drill.headers["content-type"] == "application/octet-stream" + from xy.channel import decode_frame + + frame = decode_frame(drill.content) + assert frame.message["type"] == "density_update" + assert frame.message["seq"] == 1 + assert frame.buffers # the density grid rides raw, not base64 in JSON # --- Reflex app structure (source text, no reflex import) ------------------- From b2e31c1caf9482ac184fa7b897f3014b06b892bd Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:39 +0000 Subject: [PATCH 3/4] Add the 100M drilldown to examples/reflex, adapter-native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve the fastapi example's live drilldown scatter — same seed-11 chunked columns, same mark config, same XY_LIVE_POINTS override — from the reflex-xy adapter as one inline() token with zero transport code. The fastapi app wires this chart through a custom HTTP transport (a Starlette endpoint plus a comm bridge); the adapter's websocket namespace and the kernel's density tiers replace all of it, so behavior differences between the two hosts isolate what that custom code adds versus what is fundamental to the engine (density tiers, pyramid binning, drill-in). - examples/reflex: §6 section + drilldown_chart()/drilldown_view(), README run notes (import-time build, memory, XY_LIVE_POINTS override) - tests/test_example_apps.py: source markers for §6; the import test pins XY_LIVE_POINTS=50000 (same override the fastapi test uses) and asserts the inline token mints - scripts/reflex_ws_smoke.py: count the seventh live source, wait for the drilldown payload, and pixel-check its density surface - spec: reflex-integration §7 inventory + production-readiness example focus bullet name the deliberate cross-host overlap --- examples/reflex/README.md | 18 ++++ .../reflex/xy_reflex_demo/xy_reflex_demo.py | 96 ++++++++++++++++++- scripts/reflex_ws_smoke.py | 34 +++++-- spec/design/reflex-integration.md | 8 +- spec/process/production-readiness.md | 8 +- tests/test_example_apps.py | 12 ++- 6 files changed, 161 insertions(+), 15 deletions(-) diff --git a/examples/reflex/README.md b/examples/reflex/README.md index f68bfc0b..c63a511b 100644 --- a/examples/reflex/README.md +++ b/examples/reflex/README.md @@ -23,6 +23,14 @@ binary columns; Reflex state holds only a token string per chart. 5. **Fixed data, two ways** — a `xy.Chart` passed straight to `reflex_xy.chart` (static payload tier) and a `reflex_xy.inline` token (fixed data served through the kernel). +6. **The 100M drilldown, adapter-native** — the live drilldown scatter + from [`examples/fastapi`](../fastapi) (identical seed-11 data and mark + config, a density surface that drills into exact points on zoom) as a + single `reflex_xy.inline` token. The FastAPI app hand-rolls its transport + for this chart (a Starlette endpoint plus an HTTP comm bridge); here the + adapter's websocket namespace and the kernel's density tiers do all of it, + so behavioral differences between the two apps isolate what that custom + code adds. ## Run @@ -36,6 +44,16 @@ reflex-xy) into a local environment. Open the URL Reflex prints (usually ). Zoom into the cloud to drill density into exact points; box-select to cross-filter the histogram; press **go live** to stream. +`XY_LIVE_POINTS` sets §6's point count — the same override the FastAPI app +honors, so both apps build the identical dataset at any size. Unlike the +FastAPI app (lazy, on first use) the columns are built at import, because +`inline()` registers at module scope; the default 100M costs a few gigabytes +of RAM and some startup seconds, so dial it down on small machines: + +```bash +XY_LIVE_POINTS=1000000 uv run reflex run +``` + The adapter is wired in one line — `plugins=[reflex_xy.XYPlugin()]` in [`rxconfig.py`](rxconfig.py). diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 91124a26..4913659d 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -1,6 +1,6 @@ """reflex-xy showcase: ways to link chart data into a Reflex app. -One page of five sections; each has a "Code" accordion showing its own source +One page of six sections; each has a "Code" accordion showing its own source via `inspect.getsource`. 1. **Live figure var + events.** A 1M-point drillable scatter from an @@ -17,6 +17,10 @@ 5. **Fixed data, two ways.** A ``xy.Chart`` passed straight to ``reflex_xy.chart`` (static payload tier) and a ``reflex_xy.inline`` token (fixed data served through the kernel). +6. **The drilldown, adapter-native.** The 100M-point live drilldown + scatter from ``examples/fastapi`` — identical data and mark config — as one + ``reflex_xy.inline`` token with zero transport code, for A/B-ing the two + hosts. ``XY_LIVE_POINTS`` resizes it (both apps honor the same override). Run from ``examples/reflex``:: @@ -27,6 +31,8 @@ import asyncio import inspect +import os +import warnings from functools import lru_cache from typing import Any @@ -112,6 +118,79 @@ def orbits_chart() -> xy.Chart: ORBITS_TOKEN = reflex_xy.inline(orbits_chart()) +# --- the live drilldown, adapter-native (§6) -------------------------- + + +def _drilldown_points() -> int: + """Point count for the §6 drilldown chart, from ``XY_LIVE_POINTS`` — the + same override ``examples/fastapi`` honors, so both apps build the identical + dataset at any size.""" + raw = os.environ.get("XY_LIVE_POINTS") + if raw is None: + return 100_000_000 + try: + points = int(raw) + except ValueError: + points = 0 + if points < 1: + warnings.warn( + f"XY_LIVE_POINTS={raw!r} is not a positive integer; using 100,000,000", + RuntimeWarning, + stacklevel=2, + ) + return 100_000_000 + return points + + +DRILLDOWN_POINTS = _drilldown_points() + + +def _point_label(n: int) -> str: + if n % 1_000_000 == 0: + return f"{n // 1_000_000}M" + if n % 1_000 == 0: + return f"{n // 1_000}k" + return f"{n:,}" + + +def drilldown_chart(n: int = DRILLDOWN_POINTS) -> xy.Chart: + """The ``examples/fastapi`` live-drilldown scatter: same seed, same chunked + generation, same mark config. That app wires the chart through its own + HTTP transport (a Starlette endpoint plus a comm bridge); here the + kernel's density tiers answer every pan/zoom over the app websocket.""" + rng = np.random.default_rng(11) + x = np.empty(n, dtype=np.float64) + y = np.empty(n, dtype=np.float64) + color = np.empty(n, dtype=np.float64) + size = np.empty(n, dtype=np.float64) + chunk = 1_000_000 + for start in range(0, n, chunk): + end = min(start + chunk, n) + xs = rng.normal(0, 1.0, end - start) + ys = rng.normal(0, 0.55, end - start) + ys += xs * 0.55 + ss = rng.normal(6, 2.5, end - start) + np.abs(ss, out=ss) + np.clip(ss, 2, 16, out=ss) + x[start:end] = xs + y[start:end] = ys + np.hypot(xs, ys, out=color[start:end]) + size[start:end] = ss + return xy.scatter_chart( + xy.scatter(x, y, color=color, size=size, colormap="viridis", opacity=0.72, density=True), + xy.x_axis(label="feature A"), + xy.y_axis(label="feature B"), + title=f"{_point_label(n)} live drilldown scatter", + width="100%", + height=430, + ) + + +# One shared kernel-backed figure for every viewer, expressed as a single +# inline() token; the registry keeps it process-global. +DRILLDOWN_TOKEN = reflex_xy.inline(drilldown_chart()) + + # --- state ------------------------------------------------------------------ @@ -435,6 +514,11 @@ def fixed_view() -> rx.Component: ) +# §6 wiring — the whole drilldown integration is this one line +def drilldown_view() -> rx.Component: + return reflex_xy.chart(DRILLDOWN_TOKEN, height="430px", id="drilldown") + + def index() -> rx.Component: return rx.container( rx.vstack( @@ -524,6 +608,16 @@ def index() -> rx.Component: fixed_view(), code_accordion(sparkline_chart, orbits_chart, fixed_view), ), + section( + f"6 · The {_point_label(DRILLDOWN_POINTS)} drilldown, adapter-native", + "The live drilldown scatter from examples/fastapi — same data, " + "same mark config — with the adapter replacing that app's custom " + "HTTP transport (Starlette endpoint plus comm bridge). Zoom until " + "the density surface drills into exact points; XY_LIVE_POINTS " + "resizes both apps for side-by-side comparison.", + drilldown_view(), + code_accordion(drilldown_chart, drilldown_view), + ), spacing="5", width="100%", ), diff --git a/scripts/reflex_ws_smoke.py b/scripts/reflex_ws_smoke.py index 2948d0d8..0db393df 100644 --- a/scripts/reflex_ws_smoke.py +++ b/scripts/reflex_ws_smoke.py @@ -6,8 +6,9 @@ 1. ONE physical websocket to the backend carries both the app plane and the chart data plane (socket.io namespace multiplexing) — counted via CDP. -2. All three charts paint real pixels from binary socket payloads (screenshot - evidence; there are no HTTP data endpoints to fall back on). +2. The charts paint real pixels from binary socket payloads (screenshot + evidence; there are no HTTP data endpoints to fall back on) — including + the §6 fastapi-parity drilldown chart's density surface. 3. Deep zoom drills the 1M-point density scatter to exact points (density_view round-trips over the socket, §16), and hovering a drilled point closes the semantic loop: kernel pick -> reflex event -> state @@ -241,9 +242,9 @@ def main() -> None: with ChromiumSession(chromium, gl="software", sandbox=False) as session: probe = Probe(session, args.frontend) - # 1) every chart mounts a view (six live figure vars + one static Chart) + # 1) every chart mounts a view (seven live sources + one static Chart) probe.wait_for( - "window.__xy_views && window.__xy_views.size >= 6", + "window.__xy_views && window.__xy_views.size >= 7", timeout_s=120.0, label="mounted chart views", ) @@ -257,17 +258,32 @@ def main() -> None: failures.append(f"expected exactly 1 backend websocket (shared transport), got {ws}") # 2b) the direct-Chart mount is truly static: it never subscribes. The - # six live sources (five figure vars + one inline() token) each sub. + # seven live sources (five figure vars + two inline() tokens: the + # orbits and the §6 drilldown) each sub. subs = probe.sent_ws_frames('"sub"') print(f"sub frames sent: {len(subs)}") - if len(subs) < 6: - failures.append(f"expected >= 6 sub frames (live sources), got {len(subs)}") + if len(subs) < 7: + failures.append(f"expected >= 7 sub frames (live sources), got {len(subs)}") # 3) pixels: every chart paints ink inside its rect (full-page shot, - # rects in page coordinates so below-the-fold charts count too) + # rects in page coordinates so below-the-fold charts count too). + # The §6 drilldown's first payload bins its full source (100M at + # the default XY_LIVE_POINTS), so wait for its trace explicitly. + probe.wait_for( + "(() => { const v = window.__xy_views.get('drilldown');" + " return !!(v && v.gpuTraces && v.gpuTraces[0]); })()", + timeout_s=120.0, + label="drilldown density payload", + ) time.sleep(1.0) png = probe.screenshot() - checks = (("cloud", 0.02), ("hist", 0.02), ("live", 0.005), ("inline", 0.005)) + checks = ( + ("cloud", 0.02), + ("hist", 0.02), + ("live", 0.005), + ("inline", 0.005), + ("drilldown", 0.02), + ) for chart_id, min_ink in checks: frac = ink_fraction(png, probe.rect(chart_id, page_coords=True), 1.0) print(f"{chart_id}: ink fraction {frac:.2%}") diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index e25a8a29..37e1b4e6 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -574,8 +574,12 @@ python/reflex-xy/ examples/reflex/ (repo root) reflex-xy showcase: figure-var drilldown with hover/click/select events, a slider-driven + cross-filtered histogram, a streaming line, an - on_view_change-computed detail chart, and both - fixed-data tiers (direct Chart + inline() token) + on_view_change-computed detail chart, both + fixed-data tiers (direct Chart + inline() token), + and the fastapi live drilldown served adapter- + natively from an inline() token (same data and + XY_LIVE_POINTS override, zero transport code — + the cross-host A/B for that chart) examples/fastapi/ (repo root) the same charts + a live 100M drilldown served from a plain FastAPI app (no committed HTML) tests/reflex_adapter/ 69 tests: token/registry/var/bridge/payload-asset diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index b9f1605b..ab058709 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -445,8 +445,12 @@ Keep pushing these in low-conflict increments: - Keep the two example apps focused: `examples/reflex` on the reflex-xy integration surfaces (figure vars, events, state-driven and streaming updates, `on_view_change`), and `examples/fastapi` on the framework-neutral - gallery plus the live 100M drilldown. Neither commits static chart HTML, and - both surface their own source via `inspect.getsource`. + gallery plus the live 100M drilldown. The one deliberate overlap is that + drilldown chart itself: `examples/reflex` §6 serves the identical dataset + adapter-natively (an `inline()` token, no transport code) so cross-host + behavior can be A/B'd against fastapi's hand-rolled transport; both honor + `XY_LIVE_POINTS`. Neither commits static chart HTML, and both surface their + own source via `inspect.getsource`. - Add first-class docs for the supported-platform matrix and the clear-error behavior when the native core is unavailable. - Move advisory type checking to a hard gate once the checker and codebase agree diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index 09108b79..df1b7635 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -150,6 +150,11 @@ def test_reflex_app_shows_every_linking_method_and_event() -> None: "reflex_xy.append(", # streaming "reflex_xy.inline(", # inline() token tier "sparkline_chart()", # static Chart tier passed directly + # the FastAPI 100M drilldown, served adapter-natively (§6); both apps + # honor the same point-count override for side-by-side comparison. + "def drilldown_chart", + "reflex_xy.inline(drilldown_chart())", + "XY_LIVE_POINTS", "on_point_hover=", "on_point_click=", "on_select_end=", @@ -178,6 +183,9 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None pytest.importorskip("reflex_xy") # A static chart compiles a payload asset into cwd/assets/xy; keep it in tmp. monkeypatch.chdir(tmp_path) + # The §6 drilldown builds its columns at import; keep the test-time build + # cheap (same override the fastapi app test uses). + monkeypatch.setenv("XY_LIVE_POINTS", "50000") sys.path.insert(0, str(REFLEX_DIR)) module = _load(REFLEX_APP, "xy_reflex_demo_under_test") @@ -186,8 +194,10 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None assert "@reflex_xy.figure" in module._source(module.Demo.cloud) assert "def cloud" in module._source(module.Demo.cloud) assert "def on_view" in module._source(module.Demo.on_view) - # The page composes without error and mints an inline() token at import. + # The page composes without error and mints inline() tokens at import. assert module.ORBITS_TOKEN.startswith("xyin-") + assert module.DRILLDOWN_TOKEN.startswith("xyin-") + assert module.DRILLDOWN_POINTS == 50000 assert module.index() is not None From 427ac4f8a5e9ec9085db13a6748b65790b179606 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:45 +0000 Subject: [PATCH 4/4] Elide density_view requests for zooms inside an exact drill window (T12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a points reply has shipped its window exactly (reduction "none" — the subset IS every point in the window), any view contained in that window is already answered by the marks on the GPU: the smaller window's points are a subset of the shipped ones. Drilling deeper therefore no longer round-trips the kernel — _scheduleViewRequest skips the trace's pending markers and its density_view message (lodDrillServesView, re-checked at debounced send time), while still bumping seq so an in-flight reply for an older, wider view dies stale instead of yanking exact marks out from under a view it cannot improve. Three conditions re-arm the request: leaving the window, a drill that is dying or not exact (only reduction "none" arms the elision, recorded as drill.exact), and depth — the shipped geometry is f32, offset-encoded on the window midpoint (dossier §16), so once the view span drops below 1/256 of the window span on either axis one request goes out purely to re-center the encoding. Data changes cannot serve stale marks through the elision: streaming append and full payload updates rebuild the GPU trace, which drops the drill. Recorded as invariant T12 in spec/design/lod-architecture.md §5, in the wire protocol's density_view entry, and in dossier §16/§28; covered by a headless-Chromium probe (tests/test_drill_zoomin_elides_request.py) asserting the elision and each re-arm condition. --- CHANGELOG.md | 6 + js/src/45_lod.ts | 34 +++++ js/src/54_kernel.ts | 38 ++++- spec/design-dossier.md | 11 +- spec/design/lod-architecture.md | 29 +++- spec/design/wire-protocol.md | 8 +- tests/test_drill_zoomin_elides_request.py | 178 ++++++++++++++++++++++ 7 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 tests/test_drill_zoomin_elides_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fad60130..4f75630f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,12 @@ in the README). gone, and the page JS is a POST transport plus a status badge. Round-trip replies ship as `XYBF` binary frames (wire-protocol §7) instead of base64-in-JSON. +- **Zooms inside an exact drill window skip the kernel round-trip (T12).** + Once a points reply has shipped its window exactly (`reduction: "none"`), + the client answers any contained view from the marks it already holds and + sends no `density_view` request, until the view leaves the window, the + drill dies, or the zoom is deep enough (1/256 of the window span) to need + a §16 re-centered f32 encoding. ### Added - **Export format parity and a unified export API (ENG-10447).** diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index f2475fd2..caf2d350 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -316,6 +316,35 @@ function lodHoldPendingDrill(view, g, d) { return estimatedVisible <= LOD_DIRECT_POINT_BUDGET * LOD_DRILL_EXIT_FACTOR; } +// Zoom-in request elision (T12): a drill that shipped its window EXACTLY +// (reduction "none" — the subset IS every point in the window) already holds +// every point of any view contained in that window, so drilling deeper needs +// no kernel round-trip. What a deeper zoom eventually needs is precision, not +// data: the shipped geometry is f32, offset-encoded around the window midpoint +// (§16), so once the view span drops below LOD_DRILL_REENCODE_SPAN of the +// window span on either axis one request goes out purely to re-center the +// encoding (at 2^-8 of the window the ~2^-24 encode quantum is still ≲0.1 px +// on a 4k-wide plot). A dying drill never elides — the kernel chose a +// different representation and the reply flow owns that transition. +const LOD_DRILL_REENCODE_SPAN = 1 / 256; + +export function lodDrillServesView(g, x0, x1, y0, y1) { + const d = g && g.drill; + if (!d || !d.exact || !d.win || g._drillDying) return false; + const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); + const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); + const wx0 = Math.min(d.win.x0, d.win.x1), wx1 = Math.max(d.win.x0, d.win.x1); + const wy0 = Math.min(d.win.y0, d.win.y1), wy1 = Math.max(d.win.y0, d.win.y1); + // Same edge tolerance as _viewInside: f32 round-trip slop at the window + // boundary must not force a request right after drilling in. + const ex = (vx1 - vx0) * 1e-4, ey = (vy1 - vy0) * 1e-4; + if (vx0 < wx0 - ex || vx1 > wx1 + ex || vy0 < wy0 - ey || vy1 > wy1 + ey) return false; + return ( + vx1 - vx0 >= (wx1 - wx0) * LOD_DRILL_REENCODE_SPAN && + vy1 - vy0 >= (wy1 - wy0) * LOD_DRILL_REENCODE_SPAN + ); +} + // Geometry-only retirement (T11): an entered-then-exited drill is kept as a // revive cache — a rapid zoom back into its window hands the exact marks // back with no kernel round-trip — but only while a nearby view could still @@ -405,6 +434,11 @@ export function lodApplyDrill(view, g, upd, buffers) { d.win = { x0: upd.x_range[0], x1: upd.x_range[1], y0: upd.y_range[0], y1: upd.y_range[1] }; d.n = Math.min(upd.x.len, upd.y.len); d.visible = upd.visible ?? d.n; + // The kernel's exactness claim (§28 Invariant L2): reduction "none" means + // the subset IS every point in the window — the fact that arms T12's + // zoom-in request elision. Anything else (or a reply that doesn't say) + // keeps the request path live. + d.exact = upd.reduction === "none"; d.seq = upd.drill_seq; // subset version — echoed with picks, gates selections d.selActive = false; // drilled subset changed; old mask indices are stale // §34 selection continuity: the swapped subset invalidates the old mask diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 12b3d8db..9511a6f6 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -1,7 +1,9 @@ import { payloadBuffers } from "./00_header"; import { buildLutData } from "./10_colormaps"; import { parseColor } from "./20_theme"; -import { lodApplyDensityUpdate, lodApplyDrill, lodDropDrill, lodRememberDensity } from "./45_lod"; +import { + lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, lodDropDrill, lodRememberDensity, +} from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -30,6 +32,18 @@ Object.assign(ChartView.prototype, { const now = this._now(); for (const g of this.gpuTraces) { if (g.tier !== "density") continue; + // Zoom-in request elision (T12): a view contained in an exact drill's + // window is already answered by the marks on the GPU — the smaller + // window's points are a subset of the shipped ones — so this trace + // goes neither pending nor on the wire. The seq bump above stands, so + // an in-flight reply for an older, wider view dies stale instead of + // yanking the exact marks out from under the view it can't improve. + if (this._drillServesView(g, view)) { + g._lodPendingView = null; + g._lodPendingSeq = null; + g._lodPendingAt = null; + continue; + } g._lodPendingView = view; g._lodPendingSeq = seq; g._lodPendingAt = now; @@ -60,6 +74,17 @@ Object.assign(ChartView.prototype, { if (needsDensity) { for (const g of this.gpuTraces) { if (g.tier !== "density") continue; + // T12 re-check at actual send time: a drill that landed during the + // debounce elides the request it made unnecessary; one that died + // during it re-arms the request the schedule-time check skipped. + if (this._drillServesView(g, view)) { + if (g._lodPendingSeq === seq) { + g._lodPendingView = null; + g._lodPendingSeq = null; + g._lodPendingAt = null; + } + continue; + } const [x0, x1] = this._axisRange(g.xAxis, view); const [y0, y1] = this._axisRange(g.yAxis, view); this.comm.send({ @@ -513,6 +538,17 @@ Object.assign(ChartView.prototype, { lodDropDrill(this, g); }, + // Can `view` be answered locally from this trace's live drill, with no + // kernel round-trip (T12)? True only for an exact (reduction "none"), + // non-dying drill whose window contains the view's per-axis ranges, and + // only until the zoom outgrows the §16 f32 encode precision. + _drillServesView(g, view) { + if (!g.drill) return false; + const [x0, x1] = this._axisRange(g.xAxis, view); + const [y0, y1] = this._axisRange(g.yAxis, view); + return lodDrillServesView(g, x0, x1, y0, y1); + }, + // Is the current view fully covered by a drilled window? A tiny epsilon // absorbs f32 round-trip slop so we don't flip to the overview at the exact // window edge right after drilling in. diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 271cbeaa..f423d6f0 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -677,6 +677,15 @@ detail inside a decade of millisecond timestamps). zoom depth spans a sliver of a decade (invisible on any log-family display) and is the same class of limit matplotlib accepts; recorded here per §28's no-silent-decisions rule. +- **Zooming inside an exact scatter drill is request-free until re-centering is + due.** A view contained in a drill window that shipped exactly (`reduction: + "none"`) needs no new data — the shipped subset already holds every point of + it — so the client elides the `density_view` round-trip entirely (LOD doc §5, + T12). It re-requests only once the view span falls below 1/256 of the drilled + window's span on either axis, an f32-safe margin (at 2⁻⁸ of the window the + ~2⁻²⁴ encode quantum is still ≲0.1 px on a 4k-wide plot); that request exists + to re-center the offset encoding per this section, and the re-centered reply + re-arms the elision around its own window. - **Time is i64 end-to-end** (Arrow timestamp columns), with calendar-aware tick generation (months are not 30×86400s). Plotly gets this right; matching it is table stakes and it must not be routed through any float path. @@ -981,7 +990,7 @@ recomputes on zoom.* | Trace kind | Canonical requirement | Tier ladder | Hover/select | On zoom | |---|---|---|---|---| | **Line / area / time series** | x sorted (or engine sorts once at ingest, stated) | direct → min-max per-px-column decimation → zone-map-pruned chunk streaming | exact point (binary search on x in canonical) at every tier | recompute decimation for visible x-range only; zone maps prune chunks | -| **Scatter** | none for Tiers 0–1; spatial bucketing pass at ingest for Tiers 2–3 | direct instanced → *no Tier 1 (decimating unordered points misleads)* → density pyramid → out-of-core tiles | Tier 0: GPU pick, exact row. Tiers 2–3: bin summary + async drill to top-k rows | pan = tile reuse; zoom = adjacent pyramid level; below pyramid floor = re-bin visible via tile index | +| **Scatter** | none for Tiers 0–1; spatial bucketing pass at ingest for Tiers 2–3 | direct instanced → *no Tier 1 (decimating unordered points misleads)* → density pyramid → out-of-core tiles | Tier 0: GPU pick, exact row. Tiers 2–3: bin summary + async drill to top-k rows | pan = tile reuse; zoom = adjacent pyramid level; below pyramid floor = re-bin visible via tile index; inside an exact drill window = nothing recomputes and no request is sent — the client already holds every point (§16, LOD doc T12) | | **Heatmap / image** | gridded input | direct texture → mip pyramid (same machinery, degenerate case) | cell value (exact, from canonical grid) | mip level selection; nothing recomputes | | **Bar / histogram** | histogram: raw column; bar: categories | bars are visually bounded (≤ ~10⁴ on screen) → direct; histogram re-bins from canonical on range change (cheap: 1-D, visible range only, zone-map-pruned) | exact bar/bin | 1-D re-bin, worker, stale-while-revalidate | | **Streaming (any kind)** | ring capacity declared up front | ring buffer + incremental decimation (Tier-1 buckets updated, not rebuilt); pyramid tiles updated incrementally for touched cells | same as base kind, within retained window | append is O(appended); eviction from ring updates affected buckets only | diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 2e4d9d64..fff965c1 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -44,7 +44,7 @@ real marks with real channels — never a sample, never an aggregate. | Kind | Tier 0 | Tier 1 | Tier 2 | zoom-in recovery | |---|---|---|---|---| -| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact visible points + channels (shipped) | +| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact visible points + channels (shipped); deeper zooms inside the exact window are answered locally, no request (T12) | | line/area | polyline | M4 per column (extrema-exact) | — (M4 already screen-bounded) | re-decimate window (shipped) | | heatmap | cell rects | — | mip-style tile reduction of the user grid (max/mean recorded) | finer pyramid level, then exact cells | | histogram/bar | rects | re-bin at viewport resolution | — (bins are already aggregates) | re-bin visible window (finer bins = more truth, never less) | @@ -417,8 +417,31 @@ invariants so future kinds don't regress them: window, and the view being far from it is their normal transient state. A dying drill (kernel chose density) still frees via the exit fade as in T2, independent of geometry. - -Any new tiered kind must state how it satisfies T1–T11 in its chart-kind +- **T12 — a zoom inside an exact drill elides the request:** once a points + reply has shipped its window EXACTLY (`reduction: "none"` — Invariant L2's + subset IS every point in the window), any requested view contained in that + window is already answered by the marks on the GPU: the smaller window's + points are a subset of the shipped ones, so `_scheduleViewRequest` sends no + `density_view` for that trace and clears its pending markers + (`lodDrillServesView` in `js/src/45_lod.ts`, re-checked at debounced send + time so a drill landing or dying mid-debounce flips the decision). The seq + still bumps, so an in-flight reply for an older, wider view dies stale + instead of yanking exact marks out from under a view it cannot improve. + Two things re-arm the request: leaving the window (any edge, same epsilon + as `_viewInside`), and depth — the shipped geometry is f32, offset-encoded + around the window midpoint (dossier §16), so once the view span drops below + `LOD_DRILL_REENCODE_SPAN` (1/256) of the window span on either axis one + request goes out purely to re-center the encoding (at 2⁻⁸ of the window the + ~2⁻²⁴ encode quantum is still ≲0.1 px on a 4k-wide plot; the reply's + re-centered window then re-arms the elision around itself). A dying drill + never elides — the kernel chose a different representation and the reply + flow owns that transition. Data changes cannot serve stale marks through + the elision: streaming append and full payload updates rebuild the GPU + trace, which drops the drill and with it the elision. Non-exact replies + (anything but `reduction: "none"`, including replies that don't say) never + arm it. + +Any new tiered kind must state how it satisfies T1–T12 in its chart-kind contract entry before it lands. --- diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index fd2a4c1e..57304179 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -61,7 +61,13 @@ produces no traces, there is no reply at all (silence, not an empty message). **`density_view`** — one request per density-tier trace, each naming its `trace` id. `w` defaults to `512`, `h` to `384`; the client sends the rounded plot width and height. A trace that is not in density mode yields -`{"traces": []}`, which is dropped rather than sent. +`{"traces": []}`, which is dropped rather than sent. Not every view change +produces a request: a view fully contained in a live drill's window is elided +client-side when that drill shipped its window exactly (`reduction: "none"` — +the subset already holds every point of any contained view; LOD doc §5 T12). +The elision ends, and one request goes out purely to re-center the §16 f32 +offset encoding, once the view span drops below 1/256 of the drilled window's +span on either axis. **`pick`** — `trace` and `index` pass through `_integer_id`. `index` is a *shipped-vertex* index, translated kernel-side to a canonical row when the diff --git a/tests/test_drill_zoomin_elides_request.py b/tests/test_drill_zoomin_elides_request.py new file mode 100644 index 00000000..b7f02025 --- /dev/null +++ b/tests/test_drill_zoomin_elides_request.py @@ -0,0 +1,178 @@ +"""Zooming inside an exact drill window must not re-request points (T12). + +Once a drill reply has shipped its window EXACTLY (`reduction: "none"` — the +subset IS every point in the window), any view contained in that window is +already answered by the marks on the GPU: the smaller window's points are a +subset of the shipped ones. Drilling deeper must therefore elide the +`density_view` round-trip entirely (LOD doc §5 T12) — no pending markers, no +wire message — while still bumping `seq` so an in-flight reply for an older, +wider view dies stale instead of yanking exact marks out from under the view. + +The elision has exactly three re-arm conditions, each asserted here: +- the view leaves the drill window (any edge); +- the zoom outgrows the §16 f32 offset encoding — view span below 1/256 of + the window span re-requests purely to re-center the encoding; +- the drill stops being an exact live subset (dying, or a reply that did not + claim `reduction: "none"`). + +This drives the real client in headless Chromium with a captured comm, applies +a drill for a known window, and inspects exactly which view requests reach the +wire. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import xy +from conftest import run_browser_probe +from xy.export import find_chromium + +_RENDER_CALL = 'xy.renderStandalone(document.getElementById("chart"), spec, buf);' + +_PROBE = """ + const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); + try { + view._drawNow(); + view._raf = null; + view._sampleRebinDisabled = true; + const g = view.gpuTraces.find((t) => t.tier === "density"); + + const v0 = view.view0; + const cx = (v0.x0 + v0.x1) / 2, cy = (v0.y0 + v0.y1) / 2; + const sx = v0.x1 - v0.x0, sy = v0.y1 - v0.y0; + // Drill window: the central 10% of home, shipped exactly. + const wx0 = cx - sx * 0.05, wx1 = cx + sx * 0.05; + const wy0 = cy - sy * 0.05, wy1 = cy + sy * 0.05; + const N = 800; + const xs = new Float32Array(N), ys = new Float32Array(N); + for (let i = 0; i < N; i++) { + xs[i] = wx0 + (wx1 - wx0) * ((i * 0.6180339887) % 1); + ys[i] = wy0 + (wy1 - wy0) * ((i * 0.3141592653) % 1); + } + view._applyDrill(g, { + id: g.trace.id, mode: "points", visible: N, reduction: "none", drill_seq: 1, + x: { buf: 0, offset: 0, scale: 1, len: N }, + y: { buf: 1, offset: 0, scale: 1, len: N }, + x_range: [wx0, wx1], y_range: [wy0, wy1], + }, [xs.buffer, ys.buffer]); + const drillMarkedExact = g.drill.exact === true; + + // Kernel-connected from here on: capture what would go on the wire. + const sent = []; + view.comm = { send: (m) => sent.push(m) }; + const densitySent = () => sent.filter((m) => m.type === "density_view").length; + const request = (x0, x1, y0, y1) => { + const before = densitySent(); + view._scheduleViewRequest(view._viewFrom({ x0, x1, y0, y1 }), { delay: 0 }); + return densitySent() - before; + }; + + // 1) Zooming IN inside the exact window: every point of the smaller view + // is already on the GPU — no request, pending cleared, seq still bumped + // (a stale in-flight reply must die rather than replace exact marks). + const seqBefore = view.seq; + g._lodPendingView = { x0: wx0, x1: wx1, y0: wy0, y1: wy1 }; + const zoomInElided = request( + cx - sx * 0.02, cx + sx * 0.02, cy - sy * 0.02, cy + sy * 0.02) === 0; + const zoomInClearedPending = g._lodPendingView === null; + const zoomInBumpedSeq = view.seq === seqBefore + 1; + + // 1b) The window itself (containment up to the edge epsilon) also elides. + const fullWindowElided = request(wx0, wx1, wy0, wy1) === 0; + + // 2) Leaving the window (pan past an edge) re-arms the request. + const outsideRequested = request( + wx0 + sx * 0.03, wx1 + sx * 0.03, wy0, wy1) === 1; + + // 3) Deep zoom past the §16 re-encode bound (view span < window/256): + // the request goes out purely to re-center the f32 offset encoding. + const deep = (wx1 - wx0) / 1024; + const deepZoomRequested = request( + cx - deep / 2, cx + deep / 2, cy - deep / 2, cy + deep / 2) === 1; + + // 4) A dying drill never elides — the kernel chose a different + // representation and the reply flow owns that transition. + g._drillDying = true; + const dyingRequested = request( + cx - sx * 0.02, cx + sx * 0.02, cy - sy * 0.02, cy + sy * 0.02) === 1; + g._drillDying = false; + + // 5) A subset that did not claim reduction "none" never arms the elision. + g.drill.exact = false; + const nonExactRequested = request( + cx - sx * 0.02, cx + sx * 0.02, cy - sy * 0.02, cy + sy * 0.02) === 1; + + document.body.setAttribute("data-xy-elide-probe", JSON.stringify({ + hasDensity: !!g, + drillMarkedExact, + zoomInElided, + zoomInClearedPending, + zoomInBumpedSeq, + fullWindowElided, + outsideRequested, + deepZoomRequested, + dyingRequested, + nonExactRequested, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-elide-probe-error", + String((err && err.stack) || err), + ); + } +""" + + +def _density_html() -> str: + rng = np.random.default_rng(0) + n = 60_000 + x = rng.normal(0.0, 1.0, n) + y = rng.normal(0.0, 1.0, n) + # density=True forces the density tier (the drill is a sibling of it), + # regardless of point count, so the export exercises the request path + # deterministically and cheaply. + chart = xy.scatter_chart( + xy.scatter(x, y, density=True), + xy.x_axis(), + xy.y_axis(), + width=480, + height=360, + ) + html = chart.to_html() + assert _RENDER_CALL in html + return html + + +def test_zoom_in_within_exact_drill_sends_no_request(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + document = _density_html().replace(_RENDER_CALL, _PROBE) + result = run_browser_probe( + chromium, + document, + tmp_path / "drill_zoomin_elide.html", + "data-xy-elide-probe", + label="drill zoom-in request-elision probe", + ) + + assert result["hasDensity"] is True + # The reply's reduction "none" claim is what arms the elision. + assert result["drillMarkedExact"] is True + # Zooming in within the exact window: no wire request, no pending marker, + # but seq still advances so stale in-flight replies are dropped. + assert result["zoomInElided"] is True + assert result["zoomInClearedPending"] is True + assert result["zoomInBumpedSeq"] is True + assert result["fullWindowElided"] is True + # Each re-arm condition still requests: leaving the window, outzooming the + # f32 encoding (§16 re-centering), a dying drill, a non-exact subset. + assert result["outsideRequested"] is True + assert result["deepZoomRequested"] is True + assert result["dyingRequested"] is True + assert result["nonExactRequested"] is True