Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ in the README).
## [Unreleased]

### Added
- Mark `style=` accepts **`stroke-linecap`** (`butt`/`round`/`square`) on the
line family, with the standard SVG semantics: it shapes the polyline's two
ends and each dash end. Only the line family takes it, because it describes
stroked open-path geometry. XY's default is `round`, not CSS's `butt`, so
existing specs are byte-identical. `stroke-linejoin` is deliberately not
offered yet — the SVG and native-raster writers implement it but the WebGL
client has no join geometry, and a property two renderers honor and one
ignores is exactly what the mark style subset exists to prevent.
- Mark `style=` accepts **`marker-shape`** on `scatter`, the CSS spelling of the
existing `symbol=` argument. Both resolve to the same trace-style value, so
the two spellings build identical specs.
- `colormap=` accepts a **custom ramp** built from your own colors, not only one
of the twenty built-in names: a sequence of 2–256 CSS colors, `(position,
color)` pairs, or a CSS `linear-gradient(...)`. Every form resolves once, in
Expand Down Expand Up @@ -37,6 +48,13 @@ in the README).
that don't use them are byte-identical.

### Fixed
- The three mark renderers disagreed about line caps and never said so: the
native rasterizer capped round, the WebGL client capped butt with a
half-pixel bleed, and the SVG writer hardcoded `round` on line paths while
the area outline silently inherited SVG's `butt` — which the PDF exporter
then read back as `butt` too. All three now draw XY's documented `round`
default, and the SVG writer names both cap and join on every stroked path
instead of letting the format's defaults decide.
- The colorbar stringified its colormap, so a custom ramp reached it as an
unparseable name and silently painted viridis while the marks beside it
painted the ramp correctly.
Expand Down
14 changes: 13 additions & 1 deletion docs/styling/customize.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,19 @@ def customize_mark_paint_preview():
Use `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, and
`opacity` only on mark families that support them. Lines use stroke properties;
areas, points, bars, and columns also support fill properties. Bar-like marks
add `border-radius`, while line-like marks add `stroke-dasharray`.
add `border-radius`; line-like marks add `stroke-dasharray` and
`stroke-linecap`; and `scatter` adds `marker-shape`.

`stroke-linecap` (`butt`, `round`, `square`) carries its standard SVG meaning,
and XY defaults it to `round` rather than to the CSS initial value — the native
rasterizer has always drawn round caps and it is the reference for static
export. `marker-shape` is the CSS spelling of `symbol=` and takes any of the 17
built-in marker names.

~~~python
xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"})
xy.scatter(x, y, size=12, style={"marker-shape": "diamond"})
~~~

## Axes, grid, and ticks

Expand Down
38 changes: 36 additions & 2 deletions docs/styling/mark-styles.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ renderer cannot silently ignore a declaration that another honors.

| Mark family | Supported properties in `style=` |
| --- | --- |
| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` |
| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `stroke-linecap`, `opacity` |
| `area`, `error_band` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; `area` also supports `stroke-dasharray` |
| `scatter` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` |
| `scatter` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `marker-shape`, `opacity` |
| `histogram`, `bar`, `column` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `border-radius`, `opacity` |
| `segments`, `errorbar`, `contour`, `stem` | `stroke`, `stroke-width`, `stroke-opacity`, `opacity` |
| `box`, `violin` | `fill`, `fill-opacity`, `opacity` |
Expand Down Expand Up @@ -58,6 +58,40 @@ surfaces set the same rendered property. Inside `style`, use `stroke` for
line-like geometry and `fill` for filled geometry; `color` is deliberately not
a CSS paint alias there.

## Stroke geometry: line caps

`stroke-linecap` (`butt`, `round`, `square`) shapes the two ends of a polyline
and each dash end. It is polyline geometry, so only the line family accepts it —
a `bar` or a `scatter` raises rather than accepting a declaration no renderer
can draw.

XY defaults to `round`, **not** to the CSS initial value `butt`: the native
rasterizer has always drawn round caps and it is the reference for static
export. Set the property to opt into the CSS initial value.

~~~python
xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"})
~~~

`stroke-linejoin` is **not** available yet. The SVG and native-raster writers
both implement it, but the browser client draws polylines as one quad per
segment with no join geometry, so honoring it in two renderers out of three
would break the rule that every accepted declaration is drawn everywhere. See
the capability matrix for the current state of that row.

## Marker shape

`marker-shape` picks one of the 17 renderer-backed scatter symbols — `circle`,
`square`, `diamond`, `triangle`, `cross`, `hexagon`, `pentagon`, `star`,
`triangle_down`, `triangle_left`, `triangle_right`, `x`, `point`, `pixel`,
`thin_diamond`, `plus_line`, `x_line` — and is the CSS spelling of the existing
`symbol=` argument. It is an XY vocabulary name rather than a standard CSS
property: CSS has no shape keyword for a non-DOM point mark.

~~~python
xy.scatter(x, y, size=12, style={"marker-shape": "diamond", "fill": "#22c55e"})
~~~

## Combine mark styles

This example combines the main paint paths in one chart: a gradient area, a
Expand Down
56 changes: 45 additions & 11 deletions js/src/40_gl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,16 +469,21 @@ void main() {
outColor = vec4(rgb * u_opacity, u_opacity);
}`;

// Polylines: one instanced quad per segment. `u_cap` is the compiled
// stroke-linecap value (see LINE_CAP_MODES); XY defaults it to round, which is
// what the native rasterizer's clamped segment distance field draws
// (src/raster.rs), so all three renderers agree.
export const LINE_VS = `#version 300 es
in float ax0; in float ay0; in float ax1; in float ay1;
in float a_prevx; in float a_prevy; in float a_prevx1; in float a_prevy1;
uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width;
uniform int u_colorMode;
uniform int u_cap; uniform int u_capSegments;
uniform float u_transitionProgress; uniform int u_transitionActive;
uniform float u_revealProgress; uniform float u_revealSegments;
uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant;
in float a_len0; in float a_len1;
out float v_off; out float v_dash;
out float v_off; out float v_dash; out vec2 v_cap;
const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.));
${AXIS_GLSL}
void main() {
Expand All @@ -498,45 +503,74 @@ void main() {
vec2 n = vec2(-dir.y, dir.x);
vec2 c = corners[gl_VertexID];
float half_w = u_width * 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;
// Along-segment coordinate in px. Interior joints keep the 0.5px overlap that
// hides the seam between consecutive quads; the polyline's two outer ends
// instead grow by half_w when the cap reaches past them (round semicircle,
// square extension), giving LINE_FS room to shape it.
bool first = gl_InstanceID == 0;
bool last = gl_InstanceID == u_capSegments - 1;
float capExt = u_cap == 0 ? 0.5 : half_w;
float t = mix(first ? -capExt : -0.5, len + (last ? capExt : 0.5), c.x);
vec2 pos = pix0 + dir * t + 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;
// Signed px overrun past the polyline's start/end, far negative where that
// end is an interior joint instead; LINE_FS takes the max of the two.
v_cap = vec2(first ? -t : -1e4, last ? t - len : -1e4);
// Cumulative screen-space arc length at this fragment (device px), fed from
// CPU-computed per-vertex lengths so dashes stay continuous across segments
// and constant on screen through zoom.
v_dash = mix(a_len0, mix(a_len0, a_len1, reveal), c.x);
// and constant on screen through zoom. Driven off t rather than c.x so the
// overhang carries the pattern on at 1px of arc per px of screen instead of
// stretching the segment's slice of it over the widened quad.
float dashEnd = mix(a_len0, a_len1, reveal);
v_dash = a_len0 + t * (len > 1e-3 ? (dashEnd - a_len0) / len : 1.0);
}`;

export const LINE_FS = `#version 300 es
precision highp float; precision highp int;
uniform vec4 u_color; uniform float u_width;
uniform vec4 u_color; uniform float u_width; uniform int u_cap;
uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod;
in float v_off; in float v_dash;
in float v_off; in float v_dash; in vec2 v_cap;
out vec4 outColor;
void main() {
float half_w = u_width * 0.5;
float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a;
// How far past the nearest end of painted stroke this fragment lies, along
// the path. Two kinds of end contribute and the cap shapes both: the
// polyline's own ends (v_cap) and, when dashed, the ends of the dash run —
// the distance to the nearer dash boundary is exactly the overrun a cap has
// to bridge, so the greater of the two is the one that governs.
float axial = max(v_cap.x, v_cap.y);
if (u_dashCount > 0) {
float m = mod(v_dash, u_dashPeriod);
float acc = 0.0;
float on = 0.0;
float sd = 0.0;
for (int i = 0; i < 8; i++) {
if (i >= u_dashCount) break;
float next = acc + u_dashArr[i];
if (m < next) {
// 0.6px feather at each dash start/end so edges aren't aliased.
float d = min(m - acc, next - m);
on = (i % 2 == 0) ? clamp(d + 0.6, 0.0, 1.0) : 1.0 - clamp(d + 0.6, 0.0, 1.0);
sd = (i % 2 == 0) ? d : -d; // + inside an "on" run, - inside a gap
break;
}
acc = next;
}
alpha *= on;
axial = max(axial, -sd);
}
// round = semicircle of radius half_w about the end, so the distance field
// picks up the overrun; butt = flush; square = flush pushed out by half_w.
float radial = u_cap == 1 ? length(vec2(max(axial, 0.0), v_off)) : abs(v_off);
float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, radial)) * u_color.a;
if (u_cap == 0) alpha *= 1.0 - smoothstep(-0.5, 0.5, axial);
else if (u_cap == 2) alpha *= 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, axial);
if (alpha <= 0.001) discard;
outColor = vec4(u_color.rgb * alpha, alpha);
}`;

// Wire spellings of stroke-linecap → the u_cap int LINE_FS switches on. A
// trace omits the key at XY's default (round), so an unset style must resolve
// to `round`, not to the CSS initial value `butt`.
export const LINE_CAP_MODES = { butt: 0, round: 1, square: 2 };

// Segment marks (errorbar/stem/box whiskers/contour isolines): independent
// endpoint pairs with per-column axis metas and an optional per-segment LUT
// color. A separate program keeps the polyline path (LINE_VS/LINE_FS) free of
Expand Down
10 changes: 8 additions & 2 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { PROTOCOL, xyByteSpan } from "./00_header";
import { buildLutData, colormapKey, colormapStops } from "./10_colormaps";
import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme";
import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks";
import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl";
import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl";
import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod";
import { markOf } from "./55_marks";

Expand Down Expand Up @@ -4031,7 +4031,12 @@ export class ChartView {
const reveal = Math.max(0, Math.min(1, g._transitionReveal ?? 1));
gl.uniform1f(u("u_revealProgress"), reveal);
gl.uniform1f(u("u_revealSegments"), g.n - 1);
gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr);
const lineWidth = (width ?? g.trace.style.width ?? 1.5) * this.dpr;
gl.uniform1f(u("u_width"), lineWidth);
// Absent cap/join keys mean XY's default, which is round for both — the
// trace only carries them when they differ from it (marks._stroke_geometry).
const cap = LINE_CAP_MODES[g.trace.style.linecap] ?? LINE_CAP_MODES.round;
gl.uniform1i(u("u_cap"), cap);
const [r, gg, b, a] = color || g.color;
const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1) * (g._transitionOpacity ?? 1) * (g._legendDim ?? 1);
gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity);
Expand Down Expand Up @@ -4060,6 +4065,7 @@ export class ChartView {
}
);
const segments = Math.max(0, Math.min(g.n - 1, Math.ceil((g.n - 1) * reveal)));
gl.uniform1i(u("u_capSegments"), segments);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, segments);
}

Expand Down
2 changes: 1 addition & 1 deletion python/xy/_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM

ABI_VERSION = 41
ABI_VERSION = 42

# Rust reports invalid arguments (and, via the ffi_guard panic shield, any
# internal panic) by returning `usize::MAX` from size-returning entry points.
Expand Down
19 changes: 17 additions & 2 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
# (right-margin titles, matplotlib rotation=270).
_TEXT_ROT_CCW = 0x80
_TEXT_ROT_CW = 0x40
# stroke-linecap / stroke-linejoin — must match CAP_*/JOIN_* in src/raster.rs.
# XY's default is round for both, which is the geometry the rasterizer's
# capsule distance field has always drawn.
_CAP_CODES = {"butt": 0, "round": 1, "square": 2}
_JOIN_CODES = {"miter": 0, "round": 1, "bevel": 2}
_SYMBOLS = {
"circle": 0,
"square": 1,
Expand Down Expand Up @@ -223,6 +228,8 @@ def stroke(
color: tuple[int, ...],
closed: bool = False,
dash: Sequence[float] | None = None,
cap: str = "round",
join: str = "round",
) -> None:
if len(pts) < 2 or width <= 0:
return
Expand All @@ -242,6 +249,8 @@ def stroke(
self._u32(len(dash))
for d in dash:
self._f(d)
self.buf.append(_CAP_CODES[cap])
self.buf.append(_JOIN_CODES[join])

def point(
self,
Expand Down Expand Up @@ -489,6 +498,8 @@ def smooth_stroke(
width: float,
color: tuple[int, ...],
dash: Sequence[float] | None = None,
cap: str = "round",
join: str = "round",
) -> None:
"""Native monotone-Hermite flattening + stroke for affine axes."""
n = len(xv)
Expand All @@ -515,6 +526,8 @@ def smooth_stroke(
self._u32(len(dash))
for value in dash:
self._f(value)
self.buf.append(_CAP_CODES[cap])
self.buf.append(_JOIN_CODES[join])

def image(
self,
Expand Down Expand Up @@ -1052,11 +1065,13 @@ def _emit_line(
xv, yv = _step_arrays(xv, yv, style["step"])
c = _rgba(style.get("color"), color, _stroke_opacity(style))
width = float(style.get("width", 1.5))
cap = str(style.get("linecap", "round"))
join = str(style.get("linejoin", "round"))
if style.get("curve") == "smooth" and len(xv) >= 3 and sx.affine and sy.affine:
cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"))
cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap, join=join)
else:
pts = _scene.curve_points(xv, yv, sx, sy, False)
cmd.stroke(pts, width, c, dash=style.get("dash"))
cmd.stroke(pts, width, c, dash=style.get("dash"), cap=cap, join=join)


def _annotation_point(
Expand Down
24 changes: 22 additions & 2 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,22 @@ def _star_path(cx: float, cy: float, r: float, points: int, inner: float, start_
return f'<path d="{d} Z"'


def _cap_join_attrs(style: dict[str, Any], *, join: bool = True) -> str:
"""Polyline stroke geometry, always written out rather than inherited.

SVG's initial values are `butt`/`miter`; XY's are `round`/`round`, and the
trace only carries a key when it differs (`marks._stroke_geometry`). Naming
both attributes on every stroked path is what keeps the SVG agreeing with
the native rasterizer — and with `_pdf`, which reads them straight back out
of this markup and would otherwise fall through to SVG's defaults.
"""
cap = style.get("linecap", "round")
attrs = f' stroke-linecap="{escape(str(cap))}"'
if join:
attrs = f' stroke-linejoin="{escape(str(style.get("linejoin", "round")))}"' + attrs
return attrs


def _dash_attr(style: dict[str, Any]) -> str:
dash = style.get("dash")
if not dash:
Expand Down Expand Up @@ -1591,7 +1607,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
op = _stroke_opacity(style)
return (
f'stroke="{escape(color)}" stroke-width="{_num(w)}" fill="none" '
f'stroke-linejoin="round" stroke-linecap="round"'
+ _cap_join_attrs(style)
+ (f' stroke-opacity="{_num(op)}"' if op < 1 else "")
+ _dash_attr(style)
)
Expand Down Expand Up @@ -1640,7 +1656,11 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
outline_path = joined if style.get("stroke_perimeter") else top_path
marks.append(
f'<path d="{outline_path}" stroke="{escape(line_color)}" stroke-width="{_num(lw)}" '
f'fill="none" stroke-linejoin="round"'
f'fill="none"'
# The area outline named its join but inherited SVG's `butt`
# cap, while the native rasterizer capped it round. Naming
# both settles that on the rasterizer's answer.
+ _cap_join_attrs(style)
+ (f' stroke-opacity="{_num(lop)}"' if lop < 1 else "")
+ _dash_attr(style)
+ "/>"
Expand Down
Loading
Loading