From d77cb260e97e66ff58894f2ffae875fcec1f41e0 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Wed, 12 Aug 2026 12:00:31 +0000 Subject: [PATCH 1/4] Fix f32 precision loss for high-rate datetime axes (issue #487) Co-authored-by: alastairtree <6273429+alastairtree@users.noreply.github.com> --- js/src/40_gl.ts | 7 +++++++ js/src/50_chartview.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 9254c692..fe0c70d8 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -129,6 +129,13 @@ float xyAxisCoord(float encoded, vec2 meta, int mode, float constant) { return value; } float xyMap(float encoded, vec2 map, vec2 meta, int mode, float constant) { + // For linear axes (mode 0) the CPU has already folded the column offset into + // the affine constants (map.x, map.y) in f64 (§4/§16). Apply them directly to + // the offset-encoded value to avoid reconstructing the large absolute coordinate + // in f32, which would discard low bits for high-magnitude axes (e.g. ms-since- + // epoch datetime). Non-linear axes decode first because their transforms are + // not affine. + if (mode == 0) return encoded * map.x + map.y; return xyAxisCoord(encoded, meta, mode, constant) * map.x + map.y; } float xyViewCoord(float value, int mode, float constant) { diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 8d0c2f2b..5955bf30 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -5398,6 +5398,18 @@ export class ChartView { return [mul, add]; } const axis = this._axis(axisId); + // For linear axes the shader applies the map directly to encoded values (§4/§16): + // encoded * mul + add where encoded = (v - offset) * scale + // Fold the column offset into the affine constants here in f64 so the shader + // never reconstructs the large absolute coordinate in f32. + if (this._axisMode(axisId) === 0) { + if (!Number.isFinite(hi - lo) || hi === lo) return [0, -2]; + const scale = (meta && meta.scale) ? meta.scale : 1; + const offset = (meta && Number.isFinite(meta.offset)) ? meta.offset : 0; + const mul = 2 / ((hi - lo) * scale); + const add = ((offset - lo) / (hi - lo)) * 2 - 1; + return [mul, add]; + } const c0 = this._axisCoord(axis, lo); const c1 = this._axisCoord(axis, hi); if (![c0, c1].every(Number.isFinite) || c1 === c0) return [0, -2]; From 5bd0dcb5c12db903412c64f69664a45604f96e84 Mon Sep 17 00:00:00 2001 From: Alastair Crabtree Date: Thu, 20 Aug 2026 06:51:00 +0000 Subject: [PATCH 2/4] Fold the linear view map per encoded column (issue #487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linear-axis fold landed as one map per axis, applied to whichever column the draw happened to build it from. That is correct only for marks whose axis has a single encoded column. Segments, ribbons, funnels, meshes, rectangles, bars and an area's baseline each ship four or six columns against one axis, every one with its own offset and scale, so a sibling's map is a wholly different transform: a segment spanning 452 px vanished, and the funnel dropped out of the render smoke. Build the map where the meta is written instead. `_setAxisUniforms` now takes the axis *window* and folds it against that column's own encoding, so the map and the meta it belongs to always reach the GPU together and cannot drift apart. Draw entry points take windows rather than prefolded maps, which also fixes the area perimeter (it drew the baseline column through the value column's map) and removes ~20 uniform writes. The build-time shader lint rejects an `xyMap` call whose map and meta come from different columns, and requires every map uniform to be `vec4`. Three further corrections to the fold itself: - Centre the affine on the visible window. The map carries a `shift`, snapped to f32 so the CPU and the shader agree bit-for-bit, which the shader subtracts before the multiply. Without it a view far from the encode offset makes both terms large and opposite, and their f32 cancellation reintroduces per-point jitter. - Floor a degenerate encode scale exactly as `xyDecode` does, and validate the constants as f32 rather than f64. A zero scale otherwise yields an infinite slope, and `encoded * Infinity` rasterizes as NaN, which culls the trace instead of collapsing it onto its offset. - Keep a data-space slope in the map for `BAR_VS`. A bar's width is a data-space span, so scaling it by the per-encoded-unit slope multiplied every bar by 1/scale — bars covered the chart at any encoding scale other than 1. `tests/test_axis_map_precision.py` covers the f32 arithmetic with no browser — a decoded millisecond epoch reaches 111 distinct pixel columns where the fold reaches 821 — and pins the structural invariants: the map/meta pairing at every `xyMap` call site, `vec4` map uniforms, and one place that builds a map. --- js/build.mjs | 24 ++- js/src/40_gl.ts | 90 +++++----- js/src/45_lod.ts | 7 +- js/src/50_chartview.ts | 240 ++++++++++++++------------- js/src/55_marks.ts | 55 ++---- spec/design/renderer-architecture.md | 13 +- tests/test_axis_map_precision.py | 139 ++++++++++++++++ tests/test_funnel.py | 2 +- 8 files changed, 369 insertions(+), 201 deletions(-) create mode 100644 tests/test_axis_map_precision.py diff --git a/js/build.mjs b/js/build.mjs index a3ba8d80..5d703336 100644 --- a/js/build.mjs +++ b/js/build.mjs @@ -62,8 +62,28 @@ const staticDir = join(root, "python", "xy", "static"); `${name}: vertex shaders map data via u_*map uniforms (or add to VIEWMAP_EXEMPT with a reason)` ); } - for (const u of shader.matchAll(/uniform\s+\w+\s+(\w+)/g)) { - if (!u[1].startsWith("u_")) errs.push(`${name}: uniform '${u[1]}' must be u_-prefixed`); + for (const u of shader.matchAll(/uniform\s+(\w+)\s+(\w+)/g)) { + if (!u[2].startsWith("u_")) errs.push(`${name}: uniform '${u[2]}' must be u_-prefixed`); + // The folded view map is (mul, add, shift, dataMul) — a narrower + // declaration silently drops the re-centring term or the data-space + // slope a bar's width needs (§16). + if (/map$/.test(u[2]) && u[1] !== "vec4") { + errs.push(`${name}: map uniform '${u[2]}' must be vec4, not ${u[1]} (§16)`); + } + } + // A map folded for one encoded column is a different transform for its + // siblings, so every xyMap call takes the map and the meta of the SAME + // column: u_x0map with u_x0meta, never u_xmap with u_x0meta (§16). + for (const call of shader.matchAll(/xyMap\(\s*[\w.]+,\s*([\w.]+),\s*([\w.]+),/g)) { + const [, mapArg, metaArg] = call; + const paired = + mapArg.endsWith("map") && metaArg.endsWith("meta") && + mapArg.slice(0, -3) === metaArg.slice(0, -4); + if (!paired) { + errs.push( + `${name}: xyMap(${mapArg}, ${metaArg}) pairs one column's map with another's meta (§16)` + ); + } } if (name.endsWith("_VS")) { for (const a of shader.matchAll(/^\s*in\s+\w+\s+(\w+)/gm)) { diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index fe0c70d8..7fdd7dae 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -128,14 +128,24 @@ float xyAxisCoord(float encoded, vec2 meta, int mode, float constant) { if (mode == 2) return sign(value) * log(1.0 + abs(value) / constant); return value; } -float xyMap(float encoded, vec2 map, vec2 meta, int mode, float constant) { - // For linear axes (mode 0) the CPU has already folded the column offset into - // the affine constants (map.x, map.y) in f64 (§4/§16). Apply them directly to - // the offset-encoded value to avoid reconstructing the large absolute coordinate - // in f32, which would discard low bits for high-magnitude axes (e.g. ms-since- - // epoch datetime). Non-linear axes decode first because their transforms are - // not affine. - if (mode == 0) return encoded * map.x + map.y; +// One column's view->clip affine. The map is ALWAYS the one built for the very +// meta passed alongside it (_map, 50_chartview.ts) — a map folded for a +// different column of the same axis is a different transform, not a rounding +// difference, so the two travel together through every call site. +// +// mode 0 (linear): clip = (encoded - map.z) * map.x + map.y +// log / symlog: clip = xyAxisCoord(encoded, meta) * map.x + map.y +// +// Linear axes never rebuild the absolute coordinate in f32. The CPU folds this +// column's offset and scale into map.xy in f64, so a millisecond epoch keeps +// every bit of its intra-view spread instead of collapsing onto the ~2^24 +// grid a decoded f32 timestamp lands on (§4/§16). map.z re-centres the multiply +// on the visible window so both terms stay O(1) rather than large and +// near-cancelling at deep zoom. map.w is the same slope per *data* unit, for +// the one caller that scales a data-space width (BAR_VS). Log-family axes are +// not affine, so they decode first and map.zw is unused. +float xyMap(float encoded, vec4 map, vec2 meta, int mode, float constant) { + if (mode == 0) return (encoded - map.z) * map.x + map.y; return xyAxisCoord(encoded, meta, mode, constant) * map.x + map.y; } float xyViewCoord(float value, int mode, float constant) { @@ -331,7 +341,7 @@ export const POINT_VS = `#version 300 es in float ax; in float ay; in float a_prevx; in float a_prevy; in float a_cval; in float a_sval; in float a_sel; in float a_dval; in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; -uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec4 u_xmap; uniform vec4 u_ymap; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform int u_colorMode; uniform int u_symbol; uniform float u_dpr; uniform int u_selActive; @@ -538,7 +548,7 @@ void main() { // on software GL while producing the same circle SDF and premultiplied color. export const POINT_SIMPLE_VS = `#version 300 es in float ax; in float ay; in float a_prevx; in float a_prevy; -uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec4 u_xmap; uniform vec4 u_ymap; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform float u_size; uniform float u_dpr; uniform float u_transitionProgress; uniform int u_transitionActive; @@ -580,7 +590,7 @@ void main() { // points (GLSL highp int is signed), far beyond what GPU memory admits. export const PICK_VS = `#version 300 es in float ax; in float ay; in float a_prevx; in float a_prevy; in float a_sval; -uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec4 u_xmap; uniform vec4 u_ymap; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform float u_dpr; uniform float u_transitionProgress; uniform int u_transitionActive; @@ -753,7 +763,7 @@ void main() { export const LINE_VS = `#version 300 es in float ax0; in float ay0; in float ax1; in float ay1; in float a_prevx; in float a_prevy; in float a_prevx1; in float a_prevy1; -uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; +uniform vec4 u_xmap; uniform vec4 u_ymap; uniform vec2 u_res; uniform float u_width; uniform int u_colorMode; uniform int u_cap; uniform int u_capSegments; uniform float u_transitionProgress; uniform int u_transitionActive; @@ -863,7 +873,8 @@ export const LINE_CAP_MODES = { butt: 0, round: 1, square: 2 }; export const SEGMENT_VS = `#version 300 es in float ax0; in float ay0; in float ax1; in float ay1; in float a_cval; in vec4 a_rgba; in vec4 a_style; in float a_dash0; in float a_dashDir; -uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; +uniform vec4 u_x0map; uniform vec4 u_x1map; uniform vec4 u_y0map; uniform vec4 u_y1map; +uniform vec2 u_res; uniform float u_width; uniform float u_animationProgress; uniform int u_colorMode; uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; @@ -908,8 +919,8 @@ void main() { u_trange, u_turn, u_rshape); } } else { - p0 = vec2(xyMap(ax0, u_xmap, u_x0meta, u_x0mode, u_x0constant), xyMap(ay0, u_ymap, u_y0meta, u_y0mode, u_y0constant)); - p1 = vec2(xyMap(ax1, u_xmap, u_x1meta, u_x1mode, u_x1constant), xyMap(ay1, u_ymap, u_y1meta, u_y1mode, u_y1constant)); + p0 = vec2(xyMap(ax0, u_x0map, u_x0meta, u_x0mode, u_x0constant), xyMap(ay0, u_y0map, u_y0meta, u_y0mode, u_y0constant)); + p1 = vec2(xyMap(ax1, u_x1map, u_x1meta, u_x1mode, u_x1constant), xyMap(ay1, u_y1map, u_y1meta, u_y1mode, u_y1constant)); } vec2 center = (p0 + p1) * 0.5; p0 = mix(center, p0, u_animationProgress); @@ -985,7 +996,8 @@ export const RIBBON_STEPS = 96; export const RIBBON_VS = `#version 300 es in float ax0; in float ax1; in float ay0; in float ay1; in float ax2; in float ay2; in vec4 a_rgba; in vec4 a_rgba2; -uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec4 u_x0map; uniform vec4 u_x1map; +uniform vec4 u_y0map; uniform vec4 u_y1map; uniform vec4 u_t0map; uniform vec4 u_t1map; uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform vec2 u_t0meta; uniform vec2 u_t1meta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; @@ -996,12 +1008,12 @@ out float v_side; out float v_t; ${AXIS_GLSL} void main() { - float X0 = xyMap(ax0, u_xmap, u_x0meta, u_xmode, u_xconstant); - float X1 = xyMap(ax1, u_xmap, u_x1meta, u_xmode, u_xconstant); - float SLO = xyMap(ay0, u_ymap, u_y0meta, u_ymode, u_yconstant); - float SHI = xyMap(ay1, u_ymap, u_y1meta, u_ymode, u_yconstant); - float TLO = xyMap(ax2, u_ymap, u_t0meta, u_ymode, u_yconstant); - float THI = xyMap(ay2, u_ymap, u_t1meta, u_ymode, u_yconstant); + float X0 = xyMap(ax0, u_x0map, u_x0meta, u_xmode, u_xconstant); + float X1 = xyMap(ax1, u_x1map, u_x1meta, u_xmode, u_xconstant); + float SLO = xyMap(ay0, u_y0map, u_y0meta, u_ymode, u_yconstant); + float SHI = xyMap(ay1, u_y1map, u_y1meta, u_ymode, u_yconstant); + float TLO = xyMap(ax2, u_t0map, u_t0meta, u_ymode, u_yconstant); + float THI = xyMap(ay2, u_t1map, u_t1meta, u_ymode, u_yconstant); float t = floor(float(gl_VertexID) * 0.5) / float(max(u_segments, 1)); float side = float(gl_VertexID & 1); float u = 1.0 - t; @@ -1035,7 +1047,8 @@ void main() { export const FUNNEL_VS = `#version 300 es in float ax0; in float ax1; in float ay0; in float ay1; in float ax2; in float ay2; in vec4 a_rgba; -uniform vec2 u_pmap; uniform vec2 u_cmap; +uniform vec4 u_p0map; uniform vec4 u_p1map; +uniform vec4 u_l0map; uniform vec4 u_h0map; uniform vec4 u_l1map; uniform vec4 u_h1map; uniform vec2 u_p0meta; uniform vec2 u_p1meta; uniform vec2 u_l0meta; uniform vec2 u_h0meta; uniform vec2 u_l1meta; uniform vec2 u_h1meta; uniform int u_pmode; uniform float u_pconstant; uniform int u_cmode; uniform float u_cconstant; @@ -1046,12 +1059,12 @@ out float v_side; out float v_t; ${AXIS_GLSL} void main() { - float P0 = xyMap(ax0, u_pmap, u_p0meta, u_pmode, u_pconstant); - float P1 = xyMap(ax1, u_pmap, u_p1meta, u_pmode, u_pconstant); - float L0 = xyMap(ay0, u_cmap, u_l0meta, u_cmode, u_cconstant); - float H0 = xyMap(ay1, u_cmap, u_h0meta, u_cmode, u_cconstant); - float L1 = xyMap(ax2, u_cmap, u_l1meta, u_cmode, u_cconstant); - float H1 = xyMap(ay2, u_cmap, u_h1meta, u_cmode, u_cconstant); + float P0 = xyMap(ax0, u_p0map, u_p0meta, u_pmode, u_pconstant); + float P1 = xyMap(ax1, u_p1map, u_p1meta, u_pmode, u_pconstant); + float L0 = xyMap(ay0, u_l0map, u_l0meta, u_cmode, u_cconstant); + float H0 = xyMap(ay1, u_h0map, u_h0meta, u_cmode, u_cconstant); + float L1 = xyMap(ax2, u_l1map, u_l1meta, u_cmode, u_cconstant); + float H1 = xyMap(ay2, u_h1map, u_h1meta, u_cmode, u_cconstant); float t = floor(float(gl_VertexID) * 0.5); float side = float(gl_VertexID & 1); float pos = mix(P0, P1, t); @@ -1107,7 +1120,8 @@ void main() { export const MESH_VS = `#version 300 es in float ax0; in float ay0; in float ax1; in float ay1; in float ax2; in float ay2; in float a_cval; in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; -uniform vec2 u_xmap; uniform vec2 u_ymap; +uniform vec4 u_x0map; uniform vec4 u_x1map; uniform vec4 u_x2map; +uniform vec4 u_y0map; uniform vec4 u_y1map; uniform vec4 u_y2map; uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_x2meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform vec2 u_y2meta; uniform int u_x0mode; uniform float u_x0constant; uniform int u_x1mode; uniform float u_x1constant; uniform int u_x2mode; uniform float u_x2constant; @@ -1119,13 +1133,15 @@ void main() { int vertex = gl_VertexID % 3; float x = vertex == 0 ? ax0 : (vertex == 1 ? ax1 : ax2); float y = vertex == 0 ? ay0 : (vertex == 1 ? ay1 : ay2); - vec2 xm = vertex == 0 ? u_x0meta : (vertex == 1 ? u_x1meta : u_x2meta); - vec2 ym = vertex == 0 ? u_y0meta : (vertex == 1 ? u_y1meta : u_y2meta); + vec2 xmeta = vertex == 0 ? u_x0meta : (vertex == 1 ? u_x1meta : u_x2meta); + vec2 ymeta = vertex == 0 ? u_y0meta : (vertex == 1 ? u_y1meta : u_y2meta); + vec4 xmap = vertex == 0 ? u_x0map : (vertex == 1 ? u_x1map : u_x2map); + vec4 ymap = vertex == 0 ? u_y0map : (vertex == 1 ? u_y1map : u_y2map); int xmode = vertex == 0 ? u_x0mode : (vertex == 1 ? u_x1mode : u_x2mode); int ymode = vertex == 0 ? u_y0mode : (vertex == 1 ? u_y1mode : u_y2mode); float xconstant = vertex == 0 ? u_x0constant : (vertex == 1 ? u_x1constant : u_x2constant); float yconstant = vertex == 0 ? u_y0constant : (vertex == 1 ? u_y1constant : u_y2constant); - gl_Position = vec4(xyMap(x, u_xmap, xm, xmode, xconstant), xyMap(y, u_ymap, ym, ymode, yconstant), 0.0, 1.0); + gl_Position = vec4(xyMap(x, xmap, xmeta, xmode, xconstant), xyMap(y, ymap, ymeta, ymode, yconstant), 0.0, 1.0); v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; v_bary = vertex == 0 ? vec3(1.,0.,0.) : (vertex == 1 ? vec3(0.,1.,0.) : vec3(0.,0.,1.)); v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; @@ -1191,7 +1207,7 @@ float xyGradT(float markT, vec2 res) { // baseline column. Baseline is offset-encoded independently from y. export const AREA_VS = `#version 300 es in float ax0; in float ax1; in float ay0; in float ay1; in float ab0; in float ab1; -uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_bmap; +uniform vec4 u_xmap; uniform vec4 u_ymap; uniform vec4 u_bmap; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform vec2 u_bmeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform float u_revealProgress; uniform float u_revealSegments; @@ -1283,7 +1299,7 @@ void main() { // primitive for histogram, bar/column, waterfall, and later heatmap cells. export const RECT_VS = `#version 300 es in float ax0; in float ax1; in float ay0; in float ay1; -uniform vec2 u_x0map; uniform vec2 u_x1map; uniform vec2 u_y0map; uniform vec2 u_y1map; +uniform vec4 u_x0map; uniform vec4 u_x1map; uniform vec4 u_y0map; uniform vec4 u_y1map; uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; uniform vec4 u_edgePad; @@ -1353,7 +1369,7 @@ export const BAR_VS = `#version 300 es in float a_pos; in float a_v0; in float a_v1; in float a_cval; in float a_prevx; in float a_prevy; in float a_prevx1; in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; in vec2 a_radius; -uniform vec2 u_pmap; uniform vec2 u_v0map; uniform vec2 u_v1map; +uniform vec4 u_pmap; uniform vec4 u_v0map; uniform vec4 u_v1map; uniform vec2 u_pmeta; uniform vec2 u_v0meta; uniform vec2 u_v1meta; uniform int u_pmode; uniform float u_pconstant; uniform int u_vmode; uniform float u_vconstant; uniform float u_width; uniform int u_orientation; uniform int u_v0Mode; uniform float u_v0Const; @@ -1389,7 +1405,7 @@ void main() { } v0 += u_v0EdgePad; v1 = mix(v0, v1, u_animationProgress); - float halfW = abs(width * u_pmap.x) * 0.5; + float halfW = abs(width * u_pmap.w) * 0.5; v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; if (u_coordMode == 1) { // A polar bar is an annular sector, which four corners cannot express. The diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index b2d3e909..d073b578 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -1363,12 +1363,7 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { } const inside = d && !g._drillDying && view._viewInside(d.win); const density = lodDensityForView(view, g); - const drawMarks = (alpha) => view._drawPoints( - d, - view._map(d.xMeta, x0, x1, d.xAxis), - view._map(d.yMeta, y0, y1, d.yAxis), - alpha - ); + const drawMarks = (alpha) => view._drawPoints(d, [x0, x1], [y0, y1], alpha); if (inside) { // Boundary re-entry — or entry with an exit fade mid-flight — continues // from the marks alpha currently on screen; never a snap to full. diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 5955bf30..c6709d85 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -4397,12 +4397,7 @@ export class ChartView { g._sampleFadedOut = !s; if (changed) this._refreshReductionBadges(); if (!s) return; - this._drawPoints( - s, - this._map(s.xMeta, x0, x1, s.xAxis), - this._map(s.yMeta, y0, y1, s.yAxis), - opacityScale * pick.alpha - ); + this._drawPoints(s, [x0, x1], [y0, y1], opacityScale * pick.alpha); } // Resolve a validated `style.fill` gradient (wire: {space, dir, stops}) into @@ -4629,23 +4624,22 @@ export class ChartView { g.tooltipRows = Array.isArray(t.tooltip_rows) ? t.tooltip_rows : null; } - _drawRibbons(g, xm, ym) { + _drawRibbons(g, xr, yr) { if (g.n < 1) return; const gl = this.gl; const prog = this.ribbonProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); - this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); - this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); - this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); - this._setAxisUniforms(prog, "u_t0", g.t0Meta, g.yAxis); - this._setAxisUniforms(prog, "u_t1", g.t1Meta, g.yAxis); + this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis, yr); + this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis, yr); + this._setAxisUniforms(prog, "u_t0", g.t0Meta, g.yAxis, yr); + this._setAxisUniforms(prog, "u_t1", g.t1Meta, g.yAxis, yr); // RIBBON_VS reads the SHARED mode/constant uniforms (the RECT_VS design); - // the per-column _setAxisUniforms calls above only cover the *meta pairs, - // so without these four writes log/symlog axes silently render as linear. + // the per-column _setAxisUniforms calls above only cover each column's + // map/meta pair, so without these four writes log/symlog axes silently + // render as linear. gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1f(u("u_xconstant"), this._axisConstant(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); @@ -4909,7 +4903,7 @@ export class ChartView { if (mixing) g._funnelGeomMixed = true; } - _drawFunnels(g, xm, ym) { + _drawFunnels(g, xr, yr) { if (g.n < 1) return; const gl = this.gl; const prog = this.funnelProg; @@ -4919,14 +4913,14 @@ export class ChartView { const horizontal = g.orientation === 1; const posAxis = horizontal ? g.xAxis : g.yAxis; const crossAxis = horizontal ? g.yAxis : g.xAxis; - gl.uniform2f(u("u_pmap"), ...(horizontal ? xm : ym)); - gl.uniform2f(u("u_cmap"), ...(horizontal ? ym : xm)); - this._setAxisUniforms(prog, "u_p0", g.x0Meta, posAxis); - this._setAxisUniforms(prog, "u_p1", g.x1Meta, posAxis); - this._setAxisUniforms(prog, "u_l0", g.y0Meta, crossAxis); - this._setAxisUniforms(prog, "u_h0", g.y1Meta, crossAxis); - this._setAxisUniforms(prog, "u_l1", g.x2Meta, crossAxis); - this._setAxisUniforms(prog, "u_h1", g.y2Meta, crossAxis); + const pr = horizontal ? xr : yr; + const cr = horizontal ? yr : xr; + this._setAxisUniforms(prog, "u_p0", g.x0Meta, posAxis, pr); + this._setAxisUniforms(prog, "u_p1", g.x1Meta, posAxis, pr); + this._setAxisUniforms(prog, "u_l0", g.y0Meta, crossAxis, cr); + this._setAxisUniforms(prog, "u_h0", g.y1Meta, crossAxis, cr); + this._setAxisUniforms(prog, "u_l1", g.x2Meta, crossAxis, cr); + this._setAxisUniforms(prog, "u_h1", g.y2Meta, crossAxis, cr); gl.uniform1i(u("u_pmode"), this._axisMode(posAxis)); gl.uniform1f(u("u_pconstant"), this._axisConstant(posAxis)); gl.uniform1i(u("u_cmode"), this._axisMode(crossAxis)); @@ -5391,31 +5385,66 @@ export class ChartView { // -- drawing -------------------------------------------------------------- + // The view->clip affine for ONE column of ONE axis, in that column's own + // offset encoding (§4). The result is what `xyMap` consumes: + // + // linear: clip = (encoded - shift) * mul + add + // log/symlog: clip = xyAxisCoord(encoded, meta) * mul + add + // + // Linear axes fold the column's offset and scale into the constants HERE, in + // f64, so the shader never rebuilds the absolute coordinate in f32 (§16). A + // millisecond epoch (~1.7e12) has a ~130 s f32 quantum, which is what made + // high-rate time series render as stepped columns instead of a curve; folding + // keeps the intra-view spread at the encoded value's own precision. + // + // `shift` re-centres the multiply on the visible window so the two terms of + // the affine stay O(1) instead of large and near-cancelling once the view sits + // far from the encode offset (deep zoom). It is snapped to f32 with + // Math.fround so the value the shader subtracts is bit-identical to the one + // folded into `add` — an f64-only shift would reintroduce the very error it + // exists to remove. `dataMul` is the slope per *data* unit, which is what a + // data-space width (a bar's) must scale by; `mul` is per *encoded* unit. + // + // Marks pass WINDOWS around, not maps: several of them place four or six + // independently encoded columns per axis, and a map is only valid for the + // encoding it was folded from. `_setAxisUniforms` is the one caller, so the + // map and the meta it belongs to are written to the GPU together. + // + // A degenerate window or encoding yields the off-screen sentinel (mul 0, + // add -2) rather than an Infinity that would reach the shader as NaN. _map(meta, lo, hi, axisId = null) { - if (!axisId) { - const mul = 2 / ((hi - lo) * meta.scale); - const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; - return [mul, add]; + const degenerate = { mul: 0, add: -2, shift: 0, dataMul: 0 }; + if (!axisId || this._axisMode(axisId) === 0) { + const span = hi - lo; + if (!Number.isFinite(span) || span === 0) return degenerate; + // Mirrors xyDecode's `max(abs(meta.y), 1e-30)` floor: a zero or denormal + // encode scale must not turn the folded slope into Infinity. + const rawScale = meta && Number.isFinite(meta.scale) ? meta.scale : 1; + const scale = Math.abs(rawScale) >= 1e-30 ? rawScale : (rawScale < 0 ? -1e-30 : 1e-30); + const offset = meta && Number.isFinite(meta.offset) ? meta.offset : 0; + const dataMul = 2 / span; + const mul = dataMul / scale; + const shiftRaw = Math.fround(((lo + hi) / 2 - offset) * scale); + const shift = Number.isFinite(shiftRaw) ? shiftRaw : 0; + const add = (offset + shift / scale - lo) * dataMul - 1; + // The constants are uploaded as f32, so an f64-finite value that overflows + // on the way to the GPU is still an Infinity in the shader — and + // `encoded * Infinity` rasterizes as NaN. Check the f32 images, not the + // f64 ones. + if (!Number.isFinite(Math.fround(mul)) || !Number.isFinite(Math.fround(add))) { + return degenerate; + } + return { mul, add, shift, dataMul }; } const axis = this._axis(axisId); - // For linear axes the shader applies the map directly to encoded values (§4/§16): - // encoded * mul + add where encoded = (v - offset) * scale - // Fold the column offset into the affine constants here in f64 so the shader - // never reconstructs the large absolute coordinate in f32. - if (this._axisMode(axisId) === 0) { - if (!Number.isFinite(hi - lo) || hi === lo) return [0, -2]; - const scale = (meta && meta.scale) ? meta.scale : 1; - const offset = (meta && Number.isFinite(meta.offset)) ? meta.offset : 0; - const mul = 2 / ((hi - lo) * scale); - const add = ((offset - lo) / (hi - lo)) * 2 - 1; - return [mul, add]; - } const c0 = this._axisCoord(axis, lo); const c1 = this._axisCoord(axis, hi); - if (![c0, c1].every(Number.isFinite) || c1 === c0) return [0, -2]; + if (![c0, c1].every(Number.isFinite) || c1 === c0) return degenerate; + // Log-family axes decode before mapping, so one coordinate-space affine + // serves every column on the axis; `dataMul` keeps the pre-fold slope a + // data-space width scaled by before linear axes started folding. const mul = 2 / (c1 - c0); - const add = -1 - c0 * mul; - return [mul, add]; + return { mul, add: -1 - c0 * mul, shift: 0, dataMul: mul }; } _mapConst(value, lo, hi, axisId = null) { @@ -5439,9 +5468,21 @@ export class ChartView { return 0; } - _setAxisUniforms(prog, prefix, meta, axisId) { + // Everything one encoded column needs, written together: the map, the meta it + // was folded from, and the axis transform. `range` is the axis window as + // `_axisRange` returns it; the fold happens HERE, against THIS column's own + // encoding, so a mark with four or six independently encoded columns can + // never hand the shader a map built for a sibling (which would be a wholly + // different transform, not a rounding difference). Programs that do not + // declare `${prefix}map` have no location to write, so the paired shaders and + // the per-column ones share one call shape. + _setAxisUniforms(prog, prefix, meta, axisId, range = null) { const gl = this.gl; const u = (n) => uniformOf(gl, prog, n); + if (range) { + const m = this._map(meta, range[0], range[1], axisId); + gl.uniform4f(u(`${prefix}map`), m.mul, m.add, m.shift, m.dataMul); + } gl.uniform2f(u(`${prefix}meta`), meta && Number.isFinite(meta.offset) ? meta.offset : 0, meta && meta.scale ? meta.scale : 1); gl.uniform1i(u(`${prefix}mode`), this._axisMode(axisId)); gl.uniform1f(u(`${prefix}constant`), this._axisConstant(axisId)); @@ -5990,7 +6031,7 @@ export class ChartView { Math.max(g.lodBlendShown ?? 0, g.lodBlend ?? 0) <= 0.001; } - _drawPoints(g, xm, ym, opacityScale = 1) { + _drawPoints(g, xr, yr, opacityScale = 1) { opacityScale *= (g._transitionOpacity ?? 1) * (g._legendDim ?? 1); // Pyplot-authored contours and glyphs keep these resident point buffers // for picking/transitions but paint on the Canvas2D overlay below. Queue @@ -6002,17 +6043,15 @@ export class ChartView { } const animationScale = g._transitionScale ?? 1; if (this._canDrawSimplePoints(g)) { - this._drawSimplePoints(g, xm, ym, opacityScale); + this._drawSimplePoints(g, xr, yr, opacityScale); return; } const gl = this.gl; const prog = this.pointProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); - this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis, yr); this._setPolarUniforms(prog); gl.uniform1f(u("u_dpr"), this.dpr); const zoomStyle = this._pointZoomStyle(g); @@ -6126,15 +6165,13 @@ export class ChartView { gl.drawArrays(gl.POINTS, 0, g.n); } - _drawSimplePoints(g, xm, ym, opacityScale = 1) { + _drawSimplePoints(g, xr, yr, opacityScale = 1) { const gl = this.gl; const prog = this.pointSimpleProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); - this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis, yr); this._setPolarUniforms(prog); gl.uniform1f(u("u_dpr"), this.dpr); const zoomStyle = this._pointZoomStyle(g); @@ -6172,23 +6209,16 @@ export class ChartView { if (!Number.isInteger(index) || index < 0 || index >= g.n) return; const [x0, x1] = this._axisRange(g.xAxis); const [y0, y1] = this._axisRange(g.yAxis); - this._drawHoverPoint( - g, - index, - this._map(g.xMeta, x0, x1, g.xAxis), - this._map(g.yMeta, y0, y1, g.yAxis) - ); + this._drawHoverPoint(g, index, [x0, x1], [y0, y1]); } - _drawHoverPoint(g, index, xm, ym) { + _drawHoverPoint(g, index, xr, yr) { const gl = this.gl; const prog = this.pointProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); - this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis, yr); this._setPolarUniforms(prog); // Size-channel points hover at their encoded size, not the scalar default // (sample traces keep no CPU copy of the size column; they fall back). @@ -6326,15 +6356,13 @@ export class ChartView { gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } - _drawLine(g, xm, ym, color = null, width = null, opacity = null) { + _drawLine(g, xr, yr, color = null, width = null, opacity = null) { if (g.n < 2) return; const gl = this.gl; gl.useProgram(this.lineProg); const u = (n) => uniformOf(gl, this.lineProg, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(this.lineProg, "u_x", g.xMeta, g.xAxis); - this._setAxisUniforms(this.lineProg, "u_y", g.yMeta, g.yAxis); + this._setAxisUniforms(this.lineProg, "u_x", g.xMeta, g.xAxis, xr); + this._setAxisUniforms(this.lineProg, "u_y", g.yMeta, g.yAxis, yr); this._setPolarUniforms(this.lineProg); gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); const transitionOn = !!(g._transitionPrevXBuf && g._transitionPrevYBuf); @@ -6381,18 +6409,16 @@ export class ChartView { gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, segments); } - _drawSegments(g, xm, ym) { + _drawSegments(g, xr, yr) { if (g.n < 1) return; const gl = this.gl; const prog = this.segmentProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); - this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); - this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); - this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); + this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis, yr); + this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis, yr); this._setPolarUniforms(prog); gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr); @@ -6508,16 +6534,14 @@ export class ChartView { return true; } - _drawMesh(g, xm, ym) { + _drawMesh(g, xr, yr) { if (g.n < 1) return; const gl = this.gl; const prog = this.meshProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.xAxis); - for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis); + for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.xAxis, xr); + for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis, yr); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * (g._legendDim ?? 1)); gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], g.color[3]); @@ -6611,19 +6635,18 @@ export class ChartView { return true; } - _drawArea(g, xm, ym, bm) { + _drawArea(g, xr, yr) { if (g.n < 2) return; const gl = this.gl; const prog = this.areaProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - gl.uniform2f(u("u_bmap"), bm[0], bm[1]); - this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); - this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis, yr); this._setPolarUniforms(prog); - this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis); + // The baseline rides the y axis but keeps its own offset encoding, so it + // folds the same window against its own meta. + this._setAxisUniforms(prog, "u_b", g.baseMeta, g.yAxis, yr); const reveal = Math.max(0, Math.min(1, g._transitionReveal ?? 1)); gl.uniform1f(u("u_revealProgress"), reveal); gl.uniform1f(u("u_revealSegments"), g.n - 1); @@ -6643,20 +6666,16 @@ export class ChartView { gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count); } - _drawRects(g, x0, x1, y0, y1, edgePad = [0, 0, 0, 0]) { + _drawRects(g, xr, yr, edgePad = [0, 0, 0, 0]) { if (!g.n) return; const gl = this.gl; const prog = this.rectProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_x0map"), x0[0], x0[1]); - gl.uniform2f(u("u_x1map"), x1[0], x1[1]); - gl.uniform2f(u("u_y0map"), y0[0], y0[1]); - gl.uniform2f(u("u_y1map"), y1[0], y1[1]); - this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis); - this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis); - this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis); - this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); + this._setAxisUniforms(prog, "u_x0", g.x0Meta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_x1", g.x1Meta, g.xAxis, xr); + this._setAxisUniforms(prog, "u_y0", g.y0Meta, g.yAxis, yr); + this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis, yr); gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1f(u("u_xconstant"), this._axisConstant(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); @@ -6722,20 +6741,17 @@ export class ChartView { } } - _drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad = 0) { + _drawBars(g, pr, vr, v0Const, v0EdgePad = 0) { if (!g.n) return; const gl = this.gl; const prog = this.barProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); - gl.uniform2f(u("u_pmap"), pmap[0], pmap[1]); - gl.uniform2f(u("u_v1map"), v1map[0], v1map[1]); - gl.uniform2f(u("u_v0map"), v0map ? v0map[0] : 1, v0map ? v0map[1] : 0); const pAxis = g.orientation === 1 ? g.yAxis : g.xAxis; const vAxis = g.orientation === 1 ? g.xAxis : g.yAxis; - this._setAxisUniforms(prog, "u_p", g.posMeta, pAxis); - this._setAxisUniforms(prog, "u_v1", g.value1Meta, vAxis); - this._setAxisUniforms(prog, "u_v0", g.value0Meta, vAxis); + this._setAxisUniforms(prog, "u_p", g.posMeta, pAxis, pr); + this._setAxisUniforms(prog, "u_v1", g.value1Meta, vAxis, vr); + this._setAxisUniforms(prog, "u_v0", g.value0Meta, vAxis, vr); // Bars name their axes u_p/u_v rather than u_x/u_y, so they need this // explicitly — without it u_coordMode stays 0 and a polar bar chart draws // cartesian rectangles inside correct polar chrome. @@ -7932,12 +7948,8 @@ export class ChartView { } const [px0, px1] = this._axisRange(pg.xAxis || g.xAxis); const [py0, py1] = this._axisRange(pg.yAxis || g.yAxis); - const xm = this._map(pg.xMeta, px0, px1, pg.xAxis || g.xAxis); - const ym = this._map(pg.yMeta, py0, py1, pg.yAxis || g.yAxis); - gl.uniform2f(u("u_xmap"), xm[0], xm[1]); - gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(prog, "u_x", pg.xMeta, pg.xAxis || g.xAxis); - this._setAxisUniforms(prog, "u_y", pg.yMeta, pg.yAxis || g.yAxis); + this._setAxisUniforms(prog, "u_x", pg.xMeta, pg.xAxis || g.xAxis, [px0, px1]); + this._setAxisUniforms(prog, "u_y", pg.yMeta, pg.yAxis || g.yAxis, [py0, py1]); // The pick buffer must use the SAME transform as the colour pass. Left // cartesian under polar it still returns ids, so the picture stays right // while hover silently reports whichever row happens to sit at the diff --git a/js/src/55_marks.ts b/js/src/55_marks.ts index 8bee3fa9..37b2c68f 100644 --- a/js/src/55_marks.ts +++ b/js/src/55_marks.ts @@ -13,8 +13,9 @@ import { chartBackdrop, parseColor } from "./20_theme"; // mark with its own vertex layout (bars, // candles) uploads its own buffers. // draw(view, g, x0, x1, y0, y1) — one frame in the current data window. -// xy marks map with view._map(); a mark -// with a different transform maps itself. +// Draws take axis WINDOWS ([lo, hi]); the +// view→clip fold is per encoded column and +// happens in _setAxisUniforms (§4/§16). // // Tiering is orthogonal: a density-tier trace is handled by 45_lod.js before // this registry is consulted (its drilled marks still render as points today). @@ -41,14 +42,7 @@ const RECT_MARK = { const edgePad = g.trace.kind === "histogram" ? [0, 0, view._edgePadForValue(0, y0, y1, view.canvas.height), 0] : [0, 0, 0, 0]; - view._drawRects( - g, - view._map(g.x0Meta, x0, x1, g.xAxis), - view._map(g.x1Meta, x0, x1, g.xAxis), - view._map(g.y0Meta, y0, y1, g.yAxis), - view._map(g.y1Meta, y0, y1, g.yAxis), - edgePad - ); + view._drawRects(g, [x0, x1], [y0, y1], edgePad); }, refreshColor: (view, g) => { if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); @@ -69,11 +63,6 @@ const BAR_MARK = { const vAxis = horizontal ? g.xAxis : g.yAxis; const [p0, p1] = view._axisRange(pAxis); const [v0, v1] = view._axisRange(vAxis); - const pmap = view._map(g.posMeta, p0, p1, pAxis); - const v1map = view._map(g.value1Meta, v0, v1, vAxis); - const v0map = g.value0Mode === 1 - ? view._map(g.value0Meta, v0, v1, vAxis) - : null; const v0Const = g.value0Mode === 0 ? view._mapConst(g.value0Const, v0, v1, vAxis) : null; @@ -85,7 +74,7 @@ const BAR_MARK = { horizontal ? view.canvas.width : view.canvas.height ) : 0; - view._drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad); + view._drawBars(g, [p0, p1], [v0, v1], v0Const, v0EdgePad); }, refreshColor: (view, g) => { if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); @@ -99,11 +88,7 @@ const SEGMENT_MARK = { draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); - view._drawSegments( - g, - view._map(g.x0Meta, x0, x1, g.xAxis), - view._map(g.y0Meta, y0, y1, g.yAxis), - ); + view._drawSegments(g, [x0, x1], [y0, y1]); }, refreshColor: (view, g) => { if (!g.colorMode) g.color = parseColor(view.root, g.trace.style.color, g.color); @@ -113,13 +98,11 @@ const SEGMENT_MARK = { const AREA_MARK = { build: (view, g, t, buffer) => view._buildAreaMark(g, t, buffer), draw: (view, g) => { - const [x0, x1] = view._axisRange(g.xAxis); - const [y0, y1] = view._axisRange(g.yAxis); - const xm = view._map(g.xMeta, x0, x1, g.xAxis); - const ym = view._map(g.yMeta, y0, y1, g.yAxis); - view._drawArea(g, xm, ym, view._map(g.baseMeta, y0, y1, g.yAxis)); + const xr = view._axisRange(g.xAxis); + const yr = view._axisRange(g.yAxis); + view._drawArea(g, xr, yr); if ((g.trace.style.line_width ?? 0) > 0) { - view._drawLine(g, xm, ym, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); + view._drawLine(g, xr, yr, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); if (g.trace.style.stroke_perimeter) { // fill_between is a closed polygon. Draw its second boundary too; // the generic area mark intentionally outlines only the value curve. @@ -127,7 +110,7 @@ const AREA_MARK = { g.yBuf = g.baseBuf; g.yMeta = g.baseMeta; g._dashY = g._cpu.base; - view._drawLine(g, xm, ym, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); + view._drawLine(g, xr, yr, g.lineColor, g.trace.style.line_width, g.trace.style.line_opacity ?? 1); g.yBuf = yBuf; g.yMeta = yMeta; g._dashY = dashY; @@ -146,7 +129,7 @@ const MESH_MARK = { draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); - view._drawMesh(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); + view._drawMesh(g, [x0, x1], [y0, y1]); }, refreshColor: (view, g) => { if (g.colorMode === 0 && g.trace.color) g.color = parseColor(view.root, g.trace.color.color, g.color); @@ -171,11 +154,7 @@ export const MARK_KINDS = { draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); - view._drawRibbons( - g, - view._map(g.x0Meta, x0, x1, g.xAxis), - view._map(g.y0Meta, y0, y1, g.yAxis), - ); + view._drawRibbons(g, [x0, x1], [y0, y1]); }, // No pointPick: the GPU id pass draws gl.POINTS from the xy slots, which // for a ribbon are the target span's y values — garbage ids. Hover works @@ -199,7 +178,7 @@ export const MARK_KINDS = { draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); - view._drawFunnels(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); + view._drawFunnels(g, [x0, x1], [y0, y1]); }, // No pointPick: the GPU id pass draws gl.POINTS from the xy slots, which // for a funnel hold trailing cross edges — garbage ids. Hover works @@ -232,7 +211,7 @@ export const MARK_KINDS = { if (g.authoredMarker) return; const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); - view._drawMesh(g, view._map(g.x0Meta, x0, x1, g.xAxis), view._map(g.y0Meta, y0, y1, g.yAxis)); + view._drawMesh(g, [x0, x1], [y0, y1]); }, refreshColor: (view, g) => { if (g.colorMode === 0 && g.trace.color) g.color = parseColor(view.root, g.trace.color.color, g.color); @@ -251,7 +230,7 @@ export const MARK_KINDS = { draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); - view._drawPoints(g, view._map(g.xMeta, x0, x1, g.xAxis), view._map(g.yMeta, y0, y1, g.yAxis)); + view._drawPoints(g, [x0, x1], [y0, y1]); }, pointPick: true, retainCpu: true, @@ -267,7 +246,7 @@ export const MARK_KINDS = { draw: (view, g) => { const [x0, x1] = view._axisRange(g.xAxis); const [y0, y1] = view._axisRange(g.yAxis); - view._drawLine(g, view._map(g.xMeta, x0, x1, g.xAxis), view._map(g.yMeta, y0, y1, g.yAxis)); + view._drawLine(g, [x0, x1], [y0, y1]); }, refreshColor: (view, g) => { g.color = parseColor(view.root, g.trace.style.color, g.color); diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 79336f1d..a80d8ecb 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -55,8 +55,12 @@ relative mass, not as a budget (see §3 on why a line count failed as a metric). dense opaque mark (heatmap) would otherwise bury them. Crisp text, selectable tooltips, zero GL cost for chrome — correct division (§7). - **Uniform-only pan/zoom**: geometry is static offset-encoded f32; view - changes touch two vec2 uniforms per mark (`_map`). This is why interaction - is cheap; nothing below may regress it. + changes touch one `vec4` map uniform per *encoded column* (`_map`, written + beside that column's meta by `_setAxisUniforms`). The map is + `(mul, add, shift, dataMul)`: the linear fold's slope and intercept in the + column's own encoding, the f32-snapped re-centring term, and the same slope + per data unit for widths (dossier §16). This is why interaction is cheap; + nothing below may regress it. - **Coordinate-system seam, including non-vertex grids:** point/line/area/bar programs call the shared joint polar projection after ordinary axis decode and scale. Heatmap remains a fullscreen quad and performs the inverse joint @@ -141,7 +145,10 @@ Ordered by how much each compounds as kinds multiply. lints every shader at build time: `#version 300 es` first line, every FS declares `precision highp float;`, every VS references a `u_*map` uniform (quad shaders exempted by name), uniforms `u_`-prefixed, attributes - `a`-prefixed. Violations fail the build (negative-tested). + `a`-prefixed, every `u_*map` declared `vec4`, and every `xyMap` call pairing + one column's map with that same column's meta (dossier §16 — a mismatched + pair moves or deletes the mark). Violations fail the build + (negative-tested). - **R6 — Instancing is per-mark bespoke.** Line uses 4-corner strip + divisor-1 endpoints; rects likewise; points use POINTS. `MARK_KINDS` now registers 18 kind names over 9 mark objects (`RECT_MARK`, `BAR_MARK`, diff --git a/tests/test_axis_map_precision.py b/tests/test_axis_map_precision.py new file mode 100644 index 00000000..306abe9c --- /dev/null +++ b/tests/test_axis_map_precision.py @@ -0,0 +1,139 @@ +"""The linear-axis fold: high-magnitude precision and per-column map pairing. + +Issue #487 — a millisecond-epoch x axis renders as stepped columns with gaps — +is a numeric property of the vertex transform, so the first test here is the +arithmetic itself, in f32, with no browser. The rest pin the structural +invariants the fix depends on: one map per *encoded column*, never one per +axis, and one place that builds them (§4/§16). +""" + +from __future__ import annotations + +import re +import struct +from pathlib import Path + +import pytest + +JS = Path(__file__).parents[1] / "js" / "src" +GL_SRC = (JS / "40_gl.ts").read_text(encoding="utf-8") +VIEW_SRC = (JS / "50_chartview.ts").read_text(encoding="utf-8") + +# 2024-06-01T00:00:00Z in ms since the epoch, four hours of it, 30k samples: +# the reporter's shape (hundreds of samples a second over many hours). +T0 = 1_717_200_000_000.0 +SPAN_MS = 4 * 60 * 60 * 1000.0 +N = 30_000 +PLOT_PX = 820 + + +def f32(value: float) -> float: + """Round through a 32-bit float, the way a GPU uniform or attribute does.""" + return struct.unpack(" tuple[list[float], float, float, float, float]: + step = SPAN_MS / (N - 1) + values = [T0 + i * step for i in range(N)] + offset = T0 + SPAN_MS / 2 # Column.suggest_offset: the domain midpoint + scale = 1.0 + return values, offset, scale, T0, T0 + SPAN_MS + + +def _columns(clips: list[float]) -> int: + """Distinct pixel columns a run of clip-space x coordinates lands on.""" + return len({round((clip + 1) / 2 * PLOT_PX) for clip in clips}) + + +def _folded_clips() -> list[float]: + """`_map` + `xyMap` mode 0: fold on the CPU in f64, apply to encoded f32.""" + values, offset, scale, lo, hi = _series() + data_mul = 2 / (hi - lo) + mul = data_mul / scale + shift = f32(((lo + hi) / 2 - offset) * scale) + add = (offset + shift / scale - lo) * data_mul - 1 + return [f32(f32(f32((v - offset) * scale) - shift) * f32(mul) + f32(add)) for v in values] + + +def _decoded_clips() -> list[float]: + """The pre-fix path: rebuild the absolute coordinate in f32, then map.""" + values, offset, scale, lo, hi = _series() + mul = f32(2 / (hi - lo)) + add = f32(-1 - lo * (2 / (hi - lo))) + out = [] + for v in values: + encoded = f32((v - offset) * scale) + decoded = f32(f32(encoded / f32(scale)) + f32(offset)) + out.append(f32(f32(decoded * mul) + add)) + return out + + +def test_decoding_a_millisecond_epoch_in_f32_quantises_the_series() -> None: + """The bug, stated as arithmetic: f32 cannot hold 1.7e12 to the millisecond. + + Its quantum there is 2**17 ms, so four hours of samples can only occupy + SPAN / 2**17 distinct positions however many points are shipped. + """ + reachable = SPAN_MS / 2**17 + assert _columns(_decoded_clips()) == pytest.approx(reachable, rel=0.15) + + +def test_the_linear_fold_keeps_the_full_intra_view_spread() -> None: + """Folding the offset in f64 leaves the encoded value's own precision.""" + assert _columns(_folded_clips()) == PLOT_PX + 1 + + +def test_the_fold_lands_the_endpoints_on_the_view_edges() -> None: + """Precision is worthless if the transform itself has drifted.""" + clips = _folded_clips() + assert clips[0] == pytest.approx(-1.0, abs=1e-5) + assert clips[-1] == pytest.approx(1.0, abs=1e-5) + + +def test_every_shader_map_is_paired_with_its_own_columns_meta() -> None: + """A map folded for one column is a different transform, not a rounding + difference, so `xyMap` must never receive a sibling column's map: the + stems of the map and meta arguments have to match.""" + calls = re.findall(r"xyMap\(\s*([\w.]+),\s*([\w.]+),\s*([\w.]+),", GL_SRC) + assert calls, "no xyMap call sites found — the extraction regex is broken" + mismatched = [ + (mapped, meta) + for _, mapped, meta in calls + if not (mapped.endswith("map") and meta.endswith("meta") and mapped[:-3] == meta[:-4]) + ] + assert mismatched == [], f"xyMap called with a map from another column: {mismatched}" + + +def test_every_map_uniform_carries_the_four_folded_components() -> None: + """(mul, add, shift, dataMul) — a vec2 declaration means a stale shader.""" + narrow = re.findall(r"uniform\s+vec[23]\s+(u_\w*map)\b", GL_SRC) + assert narrow == [], f"map uniforms must be vec4: {narrow}" + + +def test_maps_are_built_in_exactly_one_place() -> None: + """`_setAxisUniforms` writes the map beside the meta it was folded from. + + Any other caller of `_map` is free to pair them wrongly, which is the + regression this whole module exists to prevent. + """ + assert VIEW_SRC.count("this._map(") == 1, "_map must have exactly one caller" + body = VIEW_SRC.split("_setAxisUniforms(prog, prefix, meta, axisId, range = null) {", 1)[1] + body = body.split("\n }\n", 1)[0] + assert "this._map(meta, range[0], range[1], axisId)" in body + assert "uniform4f(u(`${prefix}map`)" in body + assert not re.search(r'uniform[234]f\(u\("u_\w*map"\)', VIEW_SRC), ( + "map uniforms must only be written by _setAxisUniforms" + ) + + +def test_the_fold_floors_a_degenerate_encode_scale() -> None: + """`xyDecode` guards with `max(abs(meta.y), 1e-30)`; the CPU fold divides + by the same scale and must floor it identically, or a zero-scale column + yields an infinite slope and NaN clip positions.""" + body = VIEW_SRC.split("_map(meta, lo, hi, axisId = null) {", 1)[1].split("\n }\n", 1)[0] + assert "1e-30" in body, "the encode-scale floor is missing from the linear fold" + # And the constants are validated as f32, since that is how they travel to + # the GPU: an f64-finite slope that overflows on upload is still Infinity + # in the shader, and `encoded * Infinity` rasterizes as NaN. + assert "Number.isFinite(Math.fround(mul))" in body + assert "Number.isFinite(Math.fround(add))" in body diff --git a/tests/test_funnel.py b/tests/test_funnel.py index a5dea1e3..5a32f04d 100644 --- a/tests/test_funnel.py +++ b/tests/test_funnel.py @@ -1124,7 +1124,7 @@ def test_client_funnel_draw_multiplies_transition_opacity_into_both_paints() -> source = (Path(__file__).parents[1] / "js" / "src" / "50_chartview.ts").read_text( encoding="utf-8" ) - draw = source.split("_drawFunnels(g, xm, ym) {")[1].split("\n }\n\n", 1)[0] + draw = source.split("_drawFunnels(g, xr, yr) {")[1].split("\n }\n\n", 1)[0] assert "const transitionAlpha = (g._transitionOpacity ?? 1)" in draw assert 'u("u_opacity"), this._fillOpacity(g.trace.style) * transitionAlpha' in draw assert 'u("u_strokeOpacity")' in draw and "* transitionAlpha" in draw From 79f948fd8c9f895cdf6b493466cc32b96539d24d Mon Sep 17 00:00:00 2001 From: Alastair Crabtree Date: Thu, 20 Aug 2026 07:25:20 +0000 Subject: [PATCH 3/4] Transform bar edges on non-affine axes; use the shipped encode scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the per-column fold. A bar's width is a data-space span, so its clip extent is the *image* of [pos - w/2, pos + w/2], not a slope times the width. Scaling by the coordinate-space slope sized a log-axis bar in log units: a bar at x=100 of width 10 covered the whole plot instead of x=95..105. BAR_VS now transforms both edges on log/symlog — decoding is safe there, since §16 pins those axes' encode offset to 0 — which is what RECT_VS already does with four separate edge columns. Linear axes keep scaling by the data-space slope: the transform is affine, so the two agree, and it is the only form that avoids rebuilding the absolute position in f32. An edge that leaves a log axis's domain collapses onto the centre rather than culling the bar, and the two offsets are measured from the bar's own position so a transition keeps its shape. Because that slope has no meaning on a non-affine axis, `_map` now reports `dataMul` 0 there instead of the coordinate slope. A zero-width bar is a visible mistake; a plausible-looking coordinate slope is not. The encode-scale floor is gone. Flooring |scale| at 1e-30 was wrong for the legitimately tiny scales an enormous finite domain produces: the fold must divide by the very scale the vertex buffer was encoded with, and it has f64 to do it in. A scale of exactly zero encodes every value to 0, so that case is now expressed directly — the column sits on its offset — with no division and no epsilon. The f32 overflow check on the constants still catches what remains. The structural tests split the TypeScript on literal indentation and prose, so any reformat failed them without a behaviour change. They now drive the shipped `ChartView.prototype._map` out of the built ES bundle through node and apply the shader's affine in f32 on top of the constants it returns — behavioural, and immune to formatting. What is left as source regex is only what a numeric test cannot see: the map/meta pairing at each xyMap call site, vec4 map uniforms, and the bar's edge transform. All four new assertions fail against the previous commit. --- js/src/40_gl.ts | 37 ++++- js/src/50_chartview.ts | 23 +++- tests/test_axis_map_precision.py | 225 +++++++++++++++++++++---------- 3 files changed, 205 insertions(+), 80 deletions(-) diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 7fdd7dae..c065a062 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -1405,7 +1405,38 @@ void main() { } v0 += u_v0EdgePad; v1 = mix(v0, v1, u_animationProgress); - float halfW = abs(width * u_pmap.w) * 0.5; + // The bar's clip extent is the IMAGE of the data-space interval + // [pos - width/2, pos + width/2], not a slope times the width. On a linear + // axis the transform is affine and the two coincide, so the width scales by + // map.w (clip units per DATA unit) — the only form that also avoids + // rebuilding the absolute position in f32 (§16). Log-family axes are not + // affine: a bar at x=100 of width 10 spans x=95..105, which is a wider left + // half than right, not ten log units either side. Those decode (safe — §16 + // pins their encode offset to 0) and map each edge, which is what RECT_VS + // already does with four separate edge columns. + // + // The two offsets are measured from the bar's OWN position, then applied + // around the transition-mixed p, so a growing/moving bar keeps its shape. + float dLo = -abs(width * u_pmap.w) * 0.5; + float dHi = -dLo; + if (u_pmode != 0) { + float pv = xyDecode(a_pos, u_pmeta); + float hw = abs(width) * 0.5; + float cC = xyViewCoord(pv, u_pmode, u_pconstant); + float cLo = xyViewCoord(pv - hw, u_pmode, u_pconstant); + float cHi = xyViewCoord(pv + hw, u_pmode, u_pconstant); + // An edge that leaves a log axis's domain has no coordinate. Collapse that + // side onto the centre rather than culling the bar: the half that does + // exist is still real, and matplotlib and Plotly both keep drawing it. + if (isnan(cLo) || cLo < -1e29) cLo = cC; + if (isnan(cHi) || cHi < -1e29) cHi = cC; + // Ordered, so a reversed axis keeps clipA left of clipB and the corner SDF + // frame below stays the one a forward axis produces. + float a = (cLo - cC) * u_pmap.x; + float b = (cHi - cC) * u_pmap.x; + dLo = min(a, b); + dHi = max(a, b); + } v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; if (u_coordMode == 1) { // A polar bar is an annular sector, which four corners cannot express. The @@ -1450,11 +1481,11 @@ void main() { } vec2 clipA, clipB; if (u_orientation == 0) { - clipA = vec2(p - halfW, v0); clipB = vec2(p + halfW, v1); + clipA = vec2(p + dLo, v0); clipB = vec2(p + dHi, v1); gl_Position = vec4(mix(clipA.x, clipB.x, c.x), mix(clipA.y, clipB.y, c.y), 0.0, 1.0); v_t = c.y; } else { - clipA = vec2(v0, p - halfW); clipB = vec2(v1, p + halfW); + clipA = vec2(v0, p + dLo); clipB = vec2(v1, p + dHi); gl_Position = vec4(mix(clipA.x, clipB.x, c.x), mix(clipA.y, clipB.y, c.y), 0.0, 1.0); v_t = c.x; } diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index c6709d85..e03b4512 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -5404,6 +5404,10 @@ export class ChartView { // folded into `add` — an f64-only shift would reintroduce the very error it // exists to remove. `dataMul` is the slope per *data* unit, which is what a // data-space width (a bar's) must scale by; `mul` is per *encoded* unit. + // A non-affine axis has no such constant, so `dataMul` is 0 there and a + // caller needing a data-space span transforms both of its edges instead + // (BAR_VS) — a zero-width bar is a visible mistake, a plausible-looking + // coordinate-space slope is not. // // Marks pass WINDOWS around, not maps: several of them place four or six // independently encoded columns per axis, and a map is only valid for the @@ -5417,12 +5421,18 @@ export class ChartView { if (!axisId || this._axisMode(axisId) === 0) { const span = hi - lo; if (!Number.isFinite(span) || span === 0) return degenerate; - // Mirrors xyDecode's `max(abs(meta.y), 1e-30)` floor: a zero or denormal - // encode scale must not turn the folded slope into Infinity. - const rawScale = meta && Number.isFinite(meta.scale) ? meta.scale : 1; - const scale = Math.abs(rawScale) >= 1e-30 ? rawScale : (rawScale < 0 ? -1e-30 : 1e-30); + const scale = meta && Number.isFinite(meta.scale) ? meta.scale : 1; const offset = meta && Number.isFinite(meta.offset) ? meta.offset : 0; const dataMul = 2 / span; + // A zero encode scale encodes every value to 0, so the honest picture is + // "the whole column sits on its offset" — expressible exactly, with no + // division and no epsilon. Flooring |scale| instead would be wrong for + // the legitimately tiny scales an enormous finite domain produces + // (~1e-38): the fold has f64 to divide in, and must use the very scale + // the vertex buffer was encoded with, not a clamped stand-in. + if (scale === 0) { + return { mul: 0, add: (offset - lo) * dataMul - 1, shift: 0, dataMul }; + } const mul = dataMul / scale; const shiftRaw = Math.fround(((lo + hi) / 2 - offset) * scale); const shift = Number.isFinite(shiftRaw) ? shiftRaw : 0; @@ -5441,10 +5451,9 @@ export class ChartView { const c1 = this._axisCoord(axis, hi); if (![c0, c1].every(Number.isFinite) || c1 === c0) return degenerate; // Log-family axes decode before mapping, so one coordinate-space affine - // serves every column on the axis; `dataMul` keeps the pre-fold slope a - // data-space width scaled by before linear axes started folding. + // serves every column on the axis. const mul = 2 / (c1 - c0); - return { mul, add: -1 - c0 * mul, shift: 0, dataMul: mul }; + return { mul, add: -1 - c0 * mul, shift: 0, dataMul: 0 }; } _mapConst(value, lo, hi, axisId = null) { diff --git a/tests/test_axis_map_precision.py b/tests/test_axis_map_precision.py index 306abe9c..9fb0db87 100644 --- a/tests/test_axis_map_precision.py +++ b/tests/test_axis_map_precision.py @@ -1,23 +1,34 @@ """The linear-axis fold: high-magnitude precision and per-column map pairing. Issue #487 — a millisecond-epoch x axis renders as stepped columns with gaps — -is a numeric property of the vertex transform, so the first test here is the -arithmetic itself, in f32, with no browser. The rest pin the structural -invariants the fix depends on: one map per *encoded column*, never one per -axis, and one place that builds them (§4/§16). +is a numeric property of the vertex transform, so most of this module drives +the *shipped* `_map` out of the built ES bundle through node and applies the +shader's own one-line affine in f32 on top of the constants it returns. That +keeps the assertions behavioural: they survive any reformatting of the client +and fail on a real change of transform. + +The remaining checks are whitespace-tolerant regexes over the GLSL, for the two +invariants a numeric test cannot see: that every `xyMap` call takes the map and +the meta of the *same* encoded column, and that a bar's data-space width is +transformed rather than scaled on a non-affine axis (§4/§16). + +Run `node js/build.mjs` once per checkout so the bundle exists. """ from __future__ import annotations +import json import re import struct +import subprocess from pathlib import Path import pytest -JS = Path(__file__).parents[1] / "js" / "src" -GL_SRC = (JS / "40_gl.ts").read_text(encoding="utf-8") -VIEW_SRC = (JS / "50_chartview.ts").read_text(encoding="utf-8") +ROOT = Path(__file__).resolve().parents[1] +BUNDLE = ROOT / "python" / "xy" / "static" / "index.js" +GL_SRC = (ROOT / "js" / "src" / "40_gl.ts").read_text(encoding="utf-8") +VIEW_SRC = (ROOT / "js" / "src" / "50_chartview.ts").read_text(encoding="utf-8") # 2024-06-01T00:00:00Z in ms since the epoch, four hours of it, 30k samples: # the reporter's shape (hundreds of samples a second over many hours). @@ -26,75 +37,158 @@ N = 30_000 PLOT_PX = 820 +pytestmark = pytest.mark.skipif( + not BUNDLE.exists(), reason="run `node js/build.mjs` to build the client bundle" +) + def f32(value: float) -> float: """Round through a 32-bit float, the way a GPU uniform or attribute does.""" return struct.unpack(" tuple[list[float], float, float, float, float]: - step = SPAN_MS / (N - 1) - values = [T0 + i * step for i in range(N)] - offset = T0 + SPAN_MS / 2 # Column.suggest_offset: the domain midpoint - scale = 1.0 - return values, offset, scale, T0, T0 + SPAN_MS +def js_maps(cases: list[dict]) -> list[dict]: + """Call the shipped `ChartView.prototype._map` on each case. + `_map` reads only `_axis`/`_axisMode`/`_axisCoord`, all of which are pure + apart from `this.axes`, so a bare context is enough — no DOM, no WebGL. + """ + script = """ +import { ChartView } from "./python/xy/static/index.js"; +const proto = ChartView.prototype; +const out = JSON.parse(process.env.XY_CASES).map((c) => { + const ctx = { + axes: { x: c.axis || {} }, + _axis: proto._axis, + _axisMode: proto._axisMode, + _axisCoord: proto._axisCoord, + _axisConstant: proto._axisConstant, + }; + return proto._map.call(ctx, c.meta, c.lo, c.hi, "x"); +}); +process.stdout.write(JSON.stringify(out)); +""" + completed = subprocess.run( + ["node", "--input-type=module", "--eval", script], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + check=True, + env={"PATH": "/usr/bin:/bin:/usr/local/bin", "XY_CASES": json.dumps(cases)}, + ) + return json.loads(completed.stdout) -def _columns(clips: list[float]) -> int: - """Distinct pixel columns a run of clip-space x coordinates lands on.""" - return len({round((clip + 1) / 2 * PLOT_PX) for clip in clips}) +def clip(encoded: float, m: dict) -> float: + """`xyMap` mode 0, in f32: `(encoded - map.z) * map.x + map.y`.""" + return f32(f32(f32(encoded) - f32(m["shift"])) * f32(m["mul"]) + f32(m["add"])) -def _folded_clips() -> list[float]: - """`_map` + `xyMap` mode 0: fold on the CPU in f64, apply to encoded f32.""" - values, offset, scale, lo, hi = _series() - data_mul = 2 / (hi - lo) - mul = data_mul / scale - shift = f32(((lo + hi) / 2 - offset) * scale) - add = (offset + shift / scale - lo) * data_mul - 1 - return [f32(f32(f32((v - offset) * scale) - shift) * f32(mul) + f32(add)) for v in values] +def columns(clips: list[float]) -> int: + """Distinct pixel columns a run of clip-space x coordinates lands on.""" + return len({round((c + 1) / 2 * PLOT_PX) for c in clips}) -def _decoded_clips() -> list[float]: - """The pre-fix path: rebuild the absolute coordinate in f32, then map.""" - values, offset, scale, lo, hi = _series() - mul = f32(2 / (hi - lo)) - add = f32(-1 - lo * (2 / (hi - lo))) - out = [] - for v in values: - encoded = f32((v - offset) * scale) - decoded = f32(f32(encoded / f32(scale)) + f32(offset)) - out.append(f32(f32(decoded * mul) + add)) - return out + +def epoch_series() -> tuple[list[float], float, float, float]: + step = SPAN_MS / (N - 1) + values = [T0 + i * step for i in range(N)] + offset = T0 + SPAN_MS / 2 # Column.suggest_offset: the domain midpoint + return values, offset, T0, T0 + SPAN_MS def test_decoding_a_millisecond_epoch_in_f32_quantises_the_series() -> None: """The bug, stated as arithmetic: f32 cannot hold 1.7e12 to the millisecond. Its quantum there is 2**17 ms, so four hours of samples can only occupy - SPAN / 2**17 distinct positions however many points are shipped. + SPAN / 2**17 distinct positions however many points are shipped. This is + the transform the client used to apply, written out; nothing calls it now. """ - reachable = SPAN_MS / 2**17 - assert _columns(_decoded_clips()) == pytest.approx(reachable, rel=0.15) + values, offset, lo, hi = epoch_series() + mul, add = f32(2 / (hi - lo)), f32(-1 - lo * (2 / (hi - lo))) + decoded = [f32(f32(f32(f32(v - offset) + f32(offset)) * mul) + add) for v in values] + assert columns(decoded) == pytest.approx(SPAN_MS / 2**17, rel=0.15) -def test_the_linear_fold_keeps_the_full_intra_view_spread() -> None: +def test_the_shipped_fold_keeps_the_full_intra_view_spread() -> None: """Folding the offset in f64 leaves the encoded value's own precision.""" - assert _columns(_folded_clips()) == PLOT_PX + 1 - - -def test_the_fold_lands_the_endpoints_on_the_view_edges() -> None: - """Precision is worthless if the transform itself has drifted.""" - clips = _folded_clips() + values, offset, lo, hi = epoch_series() + (m,) = js_maps([{"meta": {"offset": offset, "scale": 1.0}, "lo": lo, "hi": hi}]) + clips = [clip(v - offset, m) for v in values] + assert columns(clips) == PLOT_PX + 1 + # Precision is worthless if the transform itself has drifted. assert clips[0] == pytest.approx(-1.0, abs=1e-5) assert clips[-1] == pytest.approx(1.0, abs=1e-5) +def test_a_zero_encode_scale_pins_the_column_to_its_offset() -> None: + """Every encoded value is 0, so the honest picture is "all on the offset". + + Dividing by that scale instead hands the shader an infinite slope, and + `encoded * Infinity` rasterizes as NaN — the trace vanishes. + """ + (m,) = js_maps([{"meta": {"offset": 400.0, "scale": 0.0}, "lo": 0.0, "hi": 1000.0}]) + assert m["mul"] == 0 + assert clip(0.0, m) == pytest.approx(400.0 / 1000.0 * 2 - 1, abs=1e-6) + + +def test_a_tiny_but_finite_encode_scale_is_used_as_shipped() -> None: + """An enormous finite domain gets a legitimately tiny encode scale. + + The fold divides in f64, so it must use the very scale the vertex buffer + was encoded with. Flooring |scale| at some epsilon would rescale the whole + trace — at 1e-30 this case would land its endpoints at ±0.1, not ±1. + """ + scale, offset, half = 1e-31, 5e68, 5e68 + (m,) = js_maps([{"meta": {"offset": offset, "scale": scale}, "lo": 0.0, "hi": 2 * half}]) + assert clip((0.0 - offset) * scale, m) == pytest.approx(-1.0, abs=1e-3) + assert clip((2 * half - offset) * scale, m) == pytest.approx(1.0, abs=1e-3) + + +def test_a_non_affine_axis_reports_no_data_space_slope() -> None: + """There is no constant clip-per-data-unit on log/symlog, so `dataMul` is + 0 and a caller needing a data-space span must transform both edges.""" + maps = js_maps( + [ + { + "axis": {"scale": "log"}, + "meta": {"offset": 0.0, "scale": 1.0}, + "lo": 1.0, + "hi": 1000.0, + }, + { + "axis": {"scale": "symlog", "constant": 1}, + "meta": {"offset": 0.0, "scale": 1.0}, + "lo": -100.0, + "hi": 100.0, + }, + ] + ) + assert [m["dataMul"] for m in maps] == [0, 0] + assert [m["shift"] for m in maps] == [0, 0] + # A linear axis does report one: clip units per data unit over the window. + (linear,) = js_maps([{"meta": {"offset": 0.0, "scale": 1.0}, "lo": 0.0, "hi": 1000.0}]) + assert linear["dataMul"] == pytest.approx(2 / 1000) + + +def test_a_degenerate_window_maps_off_screen() -> None: + """`mul` 0 with `add` -2 parks the mark outside clip space, which is what + every unrepresentable transform has always returned.""" + maps = js_maps( + [ + {"meta": {"offset": 0.0, "scale": 1.0}, "lo": 5.0, "hi": 5.0}, + {"axis": {"scale": "log"}, "meta": {"offset": 0.0, "scale": 1.0}, "lo": 0.0, "hi": 0.0}, + ] + ) + for m in maps: + assert (m["mul"], m["add"]) == (0, -2) + + def test_every_shader_map_is_paired_with_its_own_columns_meta() -> None: """A map folded for one column is a different transform, not a rounding difference, so `xyMap` must never receive a sibling column's map: the stems of the map and meta arguments have to match.""" - calls = re.findall(r"xyMap\(\s*([\w.]+),\s*([\w.]+),\s*([\w.]+),", GL_SRC) + calls = re.findall(r"xyMap\(\s*([\w.]+),\s*([\w.]+),\s*([\w.]+)\s*,", GL_SRC) assert calls, "no xyMap call sites found — the extraction regex is broken" mismatched = [ (mapped, meta) @@ -110,30 +204,21 @@ def test_every_map_uniform_carries_the_four_folded_components() -> None: assert narrow == [], f"map uniforms must be vec4: {narrow}" -def test_maps_are_built_in_exactly_one_place() -> None: - """`_setAxisUniforms` writes the map beside the meta it was folded from. +def test_a_bar_transforms_both_edges_on_a_non_affine_axis() -> None: + """A bar's width is a data-space span. Scaling it by a coordinate-space + slope sizes a log-axis bar in log units; the edges must be transformed.""" + bar = GL_SRC.split("export const BAR_VS", 1)[1].split("export const", 1)[0] + assert re.search(r"u_pmode\s*!=\s*0", bar), "BAR_VS has no non-affine width branch" + assert bar.count("xyViewCoord(") >= 3, "BAR_VS must map both edges and the centre" + # And the affine branch scales by the DATA-space slope, never map.x. + assert re.search(r"width\s*\*\s*u_pmap\.w", bar) + assert not re.search(r"width\s*\*\s*u_pmap\.x", bar) - Any other caller of `_map` is free to pair them wrongly, which is the - regression this whole module exists to prevent. - """ + +def test_a_map_is_only_ever_written_beside_its_own_meta() -> None: + """One producer (`_setAxisUniforms`), so no draw can pair them wrongly.""" assert VIEW_SRC.count("this._map(") == 1, "_map must have exactly one caller" - body = VIEW_SRC.split("_setAxisUniforms(prog, prefix, meta, axisId, range = null) {", 1)[1] - body = body.split("\n }\n", 1)[0] - assert "this._map(meta, range[0], range[1], axisId)" in body - assert "uniform4f(u(`${prefix}map`)" in body - assert not re.search(r'uniform[234]f\(u\("u_\w*map"\)', VIEW_SRC), ( - "map uniforms must only be written by _setAxisUniforms" + literal_writes = re.findall(r'uniform[234]f\(\s*u\(\s*"u_\w*map"', VIEW_SRC) + assert literal_writes == [], ( + f"map uniforms must be written by _setAxisUniforms alone: {literal_writes}" ) - - -def test_the_fold_floors_a_degenerate_encode_scale() -> None: - """`xyDecode` guards with `max(abs(meta.y), 1e-30)`; the CPU fold divides - by the same scale and must floor it identically, or a zero-scale column - yields an infinite slope and NaN clip positions.""" - body = VIEW_SRC.split("_map(meta, lo, hi, axisId = null) {", 1)[1].split("\n }\n", 1)[0] - assert "1e-30" in body, "the encode-scale floor is missing from the linear fold" - # And the constants are validated as f32, since that is how they travel to - # the GPU: an f64-finite slope that overflows on upload is still Infinity - # in the shader, and `encoded * Infinity` rasterizes as NaN. - assert "Number.isFinite(Math.fround(mul))" in body - assert "Number.isFinite(Math.fround(add))" in body From 7cfcffa75efead03230d52bab50be7fa97a8475d Mon Sep 17 00:00:00 2001 From: Alastair Crabtree Date: Thu, 20 Aug 2026 08:20:09 +0000 Subject: [PATCH 4/4] Validate every map constant in f32; scope the bundle skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four follow-up review findings. `_map` had two exits that skipped the f32 finiteness check — the zero-scale one, and `dataMul` on every exit. An f64-finite constant that overflows on upload is still an Infinity in the shader, so a zero-scale column with a distant offset could hand the rasterizer a NaN, and a view span too narrow for f32 to hold 2/span made `BAR_VS` multiply a width by Infinity. Every non-degenerate exit now goes through one validator. Unrepresentable positions park the mark off-screen as before; an unrepresentable `dataMul` zeroes only itself, since nothing but a bar's data-space width reads it and a zero-width bar is a visible mistake where an Infinity is a NaN coordinate. The bar's out-of-domain edge guard becomes `!(abs(c) < 1e29)`, which is false for NaN, for either infinity, and for mode 1's -1e30 sentinel — one predicate for every unusable coordinate instead of two that missed +inf. No behaviour change on the cases that reach it today. The test harness handed node a hand-built environment, which breaks on any runner that installs node outside the three hardcoded PATH entries and drops NODE_OPTIONS/NODE_PATH; it inherits os.environ now. The module-wide bundle skip also disabled the GLSL source checks, which need no bundle — the skip moved inside the node helper, so a checkout with no bundle built still runs 5 of the 12 tests instead of none. Both new numeric assertions fail against the previous commit. --- js/src/40_gl.ts | 6 ++++-- js/src/50_chartview.ts | 36 +++++++++++++++++++------------- tests/test_axis_map_precision.py | 31 ++++++++++++++++++++++----- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index c065a062..82cfbcc8 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -1428,8 +1428,10 @@ void main() { // An edge that leaves a log axis's domain has no coordinate. Collapse that // side onto the centre rather than culling the bar: the half that does // exist is still real, and matplotlib and Plotly both keep drawing it. - if (isnan(cLo) || cLo < -1e29) cLo = cC; - if (isnan(cHi) || cHi < -1e29) cHi = cC; + // The predicate is false for NaN as well as for either infinity and for + // mode 1's -1e30 sentinel, so one test covers every unusable coordinate. + if (!(abs(cLo) < 1e29)) cLo = cC; + if (!(abs(cHi) < 1e29)) cHi = cC; // Ordered, so a reversed axis keeps clipA left of clipB and the corner SDF // frame below stays the one a forward axis produces. float a = (cLo - cC) * u_pmap.x; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index e03b4512..d74a56f1 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -5417,10 +5417,25 @@ export class ChartView { // A degenerate window or encoding yields the off-screen sentinel (mul 0, // add -2) rather than an Infinity that would reach the shader as NaN. _map(meta, lo, hi, axisId = null) { - const degenerate = { mul: 0, add: -2, shift: 0, dataMul: 0 }; + const offscreen = { mul: 0, add: -2, shift: 0, dataMul: 0 }; + // Every non-degenerate exit goes through this. The constants travel to the + // GPU as f32, so an f64-finite value that overflows on upload is still an + // Infinity in the shader, and `encoded * Infinity` rasterizes as NaN — + // validate the f32 images, not the f64 ones. + const gpu = (mul, add, shift, dataMul) => { + if (!Number.isFinite(Math.fround(mul)) || !Number.isFinite(Math.fround(add))) { + return offscreen; + } + // `dataMul` feeds nothing but a data-space width (BAR_VS), so a window + // too narrow for f32 to hold its slope zeroes that alone: a zero-width + // bar is a visible mistake, an Infinity is a NaN clip coordinate, and + // positions — which never read it — keep working either way. + const width = Number.isFinite(Math.fround(dataMul)) ? dataMul : 0; + return { mul, add, shift, dataMul: width }; + }; if (!axisId || this._axisMode(axisId) === 0) { const span = hi - lo; - if (!Number.isFinite(span) || span === 0) return degenerate; + if (!Number.isFinite(span) || span === 0) return offscreen; const scale = meta && Number.isFinite(meta.scale) ? meta.scale : 1; const offset = meta && Number.isFinite(meta.offset) ? meta.offset : 0; const dataMul = 2 / span; @@ -5430,30 +5445,21 @@ export class ChartView { // the legitimately tiny scales an enormous finite domain produces // (~1e-38): the fold has f64 to divide in, and must use the very scale // the vertex buffer was encoded with, not a clamped stand-in. - if (scale === 0) { - return { mul: 0, add: (offset - lo) * dataMul - 1, shift: 0, dataMul }; - } + if (scale === 0) return gpu(0, (offset - lo) * dataMul - 1, 0, dataMul); const mul = dataMul / scale; const shiftRaw = Math.fround(((lo + hi) / 2 - offset) * scale); const shift = Number.isFinite(shiftRaw) ? shiftRaw : 0; const add = (offset + shift / scale - lo) * dataMul - 1; - // The constants are uploaded as f32, so an f64-finite value that overflows - // on the way to the GPU is still an Infinity in the shader — and - // `encoded * Infinity` rasterizes as NaN. Check the f32 images, not the - // f64 ones. - if (!Number.isFinite(Math.fround(mul)) || !Number.isFinite(Math.fround(add))) { - return degenerate; - } - return { mul, add, shift, dataMul }; + return gpu(mul, add, shift, dataMul); } const axis = this._axis(axisId); const c0 = this._axisCoord(axis, lo); const c1 = this._axisCoord(axis, hi); - if (![c0, c1].every(Number.isFinite) || c1 === c0) return degenerate; + if (![c0, c1].every(Number.isFinite) || c1 === c0) return offscreen; // Log-family axes decode before mapping, so one coordinate-space affine // serves every column on the axis. const mul = 2 / (c1 - c0); - return { mul, add: -1 - c0 * mul, shift: 0, dataMul: 0 }; + return gpu(mul, -1 - c0 * mul, 0, 0); } _mapConst(value, lo, hi, axisId = null) { diff --git a/tests/test_axis_map_precision.py b/tests/test_axis_map_precision.py index 9fb0db87..44b8575e 100644 --- a/tests/test_axis_map_precision.py +++ b/tests/test_axis_map_precision.py @@ -18,6 +18,7 @@ from __future__ import annotations import json +import os import re import struct import subprocess @@ -37,10 +38,6 @@ N = 30_000 PLOT_PX = 820 -pytestmark = pytest.mark.skipif( - not BUNDLE.exists(), reason="run `node js/build.mjs` to build the client bundle" -) - def f32(value: float) -> float: """Round through a 32-bit float, the way a GPU uniform or attribute does.""" @@ -52,7 +49,12 @@ def js_maps(cases: list[dict]) -> list[dict]: `_map` reads only `_axis`/`_axisMode`/`_axisCoord`, all of which are pure apart from `this.axes`, so a bare context is enough — no DOM, no WebGL. + + Only the callers of this helper need the bundle; the GLSL checks below read + source, so the skip belongs here rather than on the module. """ + if not BUNDLE.exists(): + pytest.skip("run `node js/build.mjs` to build the client bundle") script = """ import { ChartView } from "./python/xy/static/index.js"; const proto = ChartView.prototype; @@ -75,7 +77,10 @@ def js_maps(cases: list[dict]) -> list[dict]: text=True, timeout=60, check=True, - env={"PATH": "/usr/bin:/bin:/usr/local/bin", "XY_CASES": json.dumps(cases)}, + # Inherit the environment: node needs whatever PATH, HOME, NODE_OPTIONS + # and NODE_PATH the runner set, and a hand-built env silently breaks on + # any machine that installs node somewhere else. + env={**os.environ, "XY_CASES": json.dumps(cases)}, ) return json.loads(completed.stdout) @@ -171,6 +176,22 @@ def test_a_non_affine_axis_reports_no_data_space_slope() -> None: assert linear["dataMul"] == pytest.approx(2 / 1000) +def test_an_unrepresentable_constant_maps_off_screen() -> None: + """Every exit is validated in f32, including the zero-scale one: an f64 + finite `add` that overflows on upload is still an Infinity in the shader.""" + (m,) = js_maps([{"meta": {"offset": 1e30, "scale": 0.0}, "lo": 0.0, "hi": 1e-12}]) + assert (m["mul"], m["add"], m["dataMul"]) == (0, -2, 0) + + +def test_a_window_too_narrow_for_an_f32_slope_only_loses_the_width() -> None: + """`dataMul` feeds nothing but a bar's data-space width, so an overflow + there zeroes that alone — positions, which never read it, keep working.""" + (m,) = js_maps([{"meta": {"offset": 5e-41, "scale": 1e3}, "lo": 0.0, "hi": 1e-40}]) + assert m["dataMul"] == 0, "an f32-infinite data slope must not reach BAR_VS" + assert m["mul"] != 0 and m["add"] == pytest.approx(0.0, abs=1e-6) + assert clip((0.0 - 5e-41) * 1e3, m) == pytest.approx(-1.0, abs=1e-3) + + def test_a_degenerate_window_maps_off_screen() -> None: """`mul` 0 with `add` -2 parks the mark outside clip space, which is what every unrepresentable transform has always returned."""