Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/charts/scatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ or measures without changing the x/y relationship.
uses `colormap` and optional `color_domain`; categorical color creates a stable
palette. `size_range` maps numeric size values into pixel diameters.

Markers support 17 renderer-backed symbols, from `circle`, `square`, and
Markers support 19 renderer-backed symbols, from `circle`, `square`, and
directional triangles through `star`, `hexagon`, pixel/point, and line-only
glyphs, plus `stroke` and `stroke_width` for crisp borders. The complete list
is in [Customize Each Part](/docs/xy/styling/customize/#fill,-stroke,-opacity,-and-gradients).
Expand Down
16 changes: 9 additions & 7 deletions docs/styling/mark-styles.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,13 @@ Joins are always round and are not selectable.

## Marker shape

`marker-shape` picks one of the 17 renderer-backed scatter symbols — `circle`,
`marker-shape` picks one of the 19 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.
`thin_diamond`, `plus_line`, `x_line`, `horizontal_line`, `vertical_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"})
Expand Down Expand Up @@ -210,11 +211,12 @@ declarations:
Use `{"gradient": "...", "space": "plot"}` for one plot-space gradient.
- `corner_radius=(tip, base)` rounds value and baseline ends independently for
bars, columns, and histograms.
- Scatter `symbol` accepts all 17 renderer-backed shapes: `circle`, `square`,
- Scatter `symbol` accepts all 19 renderer-backed shapes: `circle`, `square`,
`diamond`, `triangle`, `triangle_down`, `triangle_left`, `triangle_right`,
`cross`, `x`, `hexagon`, `pentagon`, `star`, `point`, `pixel`,
`thin_diamond`, `plus_line`, and `x_line`. Every shape combines with
`stroke` / `stroke_width`; the last two are intentionally line-only glyphs.
`thin_diamond`, `plus_line`, `x_line`, `horizontal_line`, and `vertical_line`.
Every shape combines with `stroke` / `stroke_width`; the last four are
intentionally line-only glyphs.
- Box plots expose `whisker_style`, `median_style`, and `outlier_style` for
their compound parts; the main `style` mapping controls the box body.

Expand Down
5 changes: 3 additions & 2 deletions js/src/00_header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@
// stop array, misses, and paints viridis without erroring.
// v8: legend/colorbar geometry, extra colormap names, and match-fill strokes
// add wire values an older v7 client would accept but silently misrender.
// v9: scalar-normalization scale, colorbar padding/explicit-axes placement,
// and contour-line overlays. A v8 client silently misrenders these values.
// v9: explicit minor axis ticks/styles, log nonpositive behavior,
// scalar-normalization scale, colorbar padding/explicit-axes placement, and
// contour-line overlays. A v8 client silently misrenders these values.
export const PROTOCOL = 9;

// HTTP binary frame v1 (spec/design/wire-protocol.md §7; Python side in
Expand Down
18 changes: 13 additions & 5 deletions js/src/40_gl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ float xyDecode(float encoded, vec2 meta) {
float xyAxisCoord(float encoded, vec2 meta, int mode, float constant) {
float value = xyDecode(encoded, meta);
if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30;
if (mode == 3) return value > 0.0 ? log(value) / log(10.0) : uintBitsToFloat(0x7fc00000u);
if (mode == 2) return sign(value) * log(1.0 + abs(value) / constant);
return value;
}
Expand All @@ -92,11 +93,12 @@ float xyMap(float encoded, vec2 map, vec2 meta, int mode, float constant) {
}
float xyViewCoord(float value, int mode, float constant) {
if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30;
if (mode == 3) return value > 0.0 ? log(value) / log(10.0) : uintBitsToFloat(0x7fc00000u);
if (mode == 2) return sign(value) * log(1.0 + abs(value) / constant);
return value;
}
float xyViewValue(float coord, int mode, float constant) {
if (mode == 1) return pow(10.0, coord);
if (mode == 1 || mode == 3) return pow(10.0, coord);
if (mode == 2) return sign(coord) * constant * (exp(abs(coord)) - 1.0);
return coord;
}
Expand Down Expand Up @@ -233,13 +235,19 @@ void main() {
vec2 d = gl_PointCoord - 0.5;
float sd;
int symbol = v_style.w >= 0.0 ? int(v_style.w + 0.5) : u_symbol;
bool lineMarker = symbol == 15 || symbol == 16;
bool lineMarker = symbol == 15 || symbol == 16 || symbol == 17 || symbol == 18;
if (lineMarker) {
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));
if (symbol == 17) {
sd = max(abs(d.x) - 0.5, abs(d.y) - halfWidth);
} else if (symbol == 18) {
sd = max(abs(d.y) - 0.5, abs(d.x) - halfWidth);
} else {
vec2 q = symbol == 16 ? vec2(d.x + d.y, d.y - d.x) * 0.707106781 : d;
vec2 a = abs(q);
sd = min(max(a.x - 0.5, a.y - halfWidth), max(a.y - 0.5, a.x - halfWidth));
}
} else {
// Scalar-only equivalent: xyMarkerSdf(d, u_symbol). The resolved symbol
// also permits a per-item glyph override from v_style.w.
Expand Down
80 changes: 76 additions & 4 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,8 +631,10 @@ export class ChartView {
}

_axisMode(axisId) {
const scale = this._axis(axisId).scale;
return scale === "log" ? 1 : scale === "symlog" ? 2 : 0;
const axis = this._axis(axisId);
const scale = axis.scale;
return scale === "log" ? (axis.nonpositive === "mask" ? 3 : 1)
: scale === "symlog" ? 2 : 0;
}

_axisConstant(axisId) {
Expand Down Expand Up @@ -730,7 +732,10 @@ export class ChartView {
_axisCoord(axis, value) {
const v = Number(value);
if (!Number.isFinite(v)) return NaN;
if (axis && axis.scale === "log") return v > 0 ? Math.log10(v) : NaN;
if (axis && axis.scale === "log") {
if (v > 0) return Math.log10(v);
return axis.nonpositive === "mask" ? NaN : -300;
}
if (axis && axis.scale === "symlog") {
const c = Number(axis.constant) || 1;
return Math.sign(v) * Math.log1p(Math.abs(v) / c);
Expand Down Expand Up @@ -2108,6 +2113,7 @@ export class ChartView {
triangle: "M9 2l-5 10h10z", triangle_down: "M9 12L4 2h10z",
triangle_left: "M4 7L14 2v10z", triangle_right: "M14 7L4 2v10z",
plus_line: "M9 2v10M4 7h10", x_line: "M5 3l8 8M13 3l-8 8",
horizontal_line: "M4 7h10", vertical_line: "M9 2v10",
cross: "M7.5 2h3v3.5H14v3h-3.5V12h-3V8.5H4v-3h3.5z",
x: "M5.5 2L9 5.5 12.5 2 14 3.5 10.5 7 14 10.5 12.5 12 9 8.5 5.5 12 4 10.5 7.5 7 4 3.5z",
pentagon: "M9 2.5L13.28 5.61 11.65 10.64H6.35L4.72 5.61z",
Expand Down Expand Up @@ -3142,7 +3148,7 @@ export class ChartView {
_pointMarkStyle(g, t) {
const s = t.style || {};
g.authoredMarker = s.marker_path || s.marker_glyph || null;
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.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, horizontal_line: 17, vertical_line: 18 }[s.symbol] || 0;
g.pointStrokeWidth = Number(s.stroke_width) || 0;
g.pointStrokeFace = !s.stroke && (!t.stroke || t.stroke.mode === "match_fill");
g.pointStroke = s.stroke
Expand Down Expand Up @@ -5194,9 +5200,49 @@ export class ChartView {
this._axisTickTarget("x", Math.max(3, p.w / (xAxis.kind === "time" ? 90 : 80))),
);
const yt = this._axisTicks("y", this._axisTickTarget("y", Math.max(3, p.h / 45)));
const minorTicks = (axis, axisId) => {
if (!Array.isArray(axis.minor_tick_values)) return [];
const [lo, hi] = this._axisRange(axisId);
const a = Math.min(lo, hi), b = Math.max(lo, hi);
return axis.minor_tick_values.map(Number)
.filter((v) => Number.isFinite(v) && v >= a && v <= b);
};
const xmt = minorTicks(xAxis, "x");
const ymt = minorTicks(yAxis, "y");
const minorAxis = (axis) => ({ ...axis, style: axis.minor_style || {} });
const xmAxis = minorAxis(xAxis);
const ymAxis = minorAxis(yAxis);
const xEdge = (px) => Math.min(p.x + p.w - 0.5, Math.max(p.x + 0.5, Math.round(px) + 0.5));
const yEdge = (py) => Math.min(p.y + p.h - 0.5, Math.max(p.y + 0.5, Math.round(py) + 0.5));

ctx.strokeStyle = this._axisStylePaint(xmAxis, "grid_color", "transparent");
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xmAxis, "grid_width", 1));
ctx.globalAlpha = this._axisStyleNumber(xmAxis, "grid_opacity", 1);
ctx.setLineDash(this._axisGridDash(xmAxis));
ctx.beginPath();
for (const v of (hideX ? [] : xmt)) {
const px = this._dataPx("x", v);
if (!Number.isFinite(px)) continue;
const x = xEdge(px);
ctx.moveTo(x, p.y);
ctx.lineTo(x, p.y + p.h);
}
ctx.stroke();

ctx.strokeStyle = this._axisStylePaint(ymAxis, "grid_color", "transparent");
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(ymAxis, "grid_width", 1));
ctx.globalAlpha = this._axisStyleNumber(ymAxis, "grid_opacity", 1);
ctx.setLineDash(this._axisGridDash(ymAxis));
ctx.beginPath();
for (const v of (hideY ? [] : ymt)) {
const py = this._dataPx("y", v);
if (!Number.isFinite(py)) continue;
const y = yEdge(py);
ctx.moveTo(p.x, y);
ctx.lineTo(p.x + p.w, y);
}
ctx.stroke();

ctx.strokeStyle = this._axisStylePaint(xAxis, "grid_color", this.theme.grid);
ctx.lineWidth = Math.max(0.5, this._axisStyleNumber(xAxis, "grid_width", 1));
ctx.globalAlpha = this._axisStyleNumber(xAxis, "grid_opacity", 1);
Expand Down Expand Up @@ -5283,6 +5329,19 @@ export class ChartView {
}

if (!hideX) {
const minorTick = tickParts(xmAxis);
const minorSide = xAxis.side || "bottom";
const minorEdge = minorSide === "top" ? p.y : p.y + p.h;
for (const value of xmt) {
const x = this._dataPx("x", value);
if (!Number.isFinite(x) || x < p.x - 1 || x > p.x + p.w + 1) continue;
const top = minorSide === "top"
? minorEdge - minorTick.outward : minorEdge - minorTick.inward;
rule(
xmAxis, x - minorTick.width / 2, top, minorTick.width,
minorTick.inward + minorTick.outward, "tick_color",
);
}
const tick = tickParts(xAxis);
const side = xAxis.side || "bottom";
const edge = side === "top" ? p.y : p.y + p.h;
Expand All @@ -5294,6 +5353,19 @@ export class ChartView {
}
}
if (!hideY) {
const minorTick = tickParts(ymAxis);
const minorSide = yAxis.side || "left";
const minorEdge = minorSide === "right" ? p.x + p.w : p.x;
for (const value of ymt) {
const y = this._dataPx("y", value);
if (!Number.isFinite(y) || y < p.y - 1 || y > p.y + p.h + 1) continue;
const left = minorSide === "right"
? minorEdge - minorTick.inward : minorEdge - minorTick.outward;
rule(
ymAxis, left, y - minorTick.width / 2,
minorTick.inward + minorTick.outward, minorTick.width, "tick_color",
);
}
const tick = tickParts(yAxis);
const side = yAxis.side || "left";
const edge = side === "right" ? p.x + p.w : p.x;
Expand Down
22 changes: 22 additions & 0 deletions python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,16 @@ def set_axis(
format: Optional[str] = None,
tick_count: Optional[int] = None,
tick_values: Optional[Any] = None,
minor_tick_values: Optional[Any] = None,
tick_labels: Optional[Any] = None,
tick_label_angle: Optional[float] = None,
tick_label_strategy: Optional[str] = None,
tick_label_anchor: Optional[str] = None,
tick_label_min_gap: Optional[float] = None,
side: Optional[str] = None,
style: Optional[dict[str, Any]] = None,
minor_style: Optional[dict[str, Any]] = None,
nonpositive: Optional[str] = None,
) -> "Figure":
axis_id = self._axis_id(axis_id, "axis id")
axis_dim = self._axis_dim(axis_id)
Expand Down Expand Up @@ -289,6 +292,16 @@ def set_axis(
if tick_values is None
else [self._finite_scalar(value, f"{axis_id} tick value") for value in tick_values]
)
minor_values = (
None
if minor_tick_values is None
else [
self._finite_scalar(value, f"{axis_id} minor tick value")
for value in minor_tick_values
]
)
if nonpositive is not None and (type_ != "log" or nonpositive not in {"clip", "mask"}):
raise ValueError(f"{axis_id} axis nonpositive must be 'clip' or 'mask' on a log axis")
labels = None if tick_labels is None else [str(value) for value in tick_labels]
if labels is not None and (values is None or len(labels) != len(values)):
raise ValueError(f"{axis_id} tick_labels must match tick_values")
Expand All @@ -310,6 +323,7 @@ def set_axis(
"format": self._optional_text(format, f"{axis_id} axis format"),
"tick_count": self._optional_positive_int(tick_count, f"{axis_id} axis tick_count"),
"tick_values": values,
"minor_tick_values": minor_values,
"tick_labels": labels,
"tick_label_angle": self._optional_finite_scalar(
tick_label_angle, f"{axis_id} axis tick_label_angle"
Expand All @@ -325,6 +339,8 @@ def set_axis(
else self._nonnegative_scalar(tick_label_min_gap, f"{axis_id} axis tick_label_min_gap"),
"side": side,
"style": styles.compile_axis_style(style, f"{axis_id} axis style"),
"minor_style": styles.compile_axis_style(minor_style, f"{axis_id} minor axis style"),
"nonpositive": nonpositive,
}
if axis_id == "x":
self.x_label = self.axis_options[axis_id]["label"]
Expand Down Expand Up @@ -1267,6 +1283,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any
spec["tick_count"] = tick_count
if opts.get("tick_values") is not None:
spec["tick_values"] = list(opts["tick_values"])
if opts.get("minor_tick_values") is not None:
spec["minor_tick_values"] = list(opts["minor_tick_values"])
if opts.get("tick_labels") is not None:
spec["tick_labels"] = list(opts["tick_labels"])
if tick_label_angle is not None:
Expand All @@ -1282,6 +1300,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any
spec["scale"] = scale
if scale == "symlog":
spec["constant"] = opts.get("constant") or 1.0
if scale == "log" and opts.get("nonpositive") is not None:
spec["nonpositive"] = opts["nonpositive"]
if opts.get("reverse"):
spec["reverse"] = True
if opts.get("domain") is not None:
Expand All @@ -1293,6 +1313,8 @@ def _axis_spec(self, axis_id: str, range_: tuple[float, float]) -> dict[str, Any
bounds = self._range(axis_id, use_domain=False)
if bounds is not None:
spec["bounds"] = sorted(bounds)
if opts.get("minor_style"):
spec["minor_style"] = dict(opts["minor_style"])
if opts.get("format") is not None:
spec["format"] = opts["format"]
style = styles.compile_axis_style(opts.get("style"), f"{axis_id} axis style")
Expand Down
Loading
Loading