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"