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 9254c692..82cfbcc8 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -128,7 +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) { +// 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) { @@ -324,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; @@ -531,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; @@ -573,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; @@ -746,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; @@ -856,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; @@ -901,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); @@ -978,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; @@ -989,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; @@ -1028,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; @@ -1039,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); @@ -1100,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; @@ -1112,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; @@ -1184,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; @@ -1276,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; @@ -1346,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; @@ -1382,7 +1405,40 @@ void main() { } v0 += u_v0EdgePad; v1 = mix(v0, v1, u_animationProgress); - float halfW = abs(width * u_pmap.x) * 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. + // 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; + 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 @@ -1427,11 +1483,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/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 8d0c2f2b..d74a56f1 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,19 +5385,81 @@ 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. + // 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 + // 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 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 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; + // 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 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; + 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 [0, -2]; + 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); - const add = -1 - c0 * mul; - return [mul, add]; + return gpu(mul, -1 - c0 * mul, 0, 0); } _mapConst(value, lo, hi, axisId = null) { @@ -5427,9 +5483,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)); @@ -5978,7 +6046,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 @@ -5990,17 +6058,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); @@ -6114,15 +6180,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); @@ -6160,23 +6224,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). @@ -6314,15 +6371,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); @@ -6369,18 +6424,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); @@ -6496,16 +6549,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]); @@ -6599,19 +6650,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); @@ -6631,20 +6681,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)); @@ -6710,20 +6756,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. @@ -7920,12 +7963,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..44b8575e --- /dev/null +++ b/tests/test_axis_map_precision.py @@ -0,0 +1,245 @@ +"""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 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 os +import re +import struct +import subprocess +from pathlib import Path + +import pytest + +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). +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(" 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. + + 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; +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, + # 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) + + +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 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 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. This is + the transform the client used to apply, written out; nothing calls it now. + """ + 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_shipped_fold_keeps_the_full_intra_view_spread() -> None: + """Folding the offset in f64 leaves the encoded value's own precision.""" + 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_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.""" + 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.]+)\s*,", 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_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) + + +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" + 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}" + ) 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