diff --git a/benchmarks/bench_scatter_native.py b/benchmarks/bench_scatter_native.py index 58e1f470..4e0fb2d6 100644 --- a/benchmarks/bench_scatter_native.py +++ b/benchmarks/bench_scatter_native.py @@ -46,6 +46,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(ROOT / "scripts")) +from _protocol import PROTOCOL_VERSION # noqa: E402 from abi_smoke import load # noqa: E402 from categories import BENCHMARK_CATEGORIES, categories_for # noqa: E402 from environment import SCHEMA_VERSION, collect_environment_metadata # noqa: E402 @@ -357,7 +358,7 @@ def ship_scalar(vals): cols[trace["y"]].update({"offset": 0.0, "scale": 1.0, "kind": "float"}) spec = { - "protocol": 3, + "protocol": PROTOCOL_VERSION, "width": RENDER_W, "height": RENDER_H, "title": None, diff --git a/js/src/00_header.js b/js/src/00_header.js index 53f7e958..fb2fa53c 100644 --- a/js/src/00_header.js +++ b/js/src/00_header.js @@ -22,7 +22,7 @@ "use strict"; -const PROTOCOL = 3; +const PROTOCOL = 4; // HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in // python/xy/_framing.py). The chart spec's PROTOCOL diff --git a/js/src/40_gl.js b/js/src/40_gl.js index 7475250a..8c8c95cb 100644 --- a/js/src/40_gl.js +++ b/js/src/40_gl.js @@ -27,6 +27,7 @@ const ATTR_SLOTS = { a_cval: 6, a_sval: 7, a_sel: 8, a_dval: 9, a_len0: 10, a_len1: 11, a_dash0: 10, a_dashDir: 11, + a_rgba: 12, a_style: 13, a_stroke: 14, a_radius: 15, }; function makeProgram(gl, vs, fs) { @@ -92,12 +93,14 @@ float xyViewValue(float coord, int mode) { const POINT_VS = `#version 300 es in float ax; in float ay; 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 vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform int u_colorMode; uniform float u_dpr; uniform int u_selActive; uniform float u_selectedOpacity; uniform float u_unselectedOpacity; out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; ${AXIS_GLSL} void main() { gl_Position = vec4(xyMap(ax, u_xmap, u_xmeta, u_xmode), xyMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); @@ -105,6 +108,9 @@ void main() { gl_PointSize = sz * u_dpr; v_ptSize = sz * u_dpr; v_sel = a_sel; + v_rgba = a_rgba; + v_style = a_style; + v_stroke = a_stroke; // continuous: coord = value in [0,1]; categorical: center of texel a_cval. v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; // Local log-density LUT coord (drill handoff, §5): lets freshly drilled @@ -200,26 +206,33 @@ precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; uniform sampler2D u_dlut; uniform float u_dblend; uniform int u_symbol; uniform vec4 u_ptStroke; uniform float u_ptStrokeWidth; uniform int u_ptStrokeFace; +uniform int u_strokeMode; uniform float u_strokeOpacity; uniform int u_selActive; uniform vec4 u_selColor; uniform vec4 u_unselColor; in float v_lutCoord; in float v_dim; in float v_dval; in float v_ptSize; in float v_sel; +in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; out vec4 outColor; ${MARKER_SDF_GLSL} void main() { vec2 d = gl_PointCoord - 0.5; float sd; - bool lineMarker = u_symbol == 15 || u_symbol == 16; + int symbol = v_style.w >= 0.0 ? int(v_style.w + 0.5) : u_symbol; + bool lineMarker = symbol == 15 || symbol == 16; if (lineMarker) { - vec2 q = u_symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; - float halfWidth = max(u_ptStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); + vec2 q = symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; + float itemStrokeWidth = v_style.z >= 0.0 ? v_style.z : u_ptStrokeWidth; + float halfWidth = max(itemStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); vec2 a = abs(q); sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth)); } else { - sd = xyMarkerSdf(d, u_symbol); + // Scalar-only equivalent: xyMarkerSdf(d, u_symbol). The resolved symbol + // also permits a per-item glyph override from v_style.w. + sd = xyMarkerSdf(d, symbol); } float aa = fwidth(sd) + 1e-4; float shapeCov = clamp(0.5 - sd / aa, 0.0, 1.0); if (shapeCov <= 0.001) discard; - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); + vec3 rgb = paint.rgb; // Drill handoff (§5): near the density boundary, paint by local density with // the density ramp; ease into native colors as the zoom deepens (u_dblend->0). if (u_dblend > 0.001) { @@ -232,15 +245,22 @@ void main() { vec4 sc = v_sel > 0.5 ? u_selColor : u_unselColor; rgb = mix(rgb, sc.rgb, sc.a); } - float fillAlpha = u_opacity; + float intrinsicAlpha = paint.a; + float fillAlpha = (v_style.y >= 0.0 ? v_style.y : intrinsicAlpha) * v_style.x * u_opacity; vec4 px = vec4(rgb * fillAlpha, fillAlpha); // premultiplied fill - vec4 strokePx = u_ptStrokeFace == 1 ? px : u_ptStroke; + // Uniform (u_ptStroke) and per-item (v_stroke) stroke paint ship straight + // alpha and go through the same artist-alpha/opacity stack, so a scalar + // CSS edge fades under alpha overrides exactly like SVG/PNG export. + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_ptStroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 strokePx = u_ptStrokeFace == 1 ? px : vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); if (lineMarker) { outColor = strokePx * (shapeCov * v_dim); return; } - if (u_ptStrokeWidth > 0.0) { - float sw = u_ptStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units + float itemStrokeWidth = v_style.z >= 0.0 ? v_style.z : u_ptStrokeWidth; + if (itemStrokeWidth > 0.0) { + float sw = itemStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units // The supplied point size includes the edge. Recover Matplotlib's path // boundary half a stroke inside it, then source-over the centered stroke. float pathCov = clamp(0.5 - (sd + sw * 0.5) / aa, 0.0, 1.0); @@ -460,13 +480,13 @@ void main() { // color. A separate program keeps the polyline path (LINE_VS/LINE_FS) free of // the extra meta uniforms and the sampler on its per-frame draw path. const SEGMENT_VS = `#version 300 es -in float ax0; in float ay0; in float ax1; in float ay1; in float a_cval; +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 int u_colorMode; uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform int u_x0mode; uniform int u_x1mode; uniform int u_y0mode; uniform int u_y1mode; -out float v_off; out float v_cval; out float v_dash; +out float v_off; out float v_cval; out float v_dash; out vec4 v_rgba; out vec4 v_style; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -479,24 +499,29 @@ void main() { dir /= len; vec2 n = vec2(-dir.y, dir.x); vec2 c = corners[gl_VertexID]; - float half_w = u_width * 0.5 + 0.5; + float itemWidth = a_style.z >= 0.0 ? a_style.z : u_width; + float half_w = itemWidth * 0.5 + 0.5; vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); v_off = c.y * half_w; v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; v_dash = a_dash0 + c.x * len * a_dashDir; + v_rgba = a_rgba; v_style = a_style; }`; const SEGMENT_FS = `#version 300 es precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; +uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_cval; in float v_dash; +in float v_off; in float v_cval; in float v_dash; in vec4 v_rgba; in vec4 v_style; out vec4 outColor; void main() { - float half_w = u_width * 0.5; - vec3 rgb = u_colorMode != 0 ? texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb : u_color.rgb; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + float itemWidth = v_style.z >= 0.0 ? v_style.z : u_width; + float half_w = itemWidth * 0.5; + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode != 0 ? vec4(texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb, 1.0) : u_color); + vec3 rgb = paint.rgb; + float paintAlpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * paintAlpha; if (u_dashCount > 0) { float m = mod(v_dash, u_dashPeriod); float acc = 0.0; @@ -517,13 +542,14 @@ void main() { // color and antialiased barycentric edge strokes. 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 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 int u_x1mode; uniform int u_x2mode; uniform int u_y0mode; uniform int u_y1mode; uniform int u_y2mode; uniform int u_colorMode; -out float v_cval; out vec3 v_bary; +out float v_cval; out vec3 v_bary; out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; ${AXIS_GLSL} void main() { int vertex = gl_VertexID % 3; @@ -536,21 +562,29 @@ void main() { gl_Position = vec4(xyMap(x, u_xmap, xm, xmode), xyMap(y, u_ymap, ym, ymode), 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; }`; const MESH_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; -uniform vec4 u_stroke; uniform float u_strokeWidth; -in float v_cval; in vec3 v_bary; +uniform vec4 u_stroke; uniform float u_strokeWidth; uniform int u_strokeMode; uniform float u_strokeOpacity; +in float v_cval; in vec3 v_bary; in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; out vec4 outColor; void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb; - vec4 fill = vec4(rgb * u_opacity, u_opacity); - if (u_strokeWidth > 0.0) { + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb, 1.0)); + float alpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + vec4 fill = vec4(paint.rgb * alpha, alpha); + float strokeWidth = v_style.z >= 0.0 ? v_style.z : u_strokeWidth; + if (strokeWidth > 0.0) { float edge = min(v_bary.x, min(v_bary.y, v_bary.z)); - float coverage = smoothstep(0.0, max(fwidth(edge) * u_strokeWidth, 1e-5), edge); - outColor = mix(u_stroke, fill, coverage); + float coverage = smoothstep(0.0, max(fwidth(edge) * strokeWidth, 1e-5), edge); + // Both stroke sources ship straight alpha; the per-item alpha stack + // applies to scalar strokes as well (parity with static exporters). + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + outColor = mix(stroke, fill, coverage); } else { outColor = fill; } @@ -649,9 +683,11 @@ uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec uniform int u_xmode; uniform int u_ymode; uniform vec4 u_edgePad; uniform vec2 u_res; -in float a_cval; uniform int u_colorMode; +in float a_cval; in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; in vec2 a_radius; +uniform int u_colorMode; out float v_lutCoord; out vec2 v_local; out vec2 v_half; out float v_t; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -668,6 +704,7 @@ void main() { v_half = abs(pB - pA) * 0.5; v_local = mix(pA, pB, c) - (pA + pB) * 0.5; v_t = c.y; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; v_radius = a_radius; gl_Position = vec4(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0); }`; @@ -677,6 +714,7 @@ void main() { // histograms/candles/waterfalls. const BAR_VS = `#version 300 es in float a_pos; in float a_v0; in float a_v1; in float a_cval; +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 vec2 u_pmeta; uniform vec2 u_v0meta; uniform vec2 u_v1meta; uniform int u_pmode; uniform int u_vmode; @@ -686,6 +724,7 @@ uniform vec2 u_res; uniform int u_colorMode; out float v_lutCoord; out vec2 v_local; out vec2 v_half; out float v_t; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -711,6 +750,7 @@ void main() { vec2 pB = (clipB * 0.5 + 0.5) * u_res; v_half = abs(pB - pA) * 0.5; v_local = vec2(mix(pA.x, pB.x, c.x), mix(pA.y, pB.y, c.y)) - (pA + pB) * 0.5; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; v_radius = a_radius; }`; // Shared by the rect and compact-bar programs: flat fill or LUT color, then an @@ -722,29 +762,45 @@ const RECT_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform vec2 u_radius; uniform float u_strokeWidth; uniform vec4 u_stroke; +uniform int u_strokeMode; uniform float u_strokeOpacity; +uniform float u_opacity; uniform vec2 u_res; in float v_lutCoord; in vec2 v_local; in vec2 v_half; in float v_t; +in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; in vec2 v_radius; out vec4 outColor; ${GRAD_GLSL} void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; - vec4 premult = vec4(rgb * u_color.a, u_color.a); - // Compose the mark opacity (u_color.a) over the gradient — premultiplied, so - // one scalar multiply fades every stop, including a fade-to-transparent. - if (u_gradMode != 0) premult = xyGradSample(xyGradT(v_t, u_res)) * u_color.a; - if (u_radius.x > 0.0 || u_radius.y > 0.0 || u_strokeWidth > 0.0) { + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); + float alpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + vec4 premult = vec4(paint.rgb * alpha, alpha); + if (u_gradMode != 0) { + vec4 gradient = xyGradSample(xyGradT(v_t, u_res)); + float gradientAlpha = (v_style.y >= 0.0 ? v_style.y : gradient.a) * v_style.x * u_opacity; + // Gradient stops are uploaded premultiplied. Recover their straight RGB + // before applying an artist-alpha override, then premultiply the result. + vec3 gradientRgb = gradient.a > 1e-6 ? gradient.rgb / gradient.a : vec3(0.0); + premult = vec4(gradientRgb * gradientAlpha, gradientAlpha); + } + vec2 radius = v_radius.x >= 0.0 ? v_radius : u_radius; + float strokeWidth = v_style.z >= 0.0 ? v_style.z : u_strokeWidth; + if (radius.x > 0.0 || radius.y > 0.0 || strokeWidth > 0.0) { // u_radius = (tip, base) in mark space: v_t > 0.5 is the tip half, so // corner_radius=(6, 0) rounds only the value end of the bar. On the // straight sides the SDF reduces to |local|-half independent of r, so // differing radii meet with no seam. - float r = min(v_t > 0.5 ? u_radius.x : u_radius.y, min(v_half.x, v_half.y)); + float r = min(v_t > 0.5 ? radius.x : radius.y, min(v_half.x, v_half.y)); vec2 q = abs(v_local) - (v_half - vec2(r)); float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; float aa = 0.75; - if (u_strokeWidth > 0.0) { - float inner = 1.0 - smoothstep(-aa, aa, d + u_strokeWidth); - premult = mix(u_stroke, premult, inner); + if (strokeWidth > 0.0) { + // Both stroke sources ship straight alpha; the per-item alpha stack + // applies to scalar strokes as well (parity with static exporters). + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + float inner = 1.0 - smoothstep(-aa, aa, d + strokeWidth); + premult = mix(stroke, premult, inner); } premult *= 1.0 - smoothstep(-aa, aa, d); } diff --git a/js/src/45_lod.js b/js/src/45_lod.js index 32d509aa..f28dbe5d 100644 --- a/js/src/45_lod.js +++ b/js/src/45_lod.js @@ -220,6 +220,7 @@ function lodApplyDrill(view, g, upd, buffers) { if (!d) { d = g.drill = { trace: g.trace, xBuf: gl.createBuffer(), yBuf: gl.createBuffer() }; } + d.trace = { ...g.trace, style: upd.style || g.trace.style || {} }; d.xAxis = g.xAxis; d.yAxis = g.yAxis; gl.bindBuffer(gl.ARRAY_BUFFER, d.xBuf); @@ -240,17 +241,21 @@ function lodApplyDrill(view, g, upd, buffers) { d.colorMode = 0; d.color = parseColor(view.root, upd.color && upd.color.color, [0.3, 0.47, 0.66, 1]); if (upd.color && upd.color.buf !== undefined) { - d.colorMode = upd.color.mode === "continuous" ? 1 : 2; - if (!d.cBuf) d.cBuf = gl.createBuffer(); + d.colorMode = upd.color.mode === "continuous" ? 1 : + (upd.color.mode === "categorical" ? 2 : 3); const colorValues = upd.color.dtype === "u8" ? view._asU8(buffers[upd.color.buf]) : view._asF32(buffers[upd.color.buf]); - d.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; - gl.bindBuffer(gl.ARRAY_BUFFER, d.cBuf); + const colorBufferName = d.colorMode === 3 ? "rgbaBuf" : "cBuf"; + if (!d[colorBufferName]) d[colorBufferName] = gl.createBuffer(); + d[colorBufferName]._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; + gl.bindBuffer(gl.ARRAY_BUFFER, d[colorBufferName]); gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); - d.lut = upd.color.mode === "continuous" - ? view._lut(upd.color.colormap) - : view._paletteLut(upd.color.palette); + if (d.colorMode !== 3) { + d.lut = upd.color.mode === "continuous" + ? view._lut(upd.color.colormap) + : view._paletteLut(upd.color.palette); + } } d.sizeMode = 0; d.size = (upd.size && upd.size.size) || 4.0; @@ -262,6 +267,43 @@ function lodApplyDrill(view, g, upd, buffers) { gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.size.buf]), gl.STATIC_DRAW); d.sizeRange = upd.size.range_px; } + const styleChannel = (name) => upd.channels && upd.channels[name]; + const artistScalar = Number(d.trace.style && d.trace.style.artist_alpha); + if (styleChannel("opacity") || styleChannel("artist_alpha") || + styleChannel("stroke_width") || styleChannel("symbol") || Number.isFinite(artistScalar)) { + const values = new Float32Array(d.n * 4); + for (let i = 0; i < d.n; i++) { + values[i * 4] = 1; + values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; + values[i * 4 + 2] = -1; + values[i * 4 + 3] = -1; + } + const copy = (name, component, scale = 1) => { + const spec = styleChannel(name); + if (!spec) return; + const source = spec.dtype === "u8" + ? view._asU8(buffers[spec.buf]) + : view._asF32(buffers[spec.buf]); + const components = spec.components || 1; + for (let i = 0; i < d.n; i++) values[i * 4 + component] = source[i * components] * scale; + }; + copy("opacity", 0); + copy("artist_alpha", 1); + copy("stroke_width", 2, view.dpr); + copy("symbol", 3); + if (!d.styleBuf) d.styleBuf = gl.createBuffer(); + d.styleBuf._fcType = gl.FLOAT; + gl.bindBuffer(gl.ARRAY_BUFFER, d.styleBuf); + gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); + } + if (upd.stroke && upd.stroke.mode === "direct_rgba") { + const values = view._asU8(buffers[upd.stroke.buf]); + if (!d.strokeBuf) d.strokeBuf = gl.createBuffer(); + d.strokeBuf._fcType = gl.UNSIGNED_BYTE; + gl.bindBuffer(gl.ARRAY_BUFFER, d.strokeBuf); + gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); + } + view._pointMarkStyle(d, d.trace); // Color-continuous handoff (§5): per-point local log-density + a blend // weight. Fresh at the boundary (blend≈1) the marks wear the aggregate's // colormap, so the texture->marks swap doesn't recolor the chart; deeper @@ -304,7 +346,8 @@ function lodDropDrill(view, g) { if (!d) return; const gl = view.gl; view._deleteVaos(d); // the drill sibling carries its own VAOs - for (const b of [d.xBuf, d.yBuf, d.cBuf, d.sBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); + for (const b of [d.xBuf, d.yBuf, d.cBuf, d.rgbaBuf, d.sBuf, d.styleBuf, + d.strokeBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); g.drill = null; g._drillFadeStart = null; g._drillExitFadeStart = null; diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index 22ad920a..5dd268ce 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -1671,6 +1671,47 @@ class ChartView { g.yBuf = this._upload(y); } + _buildInstanceStyleChannels(g, t, buffer, widthName) { + const channel = (name) => t.channels && t.channels[name]; + const artistScalar = Number(t.style && t.style.artist_alpha); + const hasStyle = channel("opacity") || channel("artist_alpha") || + channel(widthName) || channel("symbol") || Number.isFinite(artistScalar); + if (hasStyle) { + const values = new Float32Array(g.n * 4); + for (let i = 0; i < g.n; i++) { + values[i * 4] = 1; + values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; + values[i * 4 + 2] = -1; + values[i * 4 + 3] = -1; + } + const copy = (name, component, scale = 1) => { + const spec = channel(name); + if (!spec) return; + const source = this._columnView(buffer, this.spec.columns[spec.buf]); + for (let i = 0; i < g.n; i++) values[i * 4 + component] = source[i * (spec.components || 1)] * scale; + }; + copy("opacity", 0); + copy("artist_alpha", 1); + copy(widthName, 2, this.dpr); + copy("symbol", 3); + g.styleBuf = this._upload(values); + } + const radius = channel("corner_radius"); + if (radius) { + const source = this._columnView(buffer, this.spec.columns[radius.buf]); + const components = radius.components || 1; + const values = new Float32Array(g.n * 2); + for (let i = 0; i < g.n; i++) { + values[i * 2] = source[i * components] * this.dpr; + values[i * 2 + 1] = (components > 1 ? source[i * components + 1] : source[i * components]) * this.dpr; + } + g.radiusBuf = this._upload(values); + } + if (t.stroke && t.stroke.mode === "direct_rgba") { + g.strokeBuf = this._upload(this._columnView(buffer, this.spec.columns[t.stroke.buf])); + } + } + _buildScatterMark(g, t, buffer) { this._buildXY(g, t, buffer); g.colorMode = 0; @@ -1685,6 +1726,10 @@ class ChartView { g._cpu.color = this._columnView(buffer, this.spec.columns[t.color.buf]); g.cBuf = this._upload(g._cpu.color); g.lut = this._paletteLut(t.color.palette); + } else if (t.color && t.color.mode === "direct_rgba") { + g.colorMode = 3; + g._cpu.rgba = this._columnView(buffer, this.spec.columns[t.color.buf]); + g.rgbaBuf = this._upload(g._cpu.rgba); } g.sizeMode = 0; g.size = (t.size && t.size.size) || 4.0; @@ -1695,6 +1740,7 @@ class ChartView { g.sBuf = this._upload(g._cpu.size); g.sizeRange = t.size.range_px; } + this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._pointMarkStyle(g, t); } @@ -1704,7 +1750,7 @@ class ChartView { const s = t.style || {}; g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16 }[s.symbol] || 0; g.pointStrokeWidth = Number(s.stroke_width) || 0; - g.pointStrokeFace = !s.stroke; + g.pointStrokeFace = !s.stroke && (!t.stroke || t.stroke.mode === "match_fill"); g.pointStroke = s.stroke ? parseColor(this.root, s.stroke, [g.color[0], g.color[1], g.color[2], 1]) : null; @@ -1723,6 +1769,8 @@ class ChartView { y_axis: parentTrace.y_axis, color: sample.color, size: sample.size, + stroke: sample.stroke, + channels: sample.channels, }; } @@ -1749,7 +1797,8 @@ class ChartView { _destroyDensitySample(g) { const s = g && g.sampleOverlay; if (!s || !this.gl) return; - for (const b of [s.xBuf, s.yBuf, s.cBuf, s.sBuf, s.selBuf, s.dBuf]) { + for (const b of [s.xBuf, s.yBuf, s.cBuf, s.rgbaBuf, s.sBuf, s.styleBuf, + s.strokeBuf, s.selBuf, s.dBuf]) { if (b) this.gl.deleteBuffer(b); } g.sampleOverlay = null; @@ -1772,6 +1821,8 @@ class ChartView { y_axis: g.trace.y_axis, color: sample.color, size: sample.size, + stroke: sample.stroke, + channels: sample.channels, }; const s = { trace, @@ -1800,17 +1851,21 @@ class ChartView { gl.bindBuffer(gl.ARRAY_BUFFER, s.yBuf); gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.y.buf]), gl.STATIC_DRAW); if (sample.color && sample.color.buf !== undefined) { - s.colorMode = sample.color.mode === "continuous" ? 1 : 2; - s.cBuf = gl.createBuffer(); + s.colorMode = sample.color.mode === "continuous" ? 1 : + (sample.color.mode === "categorical" ? 2 : 3); const colorValues = sample.color.dtype === "u8" ? this._asU8(buffers[sample.color.buf]) : this._asF32(buffers[sample.color.buf]); - s.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; - gl.bindBuffer(gl.ARRAY_BUFFER, s.cBuf); + const colorBufferName = s.colorMode === 3 ? "rgbaBuf" : "cBuf"; + s[colorBufferName] = gl.createBuffer(); + s[colorBufferName]._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; + gl.bindBuffer(gl.ARRAY_BUFFER, s[colorBufferName]); gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); - s.lut = sample.color.mode === "continuous" - ? this._lut(sample.color.colormap) - : this._paletteLut(sample.color.palette); + if (s.colorMode !== 3) { + s.lut = sample.color.mode === "continuous" + ? this._lut(sample.color.colormap) + : this._paletteLut(sample.color.palette); + } } if (sample.size && sample.size.mode === "continuous") { s.sizeMode = 1; @@ -1819,6 +1874,36 @@ class ChartView { gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.size.buf]), gl.STATIC_DRAW); s.sizeRange = sample.size.range_px; } + const channel = (name) => sample.channels && sample.channels[name]; + const artistScalar = Number(trace.style && trace.style.artist_alpha); + if (channel("opacity") || channel("artist_alpha") || channel("stroke_width") || + channel("symbol") || Number.isFinite(artistScalar)) { + const values = new Float32Array(s.n * 4); + for (let i = 0; i < s.n; i++) { + values[i * 4] = 1; + values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; + values[i * 4 + 2] = -1; + values[i * 4 + 3] = -1; + } + const copy = (name, component, scale = 1) => { + const spec = channel(name); + if (!spec) return; + const source = spec.dtype === "u8" + ? this._asU8(buffers[spec.buf]) + : this._asF32(buffers[spec.buf]); + const components = spec.components || 1; + for (let i = 0; i < s.n; i++) values[i * 4 + component] = source[i * components] * scale; + }; + copy("opacity", 0); + copy("artist_alpha", 1); + copy("stroke_width", 2, this.dpr); + copy("symbol", 3); + s.styleBuf = this._upload(values); + } + if (sample.stroke && sample.stroke.mode === "direct_rgba") { + s.strokeBuf = this._upload(this._asU8(buffers[sample.stroke.buf])); + } + this._pointMarkStyle(s, trace); g.sampleOverlay = s; this._refreshReductionBadges(); } @@ -1900,9 +1985,12 @@ class ChartView { const cr = g.cornerRadius || [0, 0]; gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr); gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr); + // Straight alpha: RECT_FS folds u_strokeOpacity and the per-item alpha + // stack in and premultiplies there (uniform and buffer strokes alike). const sc = g.strokeColor || [0, 0, 0, 0]; - const sa = sc[3] * this._strokeOpacity(g.trace.style || {}); - gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa); + gl.uniform4f(u("u_stroke"), sc[0], sc[1], sc[2], sc[3]); + gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); + gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {})); this._setGradientUniforms(prog, g.grad); } @@ -2007,7 +2095,11 @@ class ChartView { g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); + } else if (t.color && t.color.mode === "direct_rgba") { + g.colorMode = 3; + g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } + this._buildInstanceStyleChannels(g, t, buffer, "width"); g._cpu = { x: x0, y: y1, xMeta: g.x0Meta, yMeta: g.y1Meta }; } @@ -2028,7 +2120,11 @@ class ChartView { g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); + } else if (t.color && t.color.mode === "direct_rgba") { + g.colorMode = 3; + g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } + this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); const style = t.style || {}; g.meshStrokeWidth = Number(style.stroke_width) || 0; g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); @@ -2137,7 +2233,11 @@ class ChartView { g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); + } else if (t.color && t.color.mode === "direct_rgba") { + g.colorMode = 3; + g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } + this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._rectMarkStyleGpu(g, t); } @@ -2175,7 +2275,11 @@ class ChartView { g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); + } else if (t.color && t.color.mode === "direct_rgba") { + g.colorMode = 3; + g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } + this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._rectMarkStyleGpu(g, t); } @@ -2328,11 +2432,11 @@ class ChartView { // Enable slot + pointer into `buf` — only ever called inside a _bindVao // setup closure, so the state lands in that VAO, not global state. - _vaoAttr(slot, buf, byteOffset, divisor, size = 1) { + _vaoAttr(slot, buf, byteOffset, divisor, size = 1, normalized = false) { const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.enableVertexAttribArray(slot); - gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, false, 0, byteOffset); + gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, normalized, 0, byteOffset); gl.vertexAttribDivisor(slot, divisor); } @@ -2468,12 +2572,15 @@ class ChartView { } - _drawPoints(g, xm, ym, opacityScale = 1) { - const simple = - g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && + _canDrawSimplePoints(g) { + return g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && + !g.rgbaBuf && !g.styleBuf && !g.strokeBuf && (g.symbol || 0) === 0 && (g.pointStrokeWidth || 0) <= 0 && Math.max(g.lodBlendShown ?? 0, g.lodBlend ?? 0) <= 0.001; - if (simple) { + } + + _drawPoints(g, xm, ym, opacityScale = 1) { + if (this._canDrawSimplePoints(g)) { this._drawSimplePoints(g, xm, ym, opacityScale); return; } @@ -2501,22 +2608,26 @@ class ChartView { }; stateColor(u("u_selColor"), this._markStateValue("selected", "color")); stateColor(u("u_unselColor"), this._markStateValue("unselected", "color")); - const [r, gg, b] = g.color; - gl.uniform4f(u("u_color"), r, gg, b, 1); + const [r, gg, b, a] = g.color; + gl.uniform4f(u("u_color"), r, gg, b, a); gl.uniform1i(u("u_symbol"), g.symbol || 0); const sc = g.pointStroke; - const strokeAlpha = sc - ? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale - : 0; gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr); gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0); - gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0, - sc ? sc[2] * strokeAlpha : 0, strokeAlpha); + gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); + gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style, 0.8) * opacityScale); + // Straight alpha: POINT_FS folds u_strokeOpacity and the per-item alpha + // stack in and premultiplies there (uniform and buffer strokes alike). + gl.uniform4f(u("u_ptStroke"), sc ? sc[0] : 0, sc ? sc[1] : 0, + sc ? sc[2] : 0, sc ? sc[3] : 0); gl.uniform1i(u("u_selActive"), g.selActive ? 1 : 0); const colorOn = g.colorMode !== 0 && g.cBuf; const sizeOn = g.sizeMode === 1 && g.sBuf; const selOn = g.selActive && g.selBuf; + const rgbaOn = g.colorMode === 3 && g.rgbaBuf; + const styleOn = !!g.styleBuf; + const strokeOn = !!g.strokeBuf; if (g.lut) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -2556,6 +2667,9 @@ class ChartView { sizeOn ? g.sBuf._fcId : 0, selOn ? g.selBuf._fcId : 0, blendOn ? g.dBuf._fcId : 0, + rgbaOn ? g.rgbaBuf._fcId : 0, + styleOn ? g.styleBuf._fcId : 0, + strokeOn ? g.strokeBuf._fcId : 0, ], () => { this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); @@ -2564,6 +2678,9 @@ class ChartView { if (sizeOn) this._vaoAttr(ATTR_SLOTS.a_sval, g.sBuf, 0, 0); if (selOn) this._vaoAttr(ATTR_SLOTS.a_sel, g.selBuf, 0, 0); if (blendOn) this._vaoAttr(ATTR_SLOTS.a_dval, g.dBuf, 0, 0); + if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 0, 4, true); + if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 0, 4); + if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 0, 4, true); } ); // Generic (constant) attribute values are context state, not VAO state — @@ -2572,6 +2689,9 @@ class ChartView { if (!sizeOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); if (!selOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1.0); if (!blendOn) gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); + if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); + if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); + if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, r, gg, b, a); gl.drawArrays(gl.POINTS, 0, g.n); } @@ -2586,8 +2706,10 @@ class ChartView { this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); gl.uniform1f(u("u_dpr"), this.dpr); gl.uniform1f(u("u_size"), g.size); - const [r, gg, b] = g.color; - gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale); + const [r, gg, b, a] = g.color; + gl.uniform4f( + u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.8) * opacityScale + ); this._bindVao( g, "points-simple", @@ -2771,7 +2893,8 @@ class ChartView { gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr); const [r, gg, b, a] = g.color; - gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style)); + gl.uniform4f(u("u_color"), r, gg, b, a); + gl.uniform1f(u("u_opacity"), this._strokeOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); const dashed = this._segmentDash(g, prog); if (g.colorMode && g.lut) { @@ -2783,7 +2906,9 @@ class ChartView { g, "segment", [g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, - g.colorMode ? g.cBuf._fcId : 0, + g.colorMode && g.cBuf ? g.cBuf._fcId : 0, + g.rgbaBuf ? g.rgbaBuf._fcId : 0, + g.styleBuf ? g.styleBuf._fcId : 0, dashed ? g._segmentDashOffsetBuf._fcId : 0, dashed ? g._segmentDashDirBuf._fcId : 0], () => { @@ -2791,14 +2916,18 @@ class ChartView { this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); - if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); + if (g.colorMode && g.cBuf) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); + if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); + if (g.styleBuf) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); if (dashed) { this._vaoAttr(ATTR_SLOTS.a_dash0, g._segmentDashOffsetBuf, 0, 1); this._vaoAttr(ATTR_SLOTS.a_dashDir, g._segmentDashDirBuf, 0, 1); } } ); - if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); + if (!g.cBuf) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); + if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); + if (!g.styleBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } @@ -2887,26 +3016,35 @@ class ChartView { for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); - gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1); + gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], g.color[3]); + // Straight alpha: MESH_FS folds u_strokeOpacity and the per-item alpha + // stack in and premultiplies there (uniform and buffer strokes alike). const stroke = g.meshStroke || [0, 0, 0, 0]; - const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style); - gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha, - stroke[2] * strokeAlpha, strokeAlpha); + gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]); gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0); + gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); + gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style)); if (g.colorMode && g.lut) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); gl.uniform1i(u("u_lut"), 0); } const parts = ["x0", "x1", "x2", "y0", "y1", "y2"].map((name) => g[name + "Buf"]._fcId); - parts.push(g.colorMode ? g.cBuf._fcId : 0); + parts.push(g.cBuf ? g.cBuf._fcId : 0, g.rgbaBuf ? g.rgbaBuf._fcId : 0, + g.styleBuf ? g.styleBuf._fcId : 0, g.strokeBuf ? g.strokeBuf._fcId : 0); this._bindVao(g, "mesh", parts, () => { for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { this._vaoAttr(ATTR_SLOTS["a" + name], g[name + "Buf"], 0, 1); } - if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); + if (g.cBuf) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); + if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); + if (g.styleBuf) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); + if (g.strokeBuf) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); }); - if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); + if (!g.cBuf) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); + if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, ...g.color); + if (!g.styleBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); + if (!g.strokeBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...stroke); gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, g.n); } @@ -3003,10 +3141,15 @@ class ChartView { gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]); const [r, gg, b, a] = g.color; - gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); + gl.uniform4f(u("u_color"), r, gg, b, a); + gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); this._setRectStyleUniforms(prog, g); - const colorOn = g.colorMode && g.cBuf; + const colorOn = !!g.cBuf; + const rgbaOn = !!g.rgbaBuf; + const styleOn = !!g.styleBuf; + const strokeOn = !!g.strokeBuf; + const radiusOn = !!g.radiusBuf; if (colorOn) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -3015,16 +3158,27 @@ class ChartView { this._bindVao( g, "rects", - [g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, colorOn ? g.cBuf._fcId : 0], + [g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, + colorOn ? g.cBuf._fcId : 0, rgbaOn ? g.rgbaBuf._fcId : 0, + styleOn ? g.styleBuf._fcId : 0, strokeOn ? g.strokeBuf._fcId : 0, + radiusOn ? g.radiusBuf._fcId : 0], () => { this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); + if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); + if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); + if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); + if (radiusOn) this._vaoAttr(ATTR_SLOTS.a_radius, g.radiusBuf, 0, 1, 2); } ); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); + if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); + if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); + if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); + if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } @@ -3050,11 +3204,16 @@ class ChartView { gl.uniform1f(u("u_v0Const"), v0Const ?? 0); gl.uniform1f(u("u_v0EdgePad"), v0EdgePad); const [r, gg, b, a] = g.color; - gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); + gl.uniform4f(u("u_color"), r, gg, b, a); + gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); this._setRectStyleUniforms(prog, g); const v0On = g.value0Mode === 1 && g.value0Buf; - const colorOn = g.colorMode && g.cBuf; + const colorOn = !!g.cBuf; + const rgbaOn = !!g.rgbaBuf; + const styleOn = !!g.styleBuf; + const strokeOn = !!g.strokeBuf; + const radiusOn = !!g.radiusBuf; if (colorOn) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -3067,16 +3226,28 @@ class ChartView { g.posBuf._fcId, g.value1Buf._fcId, v0On ? g.value0Buf._fcId : 0, colorOn ? g.cBuf._fcId : 0, + rgbaOn ? g.rgbaBuf._fcId : 0, + styleOn ? g.styleBuf._fcId : 0, + strokeOn ? g.strokeBuf._fcId : 0, + radiusOn ? g.radiusBuf._fcId : 0, ], () => { this._vaoAttr(ATTR_SLOTS.a_pos, g.posBuf, 0, 1); this._vaoAttr(ATTR_SLOTS.a_v1, g.value1Buf, 0, 1); if (v0On) this._vaoAttr(ATTR_SLOTS.a_v0, g.value0Buf, 0, 1); if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); + if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); + if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); + if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); + if (radiusOn) this._vaoAttr(ATTR_SLOTS.a_radius, g.radiusBuf, 0, 1, 2); } ); if (!v0On) gl.vertexAttrib1f(ATTR_SLOTS.a_v0, 0); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); + if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); + if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); + if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); + if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 966a678b..00810e6a 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -420,6 +420,9 @@ def _append_bar_rect( opacity: float, role: str, extra_style: Optional[dict[str, Any]] = None, + color_ch: Optional[ColorChannel] = None, + stroke_ch: Optional[ColorChannel] = None, + style_channels: Optional[dict[str, Any]] = None, ) -> None: if orientation == "vertical": self._append_rect_trace( @@ -434,6 +437,9 @@ def _append_bar_rect( role=role, orientation=orientation, extra_style=extra_style, + color_ch=color_ch, + stroke_ch=stroke_ch, + style_channels=style_channels, ) else: self._append_rect_trace( @@ -448,6 +454,9 @@ def _append_bar_rect( role=role, orientation=orientation, extra_style=extra_style, + color_ch=color_ch, + stroke_ch=stroke_ch, + style_channels=style_channels, ) @staticmethod @@ -772,7 +781,9 @@ def _append_rect_trace( role: str, orientation: Optional[str] = None, color_ch: Optional[ColorChannel] = None, + stroke_ch: Optional[ColorChannel] = None, size_ch: Optional[SizeChannel] = None, + style_channels: Optional[dict[str, Any]] = None, count: Optional[int] = None, extra_style: Optional[dict[str, Any]] = None, ) -> None: @@ -812,7 +823,9 @@ def _append_rect_trace( name=name, style=style, color_ch=color_ch, + stroke_ch=stroke_ch, size_ch=size_ch, + style_channels=style_channels or {}, count=count, ) ) @@ -1206,14 +1219,37 @@ def decimate_view( return interaction.decimate_view(self, x0, x1, px_width) def append( - self, trace_id: int, x: Any, y: Any, *, color: Any = None, size: Any = None + self, + trace_id: int, + x: Any, + y: Any, + *, + color: Any = None, + size: Any = None, + stroke: Any = None, + opacity: Any = None, + alpha: Any = None, + stroke_width: Any = None, + symbol: Any = None, ) -> tuple[dict[str, Any], list[bytes]]: """Streaming append: extend a scatter/line trace's canonical columns and get the client refresh message back. The widget's `append` sends it; headless callers can inspect or discard it. Payloads stay screen-bounded, so this is O(pixels) on the wire regardless of how much data has accumulated.""" - return interaction.append_data(self, trace_id, x, y, color, size) + return interaction.append_data( + self, + trace_id, + x, + y, + color, + size, + stroke, + opacity, + alpha, + stroke_width, + symbol, + ) # -- output ----------------------------------------------------------- diff --git a/python/xy/_native.py b/python/xy/_native.py index 6296481e..fb5182c5 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 36 +ABI_VERSION = 37 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. diff --git a/python/xy/_paint.py b/python/xy/_paint.py new file mode 100644 index 00000000..e072b9e5 --- /dev/null +++ b/python/xy/_paint.py @@ -0,0 +1,85 @@ +"""Shared direct-paint and alpha resolution for static exporters.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import numpy as np + +ColumnReader = Callable[[int], np.ndarray] + + +def direct_rgba(channel: dict[str, Any], n: int, read_column: ColumnReader) -> np.ndarray | None: + """Decode a packed normalized RGBA8 channel to canonical float RGBA.""" + if channel.get("mode") != "direct_rgba": + return None + raw = np.asarray(read_column(int(channel["buf"])), dtype=np.uint8).reshape(-1) + expected = n * 4 + if len(raw) < expected: + raise ValueError(f"direct RGBA buffer has {len(raw)} bytes; expected {expected}") + return raw[:expected].reshape(n, 4).astype(np.float64) / 255.0 + + +def style_values( + trace: dict[str, Any], + name: str, + n: int, + read_column: ColumnReader, + default: float, +) -> np.ndarray: + """Resolve one scalar/direct numeric style channel to N float values.""" + channel = (trace.get("channels") or {}).get(name) + if channel is None: + return np.full(n, float(trace.get("style", {}).get(name, default)), dtype=np.float64) + raw = np.asarray(read_column(int(channel["buf"])), dtype=np.float64) + components = int(channel.get("components", 1)) + expected = n * components + if raw.size < expected: + raise ValueError(f"{name} style buffer has {raw.size} values; expected {expected}") + return raw[:expected].reshape(n, components)[:, 0] + + +def style_matrix( + trace: dict[str, Any], + name: str, + n: int, + read_column: ColumnReader, +) -> np.ndarray | None: + """Return a direct style channel as its ``(N, components)`` matrix.""" + channel = (trace.get("channels") or {}).get(name) + if channel is None: + return None + components = int(channel.get("components", 1)) + raw = np.asarray(read_column(int(channel["buf"])), dtype=np.float64) + expected = n * components + if raw.size < expected: + raise ValueError(f"{name} style buffer has {raw.size} values; expected {expected}") + return raw[:expected].reshape(n, components) + + +def effective_rgba( + intrinsic: np.ndarray, + trace: dict[str, Any], + read_column: ColumnReader, + *, + component: str, + default_opacity: float, +) -> np.ndarray: + """Apply Matplotlib artist alpha and xy opacity in the documented order. + + Intrinsic paint alpha is replaced (not multiplied) when artist alpha is + non-negative. Core opacity and the component-specific fill/stroke opacity + remain multiplicative. + """ + rgba = np.asarray(intrinsic, dtype=np.float64).copy() + if rgba.ndim != 2 or rgba.shape[1] != 4: + raise ValueError(f"intrinsic paint must have shape (N, 4), got {rgba.shape}") + n = len(rgba) + style = trace.get("style") or {} + artist = style_values(trace, "artist_alpha", n, read_column, -1.0) + base_alpha = np.where(artist >= 0.0, artist, rgba[:, 3]) + opacity = style_values(trace, "opacity", n, read_column, default_opacity) + component_opacity = float(style.get(f"{component}_opacity", 1.0)) + rgba[:, 3] = np.clip(base_alpha * opacity * component_opacity, 0.0, 1.0) + return np.clip(rgba, 0.0, 1.0) diff --git a/python/xy/_payload.py b/python/xy/_payload.py index 4b7053df..7332ee46 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -414,6 +414,7 @@ def _emit_scatter( xv, yv = xv[visible], yv[visible] entry = self._base_entry(t, pw, xv, yv, "direct", dict(t.style)) entry["color"], entry["size"] = self._ship_channels(t, sel, pw.ship_scalar, pw.ship_u8) + self._ship_trace_styles(entry, t, sel, pw) t.shipped_sel = sel # pick/selection translation (§17) return entry @@ -536,6 +537,7 @@ def _emit_segments( raise ValueError(f"{t.kind} trace missing segment columns") x0v, x1v, y0v, y1v = t.x0.values, t.x1.values, t.y0.values, t.y1.values tier = "direct" + source_sel: Optional[np.ndarray] = None if t.kind == "errorbar" and t.count: # Segments ship grouped by role, count per group: 3 groups with # caps (main + two cap blocks), 1 without. Decimate per point @@ -546,14 +548,22 @@ def _emit_segments( chosen = np.linspace(0, t.count - 1, max_groups, dtype=np.int64) indices = np.concatenate([chosen + k * t.count for k in range(seg_per)]) x0v, x1v, y0v, y1v = x0v[indices], x1v[indices], y0v[indices], y1v[indices] + source_sel = indices tier = "decimated" elif t.kind == "stem" and len(x0v) > max(1024, int(px_width) * 4): chosen = np.linspace(0, len(x0v) - 1, max(1024, int(px_width) * 4), dtype=np.int64) x0v, x1v, y0v, y1v = x0v[chosen], x1v[chosen], y0v[chosen], y1v[chosen] + source_sel = chosen tier = "decimated" - sel_arg = self._rect_finite_sel(t, x0v, x1v, y0v, y1v) - if sel_arg is not None: - x0v, x1v, y0v, y1v = x0v[sel_arg], x1v[sel_arg], y0v[sel_arg], y1v[sel_arg] + finite_sel = self._rect_finite_sel(t, x0v, x1v, y0v, y1v) + if finite_sel is not None: + x0v, x1v, y0v, y1v = ( + x0v[finite_sel], + x1v[finite_sel], + y0v[finite_sel], + y1v[finite_sel], + ) + source_sel = finite_sel if source_sel is None else source_sel[finite_sel] entry = { "id": t.id, "kind": t.kind, @@ -570,7 +580,8 @@ def _emit_segments( "y1": pw.ship(y1v, t.y1), } if t.color_ch is not None: - entry["color"], _size = self._ship_channels(t, sel_arg, pw.ship_scalar, pw.ship_u8) + entry["color"], _size = self._ship_channels(t, source_sel, pw.ship_scalar, pw.ship_u8) + self._ship_trace_styles(entry, t, source_sel, pw) return entry def _emit_triangle_mesh( @@ -613,6 +624,7 @@ def _emit_triangle_mesh( } if t.color_ch is not None: entry["color"], _size = self._ship_channels(t, sel_arg, pw.ship_scalar, pw.ship_u8) + self._ship_trace_styles(entry, t, sel_arg, pw) return entry def _emit_errorbar( @@ -668,6 +680,7 @@ def _emit_rect( } if t.color_ch is not None: entry["color"], _size = self._ship_channels(t, sel_arg, pw.ship_scalar, pw.ship_u8) + self._ship_trace_styles(entry, t, sel_arg, pw) return entry def _emit_bar_compact( @@ -738,6 +751,7 @@ def _emit_bar_compact( } if t.color_ch is not None: entry["color"], _size = self._ship_channels(t, sel_arg, pw.ship_scalar, pw.ship_u8) + self._ship_trace_styles(entry, t, sel_arg, pw) return entry def _ship_channels(self, t: Trace, sel, ship_scalar, ship_u8) -> tuple[Any, Any]: # noqa: ANN001 @@ -745,6 +759,18 @@ def _ship_channels(self, t: Trace, sel, ship_scalar, ship_u8) -> tuple[Any, Any] same wire shape serves the build path and drill-in view updates).""" return channels.ship_channels(t, sel, ship_scalar, ship_u8, DEFAULT_PALETTE) + @staticmethod + def _ship_trace_styles(entry: dict[str, Any], t: Trace, sel, pw: "_PayloadWriter") -> None: # noqa: ANN001 + """Attach outline paint and direct instance attributes to a trace spec.""" + if t.stroke_ch is not None: + entry["stroke"] = channels.ship_color_channel( + t.stroke_ch, sel, pw.ship_scalar, pw.ship_u8, DEFAULT_PALETTE + ) + if t.style_channels: + entry["channels"] = channels.ship_style_channels( + t.style_channels, sel, pw.ship_scalar, pw.ship_u8 + ) + def _density_sample_spec( self, t: Trace, @@ -778,7 +804,7 @@ def _density_sample_spec( style["opacity"] = 0.55 x_col = pw.ship_values(t.x.values[sample_sel], kind=t.x.kind) y_col = pw.ship_values(t.y.values[sample_sel], kind=t.y.kind) - return { + sample = { "mode": "sampled", "n": int(len(sample_sel)), "visible": int(visible), @@ -793,6 +819,8 @@ def _density_sample_spec( "size": size_spec, "style": style, } + self._ship_trace_styles(sample, t, sample_sel, pw) + return sample def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> dict[str, Any]: # noqa: ANN001 """Bin a scatter into a density grid and build its spec entry (§5 Tier 2). @@ -883,9 +911,7 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d if (t.color_ch and t.color_ch.mode in ("constant", "continuous")) else channels.DEFAULT_COLORMAP ) - color_dropped = bool(t.color_ch and t.color_ch.mode != "constant") - size_dropped = bool(t.size_ch and t.size_ch.mode != "constant") - dropped = color_dropped or size_dropped + dropped_channels = list(t.per_item_channel_names()) density = { "buf": pw.ship_u8(encoded_grid), "w": w, @@ -895,7 +921,8 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d "colormap": cmap, "x_range": list(xr), "y_range": list(yr), - "channels_dropped": dropped, # never silent (§28) + "channels_dropped": bool(dropped_channels), # compatibility boolean + "dropped_channels": dropped_channels, # complete, actionable list (§28) } if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None: density["color"] = t.color_ch.constant diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 0ce3baf5..24109955 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -18,7 +18,7 @@ import numpy as np -from . import _png, _scene +from . import _paint, _png, _scene from ._arrowgeom import arrow_shapes as _arrow_shapes from ._svg import ( _AXIS, @@ -1247,6 +1247,35 @@ def _emit_area( cmd.stroke(base, lw, line_color, dash=style.get("dash")) +def _trace_paint_rgba( + trace: dict[str, Any], + key: str, + n: int, + fallback: str, + read: _paint.ColumnReader, +) -> np.ndarray: + """Resolve one payload paint channel to intrinsic float RGBA.""" + channel = trace.get(key) or {} + direct = _paint.direct_rgba(channel, n, read) + if direct is not None: + return direct + rgba = np.empty((n, 4), dtype=np.float64) + rgba[:, 3] = 1.0 + mode = channel.get("mode") + if mode == "continuous": + rgba[:, :3] = _lut(channel.get("colormap", "viridis"), read(channel["buf"])[:n]) / 255.0 + elif mode == "categorical": + codes = np.asarray(read(channel["buf"]), dtype=np.int64)[:n] + palette = channel.get("palette") or DEFAULT_PALETTE + table = np.asarray([_parse_color(value) for value in palette], dtype=np.float64) / 255.0 + rgba[:] = table[codes % len(table)] + else: + rgba[:] = ( + np.asarray(_parse_color(_css(channel.get("color"), fallback)), dtype=np.float64) / 255.0 + ) + return rgba + + def _emit_scatter( cmd: _Cmd, t: dict[str, Any], @@ -1259,6 +1288,10 @@ def _emit_scatter( ) -> None: ch = t.get("color") or {} size_ch = t.get("size") or {} + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + fill_op = _fill_opacity(style, 0.8) stroke_op = _stroke_opacity(style, 0.8) sw = float(style.get("stroke_width", 0.0)) @@ -1277,6 +1310,8 @@ def _emit_scatter( if ( sx.affine and sy.affine + and not t.get("channels") + and (t.get("stroke") is None or t["stroke"].get("mode") == "match_fill") and (color_mode in {"continuous", "categorical"} or size_mode == "continuous") ): alpha = max(0, min(255, int(round(fill_op * 255)))) @@ -1303,8 +1338,10 @@ def _emit_scatter( if ( sx.affine and sy.affine - and ch.get("mode") not in {"continuous", "categorical"} + and ch.get("mode") not in {"continuous", "categorical", "direct_rgba"} and size_ch.get("mode") != "continuous" + and not t.get("channels") + and (t.get("stroke") is None or t["stroke"].get("mode") == "match_fill") ): alpha = max(0, min(255, int(round(fill_op * 255)))) rgb = _parse_color(_css(ch.get("color"), color))[:3] @@ -1316,18 +1353,13 @@ def _emit_scatter( xv, yv = _column(blob, cols[t["x"]]), _column(blob, cols[t["y"]]) px, py = sx(xv), sy(yv) n = len(xv) - fills = np.empty((n, 4), dtype=np.uint8) - fills[:, 3] = max(0, min(255, int(round(fill_op * 255)))) - if ch.get("mode") == "continuous": - fills[:, :3] = _lut(ch.get("colormap", "viridis"), _column(blob, cols[ch["buf"]])) - elif ch.get("mode") == "categorical": - codes = _column(blob, cols[ch["buf"]]).astype(np.int64) - pal = ch.get("palette") or DEFAULT_PALETTE - # Resolve each palette entry once; per-point colors are a table gather. - pal_rgb = np.array([_parse_color(c)[:3] for c in pal], dtype=np.uint8) - fills[:, :3] = pal_rgb[codes % len(pal)] - else: - fills[:, :3] = _parse_color(_css(ch.get("color"), color))[:3] + if n == 0: + return + face_intrinsic = _trace_paint_rgba(t, "color", n, color, read) + fills = np.rint( + _paint.effective_rgba(face_intrinsic, t, read, component="fill", default_opacity=0.8) + * 255.0 + ).astype(np.uint8) if size_ch.get("mode") == "continuous": sv = _column(blob, cols[size_ch["buf"]]) @@ -1336,7 +1368,53 @@ def _emit_scatter( else: radii = np.full(n, float(size_ch.get("size", 4.0)) / 2) - cmd.points(px, py, radii, fills, sym, sw, stroke) + widths = _paint.style_values(t, "stroke_width", n, read, sw) + symbol_channel = (t.get("channels") or {}).get("symbol") + symbols = ( + np.asarray(read(symbol_channel["buf"]), dtype=np.uint8)[:n] + if symbol_channel is not None + else np.full(n, sym, dtype=np.uint8) + ) + if (t.get("stroke") or {}).get("mode") == "match_fill": + stroke_intrinsic = face_intrinsic + elif t.get("stroke") is not None: + stroke_intrinsic = _trace_paint_rgba(t, "stroke", n, color, read) + elif style.get("stroke") is not None: + stroke_intrinsic = np.tile( + np.asarray(_parse_color(_css(style.get("stroke"), color)), dtype=np.float64) / 255.0, + (n, 1), + ) + else: + stroke_intrinsic = face_intrinsic + strokes = np.rint( + _paint.effective_rgba(stroke_intrinsic, t, read, component="stroke", default_opacity=0.8) + * 255.0 + ).astype(np.uint8) + if ( + np.all(widths == widths[0]) + and np.all(symbols == symbols[0]) + and np.all(strokes == strokes[0]) + ): + cmd.points( + px, + py, + radii, + fills, + int(symbols[0]), + float(widths[0]), + tuple(int(value) for value in strokes[0]), + ) + else: + for index in range(n): + cmd.points( + px[index : index + 1], + py[index : index + 1], + radii[index : index + 1], + fills[index : index + 1], + int(symbols[index]), + float(widths[index]), + tuple(int(value) for value in strokes[index]), + ) def _emit_segments( @@ -1353,20 +1431,16 @@ def _emit_segments( x1 = _column(blob, cols[t["x1"]]) y0 = _column(blob, cols[t["y0"]]) y1 = _column(blob, cols[t["y1"]]) - ch = t.get("color") or {} - opacity = _stroke_opacity(style) - colors = np.empty((len(x0), 4), dtype=np.uint8) - colors[:, 3] = max(0, min(255, int(round(255 * opacity)))) - if ch.get("mode") == "continuous": - colors[:, :3] = _lut(ch.get("colormap", "viridis"), _column(blob, cols[ch["buf"]])) - elif ch.get("mode") == "categorical": - codes = _column(blob, cols[ch["buf"]]).astype(np.int64) - palette = ch.get("palette") or DEFAULT_PALETTE - palette_rgb = np.array([_parse_color(entry)[:3] for entry in palette], dtype=np.uint8) - colors[:, :3] = palette_rgb[codes % len(palette_rgb)] - else: - colors[:] = _rgba(style.get("color"), color, opacity) - width = float(style.get("width", 1.2)) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + n = len(x0) + intrinsic = _trace_paint_rgba(t, "color", n, color, read) + colors = np.rint( + _paint.effective_rgba(intrinsic, t, read, component="stroke", default_opacity=1.0) * 255.0 + ).astype(np.uint8) + widths = _paint.style_values(t, "width", n, read, float(style.get("width", 1.2))) dash = style.get("dash") if dash: # The batched segments primitive cannot dash; fall back to one dashed @@ -1378,12 +1452,26 @@ def _emit_segments( for index in range(len(x0)): cmd.stroke( [(float(px0[index]), float(py0[index])), (float(px1[index]), float(py1[index]))], - width, + float(widths[index]), tuple(int(v) for v in colors[index]), dash=dash_pattern, ) return - cmd.segments(sx(x0), sy(y0), sx(x1), sy(y1), width, colors) + if n == 0: + return + if np.all(widths == widths[0]): + cmd.segments(sx(x0), sy(y0), sx(x1), sy(y1), float(widths[0]), colors) + else: + px0, py0, px1, py1 = sx(x0), sy(y0), sx(x1), sy(y1) + for index in range(n): + cmd.stroke( + [ + (float(px0[index]), float(py0[index])), + (float(px1[index]), float(py1[index])), + ], + float(widths[index]), + tuple(int(value) for value in colors[index]), + ) def _mesh_fill_rgba( @@ -1394,20 +1482,13 @@ def _mesh_fill_rgba( style: dict[str, Any], color: str, ) -> np.ndarray: - ch = t.get("color") or {} - fill_op = _fill_opacity(style) - fills = np.empty((n, 4), dtype=np.uint8) - fills[:, 3] = max(0, min(255, int(round(fill_op * 255)))) - if ch.get("mode") == "continuous": - fills[:, :3] = _lut(ch.get("colormap", "viridis"), _column(blob, cols[ch["buf"]])[:n]) - elif ch.get("mode") == "categorical": - codes = _column(blob, cols[ch["buf"]])[:n].astype(np.int64) - palette = ch.get("palette") or DEFAULT_PALETTE - palette_rgb = np.array([_parse_color(entry)[:3] for entry in palette], dtype=np.uint8) - fills[:, :3] = palette_rgb[codes % len(palette_rgb)] - else: - fills[:, :3] = _parse_color(_css(ch.get("color"), color))[:3] - return fills + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + intrinsic = _trace_paint_rgba(t, "color", n, color, read) + return np.rint( + _paint.effective_rgba(intrinsic, t, read, component="fill", default_opacity=1.0) * 255.0 + ).astype(np.uint8) def _emit_hexbin( @@ -1449,23 +1530,50 @@ def _emit_triangle_mesh( ) -> None: vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")] n = min(len(values) for values in vertices) - stroke_op = _stroke_opacity(style) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + fills = _mesh_fill_rgba(t, blob, cols, n, style, color) x0, y0, x1, y1, x2, y2 = vertices - sw = float(style.get("stroke_width", 0.0)) - stroke = _rgba(style.get("stroke"), color, stroke_op) if sw > 0 else (0, 0, 0, 0) - cmd.triangles( - sx(x0[:n]), - sy(y0[:n]), - sx(x1[:n]), - sy(y1[:n]), - sx(x2[:n]), - sy(y2[:n]), - fills, - sw, - stroke, - ) + widths = _paint.style_values(t, "stroke_width", n, read, float(style.get("stroke_width", 0.0))) + if t.get("stroke") is not None: + stroke_intrinsic = _trace_paint_rgba(t, "stroke", n, color, read) + elif style.get("stroke") is not None: + stroke_intrinsic = np.tile( + np.asarray(_parse_color(_css(style.get("stroke"), color)), dtype=np.float64) / 255.0, + (n, 1), + ) + else: + stroke_intrinsic = _trace_paint_rgba(t, "color", n, color, read) + strokes = np.rint( + _paint.effective_rgba(stroke_intrinsic, t, read, component="stroke", default_opacity=1.0) + * 255.0 + ).astype(np.uint8) + projected = (sx(x0[:n]), sy(y0[:n]), sx(x1[:n]), sy(y1[:n]), sx(x2[:n]), sy(y2[:n])) + if n == 0: + return + if np.all(widths == widths[0]) and np.all(strokes == strokes[0]): + cmd.triangles( + *projected, + fills, + float(widths[0]), + tuple(int(value) for value in strokes[0]), + ) + else: + for index in range(n): + cmd.triangles( + projected[0][index : index + 1], + projected[1][index : index + 1], + projected[2][index : index + 1], + projected[3][index : index + 1], + projected[4][index : index + 1], + projected[5][index : index + 1], + fills[index : index + 1], + float(widths[index]), + tuple(int(value) for value in strokes[index]), + ) def _bar_geom( @@ -1527,6 +1635,45 @@ def fill_cmd(poly: list[tuple[float, float]]) -> None: return fill_cmd, stroke_c, sw +def _rect_style_arrays( + trace: dict[str, Any], + n: int, + fallback: str, + read: _paint.ColumnReader, + default_opacity: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Resolve batched rectangle paint, stroke width, and radii.""" + face = _trace_paint_rgba(trace, "color", n, fallback, read) + fills = np.rint( + _paint.effective_rgba(face, trace, read, component="fill", default_opacity=default_opacity) + * 255.0 + ).astype(np.uint8) + style = trace.get("style") or {} + if trace.get("stroke") is not None: + stroke_face = _trace_paint_rgba(trace, "stroke", n, fallback, read) + elif style.get("stroke") is not None: + stroke_face = np.tile( + np.asarray(_parse_color(_css(style.get("stroke"), fallback)), dtype=np.float64) / 255.0, + (n, 1), + ) + else: + stroke_face = face + strokes = np.rint( + _paint.effective_rgba( + stroke_face, trace, read, component="stroke", default_opacity=default_opacity + ) + * 255.0 + ).astype(np.uint8) + widths = _paint.style_values( + trace, "stroke_width", n, read, float(style.get("stroke_width", 0.0)) + ) + radii = _paint.style_matrix(trace, "corner_radius", n, read) + if radii is None: + tip, base = _corner_radii(style) + radii = np.tile(np.asarray([[tip, base]], dtype=np.float64), (n, 1)) + return fills, strokes, widths, radii + + def _emit_bars( cmd: _Cmd, t: dict[str, Any], @@ -1548,9 +1695,12 @@ def _emit_bars( ) horizontal = b.get("orientation") == "horizontal" half = float(b["width"]) / 2 - r_tip, r_base = _corner_radii(style) - sw = float(style.get("stroke_width", 0.0)) - if not isinstance(style.get("fill"), dict) and not (r_tip or r_base or sw > 0): + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + fills, strokes, widths, radii = _rect_style_arrays(t, len(pos), color, read, 0.85) + if not isinstance(style.get("fill"), dict) and not np.any(radii) and not np.any(widths): if horizontal: xa, xb = sx(np.minimum(v0, v1)), sx(np.maximum(v0, v1)) ya, yb = sy(pos + half), sy(pos - half) @@ -1559,10 +1709,41 @@ def _emit_bars( ya, yb = sy(np.maximum(v0, v1)), sy(np.minimum(v0, v1)) x0, x1 = np.minimum(xa, xb), np.maximum(xa, xb) y0, y1 = np.minimum(ya, yb), np.maximum(ya, yb) - fill = _rgba(style.get("color"), color, _fill_opacity(style, 0.85)) - fills = np.tile(np.asarray(fill, dtype=np.uint8), (len(pos), 1)) cmd.rects(x0, y0, x1, y1, fills) return + if not isinstance(style.get("fill"), dict): + for i in range(len(pos)): + if horizontal: + x0, x1 = float(sx(min(v0[i], v1[i]))), float(sx(max(v0[i], v1[i]))) + y0, y1 = float(sy(pos[i] + half)), float(sy(pos[i] - half)) + tip_top = True + else: + x0, x1 = float(sx(pos[i] - half)), float(sx(pos[i] + half)) + y0, y1 = float(sy(max(v0[i], v1[i]))), float(sy(min(v0[i], v1[i]))) + tip_top = bool(v1[i] >= v0[i]) + item_style = dict(style) + item_style["corner_radius"] = ( + float(radii[i, 0]) + if radii.shape[1] == 1 + else [float(radii[i, 0]), float(radii[i, 1])] + ) + + def fill_item(poly: list[tuple[float, float]], index: int = i) -> None: + cmd.fill(poly, tuple(int(value) for value in fills[index])) + + _bar_geom( + cmd, + min(x0, x1), + min(y0, y1), + abs(x1 - x0), + abs(y1 - y0), + item_style, + fill_item, + tuple(int(value) for value in strokes[i]), + float(widths[i]), + tip_top, + ) + return fill_cmd, stroke_c, sw = _fill_maker(cmd, style, color, plot) for i in range(len(pos)): if horizontal: @@ -1590,13 +1771,14 @@ def _emit_rects( ) -> None: x0v, x1v = _column(blob, cols[t["x0"]]), _column(blob, cols[t["x1"]]) y0v, y1v = _column(blob, cols[t["y0"]]), _column(blob, cols[t["y1"]]) - r_tip, r_base = _corner_radii(style) - sw = float(style.get("stroke_width", 0.0)) - if not isinstance(style.get("fill"), dict) and not (r_tip or r_base or sw > 0): + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + fills, strokes, widths, radii = _rect_style_arrays(t, len(x0v), color, read, 0.85) + if not isinstance(style.get("fill"), dict) and not np.any(radii) and not np.any(widths): xa, xb = sx(x0v), sx(x1v) ya, yb = sy(y0v), sy(y1v) - fill = _rgba(style.get("color"), color, _fill_opacity(style, 0.85)) - fills = np.tile(np.asarray(fill, dtype=np.uint8), (len(x0v), 1)) cmd.rects( np.minimum(xa, xb), np.minimum(ya, yb), @@ -1605,6 +1787,33 @@ def _emit_rects( fills, ) return + if not isinstance(style.get("fill"), dict): + for i in range(len(x0v)): + xa_, xb = float(sx(x0v[i])), float(sx(x1v[i])) + ya_, yb = float(sy(y0v[i])), float(sy(y1v[i])) + item_style = dict(style) + item_style["corner_radius"] = ( + float(radii[i, 0]) + if radii.shape[1] == 1 + else [float(radii[i, 0]), float(radii[i, 1])] + ) + + def fill_item(poly: list[tuple[float, float]], index: int = i) -> None: + cmd.fill(poly, tuple(int(value) for value in fills[index])) + + _bar_geom( + cmd, + min(xa_, xb), + min(ya_, yb), + abs(xb - xa_), + abs(yb - ya_), + item_style, + fill_item, + tuple(int(value) for value in strokes[i]), + float(widths[i]), + bool(y1v[i] >= y0v[i]), + ) + return fill_cmd, stroke_c, sw = _fill_maker(cmd, style, color, plot) for i in range(len(x0v)): xa_, xb = float(sx(x0v[i])), float(sx(x1v[i])) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index a135e758..74c3bf8c 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -28,7 +28,7 @@ import numpy as np -from . import _native, _png +from . import _native, _paint, _png from ._arrowgeom import arrow_shapes as _arrow_shapes from .config import DEFAULT_PALETTE @@ -1971,27 +1971,28 @@ def _segment_marks( x1 = _column(blob, cols[t["x1"]]) y0 = _column(blob, cols[t["y0"]]) y1 = _column(blob, cols[t["y1"]]) - width = float(style.get("width", 1.2)) - op = _stroke_opacity(style) - channel = t.get("color") or {} - if channel.get("mode") == "continuous": - rgb = _lut(channel.get("colormap", "viridis"), _column(blob, cols[channel["buf"]])) - colors = [f"rgb({r},{g},{b})" for r, g, b in rgb] - elif channel.get("mode") == "categorical": - codes = _column(blob, cols[channel["buf"]]).astype(int) - palette = channel.get("palette") or DEFAULT_PALETTE - colors = [palette[code % len(palette)] for code in codes] - else: - colors = [color] * len(x0) - suffix = ( - f'stroke-width="{_num(width)}" fill="none" stroke-linecap="round"' - + (f' stroke-opacity="{_num(op)}"' if op < 1 else "") - + _dash_attr(style) - ) + n = len(x0) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + intrinsic = _trace_paint_rgba(t, "color", n, color, read) + colors = _paint.effective_rgba(intrinsic, t, read, component="stroke", default_opacity=1.0) + widths = _paint.style_values(t, "width", n, read, float(style.get("width", 1.2))) + paint = t.get("color") or {} + plain_css = _css(paint.get("color"), color) + # Only an opaque constant color may pass through verbatim: a translucent + # CSS constant already contributes its alpha to stroke-opacity through + # effective_rgba, so repeating it inside stroke= would apply it twice. + constant_paint = paint.get("mode") in {None, "constant"} and _paint_rgba8(plain_css)[3] == 255 + css_paint = escape(plain_css) return "".join( f'' + f'stroke="{css_paint if constant_paint else f"rgb({round(colors[i, 0] * 255)},{round(colors[i, 1] * 255)},{round(colors[i, 2] * 255)})"}" ' + f'stroke-opacity="{_num(float(colors[i, 3]))}" ' + f'stroke-width="{_num(float(widths[i]))}" fill="none" stroke-linecap="round"' + f"{_dash_attr(style)}/>" for i in range(len(x0)) ) @@ -2004,18 +2005,30 @@ def _scatter_marks( px, py = sx(xv), sy(yv) n = len(xv) - color_ch = t.get("color") or {} - mode = color_ch.get("mode") - if mode == "continuous": - vals = _column(blob, cols[color_ch["buf"]]) - rgb = _lut(color_ch.get("colormap", "viridis"), vals) - fills = [f"rgb({r},{g},{b})" for r, g, b in rgb] - elif mode == "categorical": - codes = _column(blob, cols[color_ch["buf"]]).astype(int) - pal = color_ch.get("palette") or DEFAULT_PALETTE - fills = [pal[c % len(pal)] for c in codes] - else: - fills = [_css(color_ch.get("color"), fallback)] * n + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + face_intrinsic = _trace_paint_rgba(t, "color", n, fallback, read) + scalar_artist = style.get("artist_alpha") + grouped_alpha = scalar_artist is not None and not (t.get("channels") or {}).get("artist_alpha") + effective_trace = t + if grouped_alpha: + face_intrinsic[:, 3] = 1.0 + effective_trace = dict(t) + effective_style = dict(style) + effective_style.pop("artist_alpha", None) + effective_style["opacity"] = 1.0 + effective_style["fill_opacity"] = 1.0 + effective_style["stroke_opacity"] = 1.0 + effective_trace["style"] = effective_style + face_rgba = _paint.effective_rgba( + face_intrinsic, effective_trace, read, component="fill", default_opacity=0.8 + ) + face_channel = t.get("color") or {} + face_css = _css(face_channel.get("color"), fallback) + face_css_constant = ( + face_channel.get("mode") in {None, "constant"} and _paint_rgba8(face_css)[3] == 255 + ) size_ch = t.get("size") or {} if size_ch.get("mode") == "continuous": @@ -2025,31 +2038,69 @@ def _scatter_marks( else: radii = np.full(n, float(size_ch.get("size", 4.0)) / 2) - fill_op = _fill_opacity(style, 0.8) - stroke_op = _stroke_opacity(style, 0.8) - stroke_w = float(style.get("stroke_width", 0.0)) - line_symbol = style.get("symbol") in {"plus_line", "x_line"} - if line_symbol and stroke_w <= 0: - stroke_w = 1.0 - explicit_stroke = style.get("stroke") - stroke = _css(explicit_stroke, fallback) if explicit_stroke is not None else None - symbol = style.get("symbol", "circle") - builder = _SYMBOL_BUILDERS.get(symbol) - - # Collection alpha applies to faces and edges. A missing explicit stroke - # means edgecolors="face", so resolve the edge separately for every mark. - attrs = "" - if fill_op < 1: - attrs += f' fill-opacity="{_num(fill_op)}"' - if stroke_op < 1: - attrs += f' stroke-opacity="{_num(stroke_op)}"' - out = [f""] + stroke_widths = _paint.style_values(t, "stroke_width", n, read, 0.0) + symbols = _symbol_names(t, n, read, str(style.get("symbol", "circle"))) + if (t.get("stroke") or {}).get("mode") == "match_fill": + stroke_source = face_intrinsic.copy() + stroke_css = face_css + stroke_css_constant = face_css_constant + elif t.get("stroke") is not None: + stroke_source = _trace_paint_rgba(t, "stroke", n, fallback, read) + stroke_css = _css((t.get("stroke") or {}).get("color"), style.get("stroke") or face_css) + stroke_css_constant = (t.get("stroke") or {}).get("mode") in { + None, + "constant", + } and _paint_rgba8(stroke_css)[3] == 255 + elif style.get("stroke") is not None: + stroke_css = _css(style.get("stroke"), face_css) + stroke_source = np.tile( + np.asarray(_paint_rgba8(stroke_css), dtype=np.float64) / 255.0, (n, 1) + ) + stroke_css_constant = _paint_rgba8(stroke_css)[3] == 255 + else: + stroke_source = face_intrinsic.copy() + stroke_css = face_css + stroke_css_constant = face_css_constant + stroke_rgba = _paint.effective_rgba( + stroke_source, effective_trace, read, component="stroke", default_opacity=0.8 + ) + if grouped_alpha: + fill_group = float(scalar_artist) * _fill_opacity(style, 1.0) + stroke_group = float(scalar_artist) * _stroke_opacity(style, 1.0) + out = [f''] + else: + out = [""] for i in range(n): - fill_attr = f' fill="{escape(fills[i])}"' - point_stroke = stroke or (fills[i] if stroke_w or line_symbol else None) + fill = face_rgba[i] + fill_value = ( + escape(face_css) + if face_css_constant + else f"rgb({round(fill[0] * 255)},{round(fill[1] * 255)},{round(fill[2] * 255)})" + ) + fill_attr = f' fill="{fill_value}"' + ( + f' fill-opacity="{_num(float(fill[3]))}"' if float(fill[3]) < 1.0 else "" + ) + symbol = symbols[i] + builder = _SYMBOL_BUILDERS.get(symbol) + line_symbol = symbol in {"plus_line", "x_line"} + stroke_w = float(stroke_widths[i]) + if line_symbol and stroke_w <= 0: + stroke_w = 1.0 + stroke_color = stroke_rgba[i] + stroke_value = ( + escape(stroke_css) + if stroke_css_constant + else f"rgb({round(stroke_color[0] * 255)},{round(stroke_color[1] * 255)},{round(stroke_color[2] * 255)})" + ) stroke_attr = ( - f' stroke="{escape(point_stroke)}" stroke-width="{_num(stroke_w)}"' - if point_stroke + f' stroke="{stroke_value}"' + + ( + f' stroke-opacity="{_num(float(stroke_color[3]))}"' + if float(stroke_color[3]) < 1.0 + else "" + ) + + f' stroke-width="{_num(stroke_w)}"' + if stroke_w > 0 or line_symbol else "" ) # `size` includes the edge; SVG strokes are centered on the path. @@ -2067,6 +2118,68 @@ def _scatter_marks( return "".join(out) +_SYMBOL_NAMES = ( + "circle", + "square", + "diamond", + "triangle", + "cross", + "hexagon", + "pentagon", + "star", + "triangle_down", + "triangle_left", + "triangle_right", + "x", + "point", + "pixel", + "thin_diamond", + "plus_line", + "x_line", +) + + +def _symbol_names( + trace: dict[str, Any], n: int, read: _paint.ColumnReader, fallback: str +) -> list[str]: + channel = (trace.get("channels") or {}).get("symbol") + if channel is None: + return [fallback] * n + codes = np.asarray(read(int(channel["buf"])), dtype=np.uint8)[:n] + return [ + _SYMBOL_NAMES[int(code)] if int(code) < len(_SYMBOL_NAMES) else fallback for code in codes + ] + + +def _trace_paint_rgba( + trace: dict[str, Any], + key: str, + n: int, + fallback: str, + read: _paint.ColumnReader, +) -> np.ndarray: + """Resolve one payload paint channel to intrinsic float RGBA.""" + channel = trace.get(key) or {} + direct = _paint.direct_rgba(channel, n, read) + if direct is not None: + return direct + rgba = np.empty((n, 4), dtype=np.float64) + rgba[:, 3] = 1.0 + mode = channel.get("mode") + if mode == "continuous": + rgba[:, :3] = _lut(channel.get("colormap", "viridis"), read(channel["buf"])[:n]) / 255.0 + elif mode == "categorical": + codes = np.asarray(read(channel["buf"]), dtype=np.int64)[:n] + palette = channel.get("palette") or DEFAULT_PALETTE + table = np.asarray([_paint_rgba8(value) for value in palette], dtype=np.float64) / 255.0 + rgba[:] = table[codes % len(table)] + else: + rgba[:] = ( + np.asarray(_paint_rgba8(_css(channel.get("color"), fallback)), dtype=np.float64) / 255.0 + ) + return rgba + + # The hexagon ring around a hexbin cell center, as fractions of the cell # pitch (style hex_dx/hex_dy). Shared by the SVG and raster exporters; the JS # client mirrors it in _buildHexbinMark (js/src/50_chartview.js) — keep them @@ -2132,27 +2245,45 @@ def _triangle_mesh_marks( ) -> str: vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")] n = min(len(values) for values in vertices) - fills = _mesh_fills(t, blob, cols, n, fallback) - fill_op = _fill_opacity(style) - stroke_op = _stroke_opacity(style) - stroke_width = float(style.get("stroke_width", 0.0)) - stroke = _css(style.get("stroke"), fallback) if stroke_width else None - group_attr = f' fill-opacity="{_num(fill_op)}"' if fill_op < 1 else "" - stroke_attr = ( - f' stroke="{escape(stroke)}" stroke-width="{_num(stroke_width)}"' - + (f' stroke-opacity="{_num(stroke_op)}"' if stroke_op < 1 else "") - if stroke is not None - else "" + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + face = _trace_paint_rgba(t, "color", n, fallback, read) + fills = _paint.effective_rgba(face, t, read, component="fill", default_opacity=1.0) + if t.get("stroke") is not None: + stroke_face = _trace_paint_rgba(t, "stroke", n, fallback, read) + elif style.get("stroke") is not None: + stroke_face = np.tile( + np.asarray(_paint_rgba8(_css(style.get("stroke"), fallback)), dtype=np.float64) / 255.0, + (n, 1), + ) + else: + stroke_face = face + strokes = _paint.effective_rgba(stroke_face, t, read, component="stroke", default_opacity=1.0) + stroke_widths = _paint.style_values( + t, "stroke_width", n, read, float(style.get("stroke_width", 0.0)) ) x0, y0, x1, y1, x2, y2 = vertices - out = [f""] + out = [""] for i in range(n): points = " ".join( f"{_num(float(sx(x)))},{_num(float(sy(y)))}" for x, y in ((x0[i], y0[i]), (x1[i], y1[i]), (x2[i], y2[i])) ) - out.append(f'') + fill = fills[i] + attrs = ( + f' fill="rgb({round(fill[0] * 255)},{round(fill[1] * 255)},' + f'{round(fill[2] * 255)})" fill-opacity="{_num(float(fill[3]))}"' + ) + if stroke_widths[i] > 0: + stroke = strokes[i] + attrs += ( + f' stroke="rgb({round(stroke[0] * 255)},{round(stroke[1] * 255)},' + f'{round(stroke[2] * 255)})" stroke-opacity="{_num(float(stroke[3]))}" ' + f'stroke-width="{_num(float(stroke_widths[i]))}"' + ) + out.append(f'') out.append("") return "".join(out) @@ -2179,6 +2310,60 @@ def _corner_radii(style: dict) -> tuple[float, float]: return float(cr or 0), float(cr or 0) +def _rect_svg_styles( + trace: dict[str, Any], + n: int, + fallback: str, + read: _paint.ColumnReader, + style: dict[str, Any], + svg: _Svg, + plot: dict[str, Any], +) -> tuple[list[str], list[str], np.ndarray]: + """Resolve per-rectangle SVG fill/stroke attributes and radii.""" + radius_channel = _paint.style_matrix(trace, "corner_radius", n, read) + if radius_channel is None: + tip, base = _corner_radii(style) + radii = np.tile(np.asarray([[tip, base]], dtype=np.float64), (n, 1)) + elif radius_channel.shape[1] == 1: + radii = np.repeat(radius_channel, 2, axis=1) + else: + radii = radius_channel + if isinstance(style.get("fill"), dict): + fill, extra = _bar_fill(style, fallback, svg, plot) + return [fill] * n, [extra] * n, radii + + face = _trace_paint_rgba(trace, "color", n, fallback, read) + fills_rgba = _paint.effective_rgba(face, trace, read, component="fill", default_opacity=0.85) + if trace.get("stroke") is not None: + stroke_face = _trace_paint_rgba(trace, "stroke", n, fallback, read) + elif style.get("stroke") is not None: + stroke_face = np.tile( + np.asarray(_paint_rgba8(_css(style.get("stroke"), fallback)), dtype=np.float64) / 255.0, + (n, 1), + ) + else: + stroke_face = face + strokes = _paint.effective_rgba( + stroke_face, trace, read, component="stroke", default_opacity=0.85 + ) + widths = _paint.style_values( + trace, "stroke_width", n, read, float(style.get("stroke_width", 0.0)) + ) + fills: list[str] = [] + extras: list[str] = [] + for fill, stroke, width in zip(fills_rgba, strokes, widths, strict=True): + fills.append(f"rgb({round(fill[0] * 255)},{round(fill[1] * 255)},{round(fill[2] * 255)})") + extra = f' fill-opacity="{_num(float(fill[3]))}"' + if width > 0: + extra += ( + f' stroke="rgb({round(stroke[0] * 255)},{round(stroke[1] * 255)},' + f'{round(stroke[2] * 255)})" stroke-opacity="{_num(float(stroke[3]))}" ' + f'stroke-width="{_num(float(width))}"' + ) + extras.append(extra) + return fills, extras, radii + + def _bar_marks( t: dict, blob: bytes, @@ -2200,8 +2385,11 @@ def _bar_marks( ) horizontal = b.get("orientation") == "horizontal" half = float(b["width"]) / 2 - r_tip, r_base = _corner_radii(style) - fill, extra = _bar_fill(style, color, svg, plot) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + fills, extras, radii = _rect_svg_styles(t, len(pos), color, read, style, svg, plot) out = [] for i in range(len(pos)): if horizontal: @@ -2212,14 +2400,15 @@ def _bar_marks( y0, y1 = float(sy(max(v0[i], v1[i]))), float(sy(min(v0[i], v1[i]))) w, h = abs(x1 - x0), abs(y1 - y0) x, y = min(x0, x1), min(y0, y1) + r_tip, r_base = radii[i] if r_tip or r_base: tip_top = not horizontal and v1[i] >= v0[i] d = _rounded_rect_path(x, y, w, h, r_tip, r_base, tip_top or horizontal) - out.append(f'') + out.append(f'') else: out.append( f'' + f'fill="{fills[i]}"{extras[i]}/>' ) return "".join(out) @@ -2239,21 +2428,25 @@ def _rect_marks( x1v = _column(blob, cols[t["x1"]]) y0v = _column(blob, cols[t["y0"]]) y1v = _column(blob, cols[t["y1"]]) - r_tip, r_base = _corner_radii(style) - fill, extra = _bar_fill(style, color, svg, plot) + + def read(index: int) -> np.ndarray: + return _column(blob, cols[index]) + + fills, extras, radii = _rect_svg_styles(t, len(x0v), color, read, style, svg, plot) out = [] for i in range(len(x0v)): xa_, xb = float(sx(x0v[i])), float(sx(x1v[i])) ya_, yb = float(sy(y0v[i])), float(sy(y1v[i])) x, y = min(xa_, xb), min(ya_, yb) w, h = abs(xb - xa_), abs(yb - ya_) + r_tip, r_base = radii[i] if r_tip or r_base: d = _rounded_rect_path(x, y, w, h, r_tip, r_base, y1v[i] >= y0v[i]) - out.append(f'') + out.append(f'') else: out.append( f'' + f'fill="{fills[i]}"{extras[i]}/>' ) return "".join(out) diff --git a/python/xy/_trace.py b/python/xy/_trace.py index edd41ea5..91ee795a 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from typing import Any, Optional -from .channels import ColorChannel, SizeChannel +from .channels import ColorChannel, SizeChannel, StyleChannel from .columns import Column from .config import DIRECT_SOFT_CEILING, SCATTER_DENSITY_THRESHOLD @@ -40,7 +40,14 @@ class Trace: y0: Optional[Column] = None y1: Optional[Column] = None color_ch: Optional[ColorChannel] = None # scatter color encoding + # Independent per-mark outline paint. ``None`` means the mark family has + # no outline; a constant ``None`` color inside the channel means + # edgecolors="face" and is resolved against color_ch by the renderers. + stroke_ch: Optional[ColorChannel] = None size_ch: Optional[SizeChannel] = None # scatter size encoding + # Direct, final-unit instance attributes (alpha override, opacity, widths, + # symbols, corner radii). Constants stay in ``style`` and cost no buffer. + style_channels: dict[str, StyleChannel] = field(default_factory=dict) # Tri-state density override: None = auto (threshold), True/False = forced. # (A bool here silently ignored density=False — staff-review finding.) force_density: Optional[bool] = None @@ -71,16 +78,31 @@ def n_points(self) -> int: return self.count return len(self.x) + def per_item_channel_names(self) -> tuple[str, ...]: + """Names of channels whose values vary independently per rendered item.""" + names: list[str] = [] + if self.color_ch is not None and self.color_ch.mode != "constant": + names.append("color") + if self.stroke_ch is not None and self.stroke_ch.mode not in ("constant", "match_fill"): + names.append("stroke") + if self.size_ch is not None and self.size_ch.mode != "constant": + names.append("size") + names.extend(self.style_channels) + return tuple(names) + + def has_per_item_channels(self) -> bool: + """Whether this trace must preserve independently styled items.""" + return bool(self.per_item_channel_names()) + def use_density(self) -> bool: """Whether this scatter renders as a Tier-2 density grid (§5).""" if self.kind != "scatter": return False if self.force_density is not None: return self.force_density - per_point = (self.color_ch and self.color_ch.mode != "constant") or ( - self.size_ch and self.size_ch.mode != "constant" - ) # Per-point channels keep direct draw until the hard ceiling; plain # scatter aggregates earlier (its whole win is not drawing 10M dots). - threshold = DIRECT_SOFT_CEILING if per_point else SCATTER_DENSITY_THRESHOLD + threshold = ( + DIRECT_SOFT_CEILING if self.has_per_item_channels() else SCATTER_DENSITY_THRESHOLD + ) return self.n_points > threshold diff --git a/python/xy/channels.py b/python/xy/channels.py index d9462f3d..e91482fa 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -66,7 +66,7 @@ def is_colormap(name: str) -> bool: class ColorChannel: """Resolved color encoding for a scatter trace.""" - mode: str # "constant" | "continuous" | "categorical" + mode: str # "constant" | "continuous" | "categorical" | "direct_rgba" | "match_fill" # constant: constant: Optional[str] = None # continuous: per-point value normalized to [0,1] at ship time; domain kept @@ -80,6 +80,10 @@ class ColorChannel: # Exact dense-code counts, fused into native compact factorization. They # let full-domain stratified sampling skip a source-sized recount. counts: Optional[npt.NDArray[np.uint64]] = None + # direct_rgba: canonical straight-alpha float RGBA. The wire uses packed + # normalized RGBA8, while keeping the canonical values here lets pyplot + # getters and post-hoc artist mutation retain Matplotlib semantics. + rgba: Optional[npt.NDArray[np.float64]] = None # Append-only backing storage for streaming continuous channels. Kept out # of the wire/spec surface; values remains the exact-length view. _buffer: Optional[npt.NDArray[np.float64]] = field( @@ -97,9 +101,30 @@ def spec(self) -> dict[str, Any]: "colormap": self.colormap, "domain": list(self.domain) if self.domain else None, } + if self.mode == "direct_rgba": + return {"mode": "direct_rgba", "components": 4, "dtype": "u8"} + if self.mode == "match_fill": + return {"mode": "match_fill"} return {"mode": "categorical", "categories": self.categories} +@dataclass +class StyleChannel: + """A direct per-mark style channel in final renderer units. + + ``values`` is always canonical f64 except for integer-coded symbols. The + payload compiler chooses compact f32/u8 transport and slices it with the + same row selection as geometry and paint. + """ + + values: np.ndarray + components: int = 1 + dtype: str = "f32" # "f32" | "u8" + + def spec(self) -> dict[str, Any]: + return {"mode": "direct", "components": self.components, "dtype": self.dtype} + + @dataclass class SizeChannel: """A resolved scatter size encoding: constant, or values mapped to a @@ -391,6 +416,17 @@ def resolve_color( if hasattr(color, "to_numpy"): color = color.to_numpy() arr = np.asarray(color) + if arr.ndim == 2 and arr.shape in {(n, 3), (n, 4)}: + try: + rgba = np.asarray(arr, dtype=np.float64) + except (TypeError, ValueError) as exc: + raise ValueError("direct RGB/RGBA colors must be real numeric") from exc + if not np.isfinite(rgba).all() or np.any((rgba < 0.0) | (rgba > 1.0)): + raise ValueError("direct RGB/RGBA colors must contain finite values between 0 and 1") + if rgba.shape[1] == 3: + rgba = np.column_stack((rgba, np.ones(n, dtype=np.float64))) + return ColorChannel(mode="direct_rgba", rgba=np.ascontiguousarray(rgba)) + if arr.ndim != 1 or len(arr) != n: raise ValueError(f"color array must be 1-D length {n}, got shape {arr.shape}") @@ -478,8 +514,35 @@ def ship_channels( normalizing all N rows to ship a 200k window is O(N) work for nothing. Returns (color_spec, size_spec).""" cc = trace.color_ch or ColorChannel(mode="constant", constant=None) + color_spec = ship_color_channel(cc, sel, ship_scalar, ship_u8, palette) + sc = trace.size_ch or SizeChannel(mode="constant") + size_spec = sc.spec() + if sc.mode == "continuous": + values = sc.values + domain = sc.domain + if values is None or domain is None: + raise ValueError("continuous size channel missing values or domain") + vals = values if sel is None else values[sel] + size_spec["buf"] = ship_scalar(normalize_to_unit(vals, domain)) + return color_spec, size_spec + + +def ship_color_channel( + cc: ColorChannel, sel: Any, ship_scalar: Any, ship_u8: Any, palette: list[str] +) -> dict[str, Any]: + """Ship one fill/stroke paint channel in the common wire representation.""" color_spec = cc.spec() - if cc.mode == "continuous": + if cc.mode == "direct_rgba": + rgba = cc.rgba + if rgba is None: + raise ValueError("direct RGBA color channel missing values") + values = rgba if sel is None else rgba[sel] + packed = np.rint(np.clip(values, 0.0, 1.0) * 255.0).astype(np.uint8) + color_spec["buf"] = ship_u8(packed.reshape(-1)) + color_spec["n"] = int(len(values)) + elif cc.mode == "match_fill": + pass + elif cc.mode == "continuous": values = cc.values domain = cc.domain if values is None or domain is None: @@ -503,13 +566,52 @@ def ship_channels( color_spec["buf"] = ship_scalar(codes) color_spec["palette"] = [palette[i % len(palette)] for i in range(len(categories))] - sc = trace.size_ch or SizeChannel(mode="constant") - size_spec = sc.spec() - if sc.mode == "continuous": - values = sc.values - domain = sc.domain - if values is None or domain is None: - raise ValueError("continuous size channel missing values or domain") - vals = values if sel is None else values[sel] - size_spec["buf"] = ship_scalar(normalize_to_unit(vals, domain)) - return color_spec, size_spec + return color_spec + + +def resolve_style_channel( + value: Any, + n: int, + label: str, + *, + minimum: Optional[float] = None, + maximum: Optional[float] = None, + components: int = 1, +) -> tuple[Any, Optional[StyleChannel]]: + """Return ``(constant, channel)`` for a scalar-or-direct numeric style.""" + if value is None or (np.isscalar(value) and components == 1): + if value is None: + return None, None + constant = _finite_scalar(value, label) + if minimum is not None and constant < minimum: + raise ValueError(f"{label} must be at least {minimum}") + if maximum is not None and constant > maximum: + raise ValueError(f"{label} must be at most {maximum}") + return constant, None + arr = np.asarray(value) + expected = (n,) if components == 1 else (n, components) + if arr.shape != expected: + raise ValueError(f"{label} array must have shape {expected}, got {arr.shape}") + values = _as_real_array(arr.reshape(-1), f"{label} array").reshape(expected) + if not np.isfinite(values).all(): + raise ValueError(f"{label} array must contain only finite values") + if minimum is not None and np.any(values < minimum): + raise ValueError(f"{label} array values must be at least {minimum}") + if maximum is not None and np.any(values > maximum): + raise ValueError(f"{label} array values must be at most {maximum}") + return None, StyleChannel(np.ascontiguousarray(values), components=components) + + +def ship_style_channels( + style_channels: dict[str, StyleChannel], sel: Any, ship_scalar: Any, ship_u8: Any +) -> dict[str, Any]: + """Ship direct style channels after applying the geometry row selection.""" + result: dict[str, Any] = {} + for name, channel in style_channels.items(): + values = channel.values if sel is None else channel.values[sel] + spec = channel.spec() + flat = np.ascontiguousarray(values).reshape(-1) + spec["buf"] = ship_u8(flat) if channel.dtype == "u8" else ship_scalar(flat) + spec["n"] = int(len(values)) + result[name] = spec + return result diff --git a/python/xy/components.py b/python/xy/components.py index b1e40fb5..b17aad9a 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -321,11 +321,12 @@ def scatter( colormap: str = channels.DEFAULT_COLORMAP, color_domain: Optional[tuple[float, float]] = None, size_range: tuple[float, float] = (2.0, 18.0), - opacity: float = 0.8, + opacity: Any = 0.8, density: Optional[bool] = None, - symbol: str = "circle", - stroke: Optional[str] = None, - stroke_width: float = 0.0, + symbol: Any = "circle", + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, x_axis: str = "x", @@ -348,6 +349,7 @@ def scatter( symbol: Marker symbol name. stroke: Optional marker outline color. stroke_width: Marker outline width in pixels. + _artist_alpha: Internal Matplotlib alpha override, scalar or per marker. style: Mark style overrides. class_name: Adapter-only trace metadata; it does not style canvas geometry. x_axis: Identifier of the x axis used by this mark. @@ -372,6 +374,7 @@ def scatter( "symbol": symbol, "stroke": stroke, "stroke_width": stroke_width, + "_artist_alpha": _artist_alpha, "x_axis": x_axis, "y_axis": y_axis, }, @@ -622,8 +625,8 @@ def segments( color: Union[str, ColorLike, ArrayLike, None] = None, colormap: str = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, - width: float = 1.2, - opacity: float = 1.0, + width: Any = 1.2, + opacity: Any = 1.0, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, x_axis: str = "x", @@ -683,9 +686,9 @@ def triangle_mesh( colormap: str = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, name: Optional[str] = None, - opacity: float = 1.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + opacity: Any = 1.0, + stroke: Any = None, + stroke_width: Any = 0.0, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, x_axis: str = "x", @@ -1201,11 +1204,12 @@ def histogram( density: bool = False, cumulative: bool = False, name: Optional[str] = None, - color: Optional[str] = None, - opacity: float = 0.85, - corner_radius: Union[float, tuple[float, float]] = 0.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + color: Any = None, + opacity: Any = 0.85, + corner_radius: Any = 0.0, + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, fill: Union[str, dict[str, str], None] = None, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, @@ -1227,6 +1231,7 @@ def histogram( corner_radius: Bar corner radius in pixels. stroke: Optional bar outline color. stroke_width: Bar outline width in pixels. + _artist_alpha: Internal Matplotlib alpha override, scalar or per bin. fill: CSS fill value or linear gradient. style: Mark style overrides. class_name: Adapter-only trace metadata; it does not style canvas geometry. @@ -1250,6 +1255,7 @@ def histogram( "corner_radius": corner_radius, "stroke": stroke, "stroke_width": stroke_width, + "_artist_alpha": _artist_alpha, "fill": fill, "x_axis": x_axis, "y_axis": y_axis, @@ -1266,11 +1272,12 @@ def hist( density: bool = False, cumulative: bool = False, name: Optional[str] = None, - color: Optional[str] = None, - opacity: float = 0.85, - corner_radius: Union[float, tuple[float, float]] = 0.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + color: Any = None, + opacity: Any = 0.85, + corner_radius: Any = 0.0, + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, fill: Union[str, dict[str, str], None] = None, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, @@ -1291,6 +1298,7 @@ def hist( corner_radius=corner_radius, stroke=stroke, stroke_width=stroke_width, + _artist_alpha=_artist_alpha, fill=fill, style=style, class_name=class_name, @@ -1305,17 +1313,18 @@ def bar( *, data: TableLike = None, name: Optional[str] = None, - color: Union[str, Sequence[str], None] = None, + color: Any = None, colors: Optional[list[str]] = None, width: float = 0.8, base: Union[str, Scalar, ArrayLike] = 0.0, mode: str = "grouped", orientation: str = "vertical", series: Optional[list[str]] = None, - opacity: float = 0.85, - corner_radius: Union[float, tuple[float, float]] = 0.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + opacity: Any = 0.85, + corner_radius: Any = 0.0, + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, fill: Union[str, dict[str, str], None] = None, style: Optional[dict[str, StyleValue]] = None, class_name: Optional[str] = None, @@ -1340,6 +1349,7 @@ def bar( corner_radius: Bar corner radius in pixels. stroke: Optional bar outline color. stroke_width: Bar outline width in pixels. + _artist_alpha: Internal Matplotlib alpha override, scalar or per bar. fill: CSS fill value or linear gradient. style: Mark style overrides. class_name: Adapter-only trace metadata; it does not style canvas geometry. @@ -1366,6 +1376,7 @@ def bar( "corner_radius": corner_radius, "stroke": stroke, "stroke_width": stroke_width, + "_artist_alpha": _artist_alpha, "fill": fill, "x_axis": x_axis, "y_axis": y_axis, @@ -3920,6 +3931,7 @@ def _apply_scatter(fig: Figure, m: Mark, data: Any) -> None: symbol=m.props["symbol"], stroke=m.props["stroke"], stroke_width=m.props["stroke_width"], + _artist_alpha=m.props.get("_artist_alpha"), style=m.style, ) except Exception: @@ -4175,6 +4187,7 @@ def _apply_histogram(fig: Figure, m: Mark, data: Any) -> None: corner_radius=m.props["corner_radius"], stroke=m.props["stroke"], stroke_width=m.props["stroke_width"], + _artist_alpha=m.props.get("_artist_alpha"), fill=m.props["fill"], style=m.style, ) @@ -4210,6 +4223,7 @@ def _apply_bar(fig: Figure, m: Mark, data: Any) -> None: corner_radius=m.props["corner_radius"], stroke=m.props["stroke"], stroke_width=m.props["stroke_width"], + _artist_alpha=m.props.get("_artist_alpha"), fill=m.props["fill"], style=m.style, ) diff --git a/python/xy/config.py b/python/xy/config.py index 7e629f0c..38abbedc 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -8,7 +8,7 @@ from __future__ import annotations # Wire protocol version: the client refuses a mismatched spec loudly (§33). -PROTOCOL_VERSION = 3 +PROTOCOL_VERSION = 4 # Line traces longer than this ship M4-decimated (Tier 1, §5); the canonical # column stays kernel-side for re-decimation on zoom (§28: recompute for the diff --git a/python/xy/interaction.py b/python/xy/interaction.py index 798f293e..bdbe9861 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -21,6 +21,7 @@ from . import channels, columns, kernels, lod from .config import ( DECIMATION_THRESHOLD, + DEFAULT_PALETTE, DENSITY_SAMPLE_SEED, DENSITY_SAMPLE_TARGET, DENSITY_TARGET_POINTS_PER_CELL, # noqa: F401 (historic import path) @@ -420,7 +421,7 @@ def _density_sample_update( style["opacity"] = min(float(style.get("opacity", 0.8)), 0.55) except (TypeError, ValueError): style["opacity"] = 0.55 - return { + sample = { "mode": "sampled", "n": int(len(sample_sel)), "visible": int(visible), @@ -435,6 +436,15 @@ def _density_sample_update( "size": size_spec, "style": style, } + if t.stroke_ch is not None: + sample["stroke"] = channels.ship_color_channel( + t.stroke_ch, sample_sel, writer.add_f32, writer.add_u8, DEFAULT_PALETTE + ) + if t.style_channels: + sample["channels"] = channels.ship_style_channels( + t.style_channels, sample_sel, writer.add_f32, writer.add_u8 + ) + return sample def density_view( @@ -580,35 +590,37 @@ def _drill_points( if (t.color_ch and t.color_ch.mode == "continuous") else channels.DEFAULT_COLORMAP ) - return ( - { - "traces": [ - { - "id": t.id, - "mode": "points", - "tier": "direct", - "visible": visible, - "reduction": "none", - # The window these points cover: the client draws points - # while the view stays inside it, and falls back to the - # density overview the instant a zoom-out leaves it — so - # zooming out is never blank (§5 smooth transitions). - "x_range": [lo_x, hi_x], - "y_range": [lo_y, hi_y], - "x": x_ref, - "y": y_ref, - "color": color_spec, - "size": size_spec, - "density_val": {"buf": dval_buf}, - "lod_blend": lod_blend, - "density_colormap": cmap, - "drill_seq": drill_seq, - "style": dict(t.style), - } - ] - }, - buffers, - ) + trace_update = { + "id": t.id, + "mode": "points", + "tier": "direct", + "visible": visible, + "reduction": "none", + # The window these points cover: the client draws points + # while the view stays inside it, and falls back to the + # density overview the instant a zoom-out leaves it — so + # zooming out is never blank (§5 smooth transitions). + "x_range": [lo_x, hi_x], + "y_range": [lo_y, hi_y], + "x": x_ref, + "y": y_ref, + "color": color_spec, + "size": size_spec, + "density_val": {"buf": dval_buf}, + "lod_blend": lod_blend, + "density_colormap": cmap, + "drill_seq": drill_seq, + "style": dict(t.style), + } + if t.stroke_ch is not None: + trace_update["stroke"] = channels.ship_color_channel( + t.stroke_ch, sel, writer.add_f32, writer.add_u8, DEFAULT_PALETTE + ) + if t.style_channels: + trace_update["channels"] = channels.ship_style_channels( + t.style_channels, sel, writer.add_f32, writer.add_u8 + ) + return ({"traces": [trace_update]}, buffers) def append_data( @@ -618,6 +630,11 @@ def append_data( y: Any, color: Any = None, size: Any = None, + stroke: Any = None, + opacity: Any = None, + alpha: Any = None, + stroke_width: Any = None, + symbol: Any = None, ) -> tuple[dict[str, Any], list[bytes]]: """Streaming append (Phase-0): extend a trace's canonical columns in place and return the client refresh message. @@ -673,22 +690,91 @@ def append_data( ) def _channel_tail(ch: Any, values: Any, name: str) -> Optional[np.ndarray]: - has = ch is not None and ch.mode != "constant" - if has and ch.mode != "continuous": + has = ch is not None and ch.mode not in {"constant", "match_fill"} + if has and ch.mode not in {"continuous", "direct_rgba"}: raise ValueError(f"append does not support categorical {name} channels yet") if has and values is None: - raise ValueError(f"trace {t.id} has a continuous {name} channel; pass {name}=") + qualifier = "continuous" if ch.mode == "continuous" else "per-point" + raise ValueError(f"trace {t.id} has a {qualifier} {name} channel; pass {name}=") if not has and values is not None: raise ValueError(f"trace {t.id} has no per-point {name} channel") if values is None: return None + if ch.mode == "direct_rgba": + resolved = channels.resolve_color(values, len(ax), default_constant="#000000") + if resolved.mode != "direct_rgba" or resolved.rgba is None: + raise ValueError(f"appended {name} must be an RGB(A) array") + return resolved.rgba arr = np.asarray(values, dtype=np.float64).ravel() if len(arr) != len(ax): raise ValueError(f"{name} length {len(arr)} != appended row count {len(ax)}") return arr + symbol_ids = { + name: index + for index, name in enumerate( + ( + "circle", + "square", + "diamond", + "triangle", + "cross", + "hexagon", + "pentagon", + "star", + "triangle_down", + "triangle_left", + "triangle_right", + "x", + "point", + "pixel", + "thin_diamond", + "plus_line", + "x_line", + ) + ) + } + + def _style_tail(name: str, values: Any) -> Optional[np.ndarray]: + channel = t.style_channels.get(name) + if channel is None: + if values is not None: + raise ValueError(f"trace {t.id} has no per-point {name} channel") + return None + if values is None: + raise ValueError(f"trace {t.id} has a per-point {name} channel; pass {name}=") + if name == "symbol": + raw = np.asarray(values) + if raw.shape != (len(ax),): + raise ValueError(f"symbol length {raw.size} != appended row count {len(ax)}") + try: + result = np.asarray([symbol_ids[str(value)] for value in raw], dtype=np.uint8) + except KeyError as exc: + raise ValueError(f"unsupported appended symbol {exc.args[0]!r}") from None + return result + expected = (len(ax),) if channel.components == 1 else (len(ax), channel.components) + arr = np.asarray(values, dtype=np.float64) + if arr.shape != expected: + raise ValueError(f"{name} array must have shape {expected}, got {arr.shape}") + if not np.isfinite(arr).all(): + raise ValueError(f"{name} array must contain only finite values") + if name in {"opacity", "artist_alpha"} and ( + np.any(arr < (-1.0 if name == "artist_alpha" else 0.0)) or np.any(arr > 1.0) + ): + raise ValueError(f"{name} array values must be within [0, 1]") + if name == "stroke_width" and np.any(arr < 0.0): + raise ValueError("stroke_width array values must be non-negative") + return np.ascontiguousarray(arr) + color_tail = _channel_tail(t.color_ch, color, "color") size_tail = _channel_tail(t.size_ch, size, "size") + stroke_tail = _channel_tail(t.stroke_ch, stroke, "stroke") + style_tails = { + "opacity": _style_tail("opacity", opacity), + "artist_alpha": _style_tail("artist_alpha", alpha), + "stroke_width": _style_tail("stroke_width", stroke_width), + "symbol": _style_tail("symbol", symbol), + } appended = {id(t.x), id(t.y)} for other in fig.traces: @@ -714,9 +800,18 @@ def _channel_tail(ch: Any, values: Any, name: str) -> Optional[np.ndarray]: t.x.append(ax) t.y.append(ay) if color_tail is not None: - channels.append_continuous(t.color_ch, color_tail, "color") + if t.color_ch.mode == "direct_rgba": + t.color_ch.rgba = np.concatenate((t.color_ch.rgba, color_tail), axis=0) + else: + channels.append_continuous(t.color_ch, color_tail, "color") if size_tail is not None: channels.append_continuous(t.size_ch, size_tail, "size") + if stroke_tail is not None: + t.stroke_ch.rgba = np.concatenate((t.stroke_ch.rgba, stroke_tail), axis=0) + for name, tail in style_tails.items(): + if tail is not None: + channel = t.style_channels[name] + channel.values = np.concatenate((channel.values, tail), axis=0) pyramid = getattr(t, "_pyr_handle", None) if t.kind == "scatter" and pyramid: diff --git a/python/xy/marks.py b/python/xy/marks.py index f64bcb36..d84d994a 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -26,6 +26,196 @@ from ._figure import Figure +_SYMBOL_CODES = { + name: index + for index, name in enumerate( + ( + "circle", + "square", + "diamond", + "triangle", + "cross", + "hexagon", + "pentagon", + "star", + "triangle_down", + "triangle_left", + "triangle_right", + "x", + "point", + "pixel", + "thin_diamond", + "plus_line", + "x_line", + ) + ) +} + + +def _direct_style( + value: Any, + n: int, + label: str, + style_channels: dict[str, channels.StyleChannel], + key: str, + *, + default: float, + minimum: Optional[float] = None, + maximum: Optional[float] = None, +) -> float: + constant, channel = channels.resolve_style_channel( + value, n, label, minimum=minimum, maximum=maximum + ) + if channel is not None: + style_channels[key] = channel + # Opacity channels multiply the scalar renderer uniform; keep that + # uniform neutral so the vector is applied exactly once. Width-like + # channels select over their scalar fallback instead. + return 1.0 if key == "opacity" else default + return default if constant is None else float(constant) + + +def _direct_symbols(value: Any, n: int, style_channels: dict[str, channels.StyleChannel]) -> str: + if isinstance(value, str): + return _validate.point_symbol(value, "scatter symbol") + arr = np.asarray(value, dtype=object) + if arr.shape != (n,): + raise ValueError(f"scatter symbol array must have shape {(n,)}, got {arr.shape}") + codes = np.empty(n, dtype=np.uint8) + for index, raw in enumerate(arr): + symbol = _validate.point_symbol(raw, f"scatter symbol[{index}]") + codes[index] = _SYMBOL_CODES[symbol] + style_channels["symbol"] = channels.StyleChannel(codes, dtype="u8") + return "circle" + + +def _stroke_channel( + value: Any, n: int, label: str +) -> tuple[Optional[str], Optional[channels.ColorChannel]]: + if value is None: + return None, None + if isinstance(value, str): + return _validate.css_color(value, label), None + resolved = channels.resolve_color(value, n, default_constant="transparent") + if resolved.mode != "direct_rgba": + raise ValueError(f"{label} arrays must be numeric RGB/RGBA with shape ({n}, 3|4)") + return None, resolved + + +def _series_direct_paints( + value: Any, + n_series: int, + n_items: int, + label: str, +) -> Optional[list[channels.ColorChannel]]: + """Resolve numeric bar paint arrays without confusing CSS sequences. + + A one-series bar accepts ``(N, 3|4)`` and a multi-series bar accepts + ``(S, N, 3|4)``. Returning ``None`` leaves scalar/per-series CSS color + handling to the existing palette resolver. + """ + if value is None or isinstance(value, str): + return None + arr = np.asarray(value) + if not np.issubdtype(arr.dtype, np.number): + return None + if n_series == 1 and arr.shape in {(n_items, 3), (n_items, 4)}: + return [channels.resolve_color(arr, n_items, default_constant=DEFAULT_PALETTE[0])] + if arr.ndim == 3 and arr.shape[:2] == (n_series, n_items) and arr.shape[2] in (3, 4): + return [ + channels.resolve_color( + arr[index], n_items, default_constant=DEFAULT_PALETTE[index % len(DEFAULT_PALETTE)] + ) + for index in range(n_series) + ] + raise ValueError( + f"{label} numeric paint must have shape ({n_items}, 3|4) for one series " + f"or ({n_series}, {n_items}, 3|4), got {arr.shape}" + ) + + +def _series_style_values( + value: Any, + n_series: int, + n_items: int, + label: str, + key: str, + *, + default: float, + minimum: Optional[float] = None, + maximum: Optional[float] = None, +) -> tuple[list[float], list[dict[str, channels.StyleChannel]]]: + """Resolve scalar, ``(N,)``, or ``(S,N)`` bar style values.""" + if value is None or np.isscalar(value): + constant, _ = channels.resolve_style_channel( + value, n_items, label, minimum=minimum, maximum=maximum + ) + resolved = default if constant is None else float(constant) + return [resolved] * n_series, [{} for _ in range(n_series)] + arr = np.asarray(value) + if n_series == 1 and arr.shape == (n_items,): + rows = [arr] + elif arr.shape == (n_series, n_items): + rows = [arr[index] for index in range(n_series)] + else: + raise ValueError( + f"{label} array must have shape ({n_items},) for one series or " + f"({n_series}, {n_items}), got {arr.shape}" + ) + out: list[dict[str, channels.StyleChannel]] = [] + for row in rows: + _, channel = channels.resolve_style_channel( + row, n_items, label, minimum=minimum, maximum=maximum + ) + assert channel is not None + out.append({key: channel}) + constant = 1.0 if key == "opacity" else default + return [constant] * n_series, out + + +def _series_corner_radius( + value: Any, + n_series: int, + n_items: int, + label: str, +) -> tuple[Any, list[dict[str, channels.StyleChannel]]]: + """Resolve constant radius/pair or direct per-bar radii.""" + arr = np.asarray(value) + # A plain two-scalar tuple/list remains the existing constant (tip, base) + # form. Numeric ndarrays are direct channels, including shape (N, 2). + if ( + np.isscalar(value) + or isinstance(value, tuple) + or ( + isinstance(value, (tuple, list)) + and len(value) == 2 + and all(np.isscalar(item) for item in value) + ) + ): + return value, [{} for _ in range(n_series)] + if n_series == 1 and arr.shape == (n_items,): + rows, components = [arr], 1 + elif n_series == 1 and arr.shape == (n_items, 2): + rows, components = [arr], 2 + elif arr.shape == (n_series, n_items): + rows, components = [arr[index] for index in range(n_series)], 1 + elif arr.shape == (n_series, n_items, 2): + rows, components = [arr[index] for index in range(n_series)], 2 + else: + raise ValueError( + f"{label} array must have shape ({n_items},), ({n_items}, 2), " + f"({n_series}, {n_items}), or ({n_series}, {n_items}, 2); got {arr.shape}" + ) + result: list[dict[str, channels.StyleChannel]] = [] + for row in rows: + _, channel = channels.resolve_style_channel( + row, n_items, label, minimum=0.0, components=components + ) + assert channel is not None + result.append({"corner_radius": channel}) + return 0.0, result + + def _append_segment_trace( self: "Figure", kind: str, @@ -36,8 +226,8 @@ def _append_segment_trace( *, name: Optional[str], color: Optional[str], - opacity: float, - width: float, + opacity: Any, + width: Any, role: str, color_ch: Optional[channels.ColorChannel] = None, count: Optional[int] = None, @@ -52,14 +242,33 @@ def _append_segment_trace( renderer. """ name = self._optional_text(name, f"{kind} name") - opacity = self._opacity(opacity, f"{kind} opacity") - width = self._positive_scalar(width, f"{kind} width") arrays = [ self._as_1d_float(v, f"{kind} {label}") for label, v in (("x0", x0), ("x1", x1), ("y0", y0), ("y1", y1)) ] if len({len(v) for v in arrays}) != 1: raise ValueError(f"{kind} segment columns must have equal length") + n = len(arrays[0]) + style_channels: dict[str, channels.StyleChannel] = {} + opacity_value = _direct_style( + opacity, + n, + f"{kind} opacity", + style_channels, + "opacity", + default=1.0, + minimum=0.0, + maximum=1.0, + ) + width_value = _direct_style( + width, + n, + f"{kind} width", + style_channels, + "width", + default=1.2, + minimum=0.0, + ) checkpoint = self._checkpoint() try: x0c, x1c, y0c, y1c = [self.store.ingest(v) for v in arrays] @@ -80,13 +289,14 @@ def _append_segment_trace( name=name, style={ "color": color, - "opacity": opacity, - "width": width, + "opacity": opacity_value, + "width": width_value, "role": role, **({"dash": dash} if dash else {}), **(extra_style or {}), }, color_ch=color_ch, + style_channels=style_channels, count=count, ) ) @@ -106,8 +316,8 @@ def segments( color: Union[str, ArrayLike, None] = None, colormap: str = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, - width: float = 1.2, - opacity: float = 1.0, + width: Any = 1.2, + opacity: Any = 1.0, style: styles.StyleMapping | None = None, ) -> "Figure": """Add independent line segments through the shared instanced renderer.""" @@ -157,9 +367,9 @@ def triangle_mesh( colormap: str = channels.DEFAULT_COLORMAP, domain: Optional[tuple[float, float]] = None, name: Optional[str] = None, - opacity: float = 1.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + opacity: Any = 1.0, + stroke: Any = None, + stroke_width: Any = 0.0, style: styles.StyleMapping | None = None, ) -> "Figure": """Add independently colored filled triangles as one instanced mesh.""" @@ -169,11 +379,6 @@ def triangle_mesh( stroke = css.get("stroke", stroke) stroke_width = css.get("stroke_width", stroke_width) name = self._optional_text(name, "triangle_mesh name") - opacity = self._opacity(opacity, "triangle_mesh opacity") - stroke = self._optional_css_color(stroke, "triangle_mesh stroke") - stroke_width = self._nonnegative_scalar(stroke_width, "triangle_mesh stroke_width") - if stroke is not None and stroke_width == 0.0: - stroke_width = 1.0 arrays = [ self._as_1d_float(values, f"triangle_mesh {label}") for label, values in ( @@ -188,6 +393,33 @@ def triangle_mesh( if len({len(values) for values in arrays}) != 1: raise ValueError("triangle_mesh coordinate columns must have equal length") n = len(arrays[0]) + style_channels: dict[str, channels.StyleChannel] = {} + opacity_value = _direct_style( + opacity, + n, + "triangle_mesh opacity", + style_channels, + "opacity", + default=1.0, + minimum=0.0, + maximum=1.0, + ) + stroke_value, stroke_ch = _stroke_channel(stroke, n, "triangle_mesh stroke") + stroke_width_value = _direct_style( + stroke_width, + n, + "triangle_mesh stroke_width", + style_channels, + "stroke_width", + default=0.0, + minimum=0.0, + ) + if ( + (stroke_value is not None or stroke_ch is not None) + and not stroke_width_value + and ("stroke_width" not in style_channels) + ): + stroke_width_value = 1.0 default_color = DEFAULT_PALETTE[len(self.traces) % len(DEFAULT_PALETTE)] color_ch = channels.resolve_color(color, n, colormap=colormap, default_constant=default_color) if domain is not None: @@ -197,12 +429,12 @@ def triangle_mesh( checkpoint = self._checkpoint() try: x0c, y0c, x1c, y1c, x2c, y2c = [self.store.ingest(values) for values in arrays] - style: dict[str, Any] = {"opacity": opacity, "role": "triangle-mesh"} + style: dict[str, Any] = {"opacity": opacity_value, "role": "triangle-mesh"} style.update(styles._opacity_channels(css)) - if stroke is not None: - style["stroke"] = stroke - if stroke_width: - style["stroke_width"] = stroke_width + if stroke_value is not None: + style["stroke"] = stroke_value + if stroke_width_value: + style["stroke_width"] = stroke_width_value self.traces.append( Trace( id=len(self.traces), @@ -216,6 +448,8 @@ def triangle_mesh( name=name, style=style, color_ch=color_ch, + stroke_ch=stroke_ch, + style_channels=style_channels, count=n, ) ) @@ -359,25 +593,23 @@ def _bar_like( y: ArrayLike, *, name: Optional[str], - color: Union[str, Sequence[str], None], + color: Any, colors: Optional[list[str]], width: float, base: Union[Scalar, ArrayLike], mode: str, orientation: str, series: Optional[list[str]], - opacity: float, - corner_radius: Union[float, tuple[float, float]] = 0.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + opacity: Any, + corner_radius: Any = 0.0, + stroke: Any = None, + stroke_width: Any = 0.0, + artist_alpha: Any = None, fill: Union[str, dict[str, str], None] = None, style_extra: Optional[dict[str, Any]] = None, ) -> "Figure": name = self._optional_text(name, f"{kind} name") width = self._positive_scalar(width, f"{kind} width") - opacity = self._opacity(opacity, f"{kind} opacity") - mark_style = self._rect_mark_style(kind, corner_radius, stroke, stroke_width, fill) - mark_style.update(style_extra or {}) if mode not in {"grouped", "stacked", "normalized"}: raise ValueError(f"{kind} mode must be 'grouped', 'stacked', or 'normalized'") if orientation not in {"vertical", "horizontal"}: @@ -385,6 +617,7 @@ def _bar_like( category_axis = "x" if orientation == "vertical" else "y" pos, category_labels = self._axis_positions_with_labels(x, category_axis) vals = self._bar_value_matrix(y, len(pos), kind) + n_series, n_items = vals.shape if mode == "normalized": if np.any(vals < 0): raise ValueError( @@ -397,8 +630,69 @@ def _bar_like( totals = np.nansum(vals, axis=0) vals = vals / np.where(totals > 0.0, totals, 1.0) base_vals = self._broadcast_base(base, len(pos), kind) - series_names = self._series_names(name, series, vals.shape[0]) - series_colors = self._series_colors(color, colors, vals.shape[0]) + series_names = self._series_names(name, series, n_series) + direct_colors = _series_direct_paints(color, n_series, n_items, f"{kind} color") + series_colors = ( + [None] * n_series + if direct_colors is not None + else self._series_colors(color, colors, n_series) + ) + direct_strokes = _series_direct_paints(stroke, n_series, n_items, f"{kind} stroke") + scalar_stroke = stroke if direct_strokes is None else None + opacity_values, opacity_channels = _series_style_values( + opacity, + n_series, + n_items, + f"{kind} opacity", + "opacity", + default=0.85, + minimum=0.0, + maximum=1.0, + ) + stroke_width_values, stroke_width_channels = _series_style_values( + stroke_width, + n_series, + n_items, + f"{kind} stroke_width", + "stroke_width", + default=0.0, + minimum=0.0, + ) + constant_radius, radius_channels = _series_corner_radius( + corner_radius, n_series, n_items, f"{kind} corner_radius" + ) + alpha_values, alpha_channels = _series_style_values( + artist_alpha, + n_series, + n_items, + f"{kind} alpha", + "artist_alpha", + default=-1.0, + minimum=-1.0, + maximum=1.0, + ) + series_styles: list[dict[str, Any]] = [] + series_channels: list[dict[str, channels.StyleChannel]] = [] + for index in range(n_series): + mark_style = self._rect_mark_style( + kind, + constant_radius, + scalar_stroke, + stroke_width_values[index], + fill, + ) + mark_style.update(style_extra or {}) + merged_channels = { + **opacity_channels[index], + **stroke_width_channels[index], + **radius_channels[index], + **alpha_channels[index], + } + if alpha_values[index] >= 0.0: + # Constants remain spec-only. -1 means use intrinsic paint alpha. + mark_style["artist_alpha"] = alpha_values[index] + series_styles.append(mark_style) + series_channels.append(merged_channels) checkpoint = self._checkpoint() try: if category_labels is not None: @@ -414,11 +708,14 @@ def _bar_like( base_vals + vals[0], name=name, color=series_colors[0], - opacity=opacity, + opacity=opacity_values[0], # grouped/stacked are no-ops for one series, but normalized # rescales even a single series — record it (§28). role=f"{kind}-normalized" if mode == "normalized" else kind, - extra_style=mark_style, + extra_style=series_styles[0], + color_ch=None if direct_colors is None else direct_colors[0], + stroke_ch=None if direct_strokes is None else direct_strokes[0], + style_channels=series_channels[0], ) elif mode == "grouped": slot = width / vals.shape[0] @@ -433,9 +730,12 @@ def _bar_like( base_vals + row, name=series_names[i], color=series_colors[i], - opacity=opacity, + opacity=opacity_values[i], role=f"{kind}-grouped", - extra_style=mark_style, + extra_style=series_styles[i], + color_ch=None if direct_colors is None else direct_colors[i], + stroke_ch=None if direct_strokes is None else direct_strokes[i], + style_channels=series_channels[i], ) else: pos_base = base_vals.astype(np.float64, copy=True) @@ -452,9 +752,12 @@ def _bar_like( y1, name=series_names[i], color=series_colors[i], - opacity=opacity, + opacity=opacity_values[i], role=f"{kind}-{mode}", - extra_style=mark_style, + extra_style=series_styles[i], + color_ch=None if direct_colors is None else direct_colors[i], + stroke_ch=None if direct_strokes is None else direct_strokes[i], + style_channels=series_channels[i], ) pos_base = np.where(row >= 0, y1, pos_base) neg_base = np.where(row < 0, y1, neg_base) @@ -1018,14 +1321,15 @@ def scatter( name: Optional[str] = None, color: Union[str, ArrayLike, None] = None, size: Union[Scalar, ArrayLike, None] = 4.0, - opacity: float = 0.8, + opacity: Any = 0.8, colormap: str = channels.DEFAULT_COLORMAP, color_domain: Optional[tuple[float, float]] = None, size_range: tuple[float, float] = (2.0, 18.0), density: Optional[bool] = None, - symbol: str = "circle", - stroke: Optional[str] = None, - stroke_width: float = 0.0, + symbol: Any = "circle", + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, style: styles.StyleMapping | None = None, ) -> "Figure": """Add a scatter trace. @@ -1043,31 +1347,70 @@ def scatter( stroke = css.get("stroke", stroke) stroke_width = css.get("stroke_width", stroke_width) name = self._optional_text(name, "scatter name") - opacity = self._opacity(opacity, "scatter opacity") density = self._optional_bool(density, "scatter density") - symbol = _validate.point_symbol(symbol, "scatter symbol") - stroke = self._optional_css_color(stroke, "scatter stroke") - stroke_width = self._nonnegative_scalar(stroke_width, "scatter stroke_width") - if stroke is not None and stroke_width == 0.0: - stroke_width = 1.0 checkpoint = self._checkpoint() try: xc, yc = self._ingest_xy(x, y, "scatter") n = len(xc) + style_channels: dict[str, channels.StyleChannel] = {} + opacity_value = _direct_style( + opacity, + n, + "scatter opacity", + style_channels, + "opacity", + default=0.8, + minimum=0.0, + maximum=1.0, + ) + artist_alpha_value: Optional[float] = None + if _artist_alpha is not None: + alpha_value, alpha_ch = channels.resolve_style_channel( + _artist_alpha, n, "scatter alpha", minimum=0.0, maximum=1.0 + ) + if alpha_ch is not None: + style_channels["artist_alpha"] = alpha_ch + elif alpha_value is not None: + artist_alpha_value = float(alpha_value) + symbol_value = _direct_symbols(symbol, n, style_channels) + stroke_value, stroke_ch = _stroke_channel(stroke, n, "scatter stroke") + stroke_width_value = _direct_style( + stroke_width, + n, + "scatter stroke_width", + style_channels, + "stroke_width", + default=0.0, + minimum=0.0, + ) + if ( + (stroke_value is not None or stroke_ch is not None) + and not stroke_width_value + and ("stroke_width" not in style_channels) + ): + stroke_width_value = 1.0 + if ( + stroke_value is None + and stroke_ch is None + and (stroke_width_value or "stroke_width" in style_channels) + ): + stroke_ch = channels.ColorChannel(mode="match_fill") default_color = DEFAULT_PALETTE[len(self.traces) % len(DEFAULT_PALETTE)] color_ch = channels.resolve_color( color, n, colormap=colormap, default_constant=default_color, domain=color_domain ) size_ch = channels.resolve_size(size, n, range_px=size_range) - point_style: dict[str, Any] = {"opacity": opacity} + point_style: dict[str, Any] = {"opacity": opacity_value} + if artist_alpha_value is not None: + point_style["artist_alpha"] = artist_alpha_value point_style.update(styles._opacity_channels(css)) - if symbol != "circle": - point_style["symbol"] = symbol - if stroke is not None: - point_style["stroke"] = stroke - if stroke_width: - point_style["stroke_width"] = stroke_width + if symbol_value != "circle": + point_style["symbol"] = symbol_value + if stroke_value is not None: + point_style["stroke"] = stroke_value + if stroke_width_value: + point_style["stroke_width"] = stroke_width_value trace = Trace( id=len(self.traces), @@ -1077,17 +1420,19 @@ def scatter( name=name, style=point_style, color_ch=color_ch, + stroke_ch=stroke_ch, size_ch=size_ch, + style_channels=style_channels, force_density=density, ) - per_point = color_ch.mode != "constant" or size_ch.mode != "constant" - if density is None and per_point and n > DIRECT_SOFT_CEILING: + dropped_channels = trace.per_item_channel_names() + if density is None and dropped_channels and n > DIRECT_SOFT_CEILING: warnings.warn( - f"scatter has {n:,} points with per-point color/size — above the " + f"scatter has {n:,} points with per-point styles — above the " f"direct ceiling ({DIRECT_SOFT_CEILING:,}). Falling back to a " - "density surface; per-point channels are dropped (aggregating " - "arbitrary color/size needs the §5-F5 aggregation algebra, not yet " + f"density surface; dropped channels: {', '.join(dropped_channels)} " + "(aggregating arbitrary instance styles needs the §5-F5 aggregation algebra, not yet " "implemented). Pass density=False to keep direct draw at your risk.", RuntimeWarning, stacklevel=2, @@ -1128,11 +1473,12 @@ def histogram( density: bool = False, cumulative: bool = False, name: Optional[str] = None, - color: Optional[str] = None, - opacity: float = 0.85, - corner_radius: Union[float, tuple[float, float]] = 0.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + color: Any = None, + opacity: Any = 0.85, + corner_radius: Any = 0.0, + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, fill: Union[str, dict[str, str], None] = None, style: styles.StyleMapping | None = None, ) -> "Figure": @@ -1150,9 +1496,6 @@ def histogram( stroke_width = css.get("stroke_width", stroke_width) fill = css.get("fill", fill) name = self._optional_text(name, "histogram name") - opacity = self._opacity(opacity, "histogram opacity") - mark_style = self._rect_mark_style("histogram", corner_radius, stroke, stroke_width, fill) - mark_style.update(styles._opacity_channels(css)) density = self._bool_param(density, "histogram density") cumulative = self._bool_param(cumulative, "histogram cumulative") vals = self._as_1d_float(values, "histogram values") @@ -1183,6 +1526,56 @@ def histogram( # last bin is ~1.0 (exactly 1.0 when every value is in range); count # mode simply accumulates bin counts. counts = np.cumsum(counts * np.diff(edges)) if density else np.cumsum(counts) + n_bins = len(counts) + direct_color = ( + channels.resolve_color(color, n_bins, default_constant=DEFAULT_PALETTE[0]) + if color is not None and not isinstance(color, str) + else None + ) + color_value = color if direct_color is None else None + stroke_value, stroke_channel = _stroke_channel(stroke, n_bins, "histogram stroke") + opacity_value, opacity_channels = _series_style_values( + opacity, + 1, + n_bins, + "histogram opacity", + "opacity", + default=0.85, + minimum=0.0, + maximum=1.0, + ) + width_value, width_channels = _series_style_values( + stroke_width, + 1, + n_bins, + "histogram stroke_width", + "stroke_width", + default=0.0, + minimum=0.0, + ) + constant_radius, radius_channels = _series_corner_radius( + corner_radius, 1, n_bins, "histogram corner_radius" + ) + _, alpha_channels = _series_style_values( + _artist_alpha, + 1, + n_bins, + "histogram alpha", + "artist_alpha", + default=-1.0, + minimum=-1.0, + maximum=1.0, + ) + mark_style = self._rect_mark_style( + "histogram", constant_radius, stroke_value, width_value[0], fill + ) + mark_style.update(styles._opacity_channels(css)) + style_channels = { + **opacity_channels[0], + **width_channels[0], + **radius_channels[0], + **alpha_channels[0], + } zeros = np.zeros_like(counts, dtype=np.float64) self._append_rect_trace( "histogram", @@ -1191,11 +1584,14 @@ def histogram( zeros, counts, name=name, - color=color, - opacity=opacity, + color=color_value, + opacity=opacity_value[0], role="histogram", count=int(len(vals)), extra_style={"cumulative": cumulative, "density": density, **mark_style}, + color_ch=direct_color, + stroke_ch=stroke_channel, + style_channels=style_channels, ) return self @@ -1209,11 +1605,12 @@ def hist( density: bool = False, cumulative: bool = False, name: Optional[str] = None, - color: Optional[str] = None, - opacity: float = 0.85, - corner_radius: Union[float, tuple[float, float]] = 0.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + color: Any = None, + opacity: Any = 0.85, + corner_radius: Any = 0.0, + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, fill: Union[str, dict[str, str], None] = None, style: styles.StyleMapping | None = None, ) -> "Figure": @@ -1230,6 +1627,7 @@ def hist( corner_radius=corner_radius, stroke=stroke, stroke_width=stroke_width, + _artist_alpha=_artist_alpha, fill=fill, style=style, ) @@ -1819,17 +2217,18 @@ def bar( y: ArrayLike, *, name: Optional[str] = None, - color: Union[str, Sequence[str], None] = None, + color: Any = None, colors: Optional[list[str]] = None, width: float = 0.8, base: Union[Scalar, ArrayLike] = 0.0, mode: str = "grouped", orientation: str = "vertical", series: Optional[list[str]] = None, - opacity: float = 0.85, - corner_radius: Union[float, tuple[float, float]] = 0.0, - stroke: Optional[str] = None, - stroke_width: float = 0.0, + opacity: Any = 0.85, + corner_radius: Any = 0.0, + stroke: Any = None, + stroke_width: Any = 0.0, + _artist_alpha: Any = None, fill: Union[str, dict[str, str], None] = None, style: styles.StyleMapping | None = None, ) -> "Figure": @@ -1863,6 +2262,7 @@ def bar( corner_radius=corner_radius, stroke=stroke, stroke_width=stroke_width, + artist_alpha=_artist_alpha, fill=fill, style_extra=styles._opacity_channels(css), ) diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 4b3841d6..64fc69bc 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -11,11 +11,12 @@ import warnings from collections.abc import Iterator from itertools import pairwise +from operator import index as operator_index from typing import Any, Optional import numpy as np -from ._colors import resolve_color +from ._colors import resolve_color, resolve_rgba_array from ._rc import rcParams from ._transforms import Bbox, IdentityTransform @@ -386,6 +387,89 @@ def set_offsets(self, xy: Any) -> None: self._entry["y"] = arr[:, 1] self._touch() + def _count(self) -> int: + return len(np.asarray(self._entry.get("x", [])).reshape(-1)) + + def get_facecolors(self) -> np.ndarray: + return resolve_rgba_array( + self._entry["kwargs"].get("color", "transparent"), + self._count(), + "collection facecolors", + ) + + get_facecolor = get_facecolors + + def set_facecolors(self, colors: Any) -> None: + self._entry["kwargs"]["color"] = resolve_rgba_array( + colors, self._count(), "collection facecolors" + ) + self._touch() + + set_facecolor = set_facecolors + + def get_edgecolors(self) -> np.ndarray: + stroke = self._entry["kwargs"].get("stroke", self._entry["kwargs"].get("color")) + return resolve_rgba_array(stroke, self._count(), "collection edgecolors") + + get_edgecolor = get_edgecolors + + def set_edgecolors(self, colors: Any) -> None: + self._entry["kwargs"]["stroke"] = resolve_rgba_array( + colors, self._count(), "collection edgecolors" + ) + self._touch() + + set_edgecolor = set_edgecolors + + def set_alpha(self, alpha: Any) -> None: + if alpha is None: + self._entry["kwargs"].pop("_artist_alpha", None) + elif np.isscalar(alpha): + self._entry["kwargs"]["_artist_alpha"] = float(alpha) + else: + values = np.asarray(alpha, dtype=np.float64).reshape(-1) + if len(values) != self._count(): + raise ValueError( + f"collection alpha must have length {self._count()}, got {len(values)}" + ) + self._entry["kwargs"]["_artist_alpha"] = values + self._touch() + + def get_alpha(self) -> Any: + return self._entry["kwargs"].get("_artist_alpha") + + def set_linewidths(self, widths: Any) -> None: + values = np.asarray(widths, dtype=np.float64) + scaled = values * self._axes._point_scale() + self._entry["kwargs"]["stroke_width"] = ( + float(scaled) if scaled.ndim == 0 else scaled.reshape(-1) + ) + self._touch() + + set_linewidth = set_linewidths + + def get_linewidths(self) -> np.ndarray: + return np.atleast_1d( + np.asarray(self._entry["kwargs"].get("stroke_width", 0.0), dtype=np.float64) + ) + + get_linewidth = get_linewidths + + def set_sizes(self, sizes: Any, dpi: Any = 72.0) -> None: + del dpi # compat-noop: xy sizes use the owning figure DPI + from ._translate import marker_size_to_scatter_size + + self._entry["kwargs"]["size"] = marker_size_to_scatter_size( + sizes, + default=6.0 * self._axes._point_scale(), + point_scale=self._axes._point_scale(), + ) + self._touch() + + def get_sizes(self) -> np.ndarray: + sizes = np.asarray(self._entry["kwargs"].get("size", 0.0), dtype=np.float64) + return np.atleast_1d(sizes**2) + def legend_elements( self, prop: str = "colors", num: Any = "auto", **kwargs: Any ) -> tuple[list["PathCollection"], list[str]]: @@ -570,8 +654,35 @@ def __init__(self, axes: Any, entry: dict[str, Any]) -> None: self.datavalues = entry.get("y") self.orientation = entry.get("kwargs", {}).get("orientation", "vertical") self.errorbar = None + count = np.asarray(entry.get("y", [])).reshape(-1).size + # A categorical bar chart can contain thousands of bars while most + # callers never inspect its Matplotlib-compatible patch handles. + # Keep those indexed views lazy so the batched trace stays O(1) in + # Python objects on the chart-build hot path. + self.patches = _BarPatchViews(self, count) axes._register_container(self) + def __len__(self) -> int: + return len(self.patches) + + def __iter__(self) -> Iterator["BarPatch"]: + return iter(self.patches) + + def __getitem__(self, index: int | slice) -> Any: + return self.patches[index] + + def set_alpha(self, alpha: Any) -> None: + if alpha is None: + self._entry["kwargs"].pop("_artist_alpha", None) + else: + self._entry["kwargs"]["_artist_alpha"] = ( + float(alpha) if np.isscalar(alpha) else np.asarray(alpha, dtype=np.float64) + ) + self._touch() + + def get_alpha(self) -> Any: + return self._entry["kwargs"].get("_artist_alpha") + def remove(self) -> None: super().remove() self._axes._unregister_container(self) @@ -595,6 +706,113 @@ def tops(self) -> Any: return self.bottoms + np.asarray(self.datavalues, dtype=np.float64) +class _BarPatchViews: + """Stable list-like bar patch views, materialized only when inspected.""" + + def __init__(self, container: BarContainer, count: int) -> None: + self._container = container + self._count = count + self._cache: dict[int, "BarPatch"] = {} + + def __len__(self) -> int: + return self._count + + def _get(self, index: int) -> "BarPatch": + resolved = operator_index(index) + if resolved < 0: + resolved += self._count + if resolved < 0 or resolved >= self._count: + raise IndexError("bar patch index out of range") + patch = self._cache.get(resolved) + if patch is None: + patch = BarPatch(self._container, resolved) + self._cache[resolved] = patch + return patch + + def __getitem__(self, index: int | slice) -> "BarPatch" | list["BarPatch"]: + if isinstance(index, slice): + return [self._get(item) for item in range(*index.indices(self._count))] + return self._get(index) + + def __iter__(self) -> Iterator["BarPatch"]: + return (self._get(index) for index in range(self._count)) + + +class BarPatch: + """One indexed view over a batched bar trace.""" + + def __init__(self, container: BarContainer, index: int) -> None: + self._container = container + self._entry = container._entry + self._index = index + + def _count(self) -> int: + return len(self._container) + + def _paint(self, key: str, fallback: Any) -> np.ndarray: + return resolve_rgba_array( + self._entry["kwargs"].get(key, fallback), self._count(), f"bar {key}" + ) + + def set_facecolor(self, color: Any) -> None: + values = self._paint("color", "transparent") + values[self._index] = resolve_rgba_array(color, 1, "bar facecolor")[0] + self._entry["kwargs"]["color"] = values + self._container._touch() + + set_fc = set_facecolor + + def get_facecolor(self) -> tuple[float, float, float, float]: + return tuple(self._paint("color", "transparent")[self._index]) + + def set_edgecolor(self, color: Any) -> None: + values = self._paint("stroke", self._entry["kwargs"].get("color", "transparent")) + values[self._index] = resolve_rgba_array(color, 1, "bar edgecolor")[0] + self._entry["kwargs"]["stroke"] = values + self._container._touch() + + set_ec = set_edgecolor + + def get_edgecolor(self) -> tuple[float, float, float, float]: + fallback = self._entry["kwargs"].get("color", "transparent") + return tuple(self._paint("stroke", fallback)[self._index]) + + def set_alpha(self, alpha: Optional[float]) -> None: + current = self._entry["kwargs"].get("_artist_alpha") + if current is None or np.isscalar(current): + initial = -1.0 if current is None else float(current) + values = np.full(self._count(), initial, dtype=np.float64) + else: + values = np.asarray(current, dtype=np.float64).copy() + values[self._index] = -1.0 if alpha is None else float(alpha) + self._entry["kwargs"]["_artist_alpha"] = values + self._container._touch() + + def get_alpha(self) -> Optional[float]: + current = self._entry["kwargs"].get("_artist_alpha") + if current is None: + return None + value = float(current) if np.isscalar(current) else float(np.asarray(current)[self._index]) + return None if value < 0.0 else value + + def set_linewidth(self, width: float) -> None: + current = self._entry["kwargs"].get("stroke_width", 0.0) + values = ( + np.full(self._count(), float(current), dtype=np.float64) + if np.isscalar(current) + else np.asarray(current, dtype=np.float64).copy() + ) + values[self._index] = float(width) + self._entry["kwargs"]["stroke_width"] = values + self._container._touch() + + set_lw = set_linewidth + + def get_linewidth(self) -> float: + current = self._entry["kwargs"].get("stroke_width", 0.0) + return float(current) if np.isscalar(current) else float(np.asarray(current)[self._index]) + + class StepPatch(Artist): """Handle for ``stairs`` output backed by a compact core stairs mark.""" diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 3f4b3ee8..bc3710cc 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -36,7 +36,7 @@ Text, unit_converted_values, ) -from ._colors import PROP_CYCLE, resolve_cmap, resolve_color +from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba_array from ._fmt import parse_fmt from ._mathtext import mathtext_to_unicode from ._plot_types import PlotTypeMixin @@ -1158,40 +1158,64 @@ def scatter( xv = np.ma.asarray(x).reshape(-1) yv = np.ma.asarray(y).reshape(-1) s_arr = None if s is None or np.isscalar(s) else np.ma.asarray(s).reshape(-1) + alpha_arr = ( + None if alpha is None or np.isscalar(alpha) else np.ma.asarray(alpha).reshape(-1) + ) + linewidth_arr = ( + None + if linewidths is None or np.isscalar(linewidths) + else np.ma.asarray(linewidths).reshape(-1) + ) dropped = np.ma.getmaskarray(xv) | np.ma.getmaskarray(yv) - if s_arr is not None and len(s_arr) == len(dropped): - dropped = dropped | np.ma.getmaskarray(s_arr) + for channel in (s_arr, alpha_arr, linewidth_arr): + if channel is not None and len(channel) == len(dropped): + dropped = dropped | np.ma.getmaskarray(channel) if dropped.any(): - # matplotlib never draws rows masked in x, y, or s + # Matplotlib never draws rows masked in geometry or a style channel. keep = ~dropped xv, yv = xv[keep], yv[keep] if s_arr is not None and len(s_arr) == len(dropped): s_arr = s_arr[keep] - if ( - c is not None - and not isinstance(c, str) - and not ( - isinstance(c, (tuple, list)) - and len(c) in (3, 4) - and not hasattr(c[0], "__len__") - ) - ): - c_rows = np.ma.asarray(c) - if c_rows.ndim >= 1 and c_rows.shape[0] == len(dropped): - c = c_rows[keep] + if alpha_arr is not None and len(alpha_arr) == len(dropped): + alpha_arr = alpha_arr[keep] + if linewidth_arr is not None and len(linewidth_arr) == len(dropped): + linewidth_arr = linewidth_arr[keep] + for channel_name, channel in (("c", c), ("edgecolors", edgecolors)): + if channel is None or isinstance(channel, str): + continue + rows = np.ma.asarray(channel) + if rows.ndim >= 1 and rows.shape[0] == len(dropped): + if channel_name == "c": + c = rows[keep] + else: + edgecolors = rows[keep] xv, yv = np.asarray(xv), np.asarray(yv) if s_arr is not None: s = np.asarray(s_arr) + if alpha_arr is not None: + alpha = np.asarray(alpha_arr) + if linewidth_arr is not None: + linewidths = np.asarray(linewidth_arr) x, y = xv, yv if transform is not None: x, y = self._transform_points(x, y, transform) source_color = None - cv = None if c is None or isinstance(c, str) else np.ma.asarray(c).reshape(-1) + c_array = None if c is None or isinstance(c, str) else np.ma.asarray(c) + single_numeric_color = bool( + c_array is not None + and c_array.ndim == 1 + and c_array.shape in {(3,), (4,)} + and len(c_array) != len(xv) + and np.issubdtype(c_array.dtype, np.number) + and isinstance(c, (tuple, list)) + ) + cv = c_array if ( cv is not None and cv.ndim == 1 and len(cv) == len(xv) and np.issubdtype(cv.dtype, np.number) + and not single_numeric_color ): source_color = cv.copy() numeric_color = np.ma.asarray(cv, dtype=np.float64) @@ -1204,6 +1228,14 @@ def scatter( xv, yv, cv = xv[finite_color], yv[finite_color], numeric_color.data[finite_color] if s is not None and not np.isscalar(s): s = np.asarray(s)[finite_color] + if alpha is not None and not np.isscalar(alpha): + alpha = np.asarray(alpha)[finite_color] + if linewidths is not None and not np.isscalar(linewidths): + linewidths = np.asarray(linewidths)[finite_color] + if edgecolors is not None and not isinstance(edgecolors, str): + edge_array = np.asarray(edgecolors) + if edge_array.ndim >= 1 and edge_array.shape[0] == len(finite_color): + edgecolors = edge_array[finite_color] x, y, c = xv, yv, cv symbol = _marker_symbol(marker) if marker else "circle" @@ -1225,21 +1257,27 @@ def scatter( edge_setting = rcParams["scatter.edgecolors"] if edgecolors is None else edgecolors no_edges = isinstance(edge_setting, str) and edge_setting.lower() == "none" + raw_edge_width = rcParams["patch.linewidth"] if linewidths is None else linewidths edge_width_px = ( - 0.0 - if no_edges - else float(rcParams["patch.linewidth"] if linewidths is None else linewidths) - * self._point_scale() + 0.0 if no_edges else np.asarray(raw_edge_width, dtype=np.float64) * self._point_scale() ) + if np.ndim(edge_width_px) == 0: + edge_width_px = float(edge_width_px) size_px = np.asarray(marker_path_px) + edge_width_px if np.ndim(size_px) == 0: size_px = float(size_px) entry_kwargs: dict[str, Any] = { "size": size_px, - "opacity": float(alpha) if alpha is not None else 1.0, + # Preserve the long-standing pyplot artist metadata while the + # core receives alpha through the override channel below. + "opacity": float(alpha) if alpha is not None and np.isscalar(alpha) else 1.0, "name": str(label) if label is not None else None, "symbol": symbol, } + if alpha is not None: + entry_kwargs["_artist_alpha"] = ( + float(alpha) if np.isscalar(alpha) else np.asarray(alpha, dtype=np.float64) + ) if isinstance(size_px, np.ndarray) and size_px.size: # matplotlib s= is absolute (points²); pin the engine's size range # to the converted pixel values so normalization is the identity @@ -1252,13 +1290,13 @@ def scatter( entry_kwargs["size_range"] = (lo_px, hi_px) if c is None: entry_kwargs["color"] = self._next_color() - elif isinstance(c, str) or ( - isinstance(c, (tuple, list)) and len(c) in (3, 4) and not hasattr(c[0], "__len__") - ): + elif isinstance(c, str) or single_numeric_color: try: entry_kwargs["color"] = resolve_color(c) except ValueError: entry_kwargs["color"] = np.asarray(c) # data array, not a color + elif np.asarray(c).ndim == 2 or not np.issubdtype(np.asarray(c).dtype, np.number): + entry_kwargs["color"] = resolve_rgba_array(c, len(x), "scatter c") else: entry_kwargs["color"] = np.asarray(c) # value encoding entry_kwargs["colormap"] = resolve_cmap( @@ -1277,7 +1315,12 @@ def scatter( entry_kwargs["domain"] = (lo, hi) if not no_edges: if not (isinstance(edge_setting, str) and edge_setting.lower() == "face"): - entry_kwargs["stroke"] = resolve_color(edge_setting) + if isinstance(edge_setting, str): + entry_kwargs["stroke"] = resolve_color(edge_setting) + else: + entry_kwargs["stroke"] = resolve_rgba_array( + edge_setting, len(x), "scatter edgecolors" + ) entry_kwargs["stroke_width"] = edge_width_px entry = self._add("scatter", {"x": x, "y": y, "kwargs": entry_kwargs}) if source_color is not None: @@ -1380,73 +1423,49 @@ def _bar_like( except (TypeError, ValueError): raise ValueError("bar align='edge' requires numeric positions") from None check_unsupported(kwargs, "bar()/barh()") - colors = None - scalar_channels = False - if color is not None and not isinstance(color, str) and hasattr(color, "__len__"): - try: - color_array = np.asarray(color) - scalar_channels = color_array.shape in {(3,), (4,)} and np.issubdtype( - color_array.dtype, np.number - ) - except (TypeError, ValueError): - pass - if ( - color is not None - and not isinstance(color, str) - and hasattr(color, "__len__") - and not scalar_channels - ): - colors = [resolve_color(value) for value in color] - if colors is not None: - positions = np.asarray(cats, dtype=object).reshape(-1) - values = np.asarray(vals, dtype=np.float64).reshape(-1) - bases = np.zeros_like(values) if base is None else np.broadcast_to(base, values.shape) - if len(colors) != len(values): - raise ValueError("bar color sequence must match the number of bars") - first: Optional[dict[str, Any]] = None - for index, (position, value, base_value, item_color) in enumerate( - zip(positions, values, bases, colors, strict=True) + n_bars = int(np.asarray(vals).size) + + def paint(value: Any, label_text: str, default: Optional[str] = None) -> Any: + if value is None: + return default + if isinstance(value, str): + return resolve_color(value) + array = np.asarray(value) + if ( + array.ndim == 1 + and array.shape in {(3,), (4,)} + and np.issubdtype(array.dtype, np.number) + and isinstance(value, (tuple, list)) ): - item = self._add( - "bar", - { - "x": [position], - "y": [value], - "kwargs": { - "color": item_color, - "width": float(thickness), - "base": [base_value], - "opacity": float(alpha) if alpha is not None else 1.0, - "name": str(label[index] if isinstance(label, (list, tuple)) else label) - if label is not None - else None, - "orientation": orientation, - }, - }, - ) - first = first or item - assert first is not None - synthetic = { - "x": cats, - "y": vals, - "kwargs": {"base": bases, "orientation": orientation}, - } - return BarContainer(self, synthetic) + return resolve_color(value) + return resolve_rgba_array(value, n_bars, label_text) + entry_kwargs: dict[str, Any] = { - "color": None - if colors is not None - else (resolve_color(color) if color is not None else self._next_color()), - "colors": colors, + "color": paint(color, "bar color", self._next_color()), "width": float(thickness), - "opacity": float(alpha) if alpha is not None else 1.0, + "opacity": 1.0, "name": str(label) if label is not None else None, "orientation": orientation, } + if alpha is not None: + entry_kwargs["_artist_alpha"] = ( + float(alpha) if np.isscalar(alpha) else np.asarray(alpha, dtype=np.float64) + ) if base is not None: entry_kwargs["base"] = np.array(base, copy=True) if not np.isscalar(base) else base if edgecolor is not None: - entry_kwargs["stroke"] = resolve_color(edgecolor) - entry_kwargs["stroke_width"] = float(linewidth or 1.0) + entry_kwargs["stroke"] = paint(edgecolor, "bar edgecolor") + entry_kwargs["stroke_width"] = ( + np.asarray(linewidth, dtype=np.float64) + if linewidth is not None and not np.isscalar(linewidth) + else float(linewidth or 1.0) + ) + elif linewidth is not None: + entry_kwargs["stroke_width"] = ( + np.asarray(linewidth, dtype=np.float64) + if not np.isscalar(linewidth) + else float(linewidth) + ) entry = self._add("bar", {"x": cats, "y": vals, "kwargs": entry_kwargs}) container = BarContainer(self, entry) if xerr is not None or yerr is not None: @@ -4248,6 +4267,10 @@ def _chart_children(self) -> list[Any]: children.append(xy.line(x=e["x"], y=e["y"], **kw, **axis_kw)) elif kind == "scatter": kw = dict(kw) + if "_artist_alpha" in kw: + # pyplot alpha overrides intrinsic RGBA. Core opacity is + # an independent multiplier, so do not apply it twice. + kw["opacity"] = 1.0 domain = kw.pop("domain", None) # vmin/vmax → the color channel window levels = e.get("discrete_levels") if levels is not None and "colormap" in kw and not isinstance(kw.get("color"), str): diff --git a/python/xy/pyplot/_colors.py b/python/xy/pyplot/_colors.py index 57b58585..1dee2d15 100644 --- a/python/xy/pyplot/_colors.py +++ b/python/xy/pyplot/_colors.py @@ -8,6 +8,7 @@ from __future__ import annotations +import numbers import re from typing import Optional @@ -260,8 +261,10 @@ def _rgba_floats(value: object) -> tuple[float, float, float, float]: f"colormap extremes require a CSS hex/rgb or basic named color, got {color!r}" ) result = named[resolved.lower()] - if isinstance(alpha, (int, float)): + if isinstance(alpha, numbers.Real) and not isinstance(alpha, (bool, np.bool_)): result = (result[0], result[1], result[2], float(alpha)) + if not all(np.isfinite(result)) or any(channel < 0.0 or channel > 1.0 for channel in result): + raise ValueError(f"RGBA channels must be finite and between 0 and 1, got {value!r}") return result @@ -270,7 +273,9 @@ def _is_color_alpha_pair(value: object) -> bool: if not (isinstance(value, (tuple, list)) and len(value) == 2): return False color, alpha = value - if alpha is not None and not isinstance(alpha, (int, float)): + if alpha is not None and ( + not isinstance(alpha, numbers.Real) or isinstance(alpha, (bool, np.bool_)) + ): return False return isinstance(color, str) or ( isinstance(color, (tuple, list, np.ndarray)) and len(color) in (3, 4) @@ -282,8 +287,12 @@ def resolve_color(value: object) -> Optional[str]: if value is None: return None if not isinstance(value, str): - if isinstance(value, (tuple, list)) and len(value) == 2 and isinstance(value[0], str): - return resolve_color(value[0]) + if _is_color_alpha_pair(value): + rgba = _rgba_floats(value) + return ( + f"rgba({round(rgba[0] * 255)},{round(rgba[1] * 255)}," + f"{round(rgba[2] * 255)},{rgba[3]:g})" + ) # RGB(A) tuples in 0-1 floats. if isinstance(value, (tuple, list, np.ndarray)) and len(value) in (3, 4): channels = np.asarray(value, dtype=np.float64).reshape(-1).tolist() @@ -309,6 +318,62 @@ def resolve_color(value: object) -> Optional[str]: return f"rgb({level},{level},{level})" +def resolve_rgba(value: object) -> tuple[float, float, float, float]: + """Resolve one Matplotlib color spec to canonical straight-alpha RGBA.""" + alpha: Optional[float] = None + color = value + if _is_color_alpha_pair(value): + color, raw_alpha = value # type: ignore[misc] + alpha = None if raw_alpha is None else float(raw_alpha) + css = resolve_color(color) + if css is None: + css = "transparent" + from xy import kernels + + status, parsed = kernels.css_check(kernels.CSS_COLOR, css) + if status <= 0 or parsed is None: + raise ValueError(f"color {value!r} cannot be resolved to static RGBA") + rgba = tuple(float(channel) for channel in parsed) + if alpha is not None: + rgba = (rgba[0], rgba[1], rgba[2], alpha) + if not all(np.isfinite(rgba)) or any(channel < 0.0 or channel > 1.0 for channel in rgba): + raise ValueError(f"RGBA channels must be finite and between 0 and 1, got {value!r}") + return rgba + + +def resolve_rgba_array(values: object, n: int, label: str) -> np.ndarray: + """Resolve one color or N color specs into an ``(N, 4)`` float array.""" + try: + numeric = np.asarray(values) + except (TypeError, ValueError): + numeric = np.asarray(values, dtype=object) + if ( + numeric.ndim == 2 + and numeric.shape in {(n, 3), (n, 4)} + and np.issubdtype(numeric.dtype, np.number) + ): + rgba = np.asarray(numeric, dtype=np.float64) + if rgba.shape[1] == 3: + rgba = np.column_stack((rgba, np.ones(n, dtype=np.float64))) + if not np.isfinite(rgba).all() or np.any((rgba < 0.0) | (rgba > 1.0)): + raise ValueError(f"{label} RGBA channels must be finite and between 0 and 1") + return np.ascontiguousarray(rgba) + if ( + isinstance(values, str) + or _is_color_alpha_pair(values) + or ( + numeric.ndim == 1 + and numeric.shape in {(3,), (4,)} + and np.issubdtype(numeric.dtype, np.number) + ) + ): + return np.tile(np.asarray(resolve_rgba(values), dtype=np.float64), (n, 1)) + sequence = list(values) # type: ignore[arg-type] + if len(sequence) != n: + raise ValueError(f"{label} sequence must have length {n}, got {len(sequence)}") + return np.asarray([resolve_rgba(item) for item in sequence], dtype=np.float64) + + def resolve_cmap(name: object) -> str: """A matplotlib cmap (name or object) → engine colormap name.""" text = getattr(name, "name", name) diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 865bbe63..3a908973 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -1,6 +1,6 @@ "use strict"; -const PROTOCOL = 3; +const PROTOCOL = 4; const XY_FRAME_MAGIC = [0x58, 0x59, 0x42, 0x46]; const XY_FRAME_VERSION = 1; const XY_FRAME_HEADER_SIZE = 24; @@ -521,6 +521,7 @@ a_corner: 0, a_cval: 6, a_sval: 7, a_sel: 8, a_dval: 9, a_len0: 10, a_len1: 11, a_dash0: 10, a_dashDir: 11, +a_rgba: 12, a_style: 13, a_stroke: 14, a_radius: 15, }; function makeProgram(gl, vs, fs) { const p = gl.createProgram(); @@ -576,12 +577,14 @@ float xyViewValue(float coord, int mode) { `; const POINT_VS = `#version 300 es in float ax; in float ay; 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 vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform int u_colorMode; uniform float u_dpr; uniform int u_selActive; uniform float u_selectedOpacity; uniform float u_unselectedOpacity; out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; ${AXIS_GLSL} void main() { gl_Position = vec4(xyMap(ax, u_xmap, u_xmeta, u_xmode), xyMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); @@ -589,6 +592,9 @@ void main() { gl_PointSize = sz * u_dpr; v_ptSize = sz * u_dpr; v_sel = a_sel; + v_rgba = a_rgba; + v_style = a_style; + v_stroke = a_stroke; // continuous: coord = value in [0,1]; categorical: center of texel a_cval. v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; // Local log-density LUT coord (drill handoff, §5): lets freshly drilled @@ -679,26 +685,33 @@ precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; uniform sampler2D u_dlut; uniform float u_dblend; uniform int u_symbol; uniform vec4 u_ptStroke; uniform float u_ptStrokeWidth; uniform int u_ptStrokeFace; +uniform int u_strokeMode; uniform float u_strokeOpacity; uniform int u_selActive; uniform vec4 u_selColor; uniform vec4 u_unselColor; in float v_lutCoord; in float v_dim; in float v_dval; in float v_ptSize; in float v_sel; +in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; out vec4 outColor; ${MARKER_SDF_GLSL} void main() { vec2 d = gl_PointCoord - 0.5; float sd; - bool lineMarker = u_symbol == 15 || u_symbol == 16; + int symbol = v_style.w >= 0.0 ? int(v_style.w + 0.5) : u_symbol; + bool lineMarker = symbol == 15 || symbol == 16; if (lineMarker) { - vec2 q = u_symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; - float halfWidth = max(u_ptStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); + vec2 q = symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; + float itemStrokeWidth = v_style.z >= 0.0 ? v_style.z : u_ptStrokeWidth; + float halfWidth = max(itemStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); vec2 a = abs(q); sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth)); } else { - sd = xyMarkerSdf(d, u_symbol); + // Scalar-only equivalent: xyMarkerSdf(d, u_symbol). The resolved symbol + // also permits a per-item glyph override from v_style.w. + sd = xyMarkerSdf(d, symbol); } float aa = fwidth(sd) + 1e-4; float shapeCov = clamp(0.5 - sd / aa, 0.0, 1.0); if (shapeCov <= 0.001) discard; - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); + vec3 rgb = paint.rgb; // Drill handoff (§5): near the density boundary, paint by local density with // the density ramp; ease into native colors as the zoom deepens (u_dblend->0). if (u_dblend > 0.001) { @@ -711,15 +724,22 @@ void main() { vec4 sc = v_sel > 0.5 ? u_selColor : u_unselColor; rgb = mix(rgb, sc.rgb, sc.a); } - float fillAlpha = u_opacity; + float intrinsicAlpha = paint.a; + float fillAlpha = (v_style.y >= 0.0 ? v_style.y : intrinsicAlpha) * v_style.x * u_opacity; vec4 px = vec4(rgb * fillAlpha, fillAlpha); // premultiplied fill - vec4 strokePx = u_ptStrokeFace == 1 ? px : u_ptStroke; + // Uniform (u_ptStroke) and per-item (v_stroke) stroke paint ship straight + // alpha and go through the same artist-alpha/opacity stack, so a scalar + // CSS edge fades under alpha overrides exactly like SVG/PNG export. + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_ptStroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 strokePx = u_ptStrokeFace == 1 ? px : vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); if (lineMarker) { outColor = strokePx * (shapeCov * v_dim); return; } - if (u_ptStrokeWidth > 0.0) { - float sw = u_ptStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units + float itemStrokeWidth = v_style.z >= 0.0 ? v_style.z : u_ptStrokeWidth; + if (itemStrokeWidth > 0.0) { + float sw = itemStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units // The supplied point size includes the edge. Recover Matplotlib's path // boundary half a stroke inside it, then source-over the centered stroke. float pathCov = clamp(0.5 - (sd + sw * 0.5) / aa, 0.0, 1.0); @@ -899,13 +919,13 @@ void main() { outColor = vec4(u_color.rgb * alpha, alpha); }`; const SEGMENT_VS = `#version 300 es -in float ax0; in float ay0; in float ax1; in float ay1; in float a_cval; +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 int u_colorMode; uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform int u_x0mode; uniform int u_x1mode; uniform int u_y0mode; uniform int u_y1mode; -out float v_off; out float v_cval; out float v_dash; +out float v_off; out float v_cval; out float v_dash; out vec4 v_rgba; out vec4 v_style; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -918,23 +938,28 @@ void main() { dir /= len; vec2 n = vec2(-dir.y, dir.x); vec2 c = corners[gl_VertexID]; - float half_w = u_width * 0.5 + 0.5; + float itemWidth = a_style.z >= 0.0 ? a_style.z : u_width; + float half_w = itemWidth * 0.5 + 0.5; vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); v_off = c.y * half_w; v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; v_dash = a_dash0 + c.x * len * a_dashDir; + v_rgba = a_rgba; v_style = a_style; }`; const SEGMENT_FS = `#version 300 es precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; +uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_cval; in float v_dash; +in float v_off; in float v_cval; in float v_dash; in vec4 v_rgba; in vec4 v_style; out vec4 outColor; void main() { - float half_w = u_width * 0.5; - vec3 rgb = u_colorMode != 0 ? texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb : u_color.rgb; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + float itemWidth = v_style.z >= 0.0 ? v_style.z : u_width; + float half_w = itemWidth * 0.5; + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode != 0 ? vec4(texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb, 1.0) : u_color); + vec3 rgb = paint.rgb; + float paintAlpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * paintAlpha; if (u_dashCount > 0) { float m = mod(v_dash, u_dashPeriod); float acc = 0.0; @@ -952,13 +977,14 @@ void main() { }`; 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 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 int u_x1mode; uniform int u_x2mode; uniform int u_y0mode; uniform int u_y1mode; uniform int u_y2mode; uniform int u_colorMode; -out float v_cval; out vec3 v_bary; +out float v_cval; out vec3 v_bary; out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; ${AXIS_GLSL} void main() { int vertex = gl_VertexID % 3; @@ -971,20 +997,28 @@ void main() { gl_Position = vec4(xyMap(x, u_xmap, xm, xmode), xyMap(y, u_ymap, ym, ymode), 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; }`; const MESH_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; -uniform vec4 u_stroke; uniform float u_strokeWidth; -in float v_cval; in vec3 v_bary; +uniform vec4 u_stroke; uniform float u_strokeWidth; uniform int u_strokeMode; uniform float u_strokeOpacity; +in float v_cval; in vec3 v_bary; in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; out vec4 outColor; void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb; - vec4 fill = vec4(rgb * u_opacity, u_opacity); - if (u_strokeWidth > 0.0) { + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb, 1.0)); + float alpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + vec4 fill = vec4(paint.rgb * alpha, alpha); + float strokeWidth = v_style.z >= 0.0 ? v_style.z : u_strokeWidth; + if (strokeWidth > 0.0) { float edge = min(v_bary.x, min(v_bary.y, v_bary.z)); - float coverage = smoothstep(0.0, max(fwidth(edge) * u_strokeWidth, 1e-5), edge); - outColor = mix(u_stroke, fill, coverage); + float coverage = smoothstep(0.0, max(fwidth(edge) * strokeWidth, 1e-5), edge); + // Both stroke sources ship straight alpha; the per-item alpha stack + // applies to scalar strokes as well (parity with static exporters). + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + outColor = mix(stroke, fill, coverage); } else { outColor = fill; } @@ -1068,9 +1102,11 @@ uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec uniform int u_xmode; uniform int u_ymode; uniform vec4 u_edgePad; uniform vec2 u_res; -in float a_cval; uniform int u_colorMode; +in float a_cval; in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; in vec2 a_radius; +uniform int u_colorMode; out float v_lutCoord; out vec2 v_local; out vec2 v_half; out float v_t; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -1087,10 +1123,12 @@ void main() { v_half = abs(pB - pA) * 0.5; v_local = mix(pA, pB, c) - (pA + pB) * 0.5; v_t = c.y; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; v_radius = a_radius; gl_Position = vec4(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0); }`; const BAR_VS = `#version 300 es in float a_pos; in float a_v0; in float a_v1; in float a_cval; +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 vec2 u_pmeta; uniform vec2 u_v0meta; uniform vec2 u_v1meta; uniform int u_pmode; uniform int u_vmode; @@ -1100,6 +1138,7 @@ uniform vec2 u_res; uniform int u_colorMode; out float v_lutCoord; out vec2 v_local; out vec2 v_half; out float v_t; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -1125,34 +1164,51 @@ void main() { vec2 pB = (clipB * 0.5 + 0.5) * u_res; v_half = abs(pB - pA) * 0.5; v_local = vec2(mix(pA.x, pB.x, c.x), mix(pA.y, pB.y, c.y)) - (pA + pB) * 0.5; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; v_radius = a_radius; }`; const RECT_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform vec2 u_radius; uniform float u_strokeWidth; uniform vec4 u_stroke; +uniform int u_strokeMode; uniform float u_strokeOpacity; +uniform float u_opacity; uniform vec2 u_res; in float v_lutCoord; in vec2 v_local; in vec2 v_half; in float v_t; +in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; in vec2 v_radius; out vec4 outColor; ${GRAD_GLSL} void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; - vec4 premult = vec4(rgb * u_color.a, u_color.a); - // Compose the mark opacity (u_color.a) over the gradient — premultiplied, so - // one scalar multiply fades every stop, including a fade-to-transparent. - if (u_gradMode != 0) premult = xyGradSample(xyGradT(v_t, u_res)) * u_color.a; - if (u_radius.x > 0.0 || u_radius.y > 0.0 || u_strokeWidth > 0.0) { + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); + float alpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + vec4 premult = vec4(paint.rgb * alpha, alpha); + if (u_gradMode != 0) { + vec4 gradient = xyGradSample(xyGradT(v_t, u_res)); + float gradientAlpha = (v_style.y >= 0.0 ? v_style.y : gradient.a) * v_style.x * u_opacity; + // Gradient stops are uploaded premultiplied. Recover their straight RGB + // before applying an artist-alpha override, then premultiply the result. + vec3 gradientRgb = gradient.a > 1e-6 ? gradient.rgb / gradient.a : vec3(0.0); + premult = vec4(gradientRgb * gradientAlpha, gradientAlpha); + } + vec2 radius = v_radius.x >= 0.0 ? v_radius : u_radius; + float strokeWidth = v_style.z >= 0.0 ? v_style.z : u_strokeWidth; + if (radius.x > 0.0 || radius.y > 0.0 || strokeWidth > 0.0) { // u_radius = (tip, base) in mark space: v_t > 0.5 is the tip half, so // corner_radius=(6, 0) rounds only the value end of the bar. On the // straight sides the SDF reduces to |local|-half independent of r, so // differing radii meet with no seam. - float r = min(v_t > 0.5 ? u_radius.x : u_radius.y, min(v_half.x, v_half.y)); + float r = min(v_t > 0.5 ? radius.x : radius.y, min(v_half.x, v_half.y)); vec2 q = abs(v_local) - (v_half - vec2(r)); float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; float aa = 0.75; - if (u_strokeWidth > 0.0) { - float inner = 1.0 - smoothstep(-aa, aa, d + u_strokeWidth); - premult = mix(u_stroke, premult, inner); + if (strokeWidth > 0.0) { + // Both stroke sources ship straight alpha; the per-item alpha stack + // applies to scalar strokes as well (parity with static exporters). + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + float inner = 1.0 - smoothstep(-aa, aa, d + strokeWidth); + premult = mix(stroke, premult, inner); } premult *= 1.0 - smoothstep(-aa, aa, d); } @@ -1402,6 +1458,7 @@ let d = g.drill; if (!d) { d = g.drill = { trace: g.trace, xBuf: gl.createBuffer(), yBuf: gl.createBuffer() }; } +d.trace = { ...g.trace, style: upd.style || g.trace.style || {} }; d.xAxis = g.xAxis; d.yAxis = g.yAxis; gl.bindBuffer(gl.ARRAY_BUFFER, d.xBuf); @@ -1420,18 +1477,22 @@ view._lastRow = null; d.colorMode = 0; d.color = parseColor(view.root, upd.color && upd.color.color, [0.3, 0.47, 0.66, 1]); if (upd.color && upd.color.buf !== undefined) { -d.colorMode = upd.color.mode === "continuous" ? 1 : 2; -if (!d.cBuf) d.cBuf = gl.createBuffer(); +d.colorMode = upd.color.mode === "continuous" ? 1 : +(upd.color.mode === "categorical" ? 2 : 3); const colorValues = upd.color.dtype === "u8" ? view._asU8(buffers[upd.color.buf]) : view._asF32(buffers[upd.color.buf]); -d.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; -gl.bindBuffer(gl.ARRAY_BUFFER, d.cBuf); +const colorBufferName = d.colorMode === 3 ? "rgbaBuf" : "cBuf"; +if (!d[colorBufferName]) d[colorBufferName] = gl.createBuffer(); +d[colorBufferName]._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, d[colorBufferName]); gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); +if (d.colorMode !== 3) { d.lut = upd.color.mode === "continuous" ? view._lut(upd.color.colormap) : view._paletteLut(upd.color.palette); } +} d.sizeMode = 0; d.size = (upd.size && upd.size.size) || 4.0; d.sizeRange = [2, 18]; @@ -1442,6 +1503,43 @@ gl.bindBuffer(gl.ARRAY_BUFFER, d.sBuf); gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.size.buf]), gl.STATIC_DRAW); d.sizeRange = upd.size.range_px; } +const styleChannel = (name) => upd.channels && upd.channels[name]; +const artistScalar = Number(d.trace.style && d.trace.style.artist_alpha); +if (styleChannel("opacity") || styleChannel("artist_alpha") || +styleChannel("stroke_width") || styleChannel("symbol") || Number.isFinite(artistScalar)) { +const values = new Float32Array(d.n * 4); +for (let i = 0; i < d.n; i++) { +values[i * 4] = 1; +values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; +values[i * 4 + 2] = -1; +values[i * 4 + 3] = -1; +} +const copy = (name, component, scale = 1) => { +const spec = styleChannel(name); +if (!spec) return; +const source = spec.dtype === "u8" +? view._asU8(buffers[spec.buf]) +: view._asF32(buffers[spec.buf]); +const components = spec.components || 1; +for (let i = 0; i < d.n; i++) values[i * 4 + component] = source[i * components] * scale; +}; +copy("opacity", 0); +copy("artist_alpha", 1); +copy("stroke_width", 2, view.dpr); +copy("symbol", 3); +if (!d.styleBuf) d.styleBuf = gl.createBuffer(); +d.styleBuf._fcType = gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, d.styleBuf); +gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); +} +if (upd.stroke && upd.stroke.mode === "direct_rgba") { +const values = view._asU8(buffers[upd.stroke.buf]); +if (!d.strokeBuf) d.strokeBuf = gl.createBuffer(); +d.strokeBuf._fcType = gl.UNSIGNED_BYTE; +gl.bindBuffer(gl.ARRAY_BUFFER, d.strokeBuf); +gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); +} +view._pointMarkStyle(d, d.trace); if (upd.density_val && upd.density_val.buf !== undefined) { if (!d.dBuf) d.dBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, d.dBuf); @@ -1473,7 +1571,8 @@ const d = g.drill; if (!d) return; const gl = view.gl; view._deleteVaos(d); -for (const b of [d.xBuf, d.yBuf, d.cBuf, d.sBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); +for (const b of [d.xBuf, d.yBuf, d.cBuf, d.rgbaBuf, d.sBuf, d.styleBuf, +d.strokeBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); g.drill = null; g._drillFadeStart = null; g._drillExitFadeStart = null; @@ -3069,6 +3168,46 @@ g._cpu = { x, y, xMeta: g.xMeta, yMeta: g.yMeta }; g.xBuf = this._upload(x); g.yBuf = this._upload(y); } +_buildInstanceStyleChannels(g, t, buffer, widthName) { +const channel = (name) => t.channels && t.channels[name]; +const artistScalar = Number(t.style && t.style.artist_alpha); +const hasStyle = channel("opacity") || channel("artist_alpha") || +channel(widthName) || channel("symbol") || Number.isFinite(artistScalar); +if (hasStyle) { +const values = new Float32Array(g.n * 4); +for (let i = 0; i < g.n; i++) { +values[i * 4] = 1; +values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; +values[i * 4 + 2] = -1; +values[i * 4 + 3] = -1; +} +const copy = (name, component, scale = 1) => { +const spec = channel(name); +if (!spec) return; +const source = this._columnView(buffer, this.spec.columns[spec.buf]); +for (let i = 0; i < g.n; i++) values[i * 4 + component] = source[i * (spec.components || 1)] * scale; +}; +copy("opacity", 0); +copy("artist_alpha", 1); +copy(widthName, 2, this.dpr); +copy("symbol", 3); +g.styleBuf = this._upload(values); +} +const radius = channel("corner_radius"); +if (radius) { +const source = this._columnView(buffer, this.spec.columns[radius.buf]); +const components = radius.components || 1; +const values = new Float32Array(g.n * 2); +for (let i = 0; i < g.n; i++) { +values[i * 2] = source[i * components] * this.dpr; +values[i * 2 + 1] = (components > 1 ? source[i * components + 1] : source[i * components]) * this.dpr; +} +g.radiusBuf = this._upload(values); +} +if (t.stroke && t.stroke.mode === "direct_rgba") { +g.strokeBuf = this._upload(this._columnView(buffer, this.spec.columns[t.stroke.buf])); +} +} _buildScatterMark(g, t, buffer) { this._buildXY(g, t, buffer); g.colorMode = 0; @@ -3083,6 +3222,10 @@ g.colorMode = 2; g._cpu.color = this._columnView(buffer, this.spec.columns[t.color.buf]); g.cBuf = this._upload(g._cpu.color); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g._cpu.rgba = this._columnView(buffer, this.spec.columns[t.color.buf]); +g.rgbaBuf = this._upload(g._cpu.rgba); } g.sizeMode = 0; g.size = (t.size && t.size.size) || 4.0; @@ -3093,13 +3236,14 @@ g._cpu.size = this._columnView(buffer, this.spec.columns[t.size.buf]); g.sBuf = this._upload(g._cpu.size); g.sizeRange = t.size.range_px; } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._pointMarkStyle(g, t); } _pointMarkStyle(g, t) { const s = t.style || {}; g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16 }[s.symbol] || 0; g.pointStrokeWidth = Number(s.stroke_width) || 0; -g.pointStrokeFace = !s.stroke; +g.pointStrokeFace = !s.stroke && (!t.stroke || t.stroke.mode === "match_fill"); g.pointStroke = s.stroke ? parseColor(this.root, s.stroke, [g.color[0], g.color[1], g.color[2], 1]) : null; @@ -3117,6 +3261,8 @@ x_axis: parentTrace.x_axis, y_axis: parentTrace.y_axis, color: sample.color, size: sample.size, +stroke: sample.stroke, +channels: sample.channels, }; } _buildDensitySample(parentTrace, sample, buffer) { @@ -3141,7 +3287,8 @@ return g; _destroyDensitySample(g) { const s = g && g.sampleOverlay; if (!s || !this.gl) return; -for (const b of [s.xBuf, s.yBuf, s.cBuf, s.sBuf, s.selBuf, s.dBuf]) { +for (const b of [s.xBuf, s.yBuf, s.cBuf, s.rgbaBuf, s.sBuf, s.styleBuf, +s.strokeBuf, s.selBuf, s.dBuf]) { if (b) this.gl.deleteBuffer(b); } g.sampleOverlay = null; @@ -3163,6 +3310,8 @@ x_axis: g.trace.x_axis, y_axis: g.trace.y_axis, color: sample.color, size: sample.size, +stroke: sample.stroke, +channels: sample.channels, }; const s = { trace, @@ -3191,18 +3340,22 @@ gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.x.buf]), gl.STATIC_DRA gl.bindBuffer(gl.ARRAY_BUFFER, s.yBuf); gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.y.buf]), gl.STATIC_DRAW); if (sample.color && sample.color.buf !== undefined) { -s.colorMode = sample.color.mode === "continuous" ? 1 : 2; -s.cBuf = gl.createBuffer(); +s.colorMode = sample.color.mode === "continuous" ? 1 : +(sample.color.mode === "categorical" ? 2 : 3); const colorValues = sample.color.dtype === "u8" ? this._asU8(buffers[sample.color.buf]) : this._asF32(buffers[sample.color.buf]); -s.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; -gl.bindBuffer(gl.ARRAY_BUFFER, s.cBuf); +const colorBufferName = s.colorMode === 3 ? "rgbaBuf" : "cBuf"; +s[colorBufferName] = gl.createBuffer(); +s[colorBufferName]._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, s[colorBufferName]); gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); +if (s.colorMode !== 3) { s.lut = sample.color.mode === "continuous" ? this._lut(sample.color.colormap) : this._paletteLut(sample.color.palette); } +} if (sample.size && sample.size.mode === "continuous") { s.sizeMode = 1; s.sBuf = gl.createBuffer(); @@ -3210,6 +3363,36 @@ gl.bindBuffer(gl.ARRAY_BUFFER, s.sBuf); gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.size.buf]), gl.STATIC_DRAW); s.sizeRange = sample.size.range_px; } +const channel = (name) => sample.channels && sample.channels[name]; +const artistScalar = Number(trace.style && trace.style.artist_alpha); +if (channel("opacity") || channel("artist_alpha") || channel("stroke_width") || +channel("symbol") || Number.isFinite(artistScalar)) { +const values = new Float32Array(s.n * 4); +for (let i = 0; i < s.n; i++) { +values[i * 4] = 1; +values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; +values[i * 4 + 2] = -1; +values[i * 4 + 3] = -1; +} +const copy = (name, component, scale = 1) => { +const spec = channel(name); +if (!spec) return; +const source = spec.dtype === "u8" +? this._asU8(buffers[spec.buf]) +: this._asF32(buffers[spec.buf]); +const components = spec.components || 1; +for (let i = 0; i < s.n; i++) values[i * 4 + component] = source[i * components] * scale; +}; +copy("opacity", 0); +copy("artist_alpha", 1); +copy("stroke_width", 2, this.dpr); +copy("symbol", 3); +s.styleBuf = this._upload(values); +} +if (sample.stroke && sample.stroke.mode === "direct_rgba") { +s.strokeBuf = this._upload(this._asU8(buffers[sample.stroke.buf])); +} +this._pointMarkStyle(s, trace); g.sampleOverlay = s; this._refreshReductionBadges(); } @@ -3272,8 +3455,9 @@ const cr = g.cornerRadius || [0, 0]; gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr); gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr); const sc = g.strokeColor || [0, 0, 0, 0]; -const sa = sc[3] * this._strokeOpacity(g.trace.style || {}); -gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa); +gl.uniform4f(u("u_stroke"), sc[0], sc[1], sc[2], sc[3]); +gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); +gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {})); this._setGradientUniforms(prog, g.grad); } _rectMarkStyleGpu(g, t) { @@ -3361,7 +3545,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "width"); g._cpu = { x: x0, y: y1, xMeta: g.x0Meta, yMeta: g.y1Meta }; } _buildMeshMark(g, t, buffer) { @@ -3381,7 +3569,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); const style = t.style || {}; g.meshStrokeWidth = Number(style.stroke_width) || 0; g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); @@ -3481,7 +3673,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._rectMarkStyleGpu(g, t); } _buildBarMark(g, t, buffer) { @@ -3518,7 +3714,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._rectMarkStyleGpu(g, t); } _buildHeatmapMark(g, t, buffer) { @@ -3638,11 +3838,11 @@ const gl = this.gl; if (gl) for (const { vao } of g._vaos.values()) gl.deleteVertexArray(vao); g._vaos = null; } -_vaoAttr(slot, buf, byteOffset, divisor, size = 1) { +_vaoAttr(slot, buf, byteOffset, divisor, size = 1, normalized = false) { const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.enableVertexAttribArray(slot); -gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, false, 0, byteOffset); +gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, normalized, 0, byteOffset); gl.vertexAttribDivisor(slot, divisor); } _initPickTarget() { @@ -3745,12 +3945,14 @@ this._renderLassoSelection?.(); _now() { return performance.now(); } -_drawPoints(g, xm, ym, opacityScale = 1) { -const simple = -g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && +_canDrawSimplePoints(g) { +return g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && +!g.rgbaBuf && !g.styleBuf && !g.strokeBuf && (g.symbol || 0) === 0 && (g.pointStrokeWidth || 0) <= 0 && Math.max(g.lodBlendShown ?? 0, g.lodBlend ?? 0) <= 0.001; -if (simple) { +} +_drawPoints(g, xm, ym, opacityScale = 1) { +if (this._canDrawSimplePoints(g)) { this._drawSimplePoints(g, xm, ym, opacityScale); return; } @@ -3777,21 +3979,23 @@ gl.uniform4f(loc, c ? c[0] : 0, c ? c[1] : 0, c ? c[2] : 0, c ? 1 : 0); }; stateColor(u("u_selColor"), this._markStateValue("selected", "color")); stateColor(u("u_unselColor"), this._markStateValue("unselected", "color")); -const [r, gg, b] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, 1); +const [r, gg, b, a] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, a); gl.uniform1i(u("u_symbol"), g.symbol || 0); const sc = g.pointStroke; -const strokeAlpha = sc -? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale -: 0; gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr); gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0); -gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0, -sc ? sc[2] * strokeAlpha : 0, strokeAlpha); +gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); +gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style, 0.8) * opacityScale); +gl.uniform4f(u("u_ptStroke"), sc ? sc[0] : 0, sc ? sc[1] : 0, +sc ? sc[2] : 0, sc ? sc[3] : 0); gl.uniform1i(u("u_selActive"), g.selActive ? 1 : 0); const colorOn = g.colorMode !== 0 && g.cBuf; const sizeOn = g.sizeMode === 1 && g.sBuf; const selOn = g.selActive && g.selBuf; +const rgbaOn = g.colorMode === 3 && g.rgbaBuf; +const styleOn = !!g.styleBuf; +const strokeOn = !!g.strokeBuf; if (g.lut) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -3826,6 +4030,9 @@ colorOn ? g.cBuf._fcId : 0, sizeOn ? g.sBuf._fcId : 0, selOn ? g.selBuf._fcId : 0, blendOn ? g.dBuf._fcId : 0, +rgbaOn ? g.rgbaBuf._fcId : 0, +styleOn ? g.styleBuf._fcId : 0, +strokeOn ? g.strokeBuf._fcId : 0, ], () => { this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); @@ -3834,12 +4041,18 @@ if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 0); if (sizeOn) this._vaoAttr(ATTR_SLOTS.a_sval, g.sBuf, 0, 0); if (selOn) this._vaoAttr(ATTR_SLOTS.a_sel, g.selBuf, 0, 0); if (blendOn) this._vaoAttr(ATTR_SLOTS.a_dval, g.dBuf, 0, 0); +if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 0, 4, true); +if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 0, 4); +if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 0, 4, true); } ); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); if (!sizeOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); if (!selOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1.0); if (!blendOn) gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); +if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, r, gg, b, a); gl.drawArrays(gl.POINTS, 0, g.n); } _drawSimplePoints(g, xm, ym, opacityScale = 1) { @@ -3853,8 +4066,10 @@ this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); gl.uniform1f(u("u_dpr"), this.dpr); gl.uniform1f(u("u_size"), g.size); -const [r, gg, b] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale); +const [r, gg, b, a] = g.color; +gl.uniform4f( +u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.8) * opacityScale +); this._bindVao( g, "points-simple", @@ -4025,7 +4240,8 @@ this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr); const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style)); +gl.uniform4f(u("u_color"), r, gg, b, a); +gl.uniform1f(u("u_opacity"), this._strokeOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); const dashed = this._segmentDash(g, prog); if (g.colorMode && g.lut) { @@ -4037,7 +4253,9 @@ this._bindVao( g, "segment", [g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, -g.colorMode ? g.cBuf._fcId : 0, +g.colorMode && g.cBuf ? g.cBuf._fcId : 0, +g.rgbaBuf ? g.rgbaBuf._fcId : 0, +g.styleBuf ? g.styleBuf._fcId : 0, dashed ? g._segmentDashOffsetBuf._fcId : 0, dashed ? g._segmentDashDirBuf._fcId : 0], () => { @@ -4045,14 +4263,18 @@ this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); -if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.colorMode && g.cBuf) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (g.styleBuf) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); if (dashed) { this._vaoAttr(ATTR_SLOTS.a_dash0, g._segmentDashOffsetBuf, 0, 1); this._vaoAttr(ATTR_SLOTS.a_dashDir, g._segmentDashDirBuf, 0, 1); } } ); -if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.cBuf) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!g.styleBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } _segmentDash(g, prog) { @@ -4139,26 +4361,33 @@ for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); -gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1); +gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], g.color[3]); const stroke = g.meshStroke || [0, 0, 0, 0]; -const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style); -gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha, -stroke[2] * strokeAlpha, strokeAlpha); +gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]); gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0); +gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); +gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style)); if (g.colorMode && g.lut) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); gl.uniform1i(u("u_lut"), 0); } const parts = ["x0", "x1", "x2", "y0", "y1", "y2"].map((name) => g[name + "Buf"]._fcId); -parts.push(g.colorMode ? g.cBuf._fcId : 0); +parts.push(g.cBuf ? g.cBuf._fcId : 0, g.rgbaBuf ? g.rgbaBuf._fcId : 0, +g.styleBuf ? g.styleBuf._fcId : 0, g.strokeBuf ? g.strokeBuf._fcId : 0); this._bindVao(g, "mesh", parts, () => { for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { this._vaoAttr(ATTR_SLOTS["a" + name], g[name + "Buf"], 0, 1); } -if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.cBuf) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (g.styleBuf) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); +if (g.strokeBuf) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); }); -if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.cBuf) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, ...g.color); +if (!g.styleBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!g.strokeBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...stroke); gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, g.n); } _lineDash(g) { @@ -4248,10 +4477,15 @@ gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]); const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); +gl.uniform4f(u("u_color"), r, gg, b, a); +gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); this._setRectStyleUniforms(prog, g); -const colorOn = g.colorMode && g.cBuf; +const colorOn = !!g.cBuf; +const rgbaOn = !!g.rgbaBuf; +const styleOn = !!g.styleBuf; +const strokeOn = !!g.strokeBuf; +const radiusOn = !!g.radiusBuf; if (colorOn) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -4260,16 +4494,27 @@ gl.uniform1i(u("u_lut"), 0); this._bindVao( g, "rects", -[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, colorOn ? g.cBuf._fcId : 0], +[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, +colorOn ? g.cBuf._fcId : 0, rgbaOn ? g.rgbaBuf._fcId : 0, +styleOn ? g.styleBuf._fcId : 0, strokeOn ? g.strokeBuf._fcId : 0, +radiusOn ? g.radiusBuf._fcId : 0], () => { this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); +if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); +if (radiusOn) this._vaoAttr(ATTR_SLOTS.a_radius, g.radiusBuf, 0, 1, 2); } ); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); +if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } _drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad = 0) { @@ -4294,11 +4539,16 @@ gl.uniform1i(u("u_v0Mode"), g.value0Mode); gl.uniform1f(u("u_v0Const"), v0Const ?? 0); gl.uniform1f(u("u_v0EdgePad"), v0EdgePad); const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); +gl.uniform4f(u("u_color"), r, gg, b, a); +gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); this._setRectStyleUniforms(prog, g); const v0On = g.value0Mode === 1 && g.value0Buf; -const colorOn = g.colorMode && g.cBuf; +const colorOn = !!g.cBuf; +const rgbaOn = !!g.rgbaBuf; +const styleOn = !!g.styleBuf; +const strokeOn = !!g.strokeBuf; +const radiusOn = !!g.radiusBuf; if (colorOn) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -4311,16 +4561,28 @@ g, g.posBuf._fcId, g.value1Buf._fcId, v0On ? g.value0Buf._fcId : 0, colorOn ? g.cBuf._fcId : 0, +rgbaOn ? g.rgbaBuf._fcId : 0, +styleOn ? g.styleBuf._fcId : 0, +strokeOn ? g.strokeBuf._fcId : 0, +radiusOn ? g.radiusBuf._fcId : 0, ], () => { this._vaoAttr(ATTR_SLOTS.a_pos, g.posBuf, 0, 1); this._vaoAttr(ATTR_SLOTS.a_v1, g.value1Buf, 0, 1); if (v0On) this._vaoAttr(ATTR_SLOTS.a_v0, g.value0Buf, 0, 1); if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); +if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); +if (radiusOn) this._vaoAttr(ATTR_SLOTS.a_radius, g.radiusBuf, 0, 1, 2); } ); if (!v0On) gl.vertexAttrib1f(ATTR_SLOTS.a_v0, 0); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); +if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } _dataPxX(value) { diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index e2ff4583..37665d03 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -1,7 +1,7 @@ (() => { "use strict"; -const PROTOCOL = 3; +const PROTOCOL = 4; const XY_FRAME_MAGIC = [0x58, 0x59, 0x42, 0x46]; const XY_FRAME_VERSION = 1; const XY_FRAME_HEADER_SIZE = 24; @@ -522,6 +522,7 @@ a_corner: 0, a_cval: 6, a_sval: 7, a_sel: 8, a_dval: 9, a_len0: 10, a_len1: 11, a_dash0: 10, a_dashDir: 11, +a_rgba: 12, a_style: 13, a_stroke: 14, a_radius: 15, }; function makeProgram(gl, vs, fs) { const p = gl.createProgram(); @@ -577,12 +578,14 @@ float xyViewValue(float coord, int mode) { `; const POINT_VS = `#version 300 es in float ax; in float ay; 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 vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform int u_ymode; uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange; uniform int u_colorMode; uniform float u_dpr; uniform int u_selActive; uniform float u_selectedOpacity; uniform float u_unselectedOpacity; out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; ${AXIS_GLSL} void main() { gl_Position = vec4(xyMap(ax, u_xmap, u_xmeta, u_xmode), xyMap(ay, u_ymap, u_ymeta, u_ymode), 0.0, 1.0); @@ -590,6 +593,9 @@ void main() { gl_PointSize = sz * u_dpr; v_ptSize = sz * u_dpr; v_sel = a_sel; + v_rgba = a_rgba; + v_style = a_style; + v_stroke = a_stroke; // continuous: coord = value in [0,1]; categorical: center of texel a_cval. v_lutCoord = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; // Local log-density LUT coord (drill handoff, §5): lets freshly drilled @@ -680,26 +686,33 @@ precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; uniform sampler2D u_dlut; uniform float u_dblend; uniform int u_symbol; uniform vec4 u_ptStroke; uniform float u_ptStrokeWidth; uniform int u_ptStrokeFace; +uniform int u_strokeMode; uniform float u_strokeOpacity; uniform int u_selActive; uniform vec4 u_selColor; uniform vec4 u_unselColor; in float v_lutCoord; in float v_dim; in float v_dval; in float v_ptSize; in float v_sel; +in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; out vec4 outColor; ${MARKER_SDF_GLSL} void main() { vec2 d = gl_PointCoord - 0.5; float sd; - bool lineMarker = u_symbol == 15 || u_symbol == 16; + int symbol = v_style.w >= 0.0 ? int(v_style.w + 0.5) : u_symbol; + bool lineMarker = symbol == 15 || symbol == 16; if (lineMarker) { - vec2 q = u_symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; - float halfWidth = max(u_ptStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); + vec2 q = symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d; + float itemStrokeWidth = v_style.z >= 0.0 ? v_style.z : u_ptStrokeWidth; + float halfWidth = max(itemStrokeWidth, 1.0) / (2.0 * max(v_ptSize, 1.0)); vec2 a = abs(q); sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth)); } else { - sd = xyMarkerSdf(d, u_symbol); + // Scalar-only equivalent: xyMarkerSdf(d, u_symbol). The resolved symbol + // also permits a per-item glyph override from v_style.w. + sd = xyMarkerSdf(d, symbol); } float aa = fwidth(sd) + 1e-4; float shapeCov = clamp(0.5 - sd / aa, 0.0, 1.0); if (shapeCov <= 0.001) discard; - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); + vec3 rgb = paint.rgb; // Drill handoff (§5): near the density boundary, paint by local density with // the density ramp; ease into native colors as the zoom deepens (u_dblend->0). if (u_dblend > 0.001) { @@ -712,15 +725,22 @@ void main() { vec4 sc = v_sel > 0.5 ? u_selColor : u_unselColor; rgb = mix(rgb, sc.rgb, sc.a); } - float fillAlpha = u_opacity; + float intrinsicAlpha = paint.a; + float fillAlpha = (v_style.y >= 0.0 ? v_style.y : intrinsicAlpha) * v_style.x * u_opacity; vec4 px = vec4(rgb * fillAlpha, fillAlpha); // premultiplied fill - vec4 strokePx = u_ptStrokeFace == 1 ? px : u_ptStroke; + // Uniform (u_ptStroke) and per-item (v_stroke) stroke paint ship straight + // alpha and go through the same artist-alpha/opacity stack, so a scalar + // CSS edge fades under alpha overrides exactly like SVG/PNG export. + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_ptStroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 strokePx = u_ptStrokeFace == 1 ? px : vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); if (lineMarker) { outColor = strokePx * (shapeCov * v_dim); return; } - if (u_ptStrokeWidth > 0.0) { - float sw = u_ptStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units + float itemStrokeWidth = v_style.z >= 0.0 ? v_style.z : u_ptStrokeWidth; + if (itemStrokeWidth > 0.0) { + float sw = itemStrokeWidth / max(v_ptSize, 1.0); // px -> gl_PointCoord units // The supplied point size includes the edge. Recover Matplotlib's path // boundary half a stroke inside it, then source-over the centered stroke. float pathCov = clamp(0.5 - (sd + sw * 0.5) / aa, 0.0, 1.0); @@ -900,13 +920,13 @@ void main() { outColor = vec4(u_color.rgb * alpha, alpha); }`; const SEGMENT_VS = `#version 300 es -in float ax0; in float ay0; in float ax1; in float ay1; in float a_cval; +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 int u_colorMode; uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec2 u_y1meta; uniform int u_x0mode; uniform int u_x1mode; uniform int u_y0mode; uniform int u_y1mode; -out float v_off; out float v_cval; out float v_dash; +out float v_off; out float v_cval; out float v_dash; out vec4 v_rgba; out vec4 v_style; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -919,23 +939,28 @@ void main() { dir /= len; vec2 n = vec2(-dir.y, dir.x); vec2 c = corners[gl_VertexID]; - float half_w = u_width * 0.5 + 0.5; + float itemWidth = a_style.z >= 0.0 ? a_style.z : u_width; + float half_w = itemWidth * 0.5 + 0.5; vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); v_off = c.y * half_w; v_cval = u_colorMode == 2 ? (a_cval + 0.5) / 256.0 : a_cval; v_dash = a_dash0 + c.x * len * a_dashDir; + v_rgba = a_rgba; v_style = a_style; }`; const SEGMENT_FS = `#version 300 es precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; +uniform vec4 u_color; uniform float u_width; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_cval; in float v_dash; +in float v_off; in float v_cval; in float v_dash; in vec4 v_rgba; in vec4 v_style; out vec4 outColor; void main() { - float half_w = u_width * 0.5; - vec3 rgb = u_colorMode != 0 ? texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb : u_color.rgb; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + float itemWidth = v_style.z >= 0.0 ? v_style.z : u_width; + float half_w = itemWidth * 0.5; + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode != 0 ? vec4(texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb, 1.0) : u_color); + vec3 rgb = paint.rgb; + float paintAlpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * paintAlpha; if (u_dashCount > 0) { float m = mod(v_dash, u_dashPeriod); float acc = 0.0; @@ -953,13 +978,14 @@ void main() { }`; 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 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 int u_x1mode; uniform int u_x2mode; uniform int u_y0mode; uniform int u_y1mode; uniform int u_y2mode; uniform int u_colorMode; -out float v_cval; out vec3 v_bary; +out float v_cval; out vec3 v_bary; out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; ${AXIS_GLSL} void main() { int vertex = gl_VertexID % 3; @@ -972,20 +998,28 @@ void main() { gl_Position = vec4(xyMap(x, u_xmap, xm, xmode), xyMap(y, u_ymap, ym, ymode), 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; }`; const MESH_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; -uniform vec4 u_stroke; uniform float u_strokeWidth; -in float v_cval; in vec3 v_bary; +uniform vec4 u_stroke; uniform float u_strokeWidth; uniform int u_strokeMode; uniform float u_strokeOpacity; +in float v_cval; in vec3 v_bary; in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; out vec4 outColor; void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb; - vec4 fill = vec4(rgb * u_opacity, u_opacity); - if (u_strokeWidth > 0.0) { + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_cval, 0.0, 1.0), 0.5)).rgb, 1.0)); + float alpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + vec4 fill = vec4(paint.rgb * alpha, alpha); + float strokeWidth = v_style.z >= 0.0 ? v_style.z : u_strokeWidth; + if (strokeWidth > 0.0) { float edge = min(v_bary.x, min(v_bary.y, v_bary.z)); - float coverage = smoothstep(0.0, max(fwidth(edge) * u_strokeWidth, 1e-5), edge); - outColor = mix(u_stroke, fill, coverage); + float coverage = smoothstep(0.0, max(fwidth(edge) * strokeWidth, 1e-5), edge); + // Both stroke sources ship straight alpha; the per-item alpha stack + // applies to scalar strokes as well (parity with static exporters). + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + outColor = mix(stroke, fill, coverage); } else { outColor = fill; } @@ -1069,9 +1103,11 @@ uniform vec2 u_x0meta; uniform vec2 u_x1meta; uniform vec2 u_y0meta; uniform vec uniform int u_xmode; uniform int u_ymode; uniform vec4 u_edgePad; uniform vec2 u_res; -in float a_cval; uniform int u_colorMode; +in float a_cval; in vec4 a_rgba; in vec4 a_style; in vec4 a_stroke; in vec2 a_radius; +uniform int u_colorMode; out float v_lutCoord; out vec2 v_local; out vec2 v_half; out float v_t; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -1088,10 +1124,12 @@ void main() { v_half = abs(pB - pA) * 0.5; v_local = mix(pA, pB, c) - (pA + pB) * 0.5; v_t = c.y; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; v_radius = a_radius; gl_Position = vec4(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0); }`; const BAR_VS = `#version 300 es in float a_pos; in float a_v0; in float a_v1; in float a_cval; +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 vec2 u_pmeta; uniform vec2 u_v0meta; uniform vec2 u_v1meta; uniform int u_pmode; uniform int u_vmode; @@ -1101,6 +1139,7 @@ uniform vec2 u_res; uniform int u_colorMode; out float v_lutCoord; out vec2 v_local; out vec2 v_half; out float v_t; +out vec4 v_rgba; out vec4 v_style; out vec4 v_stroke; out vec2 v_radius; const vec2 corners[4] = vec2[4](vec2(0.,0.), vec2(1.,0.), vec2(0.,1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -1126,34 +1165,51 @@ void main() { vec2 pB = (clipB * 0.5 + 0.5) * u_res; v_half = abs(pB - pA) * 0.5; v_local = vec2(mix(pA.x, pB.x, c.x), mix(pA.y, pB.y, c.y)) - (pA + pB) * 0.5; + v_rgba = a_rgba; v_style = a_style; v_stroke = a_stroke; v_radius = a_radius; }`; const RECT_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform vec2 u_radius; uniform float u_strokeWidth; uniform vec4 u_stroke; +uniform int u_strokeMode; uniform float u_strokeOpacity; +uniform float u_opacity; uniform vec2 u_res; in float v_lutCoord; in vec2 v_local; in vec2 v_half; in float v_t; +in vec4 v_rgba; in vec4 v_style; in vec4 v_stroke; in vec2 v_radius; out vec4 outColor; ${GRAD_GLSL} void main() { - vec3 rgb = u_colorMode == 0 ? u_color.rgb : texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb; - vec4 premult = vec4(rgb * u_color.a, u_color.a); - // Compose the mark opacity (u_color.a) over the gradient — premultiplied, so - // one scalar multiply fades every stop, including a fade-to-transparent. - if (u_gradMode != 0) premult = xyGradSample(xyGradT(v_t, u_res)) * u_color.a; - if (u_radius.x > 0.0 || u_radius.y > 0.0 || u_strokeWidth > 0.0) { + vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); + float alpha = (v_style.y >= 0.0 ? v_style.y : paint.a) * v_style.x * u_opacity; + vec4 premult = vec4(paint.rgb * alpha, alpha); + if (u_gradMode != 0) { + vec4 gradient = xyGradSample(xyGradT(v_t, u_res)); + float gradientAlpha = (v_style.y >= 0.0 ? v_style.y : gradient.a) * v_style.x * u_opacity; + // Gradient stops are uploaded premultiplied. Recover their straight RGB + // before applying an artist-alpha override, then premultiply the result. + vec3 gradientRgb = gradient.a > 1e-6 ? gradient.rgb / gradient.a : vec3(0.0); + premult = vec4(gradientRgb * gradientAlpha, gradientAlpha); + } + vec2 radius = v_radius.x >= 0.0 ? v_radius : u_radius; + float strokeWidth = v_style.z >= 0.0 ? v_style.z : u_strokeWidth; + if (radius.x > 0.0 || radius.y > 0.0 || strokeWidth > 0.0) { // u_radius = (tip, base) in mark space: v_t > 0.5 is the tip half, so // corner_radius=(6, 0) rounds only the value end of the bar. On the // straight sides the SDF reduces to |local|-half independent of r, so // differing radii meet with no seam. - float r = min(v_t > 0.5 ? u_radius.x : u_radius.y, min(v_half.x, v_half.y)); + float r = min(v_t > 0.5 ? radius.x : radius.y, min(v_half.x, v_half.y)); vec2 q = abs(v_local) - (v_half - vec2(r)); float d = length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; float aa = 0.75; - if (u_strokeWidth > 0.0) { - float inner = 1.0 - smoothstep(-aa, aa, d + u_strokeWidth); - premult = mix(u_stroke, premult, inner); + if (strokeWidth > 0.0) { + // Both stroke sources ship straight alpha; the per-item alpha stack + // applies to scalar strokes as well (parity with static exporters). + vec4 strokeSrc = u_strokeMode == 1 ? v_stroke : u_stroke; + float strokeAlpha = (v_style.y >= 0.0 ? v_style.y : strokeSrc.a) * v_style.x * u_strokeOpacity; + vec4 stroke = vec4(strokeSrc.rgb * strokeAlpha, strokeAlpha); + float inner = 1.0 - smoothstep(-aa, aa, d + strokeWidth); + premult = mix(stroke, premult, inner); } premult *= 1.0 - smoothstep(-aa, aa, d); } @@ -1403,6 +1459,7 @@ let d = g.drill; if (!d) { d = g.drill = { trace: g.trace, xBuf: gl.createBuffer(), yBuf: gl.createBuffer() }; } +d.trace = { ...g.trace, style: upd.style || g.trace.style || {} }; d.xAxis = g.xAxis; d.yAxis = g.yAxis; gl.bindBuffer(gl.ARRAY_BUFFER, d.xBuf); @@ -1421,18 +1478,22 @@ view._lastRow = null; d.colorMode = 0; d.color = parseColor(view.root, upd.color && upd.color.color, [0.3, 0.47, 0.66, 1]); if (upd.color && upd.color.buf !== undefined) { -d.colorMode = upd.color.mode === "continuous" ? 1 : 2; -if (!d.cBuf) d.cBuf = gl.createBuffer(); +d.colorMode = upd.color.mode === "continuous" ? 1 : +(upd.color.mode === "categorical" ? 2 : 3); const colorValues = upd.color.dtype === "u8" ? view._asU8(buffers[upd.color.buf]) : view._asF32(buffers[upd.color.buf]); -d.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; -gl.bindBuffer(gl.ARRAY_BUFFER, d.cBuf); +const colorBufferName = d.colorMode === 3 ? "rgbaBuf" : "cBuf"; +if (!d[colorBufferName]) d[colorBufferName] = gl.createBuffer(); +d[colorBufferName]._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, d[colorBufferName]); gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); +if (d.colorMode !== 3) { d.lut = upd.color.mode === "continuous" ? view._lut(upd.color.colormap) : view._paletteLut(upd.color.palette); } +} d.sizeMode = 0; d.size = (upd.size && upd.size.size) || 4.0; d.sizeRange = [2, 18]; @@ -1443,6 +1504,43 @@ gl.bindBuffer(gl.ARRAY_BUFFER, d.sBuf); gl.bufferData(gl.ARRAY_BUFFER, view._asF32(buffers[upd.size.buf]), gl.STATIC_DRAW); d.sizeRange = upd.size.range_px; } +const styleChannel = (name) => upd.channels && upd.channels[name]; +const artistScalar = Number(d.trace.style && d.trace.style.artist_alpha); +if (styleChannel("opacity") || styleChannel("artist_alpha") || +styleChannel("stroke_width") || styleChannel("symbol") || Number.isFinite(artistScalar)) { +const values = new Float32Array(d.n * 4); +for (let i = 0; i < d.n; i++) { +values[i * 4] = 1; +values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; +values[i * 4 + 2] = -1; +values[i * 4 + 3] = -1; +} +const copy = (name, component, scale = 1) => { +const spec = styleChannel(name); +if (!spec) return; +const source = spec.dtype === "u8" +? view._asU8(buffers[spec.buf]) +: view._asF32(buffers[spec.buf]); +const components = spec.components || 1; +for (let i = 0; i < d.n; i++) values[i * 4 + component] = source[i * components] * scale; +}; +copy("opacity", 0); +copy("artist_alpha", 1); +copy("stroke_width", 2, view.dpr); +copy("symbol", 3); +if (!d.styleBuf) d.styleBuf = gl.createBuffer(); +d.styleBuf._fcType = gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, d.styleBuf); +gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); +} +if (upd.stroke && upd.stroke.mode === "direct_rgba") { +const values = view._asU8(buffers[upd.stroke.buf]); +if (!d.strokeBuf) d.strokeBuf = gl.createBuffer(); +d.strokeBuf._fcType = gl.UNSIGNED_BYTE; +gl.bindBuffer(gl.ARRAY_BUFFER, d.strokeBuf); +gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); +} +view._pointMarkStyle(d, d.trace); if (upd.density_val && upd.density_val.buf !== undefined) { if (!d.dBuf) d.dBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, d.dBuf); @@ -1474,7 +1572,8 @@ const d = g.drill; if (!d) return; const gl = view.gl; view._deleteVaos(d); -for (const b of [d.xBuf, d.yBuf, d.cBuf, d.sBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); +for (const b of [d.xBuf, d.yBuf, d.cBuf, d.rgbaBuf, d.sBuf, d.styleBuf, +d.strokeBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); g.drill = null; g._drillFadeStart = null; g._drillExitFadeStart = null; @@ -3070,6 +3169,46 @@ g._cpu = { x, y, xMeta: g.xMeta, yMeta: g.yMeta }; g.xBuf = this._upload(x); g.yBuf = this._upload(y); } +_buildInstanceStyleChannels(g, t, buffer, widthName) { +const channel = (name) => t.channels && t.channels[name]; +const artistScalar = Number(t.style && t.style.artist_alpha); +const hasStyle = channel("opacity") || channel("artist_alpha") || +channel(widthName) || channel("symbol") || Number.isFinite(artistScalar); +if (hasStyle) { +const values = new Float32Array(g.n * 4); +for (let i = 0; i < g.n; i++) { +values[i * 4] = 1; +values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; +values[i * 4 + 2] = -1; +values[i * 4 + 3] = -1; +} +const copy = (name, component, scale = 1) => { +const spec = channel(name); +if (!spec) return; +const source = this._columnView(buffer, this.spec.columns[spec.buf]); +for (let i = 0; i < g.n; i++) values[i * 4 + component] = source[i * (spec.components || 1)] * scale; +}; +copy("opacity", 0); +copy("artist_alpha", 1); +copy(widthName, 2, this.dpr); +copy("symbol", 3); +g.styleBuf = this._upload(values); +} +const radius = channel("corner_radius"); +if (radius) { +const source = this._columnView(buffer, this.spec.columns[radius.buf]); +const components = radius.components || 1; +const values = new Float32Array(g.n * 2); +for (let i = 0; i < g.n; i++) { +values[i * 2] = source[i * components] * this.dpr; +values[i * 2 + 1] = (components > 1 ? source[i * components + 1] : source[i * components]) * this.dpr; +} +g.radiusBuf = this._upload(values); +} +if (t.stroke && t.stroke.mode === "direct_rgba") { +g.strokeBuf = this._upload(this._columnView(buffer, this.spec.columns[t.stroke.buf])); +} +} _buildScatterMark(g, t, buffer) { this._buildXY(g, t, buffer); g.colorMode = 0; @@ -3084,6 +3223,10 @@ g.colorMode = 2; g._cpu.color = this._columnView(buffer, this.spec.columns[t.color.buf]); g.cBuf = this._upload(g._cpu.color); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g._cpu.rgba = this._columnView(buffer, this.spec.columns[t.color.buf]); +g.rgbaBuf = this._upload(g._cpu.rgba); } g.sizeMode = 0; g.size = (t.size && t.size.size) || 4.0; @@ -3094,13 +3237,14 @@ g._cpu.size = this._columnView(buffer, this.spec.columns[t.size.buf]); g.sBuf = this._upload(g._cpu.size); g.sizeRange = t.size.range_px; } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._pointMarkStyle(g, t); } _pointMarkStyle(g, t) { const s = t.style || {}; g.symbol = { circle: 0, square: 1, diamond: 2, triangle: 3, cross: 4, hexagon: 5, pentagon: 6, star: 7, triangle_down: 8, triangle_left: 9, triangle_right: 10, x: 11, point: 12, pixel: 13, thin_diamond: 14, plus_line: 15, x_line: 16 }[s.symbol] || 0; g.pointStrokeWidth = Number(s.stroke_width) || 0; -g.pointStrokeFace = !s.stroke; +g.pointStrokeFace = !s.stroke && (!t.stroke || t.stroke.mode === "match_fill"); g.pointStroke = s.stroke ? parseColor(this.root, s.stroke, [g.color[0], g.color[1], g.color[2], 1]) : null; @@ -3118,6 +3262,8 @@ x_axis: parentTrace.x_axis, y_axis: parentTrace.y_axis, color: sample.color, size: sample.size, +stroke: sample.stroke, +channels: sample.channels, }; } _buildDensitySample(parentTrace, sample, buffer) { @@ -3142,7 +3288,8 @@ return g; _destroyDensitySample(g) { const s = g && g.sampleOverlay; if (!s || !this.gl) return; -for (const b of [s.xBuf, s.yBuf, s.cBuf, s.sBuf, s.selBuf, s.dBuf]) { +for (const b of [s.xBuf, s.yBuf, s.cBuf, s.rgbaBuf, s.sBuf, s.styleBuf, +s.strokeBuf, s.selBuf, s.dBuf]) { if (b) this.gl.deleteBuffer(b); } g.sampleOverlay = null; @@ -3164,6 +3311,8 @@ x_axis: g.trace.x_axis, y_axis: g.trace.y_axis, color: sample.color, size: sample.size, +stroke: sample.stroke, +channels: sample.channels, }; const s = { trace, @@ -3192,18 +3341,22 @@ gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.x.buf]), gl.STATIC_DRA gl.bindBuffer(gl.ARRAY_BUFFER, s.yBuf); gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.y.buf]), gl.STATIC_DRAW); if (sample.color && sample.color.buf !== undefined) { -s.colorMode = sample.color.mode === "continuous" ? 1 : 2; -s.cBuf = gl.createBuffer(); +s.colorMode = sample.color.mode === "continuous" ? 1 : +(sample.color.mode === "categorical" ? 2 : 3); const colorValues = sample.color.dtype === "u8" ? this._asU8(buffers[sample.color.buf]) : this._asF32(buffers[sample.color.buf]); -s.cBuf._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; -gl.bindBuffer(gl.ARRAY_BUFFER, s.cBuf); +const colorBufferName = s.colorMode === 3 ? "rgbaBuf" : "cBuf"; +s[colorBufferName] = gl.createBuffer(); +s[colorBufferName]._fcType = colorValues instanceof Uint8Array ? gl.UNSIGNED_BYTE : gl.FLOAT; +gl.bindBuffer(gl.ARRAY_BUFFER, s[colorBufferName]); gl.bufferData(gl.ARRAY_BUFFER, colorValues, gl.STATIC_DRAW); +if (s.colorMode !== 3) { s.lut = sample.color.mode === "continuous" ? this._lut(sample.color.colormap) : this._paletteLut(sample.color.palette); } +} if (sample.size && sample.size.mode === "continuous") { s.sizeMode = 1; s.sBuf = gl.createBuffer(); @@ -3211,6 +3364,36 @@ gl.bindBuffer(gl.ARRAY_BUFFER, s.sBuf); gl.bufferData(gl.ARRAY_BUFFER, this._asF32(buffers[sample.size.buf]), gl.STATIC_DRAW); s.sizeRange = sample.size.range_px; } +const channel = (name) => sample.channels && sample.channels[name]; +const artistScalar = Number(trace.style && trace.style.artist_alpha); +if (channel("opacity") || channel("artist_alpha") || channel("stroke_width") || +channel("symbol") || Number.isFinite(artistScalar)) { +const values = new Float32Array(s.n * 4); +for (let i = 0; i < s.n; i++) { +values[i * 4] = 1; +values[i * 4 + 1] = Number.isFinite(artistScalar) ? artistScalar : -1; +values[i * 4 + 2] = -1; +values[i * 4 + 3] = -1; +} +const copy = (name, component, scale = 1) => { +const spec = channel(name); +if (!spec) return; +const source = spec.dtype === "u8" +? this._asU8(buffers[spec.buf]) +: this._asF32(buffers[spec.buf]); +const components = spec.components || 1; +for (let i = 0; i < s.n; i++) values[i * 4 + component] = source[i * components] * scale; +}; +copy("opacity", 0); +copy("artist_alpha", 1); +copy("stroke_width", 2, this.dpr); +copy("symbol", 3); +s.styleBuf = this._upload(values); +} +if (sample.stroke && sample.stroke.mode === "direct_rgba") { +s.strokeBuf = this._upload(this._asU8(buffers[sample.stroke.buf])); +} +this._pointMarkStyle(s, trace); g.sampleOverlay = s; this._refreshReductionBadges(); } @@ -3273,8 +3456,9 @@ const cr = g.cornerRadius || [0, 0]; gl.uniform2f(u("u_radius"), cr[0] * this.dpr, cr[1] * this.dpr); gl.uniform1f(u("u_strokeWidth"), (g.strokeWidth || 0) * this.dpr); const sc = g.strokeColor || [0, 0, 0, 0]; -const sa = sc[3] * this._strokeOpacity(g.trace.style || {}); -gl.uniform4f(u("u_stroke"), sc[0] * sa, sc[1] * sa, sc[2] * sa, sa); +gl.uniform4f(u("u_stroke"), sc[0], sc[1], sc[2], sc[3]); +gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); +gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style || {})); this._setGradientUniforms(prog, g.grad); } _rectMarkStyleGpu(g, t) { @@ -3362,7 +3546,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "width"); g._cpu = { x: x0, y: y1, xMeta: g.x0Meta, yMeta: g.y1Meta }; } _buildMeshMark(g, t, buffer) { @@ -3382,7 +3570,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); const style = t.style || {}; g.meshStrokeWidth = Number(style.stroke_width) || 0; g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]); @@ -3482,7 +3674,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._rectMarkStyleGpu(g, t); } _buildBarMark(g, t, buffer) { @@ -3519,7 +3715,11 @@ g.lut = this._lut(t.color.colormap); g.colorMode = 2; g.cBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); g.lut = this._paletteLut(t.color.palette); +} else if (t.color && t.color.mode === "direct_rgba") { +g.colorMode = 3; +g.rgbaBuf = this._upload(this._columnView(buffer, this.spec.columns[t.color.buf])); } +this._buildInstanceStyleChannels(g, t, buffer, "stroke_width"); this._rectMarkStyleGpu(g, t); } _buildHeatmapMark(g, t, buffer) { @@ -3639,11 +3839,11 @@ const gl = this.gl; if (gl) for (const { vao } of g._vaos.values()) gl.deleteVertexArray(vao); g._vaos = null; } -_vaoAttr(slot, buf, byteOffset, divisor, size = 1) { +_vaoAttr(slot, buf, byteOffset, divisor, size = 1, normalized = false) { const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.enableVertexAttribArray(slot); -gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, false, 0, byteOffset); +gl.vertexAttribPointer(slot, size, buf._fcType || gl.FLOAT, normalized, 0, byteOffset); gl.vertexAttribDivisor(slot, divisor); } _initPickTarget() { @@ -3746,12 +3946,14 @@ this._renderLassoSelection?.(); _now() { return performance.now(); } -_drawPoints(g, xm, ym, opacityScale = 1) { -const simple = -g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && +_canDrawSimplePoints(g) { +return g.colorMode === 0 && g.sizeMode === 0 && !g.selActive && +!g.rgbaBuf && !g.styleBuf && !g.strokeBuf && (g.symbol || 0) === 0 && (g.pointStrokeWidth || 0) <= 0 && Math.max(g.lodBlendShown ?? 0, g.lodBlend ?? 0) <= 0.001; -if (simple) { +} +_drawPoints(g, xm, ym, opacityScale = 1) { +if (this._canDrawSimplePoints(g)) { this._drawSimplePoints(g, xm, ym, opacityScale); return; } @@ -3778,21 +3980,23 @@ gl.uniform4f(loc, c ? c[0] : 0, c ? c[1] : 0, c ? c[2] : 0, c ? 1 : 0); }; stateColor(u("u_selColor"), this._markStateValue("selected", "color")); stateColor(u("u_unselColor"), this._markStateValue("unselected", "color")); -const [r, gg, b] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, 1); +const [r, gg, b, a] = g.color; +gl.uniform4f(u("u_color"), r, gg, b, a); gl.uniform1i(u("u_symbol"), g.symbol || 0); const sc = g.pointStroke; -const strokeAlpha = sc -? sc[3] * this._strokeOpacity(g.trace.style, 0.8) * opacityScale -: 0; gl.uniform1f(u("u_ptStrokeWidth"), (g.pointStrokeWidth || 0) * this.dpr); gl.uniform1i(u("u_ptStrokeFace"), g.pointStrokeFace ? 1 : 0); -gl.uniform4f(u("u_ptStroke"), sc ? sc[0] * strokeAlpha : 0, sc ? sc[1] * strokeAlpha : 0, -sc ? sc[2] * strokeAlpha : 0, strokeAlpha); +gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); +gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style, 0.8) * opacityScale); +gl.uniform4f(u("u_ptStroke"), sc ? sc[0] : 0, sc ? sc[1] : 0, +sc ? sc[2] : 0, sc ? sc[3] : 0); gl.uniform1i(u("u_selActive"), g.selActive ? 1 : 0); const colorOn = g.colorMode !== 0 && g.cBuf; const sizeOn = g.sizeMode === 1 && g.sBuf; const selOn = g.selActive && g.selBuf; +const rgbaOn = g.colorMode === 3 && g.rgbaBuf; +const styleOn = !!g.styleBuf; +const strokeOn = !!g.strokeBuf; if (g.lut) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -3827,6 +4031,9 @@ colorOn ? g.cBuf._fcId : 0, sizeOn ? g.sBuf._fcId : 0, selOn ? g.selBuf._fcId : 0, blendOn ? g.dBuf._fcId : 0, +rgbaOn ? g.rgbaBuf._fcId : 0, +styleOn ? g.styleBuf._fcId : 0, +strokeOn ? g.strokeBuf._fcId : 0, ], () => { this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); @@ -3835,12 +4042,18 @@ if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 0); if (sizeOn) this._vaoAttr(ATTR_SLOTS.a_sval, g.sBuf, 0, 0); if (selOn) this._vaoAttr(ATTR_SLOTS.a_sel, g.selBuf, 0, 0); if (blendOn) this._vaoAttr(ATTR_SLOTS.a_dval, g.dBuf, 0, 0); +if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 0, 4, true); +if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 0, 4); +if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 0, 4, true); } ); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); if (!sizeOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); if (!selOn) gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1.0); if (!blendOn) gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); +if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, r, gg, b, a); gl.drawArrays(gl.POINTS, 0, g.n); } _drawSimplePoints(g, xm, ym, opacityScale = 1) { @@ -3854,8 +4067,10 @@ this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); gl.uniform1f(u("u_dpr"), this.dpr); gl.uniform1f(u("u_size"), g.size); -const [r, gg, b] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, this._fillOpacity(g.trace.style, 0.8) * opacityScale); +const [r, gg, b, a] = g.color; +gl.uniform4f( +u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style, 0.8) * opacityScale +); this._bindVao( g, "points-simple", @@ -4026,7 +4241,8 @@ this._setAxisUniforms(prog, "u_y1", g.y1Meta, g.yAxis); gl.uniform2f(u("u_res"), this.canvas.width, this.canvas.height); gl.uniform1f(u("u_width"), (g.trace.style.width ?? 1.5) * this.dpr); const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._strokeOpacity(g.trace.style)); +gl.uniform4f(u("u_color"), r, gg, b, a); +gl.uniform1f(u("u_opacity"), this._strokeOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); const dashed = this._segmentDash(g, prog); if (g.colorMode && g.lut) { @@ -4038,7 +4254,9 @@ this._bindVao( g, "segment", [g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, -g.colorMode ? g.cBuf._fcId : 0, +g.colorMode && g.cBuf ? g.cBuf._fcId : 0, +g.rgbaBuf ? g.rgbaBuf._fcId : 0, +g.styleBuf ? g.styleBuf._fcId : 0, dashed ? g._segmentDashOffsetBuf._fcId : 0, dashed ? g._segmentDashDirBuf._fcId : 0], () => { @@ -4046,14 +4264,18 @@ this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); -if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.colorMode && g.cBuf) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (g.styleBuf) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); if (dashed) { this._vaoAttr(ATTR_SLOTS.a_dash0, g._segmentDashOffsetBuf, 0, 1); this._vaoAttr(ATTR_SLOTS.a_dashDir, g._segmentDashDirBuf, 0, 1); } } ); -if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.cBuf) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!g.styleBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } _segmentDash(g, prog) { @@ -4140,26 +4362,33 @@ for (const name of ["x0", "x1", "x2"]) this._setAxisUniforms(prog, "u_" + name, for (const name of ["y0", "y1", "y2"]) this._setAxisUniforms(prog, "u_" + name, g[name + "Meta"], g.yAxis); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); -gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], 1); +gl.uniform4f(u("u_color"), g.color[0], g.color[1], g.color[2], g.color[3]); const stroke = g.meshStroke || [0, 0, 0, 0]; -const strokeAlpha = stroke[3] * this._strokeOpacity(g.trace.style); -gl.uniform4f(u("u_stroke"), stroke[0] * strokeAlpha, stroke[1] * strokeAlpha, -stroke[2] * strokeAlpha, strokeAlpha); +gl.uniform4f(u("u_stroke"), stroke[0], stroke[1], stroke[2], stroke[3]); gl.uniform1f(u("u_strokeWidth"), g.meshStrokeWidth || 0); +gl.uniform1i(u("u_strokeMode"), g.strokeBuf ? 1 : 0); +gl.uniform1f(u("u_strokeOpacity"), this._strokeOpacity(g.trace.style)); if (g.colorMode && g.lut) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); gl.uniform1i(u("u_lut"), 0); } const parts = ["x0", "x1", "x2", "y0", "y1", "y2"].map((name) => g[name + "Buf"]._fcId); -parts.push(g.colorMode ? g.cBuf._fcId : 0); +parts.push(g.cBuf ? g.cBuf._fcId : 0, g.rgbaBuf ? g.rgbaBuf._fcId : 0, +g.styleBuf ? g.styleBuf._fcId : 0, g.strokeBuf ? g.strokeBuf._fcId : 0); this._bindVao(g, "mesh", parts, () => { for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) { this._vaoAttr(ATTR_SLOTS["a" + name], g[name + "Buf"], 0, 1); } -if (g.colorMode) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.cBuf) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (g.rgbaBuf) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (g.styleBuf) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); +if (g.strokeBuf) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); }); -if (!g.colorMode) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.cBuf) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!g.rgbaBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, ...g.color); +if (!g.styleBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!g.strokeBuf) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...stroke); gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, g.n); } _lineDash(g) { @@ -4249,10 +4478,15 @@ gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); gl.uniform4f(u("u_edgePad"), edgePad[0], edgePad[1], edgePad[2], edgePad[3]); const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); +gl.uniform4f(u("u_color"), r, gg, b, a); +gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); this._setRectStyleUniforms(prog, g); -const colorOn = g.colorMode && g.cBuf; +const colorOn = !!g.cBuf; +const rgbaOn = !!g.rgbaBuf; +const styleOn = !!g.styleBuf; +const strokeOn = !!g.strokeBuf; +const radiusOn = !!g.radiusBuf; if (colorOn) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -4261,16 +4495,27 @@ gl.uniform1i(u("u_lut"), 0); this._bindVao( g, "rects", -[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, colorOn ? g.cBuf._fcId : 0], +[g.x0Buf._fcId, g.x1Buf._fcId, g.y0Buf._fcId, g.y1Buf._fcId, +colorOn ? g.cBuf._fcId : 0, rgbaOn ? g.rgbaBuf._fcId : 0, +styleOn ? g.styleBuf._fcId : 0, strokeOn ? g.strokeBuf._fcId : 0, +radiusOn ? g.radiusBuf._fcId : 0], () => { this._vaoAttr(ATTR_SLOTS.ax0, g.x0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ax1, g.x1Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay0, g.y0Buf, 0, 1); this._vaoAttr(ATTR_SLOTS.ay1, g.y1Buf, 0, 1); if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); +if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); +if (radiusOn) this._vaoAttr(ATTR_SLOTS.a_radius, g.radiusBuf, 0, 1, 2); } ); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); +if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } _drawBars(g, pmap, v1map, v0map, v0Const, v0EdgePad = 0) { @@ -4295,11 +4540,16 @@ gl.uniform1i(u("u_v0Mode"), g.value0Mode); gl.uniform1f(u("u_v0Const"), v0Const ?? 0); gl.uniform1f(u("u_v0EdgePad"), v0EdgePad); const [r, gg, b, a] = g.color; -gl.uniform4f(u("u_color"), r, gg, b, a * this._fillOpacity(g.trace.style)); +gl.uniform4f(u("u_color"), r, gg, b, a); +gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_colorMode"), g.colorMode || 0); this._setRectStyleUniforms(prog, g); const v0On = g.value0Mode === 1 && g.value0Buf; -const colorOn = g.colorMode && g.cBuf; +const colorOn = !!g.cBuf; +const rgbaOn = !!g.rgbaBuf; +const styleOn = !!g.styleBuf; +const strokeOn = !!g.strokeBuf; +const radiusOn = !!g.radiusBuf; if (colorOn) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, g.lut); @@ -4312,16 +4562,28 @@ g, g.posBuf._fcId, g.value1Buf._fcId, v0On ? g.value0Buf._fcId : 0, colorOn ? g.cBuf._fcId : 0, +rgbaOn ? g.rgbaBuf._fcId : 0, +styleOn ? g.styleBuf._fcId : 0, +strokeOn ? g.strokeBuf._fcId : 0, +radiusOn ? g.radiusBuf._fcId : 0, ], () => { this._vaoAttr(ATTR_SLOTS.a_pos, g.posBuf, 0, 1); this._vaoAttr(ATTR_SLOTS.a_v1, g.value1Buf, 0, 1); if (v0On) this._vaoAttr(ATTR_SLOTS.a_v0, g.value0Buf, 0, 1); if (colorOn) this._vaoAttr(ATTR_SLOTS.a_cval, g.cBuf, 0, 1); +if (rgbaOn) this._vaoAttr(ATTR_SLOTS.a_rgba, g.rgbaBuf, 0, 1, 4, true); +if (styleOn) this._vaoAttr(ATTR_SLOTS.a_style, g.styleBuf, 0, 1, 4); +if (strokeOn) this._vaoAttr(ATTR_SLOTS.a_stroke, g.strokeBuf, 0, 1, 4, true); +if (radiusOn) this._vaoAttr(ATTR_SLOTS.a_radius, g.radiusBuf, 0, 1, 2); } ); if (!v0On) gl.vertexAttrib1f(ATTR_SLOTS.a_v0, 0); if (!colorOn) gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); +if (!rgbaOn) gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, r, gg, b, a); +if (!styleOn) gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); +if (!strokeOn) gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, ...(g.strokeColor || g.color)); +if (!radiusOn) gl.vertexAttrib2f(ATTR_SLOTS.a_radius, -1, -1); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, g.n); } _dataPxX(value) { diff --git a/python/xy/widget.py b/python/xy/widget.py index cf4b63e7..f71ec8da 100644 --- a/python/xy/widget.py +++ b/python/xy/widget.py @@ -77,11 +77,35 @@ def __init__( super().__init__(spec=spec, buffers=bufs, **kwargs) self.on_msg(self._on_custom_msg) - def append(self, trace_id: int, x: Any, y: Any, *, color: Any = None, size: Any = None) -> None: + def append( + self, + trace_id: int, + x: Any, + y: Any, + *, + color: Any = None, + size: Any = None, + stroke: Any = None, + opacity: Any = None, + alpha: Any = None, + stroke_width: Any = None, + symbol: Any = None, + ) -> None: """Streaming append: extend a trace's data and push the refresh to the client. Also refreshes the synced spec/buffers traits so a re-rendered output (notebook reopen) shows the streamed state, not the initial one.""" - msg, buffers = self._figure.append(trace_id, x, y, color=color, size=size) + msg, buffers = self._figure.append( + trace_id, + x, + y, + color=color, + size=size, + stroke=stroke, + opacity=opacity, + alpha=alpha, + stroke_width=stroke_width, + symbol=symbol, + ) self.spec = msg["spec"] self.buffers = buffers[0] self.send(msg, buffers=buffers) diff --git a/scripts/_protocol.py b/scripts/_protocol.py new file mode 100644 index 00000000..fbec6c75 --- /dev/null +++ b/scripts/_protocol.py @@ -0,0 +1,18 @@ +"""Read the payload protocol without importing the optional Python package.""" + +from __future__ import annotations + +import re +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_CONFIG = _ROOT / "python" / "xy" / "config.py" +_MATCH = re.search( + r"^PROTOCOL_VERSION\s*=\s*(\d+)\s*$", + _CONFIG.read_text(encoding="utf-8"), + re.MULTILINE, +) +if _MATCH is None: + raise RuntimeError(f"could not read PROTOCOL_VERSION from {_CONFIG}") + +PROTOCOL_VERSION = int(_MATCH.group(1)) diff --git a/scripts/browser_conformance.mjs b/scripts/browser_conformance.mjs index 5799d97c..6b092f41 100644 --- a/scripts/browser_conformance.mjs +++ b/scripts/browser_conformance.mjs @@ -6,12 +6,17 @@ * not expected to be byte-identical (§21). */ +import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium, firefox, webkit } from "playwright"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const BUNDLE = join(ROOT, "python", "xy", "static", "standalone.js"); +const protocolSource = readFileSync(join(ROOT, "python", "xy", "config.py"), "utf8"); +const protocolMatch = protocolSource.match(/^PROTOCOL_VERSION\s*=\s*(\d+)\s*$/m); +if (!protocolMatch) throw new Error("could not read PROTOCOL_VERSION from python/xy/config.py"); +const PROTOCOL_VERSION = Number(protocolMatch[1]); const ENGINES = { chromium, firefox, webkit }; const headless = process.env.XY_CONFORMANCE_HEADFUL !== "1"; const selected = process.argv.find((arg) => arg.startsWith("--browsers="))?.split("=", 2)[1] @@ -21,7 +26,7 @@ for (const name of selected) { } const spec = { - protocol: 3, + protocol: PROTOCOL_VERSION, width: 640, height: 360, title: "Cross-browser conformance", diff --git a/scripts/pick_boundary_smoke.py b/scripts/pick_boundary_smoke.py index a92541e7..36d7ce98 100644 --- a/scripts/pick_boundary_smoke.py +++ b/scripts/pick_boundary_smoke.py @@ -29,6 +29,8 @@ from array import array from pathlib import Path +from _protocol import PROTOCOL_VERSION + ROOT = Path(__file__).resolve().parents[1] STATIC = ROOT / "python" / "xy" / "static" @@ -92,7 +94,7 @@ def ship(vals): } ) spec = { - "protocol": 3, + "protocol": PROTOCOL_VERSION, "width": 900, "height": 420, "title": None, @@ -130,7 +132,7 @@ def ship2(vals): xs[BIG_PICK_INDEX] = 0.75 ys[BIG_PICK_INDEX] = 0.75 spec2 = { - "protocol": 3, + "protocol": PROTOCOL_VERSION, "width": 900, "height": 420, "title": None, diff --git a/scripts/png_export_smoke.py b/scripts/png_export_smoke.py index 5585c222..462c542e 100644 --- a/scripts/png_export_smoke.py +++ b/scripts/png_export_smoke.py @@ -28,6 +28,7 @@ sys.path.insert(0, str(ROOT / "python")) sys.path.insert(0, str(Path(__file__).resolve().parent)) # for abi_smoke.load +from _protocol import PROTOCOL_VERSION # noqa: E402 from abi_smoke import load # noqa: E402 from xy.export import find_chromium, html_to_png # noqa: E402 @@ -39,7 +40,7 @@ def build_html() -> str: # two-point line, f32 offset-encoded (offset 0.5, scale 1) — no numpy. blob = struct.pack("<2f", -0.5, 0.5) + struct.pack("<2f", -0.5, 0.5) spec = { - "protocol": 3, + "protocol": PROTOCOL_VERSION, "width": W, "height": H, "title": "png-export-smoke", diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index 97ae3281..ca37a486 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -23,6 +23,8 @@ from array import array from pathlib import Path +from _protocol import PROTOCOL_VERSION + ROOT = Path(__file__).resolve().parent.parent STATIC = ROOT / "python" / "xy" / "static" CHROMIUM_CANDIDATES = [ @@ -210,7 +212,7 @@ def ship_scalar(vals): }, ] spec = { - "protocol": 3, + "protocol": PROTOCOL_VERSION, "width": 800, "height": 400, "title": "nonumpy smoke", @@ -802,7 +804,7 @@ def main() -> None: return hcols.length-1; }} const histSpec={{ - protocol:3,width:220,height:170,title:"", + protocol:{PROTOCOL_VERSION},width:220,height:170,title:"", x_axis:{{kind:"linear",label:"",range:[-1,2]}}, y_axis:{{kind:"linear",label:"",range:[0,8]}}, traces:[{{id:0,kind:"histogram",name:"hist",style:{{color:"#3b82f6",opacity:1,role:"histogram"}}, @@ -900,7 +902,7 @@ def main() -> None: so+=vals.length; return scols.length-1;}}; const xs=[],ys=[];for(let i=0;i None: msCols.push({{byte_offset:msOff*4,len:vals.length,offset:0,scale:1,kind:"float"}}); msOff+=vals.length; return msCols.length-1;}}; const msFill={{space:"mark",dir:"down",stops:[[0,"rgba(37,99,235,1)"],[1,"rgba(37,99,235,0)"]]}}; - const msSpec={{protocol:3,width:200,height:160,title:"",backend:"none", + const msSpec={{protocol:{PROTOCOL_VERSION},width:200,height:160,title:"",backend:"none", show_legend:false,show_modebar:false, x_axis:{{kind:"linear",label:"",range:[0,4]}}, y_axis:{{kind:"linear",label:"",range:[0,8]}}, @@ -954,11 +956,19 @@ def main() -> None: x0:mscol([2.5]),x1:mscol([3.5]),y0:mscol([0]),y1:mscol([6])}}, {{id:2,kind:"bar",name:"c",tier:"direct",n_points:1,n_marks:1, style:{{color:"#2563eb",opacity:1,corner_radius:10,fill:msFill}}, - bar:{{pos:mscol([2]),value1:mscol([7.5]),width:0.6,orientation:"vertical"}}}} + bar:{{pos:mscol([2]),value1:mscol([7.5]),width:0.6,orientation:"vertical"}}}}, + {{id:3,kind:"scatter",name:"styled",tier:"direct",n_points:2,n_marks:2, + style:{{opacity:1}},x:mscol([0.2,3.8]),y:mscol([7.5,7.5]), + color:{{mode:"constant",color:"#ff0000"}},size:{{mode:"constant",size:10}}, + channels:{{opacity:{{mode:"direct",components:1,dtype:"f32",buf:mscol([1,0.2]),n:2}}}}}} ],columns:msCols}}; const holderMs=document.createElement("div");document.body.appendChild(holderMs); const vMs=xy.renderStandalone(holderMs,msSpec,msBuf); + let msSimpleCalls=0; + const msDrawSimple=vMs._drawSimplePoints; + vMs._drawSimplePoints=function(...args){{msSimpleCalls++;return msDrawSimple.call(this,...args);}}; vMs._drawNow(); + const vstyle=(msSimpleCalls===0 && !!vMs.gpuTraces[3].styleBuf)?1:0; const msRead=(dx,dy)=>{{ const g3=vMs.gl,W3=g3.drawingBufferWidth,H3=g3.drawingBufferHeight; const x=Math.max(0,Math.min(W3-1,Math.round(dx/4*W3))); @@ -992,7 +1002,7 @@ def main() -> None: const occcol=(vals)=>{{new Float32Array(occBuf,occOff*4,vals.length).set(vals); occCols.push({{byte_offset:occOff*4,len:vals.length,offset:0,scale:1,kind:"float"}}); occOff+=vals.length; return occCols.length-1;}}; - const occSpec={{protocol:3,width:200,height:160,title:"",backend:"none", + const occSpec={{protocol:{PROTOCOL_VERSION},width:200,height:160,title:"",backend:"none", show_legend:false,show_modebar:false, dom:{{style:{{"--chart-bg":"#eaeaf2","--chart-grid":"#ffffff"}}}}, x_axis:{{kind:"linear",label:"",range:[0,4]}}, @@ -1025,7 +1035,7 @@ def main() -> None: const smcol=(vals)=>{{new Float32Array(smBuf,smOff*4,vals.length).set(vals); smCols.push({{byte_offset:smOff*4,len:vals.length,offset:0,scale:1,kind:"float"}}); smOff+=vals.length; return smCols.length-1;}}; - const smSpec={{protocol:3,width:200,height:160,title:"",backend:"none", + const smSpec={{protocol:{PROTOCOL_VERSION},width:200,height:160,title:"",backend:"none", show_legend:false,show_modebar:false, x_axis:{{kind:"linear",label:"",range:[0,4]}}, y_axis:{{kind:"linear",label:"",range:[0,8]}}, @@ -1044,6 +1054,7 @@ def main() -> None: const msmooth=(gLn.n===65 && gLn._cpu.x.length===5 && gAr.n===65 && gAr._cpu.base.length===5)?1:0; vSm.destroy();holderSm.remove(); const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHidden}} modebarHover=${{modebarHover}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}}`; + const baseWithStyle=`${{base}} vstyle=${{vstyle}}`; // Responsive: 100%-by-100% chart in a 400x300 container tracks its parent; // growing the container must fire the ResizeObserver and re-render bigger. const spec2=JSON.parse(JSON.stringify(spec)); @@ -1171,7 +1182,7 @@ def main() -> None: && String(v5._dprMq.media).indexOf(`${{dpr0*2}}dppx`)>=0)?1:0; Object.defineProperty(window,"devicePixelRatio",{{value:dpr0,configurable:true}}); v5.destroy(); holder5.remove(); - document.title=`${{base}} fluid=${{fluid0}} grew=${{grew}} pick2=${{pick2}} destroyed=${{destroyed}} unsub=${{unsub}} ctxloss=${{ctxloss}} ctxcycles=${{ctxcycles}} ctxquiet=${{ctxquiet}} ctxpixels=${{ctxpixels}} ctxhashes=${{ctxhashes.join(",")}} ctxpost=${{ctxpost}} ctxevents=${{rootLost}}/${{rootRestored}} ctxcounts=${{v4._contextLossCount}}/${{v4._contextRestoreCount}} dprw=${{dprw}}`; + document.title=`${{baseWithStyle}} fluid=${{fluid0}} grew=${{grew}} pick2=${{pick2}} destroyed=${{destroyed}} unsub=${{unsub}} ctxloss=${{ctxloss}} ctxcycles=${{ctxcycles}} ctxquiet=${{ctxquiet}} ctxpixels=${{ctxpixels}} ctxhashes=${{ctxhashes.join(",")}} ctxpost=${{ctxpost}} ctxevents=${{rootLost}}/${{rootRestored}} ctxcounts=${{v4._contextLossCount}}/${{v4._contextRestoreCount}} dprw=${{dprw}}`; }}catch(e){{document.title="XY_ERROR "+(e.stack||e.message)}}}})(); }}catch(e){{document.title="XY_ERROR "+(e.stack||e.message)}}}},250); }}catch(e){{document.title="XY_ERROR "+(e.stack||e.message)}}}},200); @@ -1277,6 +1288,7 @@ def main() -> None: mark_stroke = int(re.search(r"mstroke=(\d+)", title).group(1)) bar_grad = int(re.search(r"bgrad=(\d+)", title).group(1)) bar_corner = int(re.search(r"bcorner=(\d+)", title).group(1)) + vector_style = int(re.search(r"vstyle=(\d+)", title).group(1)) mark_smooth = int(re.search(r"msmooth=(\d+)", title).group(1)) bg_occlusion = int(re.search(r"bgocc=(\d+)", title).group(1)) frac = lit / max(total, 1) @@ -1443,6 +1455,8 @@ def main() -> None: raise SystemExit("mark-space gradient fill did not fade tip->base (bar program)") if bar_corner != 1: raise SystemExit("corner_radius left the bar corner pixel lit") + if vector_style != 1: + raise SystemExit("vector-styled scatter incorrectly entered the simple-point shader") if mark_smooth != 1: raise SystemExit("curve:'smooth' did not densify line/area GPU geometry") if bg_occlusion != 1: diff --git a/spec/api/styling.md b/spec/api/styling.md index 88ac9b4b..d8a83729 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -402,6 +402,46 @@ the default area `opacity=0.35` produces a `0.35`-alpha outline. For a faint fill with an opaque outline, keep whole-mark opacity at `1` and set `style={"fill-opacity": 0.35, "stroke-opacity": 1}`. +### Vectorized instance styles + +Instanced 2-D primitives accept scalar or per-item styles without splitting a +collection into one trace per mark: + +| Mark | Direct paint | Numeric/glyph channels | +| --- | --- | --- | +| scatter | `color`, `stroke`: `(N, 3)` RGB or `(N, 4)` RGBA | `opacity`, `size`, `stroke_width`, `symbol` | +| bar, column, histogram, rectangles | `color`, `stroke`: `(N, 3|4)` | `opacity`, `stroke_width`, `corner_radius` (`N` or `N × 2`) | +| independent segments | `color`: `(N, 3|4)` | `opacity`, `width` | +| triangle mesh | `color`, `stroke`: `(N, 3|4)` | `opacity`, `stroke_width` | + +Multi-series bars accept `(S, N, 3|4)` paint and `(S, N)` numeric channels. +A one-series `(N, …)` value never broadcasts into a differently shaped series; +shape mismatches fail before the figure is mutated. Direct RGBA is packed as +four normalized bytes per item. Scalar constants remain spec-only, while +semantic one-dimensional numeric/categorical color channels keep using a +scalar plus a lookup table and may produce a colorbar. + +An outline that follows its item fill ships as the buffer-free `match_fill` +paint mode; it does not duplicate direct RGBA bytes. + +Alpha composition is ordered and shared by WebGL, PNG, and SVG: + +1. the paint contributes intrinsic alpha; +2. a Matplotlib artist-alpha override replaces that intrinsic alpha (`None` + restores it); +3. core `opacity`, component `fill_opacity`/`stroke_opacity`, and selection + opacity multiply the result. + +A scalar `style={...}` declaration is intentionally scalar-only and overrides +the corresponding typed scalar/vector argument. Dense scatter aggregation can +discard exact instance styles above the direct-render ceiling; its warning +lists every dropped channel. + +Streaming append accepts matching `color`, `size`, `stroke`, `opacity`, +`alpha`, `stroke_width`, and `symbol` tails for an existing per-item scatter +channel. All tail shapes are validated before geometry or style storage is +mutated, so a rejected append cannot leave channel lengths out of sync. + ### Scatter markers — `symbol`, `stroke`, `stroke_width` `scatter` markers take any of the 17 renderer-backed symbols listed in the diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index f410bd42..c11fb4fd 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -51,8 +51,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | matplotlib | notes | |---|---| | `plt.plot` / `ax.plot` | format strings (`'r--o'`), multiple series per call, implicit x, `label=`, `lw=`, `ls=`, `alpha=`, marker face/edge styling, directional `^`/`v`/`<`/`>` triangles and distinct `+`/`x` glyphs, `markevery`, and dependency-free affine *data* transforms (`Affine2D + ax.transData`); axes/figure-fraction transforms on data artists, partial fill styles, and cap/join policies fail loudly | -| `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; array `c` becomes a color encoding and explicit paired color bounds are retained; custom norms/marker paths fail loudly | -| `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, Matplotlib 3.11 grouped-bar containers and labels | +| `scatter(x, y, s=, c=, cmap=, vmin=, vmax=, alpha=, marker=, edgecolors=, plotnonfinite=)` | `s` (pt², area) maps to pixel diameter; numeric 1-D `c` remains a colormap encoding, while `N×3`/`N×4` face and edge colors, alpha arrays, sizes, and linewidth arrays stay in one collection. Explicit alpha replaces intrinsic RGBA alpha, matching Matplotlib; custom norms/marker paths fail loudly | +| `bar`, `barh`, `grouped_bar`, `bar_label` | string categories, stacking bases, per-bar face/edge color-alpha pairs and linewidths, plus iterable/indexable `BarContainer.patches` views whose setters mutate the parent batched trace | | `hist(bins=, range=, density=, cumulative=, weights=, orientation=, stacked=)` | Returns computed counts/edges; bar, step, and stepfilled families render in both vertical and horizontal orientations | | `hist2d`, `hexbin`, `ecdf` | 2D uniform binning uses the native Rust kernel; hexbin uses Matplotlib's two-offset-grid nearest-center assignment and six-triangle data-space cells, supports `C`, arbitrary scalar reducers, and `mincnt`, and retains only the bounded lattice rather than source points | | `boxplot`, `violinplot`, `bxp`, `violin`, `errorbar` | Boxplots support notches, bootstrap/user confidence intervals, median overrides (drawn median only; notch CIs stay data-derived like Matplotlib), percentile/custom whiskers, cap widths, `sym`, and component colors/widths/alpha — dashed component linestyles fail loudly. Violins support Scott/Silverman/scalar/callable Gaussian-KDE bandwidths, quantiles, and low/high sides; the default (bw_method omitted) uses the native histogram violin mark, whose shape differs from the explicit KDE path | @@ -81,8 +81,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | | `savefig('x.png' / '.svg' / '.html', dpi=)` | Browser-free PNG/SVG supports both single and multi-panel figures; file-like targets require an explicit `format=` and unsupported metadata/layout/export formats fail loudly | | `plt.show()` | notebooks: inline HTML display; scripts: opens the default browser | -| Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render | -| Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, any CSS color | +| Artists: `set_data` / `set_ydata` / `set_color` / `set_label` / `set_linewidth` / `remove` | mutating a handle rebuilds the chart on next render. Scatter collections additionally vectorize facecolors, edgecolors, alpha, linewidths, and sizes; `alpha=None` restores intrinsic paint alpha | +| Colors | single letters, `C0`–`C9`, `tab:*`, gray `'0.5'`, RGB(A) tuples, `(color, alpha)` pairs, per-item RGB(A) arrays, and any CSS color | | `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, turbo, coolwarm, Blues, Purples, PuBu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal (RdGy/jet render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | | `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it | diff --git a/src/lib.rs b/src/lib.rs index dc8cae60..fa061108 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,7 +80,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 36; +pub const ABI_VERSION: u32 = 37; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] diff --git a/tests/pyplot/test_vectorized_styles.py b/tests/pyplot/test_vectorized_styles.py new file mode 100644 index 00000000..5378c05e --- /dev/null +++ b/tests/pyplot/test_vectorized_styles.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import xy.pyplot as plt +from xy._figure import Figure + + +@pytest.fixture(autouse=True) +def _clean_figures(): + plt.close("all") + yield + plt.close("all") + + +def test_direct_rgba_payload_is_four_packed_bytes_per_mark() -> None: + rgba = np.array([[1.0, 0.0, 0.5, 0.25], [0.0, 1.0, 0.0, 1.0]]) + fig = Figure().scatter([0, 1], [0, 1], color=rgba) + + spec, blob = fig.build_payload() + + paint = spec["traces"][0]["color"] + column = spec["columns"][paint["buf"]] + assert paint == { + "mode": "direct_rgba", + "components": 4, + "dtype": "u8", + "buf": paint["buf"], + "n": 2, + } + assert column["len"] == 8 + packed = np.frombuffer(blob, dtype=np.uint8, count=8, offset=column["byte_offset"]) + np.testing.assert_array_equal(packed.reshape(2, 4), [[255, 0, 128, 64], [0, 255, 0, 255]]) + + +def test_invalid_vector_style_is_atomic() -> None: + fig = Figure().line([0, 1], [0, 1]) + before = len(fig.traces) + + with pytest.raises(ValueError, match="stroke_width array"): + fig.scatter([0, 1], [0, 1], stroke_width=[1, 2, 3]) + + assert len(fig.traces) == before + + +def test_matplotlib_set_alpha_gallery_bar_forms_stay_batched() -> None: + rng = np.random.default_rng(19680801) + values = rng.standard_normal(20) + colors = ["green" if value > 0 else "red" for value in values] + face_alpha = np.abs(values) / np.max(np.abs(values)) + edge_alpha = 1.0 - face_alpha + + _fig, (left, right) = plt.subplots(ncols=2) + left.bar(np.arange(20), values, color=colors, edgecolor=colors, alpha=0.5) + right.bar( + np.arange(20), + values, + color=list(zip(colors, face_alpha, strict=True)), + edgecolor=list(zip(colors, edge_alpha, strict=True)), + ) + + left_traces = left._build_chart(400, 400).figure().traces + right_traces = right._build_chart(400, 400).figure().traces + assert len(left_traces) == len(right_traces) == 1 + assert left_traces[0].style["artist_alpha"] == 0.5 + np.testing.assert_allclose(right_traces[0].color_ch.rgba[:, 3], face_alpha) + np.testing.assert_allclose(right_traces[0].stroke_ch.rgba[:, 3], edge_alpha) + + +def test_scatter_vectors_and_collection_mutations_rebuild_channels() -> None: + face = np.array([[1, 0, 0, 0.2], [0, 1, 0, 0.4], [0, 0, 1, 0.6]]) + edge = face[::-1].copy() + _fig, ax = plt.subplots() + collection = ax.scatter( + [0, 1, 2], + [0, 1, 0], + c=face, + edgecolors=edge, + alpha=[0.3, 0.6, 0.9], + linewidths=[1, 2, 3], + s=[20, 40, 80], + ) + + collection.set_alpha([0.25, 0.5, 0.75]) + collection.set_linewidths([2, 3, 4]) + (trace,) = ax._build_chart(640, 480).figure().traces + + np.testing.assert_allclose(trace.color_ch.rgba, face) + np.testing.assert_allclose(trace.stroke_ch.rgba, edge) + np.testing.assert_allclose(trace.style_channels["artist_alpha"].values, [0.25, 0.5, 0.75]) + np.testing.assert_allclose( + trace.style_channels["stroke_width"].values, + np.array([2, 3, 4]) * ax._point_scale(), + ) + + +def test_scatter_face_edge_uses_buffer_free_match_fill_mode() -> None: + _fig, ax = plt.subplots() + ax.scatter( + [0, 1], + [0, 1], + c=[[1.0, 0.0, 0.0, 0.25], [0.0, 0.0, 1.0, 0.75]], + ) + core = ax._build_chart(320, 240).figure() + spec, _blob = core.build_payload() + assert spec["traces"][0]["stroke"] == {"mode": "match_fill"} + + +def test_bar_patch_view_updates_parent_channel_without_splitting_trace() -> None: + _fig, ax = plt.subplots() + bars = ax.bar([0, 1, 2], [1, 2, 3], color=["red", "green", "blue"]) + + assert not bars.patches._cache + assert len(bars.patches) == len(bars) == 3 + assert bars[1] is list(bars)[1] + bars[1].set_alpha(0.4) + bars[2].set_facecolor((1.0, 1.0, 0.0, 0.7)) + bars[0].set_linewidth(2.0) + + (trace,) = ax._build_chart(640, 480).figure().traces + assert trace.kind == "bar" + np.testing.assert_allclose(trace.style_channels["artist_alpha"].values, [-1.0, 0.4, -1.0]) + assert trace.color_ch.rgba[2].tolist() == pytest.approx([1.0, 1.0, 0.0, 0.7]) + np.testing.assert_allclose(trace.style_channels["stroke_width"].values, [2.0, 0.0, 0.0]) + + +def test_ten_thousand_differently_styled_bars_are_one_trace() -> None: + n = 10_000 + rgba = np.empty((n, 4), dtype=np.float64) + rgba[:, 0] = np.linspace(0.0, 1.0, n) + rgba[:, 1] = 0.25 + rgba[:, 2] = 1.0 - rgba[:, 0] + rgba[:, 3] = np.linspace(0.1, 1.0, n) + fig = Figure().bar(np.arange(n), np.ones(n), color=rgba) + + spec, _blob = fig.build_payload() + + assert len(spec["traces"]) == 1 + paint = spec["traces"][0]["color"] + assert spec["columns"][paint["buf"]]["len"] == n * 4 + + +def test_streaming_scatter_appends_geometry_and_style_tails_atomically() -> None: + face = np.array([[1.0, 0.0, 0.0, 0.2], [0.0, 1.0, 0.0, 0.4]]) + edge = face[::-1].copy() + fig = Figure().scatter( + [0, 1], + [0, 1], + color=face, + stroke=edge, + opacity=[0.5, 0.6], + _artist_alpha=[0.2, 0.8], + stroke_width=[1.0, 2.0], + symbol=["circle", "square"], + ) + + fig.append( + 0, + [2], + [0], + color=[[0.0, 0.0, 1.0, 0.6]], + stroke=[[1.0, 1.0, 0.0, 0.7]], + opacity=[0.7], + alpha=[0.9], + stroke_width=[3.0], + symbol=["diamond"], + ) + trace = fig.traces[0] + assert trace.n_points == 3 + np.testing.assert_allclose(trace.color_ch.rgba[-1], [0.0, 0.0, 1.0, 0.6]) + np.testing.assert_allclose(trace.stroke_ch.rgba[-1], [1.0, 1.0, 0.0, 0.7]) + np.testing.assert_allclose(trace.style_channels["opacity"].values, [0.5, 0.6, 0.7]) + np.testing.assert_array_equal(trace.style_channels["symbol"].values, [0, 1, 2]) + + before = trace.n_points + with pytest.raises(ValueError, match="stroke_width array"): + fig.append( + 0, + [3], + [1], + color=[[1.0, 0.0, 1.0, 1.0]], + stroke=[[0.0, 0.0, 0.0, 1.0]], + opacity=[0.5], + alpha=[0.5], + stroke_width=[1.0, 2.0], + symbol=["circle"], + ) + assert trace.n_points == before + + +def test_segments_and_triangle_mesh_ship_direct_instance_styles() -> None: + rgba = np.array([[1.0, 0.0, 0.0, 0.25], [0.0, 0.0, 1.0, 0.75]]) + fig = Figure().segments( + [0, 1], + [0, 0], + [1, 2], + [1, 1], + color=rgba, + opacity=[0.4, 0.8], + width=[1.0, 3.0], + ) + spec, _blob = fig.build_payload() + segment = spec["traces"][0] + assert segment["color"]["mode"] == "direct_rgba" + assert set(segment["channels"]) == {"opacity", "width"} + assert segment["style"]["opacity"] == 1.0 + + mesh = Figure().triangle_mesh( + [0, 1], + [0, 0], + [1, 2], + [0, 0], + [0.5, 1.5], + [1, 1], + color=rgba, + stroke=rgba[::-1], + opacity=[0.5, 1.0], + stroke_width=[1.0, 2.0], + ) + mesh_spec, _blob = mesh.build_payload() + triangle = mesh_spec["traces"][0] + assert triangle["color"]["mode"] == triangle["stroke"]["mode"] == "direct_rgba" + assert set(triangle["channels"]) == {"opacity", "stroke_width"} + assert triangle["style"]["opacity"] == 1.0 + + +def test_multiseries_bar_styles_require_series_item_shapes_atomically() -> None: + rgba = np.ones((2, 3, 4), dtype=np.float64) + rgba[0, :, 0] = [0.2, 0.4, 0.6] + rgba[1, :, 2] = [0.3, 0.5, 0.7] + fig = Figure().bar( + [0, 1, 2], + [[1, 2, 3], [3, 2, 1]], + color=rgba, + opacity=[[0.2, 0.4, 0.6], [0.3, 0.5, 0.7]], + stroke_width=[[1, 2, 3], [3, 2, 1]], + ) + assert len(fig.traces) == 2 + assert all(trace.color_ch.mode == "direct_rgba" for trace in fig.traces) + assert all(trace.style["opacity"] == 1.0 for trace in fig.traces) + + before = len(fig.traces) + with pytest.raises(ValueError, match="numeric paint must have shape"): + fig.bar([0, 1, 2], [[1, 2, 3], [3, 2, 1]], color=np.ones((3, 4))) + assert len(fig.traces) == before diff --git a/tests/test_png_export.py b/tests/test_png_export.py index fb8d4f6b..1dc18f0e 100644 --- a/tests/test_png_export.py +++ b/tests/test_png_export.py @@ -885,3 +885,33 @@ def fail_normalization(*_args, **_kwargs): monkeypatch.setattr(_payload.kernels, "normalize_f32", fail_normalization) png = figure.to_png(scale=1) assert _ihdr(png)[:2] == (180, 120) + + +def _dark_pixel_count(png: bytes, threshold: int = 60) -> int: + rgba = _decode_rgba(png) + rgb = rgba[..., :3].astype(np.int64) + return int(((rgb < threshold).all(axis=-1) & (rgba[..., 3] > 200)).sum()) + + +def test_bar_scalar_stroke_color_renders_in_png() -> None: + """Regression: scalar stroke= must not collapse to the face paint.""" + fig = Figure().bar( + [0, 1, 2], [1.0, 2.0, 3.0], color="white", stroke="black", stroke_width=4.0, opacity=1.0 + ) + assert _dark_pixel_count(fig.to_png(width=300, height=200)) > 200 + + +def test_scatter_direct_edges_with_colormap_c_render_in_png() -> None: + """Regression: the affine channel fast path must defer to the styled + painter when a per-point stroke channel is present.""" + edges = np.tile([0.0, 0.0, 0.0, 1.0], (3, 1)) + fig = Figure().scatter( + [0, 1, 2], + [0, 1, 0], + color=np.array([0.9, 0.95, 1.0]), + size=24.0, + stroke=edges, + stroke_width=4.0, + opacity=1.0, + ) + assert _dark_pixel_count(fig.to_png(width=300, height=200)) > 200 diff --git a/tests/test_scatter_matrix.py b/tests/test_scatter_matrix.py index 80523c8f..b326de81 100644 --- a/tests/test_scatter_matrix.py +++ b/tests/test_scatter_matrix.py @@ -298,6 +298,53 @@ def test_tier_just_below_and_above_threshold(): assert hi.traces[0].use_density() +def test_per_item_channels_share_the_direct_density_ceiling(monkeypatch): + import xy._trace as trace_module + + monkeypatch.setattr(trace_module, "SCATTER_DENSITY_THRESHOLD", 2) + monkeypatch.setattr(trace_module, "DIRECT_SOFT_CEILING", 4) + + x = np.arange(3.0) + plain = Figure().scatter(x, x).traces[0] + opacity = Figure().scatter(x, x, opacity=[0.2, 0.5, 0.8]).traces[0] + stroke = ( + Figure() + .scatter( + x, + x, + stroke=np.array([[1.0, 0.0, 0.0, 1.0]] * 3), + ) + .traces[0] + ) + match_fill = Figure().scatter(x, x, stroke_width=1.0).traces[0] + vector_width = Figure().scatter(x, x, stroke_width=[1.0, 2.0, 3.0]).traces[0] + + assert plain.use_density() + assert not opacity.use_density() + assert not stroke.use_density() + assert opacity.per_item_channel_names() == ("opacity",) + assert stroke.per_item_channel_names() == ("stroke",) + assert match_fill.per_item_channel_names() == () + assert vector_width.per_item_channel_names() == ("stroke_width",) + + over_ceiling = Figure().scatter(np.arange(5.0), np.arange(5.0), opacity=np.ones(5)).traces[0] + assert over_ceiling.use_density() + + +def test_style_only_density_warning_and_payload_list_exact_dropped_channels(monkeypatch): + import xy.marks as marks_module + + monkeypatch.setattr(marks_module, "DIRECT_SOFT_CEILING", 4) + x = np.arange(5.0) + with pytest.warns(RuntimeWarning, match="dropped channels: opacity, stroke_width"): + fig = Figure().scatter(x, x, opacity=np.ones(5), stroke_width=np.ones(5)) + + spec, _ = fig.build_payload() + density = spec["traces"][0]["density"] + assert density["channels_dropped"] is True + assert density["dropped_channels"] == ["opacity", "stroke_width"] + + def test_force_density_on_small(): fig = Figure().scatter(np.arange(100.0), np.arange(100.0), density=True) spec, blob = _payload(fig) diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 18289272..3c14bc17 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -693,7 +693,6 @@ def test_client_renders_mark_level_styling() -> None: "u_gradMode", 'u("u_radius")', # rounded-corner SDF uniform 'u("u_strokeWidth")', - "xyGradSample(xyGradT(v_t, u_res)) * u_color.a", # opacity composes w/ gradient "(v_pos - v_base) / (abs(denom)", # area gradient: per-column, seam-free + even "xySmoothResample(", # monotone cubic (Fritsch–Carlson) "xyMonotoneTangents(", @@ -718,6 +717,20 @@ def test_client_renders_mark_level_styling() -> None: assert "g._cpu = { x, y, base, xMeta: g.xMeta, yMeta: g.yMeta };" in src +def test_rect_gradient_preserves_per_item_alpha_stack() -> None: + """Gradient rects compose vector opacity and artist-alpha while keeping + the fragment output premultiplied for WebGL blending.""" + required = ( + "vec4 gradient = xyGradSample(xyGradT(v_t, u_res));", + "(v_style.y >= 0.0 ? v_style.y : gradient.a) * v_style.x * u_opacity", + "gradient.a > 1e-6 ? gradient.rgb / gradient.a : vec3(0.0)", + "premult = vec4(gradientRgb * gradientAlpha, gradientAlpha);", + ) + for path, text in CLIENT_FILES: + for marker in required: + assert marker in text, f"{path} drops gradient alpha marker {marker!r}" + + def test_standalone_density_rebin_worker() -> None: """Kernel-less pages refine density charts by re-binning the retained §28 sample in a bundled blob-URL worker — off the main thread, recorded diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index 3fca7ab2..65e83b0a 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -684,3 +684,50 @@ def test_colormap_stops_stay_in_sync_with_js_client() -> None: assert f"[{r}, {g}, {b}]" in body, ( f"{name} stop ({r},{g},{b}) missing in 10_colormaps.js" ) + + +def test_scalar_stroke_color_survives_vectorized_style_path() -> None: + """Scalar CSS stroke= on rect-family and mesh marks must not collapse to + the face paint (regression: the per-item style refactor resolved strokes + from the trace stroke channel or face only, skipping style['stroke']).""" + bar_svg = ( + Figure() + .bar([0, 1, 2], [1.0, 2.0, 3.0], color="steelblue", stroke="black", stroke_width=2.0) + .to_svg() + ) + assert 'stroke="rgb(0,0,0)"' in bar_svg + assert 'stroke="rgb(70,130,180)"' not in bar_svg + + mesh_svg = ( + Figure() + .triangle_mesh( + [0.0], + [0.0], + [1.0], + [0.0], + [0.5], + [1.0], + color="steelblue", + stroke="black", + stroke_width=2.0, + ) + .to_svg() + ) + assert 'stroke="rgb(0,0,0)"' in mesh_svg + + +def test_segment_constant_translucent_color_applies_alpha_once() -> None: + """A translucent constant segment color must not appear verbatim in + stroke= while its alpha also feeds stroke-opacity (double application).""" + svg = Figure().segments([0.2], [0.2], [0.8], [0.8], color="rgba(255,0,0,0.5)").to_svg() + data_lines = [line for line in re.findall(r"]*/>", svg) if "255,0,0" in line] + assert data_lines, "segment line missing from SVG" + (line,) = data_lines + assert 'stroke="rgb(255,0,0)"' in line + assert 'stroke-opacity="0.5"' in line + + opaque = Figure().segments([0.2], [0.2], [0.8], [0.8], color="red").to_svg() + opaque_lines = [ + entry for entry in re.findall(r"]*/>", opaque) if 'stroke="red"' in entry + ] + assert opaque_lines, "opaque constant color should pass through verbatim"