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
12 changes: 7 additions & 5 deletions js/src/40_gl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ 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 float u_xconstant; uniform int u_ymode; uniform float u_yconstant;
uniform float u_size; uniform int u_sizeMode; uniform vec2 u_sizeRange;
uniform int u_colorMode; uniform float u_dpr; uniform int u_selActive;
uniform int u_colorMode; uniform int u_symbol; uniform float u_dpr; uniform int u_selActive;
uniform float u_selectedOpacity; uniform float u_unselectedOpacity;
uniform float u_transitionProgress; uniform int u_transitionActive;
out float v_lutCoord; out float v_dim; out float v_dval; out float v_ptSize; out float v_sel;
Expand All @@ -120,8 +120,10 @@ void main() {
float y = u_transitionActive == 1 ? mix(a_prevy, ay, u_transitionProgress) : ay;
gl_Position = vec4(xyMap(x, u_xmap, u_xmeta, u_xmode, u_xconstant), xyMap(y, u_ymap, u_ymeta, u_ymode, u_yconstant), 0.0, 1.0);
float sz = u_sizeMode == 1 ? mix(u_sizeRange.x, u_sizeRange.y, a_sval) : u_size;
gl_PointSize = sz * u_dpr;
v_ptSize = sz * u_dpr;
int symbol = a_style.w >= 0.0 ? int(a_style.w + 0.5) : u_symbol;
float symbolScale = symbol == 2 || symbol == 14 ? 1.414213562 : 1.0;
gl_PointSize = sz * u_dpr * symbolScale;
v_ptSize = sz * u_dpr * symbolScale;
v_sel = a_sel;
v_rgba = a_rgba;
v_style = a_style;
Expand Down Expand Up @@ -202,8 +204,8 @@ float xyMarkerSdf(vec2 d, int shape) {
if (shape == 3 || shape == 8 || shape == 9 || shape == 10) { // Matplotlib triangle path
vec2 q = d;
if (shape == 8) q = -d;
if (shape == 9) q = vec2(d.y, -d.x);
if (shape == 10) q = vec2(-d.y, d.x);
if (shape == 9) q = vec2(-d.y, d.x);
if (shape == 10) q = vec2(d.y, -d.x);
return xyTriangleDistance(q, vec2(0.0, -0.5), vec2(-0.5, 0.5), vec2(0.5, 0.5));
}
if (shape == 11) { // diagonal x
Expand Down
10 changes: 8 additions & 2 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,10 +970,16 @@ def _step_arrays(xv: np.ndarray, yv: np.ndarray, where: str) -> tuple[np.ndarray
f'<rect x="{_num(cx - r)}" y="{_num(cy - r)}" width="{_num(2 * r)}" height="{_num(2 * r)}"'
),
"diamond": lambda cx, cy, r: (
f'<path d="M {_num(cx)} {_num(cy - r)} L {_num(cx + r)} {_num(cy)} L {_num(cx)} {_num(cy + r)} L {_num(cx - r)} {_num(cy)} Z"'
f'<path d="M {_num(cx)} {_num(cy - 2**0.5 * r)} '
f"L {_num(cx + 2**0.5 * r)} {_num(cy)} "
f"L {_num(cx)} {_num(cy + 2**0.5 * r)} "
f'L {_num(cx - 2**0.5 * r)} {_num(cy)} Z"'
),
"thin_diamond": lambda cx, cy, r: (
f'<path d="M {_num(cx)} {_num(cy - r)} L {_num(cx + 0.6 * r)} {_num(cy)} L {_num(cx)} {_num(cy + r)} L {_num(cx - 0.6 * r)} {_num(cy)} Z"'
f'<path d="M {_num(cx)} {_num(cy - 2**0.5 * r)} '
f"L {_num(cx + 0.6 * 2**0.5 * r)} {_num(cy)} "
f"L {_num(cx)} {_num(cy + 2**0.5 * r)} "
f'L {_num(cx - 0.6 * 2**0.5 * r)} {_num(cy)} Z"'
),
"triangle": lambda cx, cy, r: (
f'<path d="M {_num(cx)} {_num(cy - r)} L {_num(cx + r)} {_num(cy + r)} L {_num(cx - r)} {_num(cy + r)} Z"'
Expand Down
91 changes: 68 additions & 23 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1506,13 +1506,16 @@ def hist(
``(counts, bin_edges, patches)`` as matplotlib does.
"""
color = kwargs.pop("color", None)
facecolor = kwargs.pop("facecolor", None)
fill = kwargs.pop("fill", None)
alpha = kwargs.pop("alpha", None)
label = kwargs.pop("label", None)
histtype = kwargs.pop("histtype", "bar")
weights = kwargs.pop("weights", None)
orientation = kwargs.pop("orientation", "vertical")
stacked = bool(kwargs.pop("stacked", False))
edgecolor = kwargs.pop("edgecolor", None)
linewidth = kwargs.pop("linewidth", kwargs.pop("lw", None))
if (
edgecolor is None
and histtype in ("bar", "barstacked")
Expand All @@ -1525,7 +1528,9 @@ def hist(
if histtype not in {"bar", "barstacked", "step", "stepfilled"}:
raise ValueError(f"unsupported histtype {histtype!r}")

if isinstance(x, np.ndarray) and x.ndim == 1 and x.dtype.kind in "fiub":
if np.isscalar(x):
datasets = [np.atleast_1d(np.asarray(x, dtype=np.float64))]
elif isinstance(x, np.ndarray) and x.ndim == 1 and x.dtype.kind in "fiub":
# The common numeric-array call must not round-trip through an
# object array: boxing every element just to sniff the input shape
# is an O(n) cost per build (tests/pyplot/test_perf_guardrail.py).
Expand Down Expand Up @@ -1573,11 +1578,30 @@ def hist(
for values in counts
]
stacked = stacked or histtype == "barstacked"
colors = (
color
if isinstance(color, (list, tuple)) and len(datasets) > 1
else [color] * len(datasets)
)

def dataset_values(value: Any, name: str, *, color_value: bool = False) -> list[Any]:
if value is None:
return [None] * len(datasets)
if color_value:
try:
resolve_color(value)
except (TypeError, ValueError):
pass
else:
return [value] * len(datasets)
if np.isscalar(value):
return [value] * len(datasets)
values = list(value)
if len(values) != len(datasets):
raise ValueError(
f"hist {name} sequence must have length {len(datasets)}, got {len(values)}"
)
return values

colors = dataset_values(color, "color", color_value=True)
facecolors = dataset_values(facecolor, "facecolor", color_value=True)
edgecolors = dataset_values(edgecolor, "edgecolor", color_value=True)
linewidths = dataset_values(linewidth, "linewidth")
labels = label if isinstance(label, (list, tuple)) else [label] * len(datasets)
containers: list[BarContainer] = []
base = np.zeros(len(edges) - 1, dtype=np.float64)
Expand All @@ -1592,10 +1616,30 @@ def hist(
for index, values in enumerate(counts):
positions = centers if stacked else centers + (index - (len(datasets) - 1) / 2) * width
current_base = base.copy() if stacked else np.zeros_like(values)
resolved_color = (
series_color = (
resolve_color(colors[index]) if colors[index] is not None else self._next_color()
)
if orientation == "horizontal" and histtype == "stepfilled":
resolved_color = (
resolve_color(facecolors[index]) if facecolors[index] is not None else series_color
)
resolved_edge = (
resolve_color(edgecolors[index]) if edgecolors[index] is not None else None
)
resolved_width = (
float(linewidths[index])
if linewidths[index] is not None
else float(rcParams["patch.linewidth"]) * self._point_scale()
)
filled = histtype == "stepfilled" if fill is None else bool(fill)
if not filled and resolved_edge is None:
resolved_edge = (
series_color
if histtype == "step"
else resolve_color(rcParams["patch.edgecolor"])
)
elif filled and histtype == "step" and resolved_edge is None:
resolved_edge = series_color
if orientation == "horizontal" and histtype.startswith("step") and filled:
# The core area primitive fills along y. Horizontal filled
# steps are equivalently represented by touching horizontal
# bars, preserving the exact bins/counts without rotating a
Expand All @@ -1612,7 +1656,8 @@ def hist(
"color": resolved_color,
"opacity": 1.0 if alpha is None else float(alpha),
"name": None if labels[index] is None else str(labels[index]),
"stroke": resolve_color(edgecolor) if edgecolor is not None else None,
"stroke": resolved_edge,
"stroke_width": resolved_width,
},
},
)
Expand All @@ -1626,19 +1671,21 @@ def hist(
"factory": "segments",
"args": (path_x[:-1], path_y[:-1], path_x[1:], path_y[1:]),
"kwargs": {
"color": resolved_color,
"width": 1.2,
"color": resolved_edge or series_color,
"width": resolved_width,
"name": None if labels[index] is None else str(labels[index]),
"opacity": 1.0 if alpha is None else float(alpha),
},
},
)
elif histtype == "stepfilled":
elif histtype.startswith("step") and filled:
# matplotlib fills the step polygon down to the baseline; the
# area mark takes the pre-expanded step vertices verbatim.
tops = values + current_base
no_edge = edgecolor is None or (
isinstance(edgecolor, str) and edgecolor.lower() == "none"
no_edge = resolved_edge in (None, "transparent") or (
isinstance(resolved_edge, str)
and resolved_edge.startswith("rgba(")
and resolved_edge.endswith(",0)")
)
entry = self._add(
"@mark",
Expand All @@ -1648,12 +1695,8 @@ def hist(
"kwargs": {
"base": np.repeat(current_base, 2),
"color": resolved_color,
"line_color": None if no_edge else resolve_color(edgecolor),
"line_width": (
0.0
if no_edge
else float(rcParams["patch.linewidth"]) * self._point_scale()
),
"line_color": None if no_edge else resolved_edge,
"line_width": 0.0 if no_edge else resolved_width,
"line_opacity": 1.0 if alpha is None else float(alpha),
"stroke_perimeter": not no_edge,
"name": None if labels[index] is None else str(labels[index]),
Expand All @@ -1669,7 +1712,8 @@ def hist(
"factory": "stairs",
"args": (step_values, edges),
"kwargs": {
"color": resolved_color,
"color": resolved_edge or series_color,
"width": resolved_width,
"name": None if labels[index] is None else str(labels[index]),
"opacity": 1.0 if alpha is None else float(alpha),
},
Expand All @@ -1685,10 +1729,11 @@ def hist(
"base": current_base,
"width": width,
"orientation": orientation,
"color": resolved_color,
"color": "transparent" if fill is False else resolved_color,
"opacity": 1.0 if alpha is None else float(alpha),
"name": None if labels[index] is None else str(labels[index]),
"stroke": resolve_color(edgecolor) if edgecolor is not None else None,
"stroke": resolved_edge,
"stroke_width": resolved_width,
},
},
)
Expand Down
72 changes: 68 additions & 4 deletions python/xy/pyplot/_plot_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@ def _limit_error(error: Any, lower_limits: Any, upper_limits: Any, size: int) ->
return np.vstack((low, high))


def _error_sides(error: Any, size: int) -> tuple[np.ndarray, np.ndarray]:
"""Return broadcast lower and upper error magnitudes."""
raw = np.asarray(error, dtype=np.float64)
if raw.ndim >= 2 and raw.shape[0] == 2:
return (
np.broadcast_to(raw[0], (size,)),
np.broadcast_to(raw[1], (size,)),
)
values = np.broadcast_to(raw, (size,))
return values, values


def _plain_label(value: Any) -> str:
text = str(value).replace("$", "")
for source, target in {
Expand Down Expand Up @@ -2201,9 +2213,38 @@ def subset_limit(flag: Any) -> Any:

lolims, uplims = subset_limit(lolims), subset_limit(uplims)
xlolims, xuplims = subset_limit(xlolims), subset_limit(xuplims)
yerr = _limit_error(yerr, lolims, uplims, len(np.asarray(y)))
xerr = _limit_error(xerr, xlolims, xuplims, len(np.asarray(x)))
x_values = np.asarray(x)
y_values = np.asarray(y)
limit_markers: list[tuple[np.ndarray, np.ndarray, str]] = []
if yerr is not None:
lower, upper = _error_sides(yerr, len(y_values))
lower_flags = np.broadcast_to(np.asarray(lolims, dtype=bool), y_values.shape)
upper_flags = np.broadcast_to(np.asarray(uplims, dtype=bool), y_values.shape)
if lower_flags.any():
limit_markers.append(
(x_values[lower_flags], y_values[lower_flags] + upper[lower_flags], "^")
)
if upper_flags.any():
limit_markers.append(
(x_values[upper_flags], y_values[upper_flags] - lower[upper_flags], "v")
)
if xerr is not None:
lower, upper = _error_sides(xerr, len(x_values))
lower_flags = np.broadcast_to(np.asarray(xlolims, dtype=bool), x_values.shape)
upper_flags = np.broadcast_to(np.asarray(xuplims, dtype=bool), x_values.shape)
if lower_flags.any():
limit_markers.append(
(x_values[lower_flags] + upper[lower_flags], y_values[lower_flags], ">")
)
if upper_flags.any():
limit_markers.append(
(x_values[upper_flags] - lower[upper_flags], y_values[upper_flags], "<")
)
yerr = _limit_error(yerr, lolims, uplims, len(y_values))
xerr = _limit_error(xerr, xlolims, xuplims, len(x_values))
base = line_kwargs(kwargs)
marker = kwargs.pop("marker", None)
markersize = kwargs.pop("markersize", kwargs.pop("ms", None))
check_unsupported(kwargs, "errorbar()")
# When ecolor is omitted, the bars inherit the resolved data-series
# color, exactly as matplotlib does: an explicit color kwarg wins, then
Expand All @@ -2223,6 +2264,10 @@ def subset_limit(flag: Any) -> Any:
if line_color is None:
line_color = self._next_color()
color = line_color
resolved_capsize = float(rcParams["errorbar.capsize"] if capsize is None else capsize)
errorbar_width = float(
elinewidth if elinewidth is not None else base.get("width", rcParams["lines.linewidth"])
)
entry = self._add(
"@mark",
{
Expand All @@ -2233,12 +2278,23 @@ def subset_limit(flag: Any) -> Any:
"xerr": xerr,
"name": base.get("name"),
"color": color,
"width": float(elinewidth or base.get("width", 1.2)),
"cap_size": None if capsize is None else float(capsize),
"width": errorbar_width,
"cap_size": resolved_capsize,
"opacity": base.get("opacity", 1.0),
},
},
)
marker_area = float(max(float(rcParams["lines.markersize"]), 2.0 * resolved_capsize) ** 2)
for marker_x, marker_y, marker_symbol in limit_markers:
self.scatter(
marker_x,
marker_y,
s=marker_area,
c=color,
marker=marker_symbol,
edgecolors=color,
linewidths=0.0,
)
data_line: Optional[Line2D] = None
if fmt.lower() != "none":
line_kwargs_for_plot: dict[str, Any] = {}
Expand All @@ -2254,6 +2310,14 @@ def subset_limit(flag: Any) -> Any:
line_kwargs_for_plot["alpha"] = base["opacity"]
if "name" in base:
line_kwargs_for_plot["label"] = base["name"]
if "linestyle" in base:
line_kwargs_for_plot["linestyle"] = base["linestyle"]
if "dash" in base:
line_kwargs_for_plot["dashes"] = base["dash"]
if marker is not None:
line_kwargs_for_plot["marker"] = marker
if markersize is not None:
line_kwargs_for_plot["markersize"] = markersize
data_line = self.plot(x, y, fmt, **line_kwargs_for_plot)[0]
return ErrorbarContainer(Artist(self, entry), data_line)

Expand Down
1 change: 1 addition & 0 deletions python/xy/pyplot/_rc.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def by_key(self) -> dict[str, list[str]]:
"lines.linewidth": 1.5,
"lines.markersize": 6.0,
"lines.markeredgewidth": 1.0,
"errorbar.capsize": 0.0,
"patch.linewidth": 1.0,
"patch.edgecolor": "black",
"patch.force_edgecolor": False,
Expand Down
15 changes: 15 additions & 0 deletions spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,21 @@ antialiased SDF in the point shader, so shapes stay crisp at any size and the
border is a true ring (a stroke width with no color borders in the mark color).
Symbols compose with the color/size channels.

Glyph geometry follows Matplotlib's marker paths, size convention included.
`diamond` is the `square` glyph rotated 45°, so its half-diagonal is √2× the
glyph radius — the rotated square keeps `square`'s side length at the same
`size` rather than shrinking to fit the unrotated footprint, and `thin_diamond`
is that same diamond squashed to 0.6 width. `triangle_left` and
`triangle_right` rotate the shared triangle path so the apex points along the
named direction and the wide base sits opposite it. Each backend reaches that
geometry by its own route and lands on the same size convention: the WebGL
client scales the point sprite by √2 and leaves the unit-space SDF untouched,
the native rasterizer scales both the SDF threshold and the bounding-box extent
it paints into, and SVG emits the widened outline directly — so one `size`
value is one on-screen glyph across WebGL, PNG, and SVG. Charts that already
used these four symbols render at a corrected size or orientation for an
unchanged `size`; the set of available symbols does not change.

Interaction state belongs to the host framework. In Reflex, use Reflex state,
event handlers, conditions, and ordinary CSS classes/styles; XY only emits the
events and renders the resulting props. The component API deliberately does not
Expand Down
Loading
Loading