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
7 changes: 5 additions & 2 deletions benchmarks/test_codspeed_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -876,9 +876,12 @@ def test_first_payload_hexbin_core_2d(benchmark, medium_data):
payload_bytes = benchmark(_hexbin_payload, x, y)
grid_height = max(2, int(HEXBIN_GRIDSIZE / np.sqrt(3.0)))
max_cells = (HEXBIN_GRIDSIZE + 1) * (grid_height + 1) + HEXBIN_GRIDSIZE * grid_height
# Each cell is six triangles with six coordinate and one color f32 buffer.
max_payload_bytes = max_cells * 6 * 7 * np.dtype(np.float32).itemsize
# Each cell ships as a center (x, y) plus one color value; renderers
# expand the shared hexagon geometry locally, so the payload stays
# grid-bounded and far below the raw input.
max_payload_bytes = max_cells * 3 * np.dtype(np.float32).itemsize
assert 0 < payload_bytes <= max_payload_bytes
assert payload_bytes < x.nbytes + y.nbytes


def test_first_payload_errorbar_large(benchmark, data):
Expand Down
54 changes: 54 additions & 0 deletions js/src/50_chartview.js
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,60 @@ class ChartView {
g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]);
}

// Hexbin ships cell centers plus one color value per cell; every hexagon
// shares the same geometry (style hex_dx/hex_dy), so the six-triangle fan
// expands here instead of on the wire. Vertices stay in the centers'
// encoded space: stored = (value - offset) * scale, so a data-space delta
// scales by meta.scale and the center columns' metas serve every vertex.
// The ring must match HEX_RING in python/xy/_svg.py.
_buildHexbinMark(g, t, buffer) {
const cx = this._columnView(buffer, this.spec.columns[t.x]);
const cy = this._columnView(buffer, this.spec.columns[t.y]);
const xMeta = { ...this.spec.columns[t.x] };
const yMeta = { ...this.spec.columns[t.y] };
const n = Math.min(cx.length, cy.length);
const style = t.style || {};
const dx = (Number(style.hex_dx) || 0) * (xMeta.scale || 1);
const dy = (Number(style.hex_dy) || 0) * (yMeta.scale || 1);
const ringX = [0, dx / 2, dx / 2, 0, -dx / 2, -dx / 2, 0];
const ringY = [-dy / 3, -dy / 6, dy / 6, dy / 3, dy / 6, -dy / 6, -dy / 3];
const parts = {};
for (const name of ["x0", "x1", "x2", "y0", "y1", "y2"]) parts[name] = new Float32Array(n * 6);
for (let i = 0; i < n; i++) {
const px = cx[i], py = cy[i];
for (let k = 0; k < 6; k++) {
const j = i * 6 + k;
parts.x0[j] = px;
parts.y0[j] = py;
parts.x1[j] = px + ringX[k];
parts.y1[j] = py + ringY[k];
parts.x2[j] = px + ringX[k + 1];
parts.y2[j] = py + ringY[k + 1];
}
}
for (const name of ["x0", "x1", "x2"]) {
g[name + "Meta"] = { ...xMeta };
g[name + "Buf"] = this._upload(parts[name]);
}
for (const name of ["y0", "y1", "y2"]) {
g[name + "Meta"] = { ...yMeta };
g[name + "Buf"] = this._upload(parts[name]);
}
g.n = n * 6;
g.color = parseColor(this.root, t.color && t.color.color, [0.3, 0.47, 0.66, 1]);
g.colorMode = 0;
if (t.color && (t.color.mode === "continuous" || t.color.mode === "categorical")) {
g.colorMode = t.color.mode === "continuous" ? 1 : 2;
const cval = this._columnView(buffer, this.spec.columns[t.color.buf]);
const expanded = new Float32Array(n * 6);
for (let i = 0; i < n; i++) expanded.fill(cval[i], i * 6, i * 6 + 6);
g.cBuf = this._upload(expanded);
g.lut = t.color.mode === "continuous" ? this._lut(t.color.colormap) : this._paletteLut(t.color.palette);
}
g.meshStrokeWidth = Number(style.stroke_width) || 0;
g.meshStroke = parseColor(this.root, style.stroke || "transparent", [0, 0, 0, 0]);
}

_buildAreaMark(g, t, buffer) {
const x = this._columnView(buffer, this.spec.columns[t.x]);
const y = this._columnView(buffer, this.spec.columns[t.y]);
Expand Down
2 changes: 1 addition & 1 deletion js/src/55_marks.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ const MARK_KINDS = {
triangle_mesh: MESH_MARK,
error_band: AREA_MARK,
hexbin: {
build: (view, g, t, buffer) => view._buildMeshMark(g, t, buffer),
build: (view, g, t, buffer) => view._buildHexbinMark(g, t, buffer),
draw: (view, g) => {
const [x0, x1] = view._axisRange(g.xAxis);
const [y0, y1] = view._axisRange(g.yAxis);
Expand Down
42 changes: 8 additions & 34 deletions python/xy/_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,50 +414,24 @@ def _emit_hexbin(
sel = self._finite_sel(t, xv, yv)
if sel is not None:
xv, yv = xv[sel], yv[sel]
style = self._default_styled(t)
dx = float(style.pop("hex_dx"))
dy = float(style.pop("hex_dy"))
# Six center-fan triangles per occupied cell preserve a true data-space
# tessellation at every aspect ratio and in every renderer.
offsets = np.asarray(
[
(0.0, -dy / 3.0),
(dx / 2.0, -dy / 6.0),
(dx / 2.0, dy / 6.0),
(0.0, dy / 3.0),
(-dx / 2.0, dy / 6.0),
(-dx / 2.0, -dy / 6.0),
(0.0, -dy / 3.0),
],
dtype=np.float64,
)
cx = np.repeat(xv, 6)
cy = np.repeat(yv, 6)
x1 = (xv[:, None] + offsets[:-1, 0]).reshape(-1)
y1 = (yv[:, None] + offsets[:-1, 1]).reshape(-1)
x2 = (xv[:, None] + offsets[1:, 0]).reshape(-1)
y2 = (yv[:, None] + offsets[1:, 1]).reshape(-1)
# Cells ship as centers plus one scalar color value. Every hexagon
# shares the same geometry (style hex_dx/hex_dy), so each renderer
# expands the six-triangle fan locally and the wire cost stays
# O(cells), not O(cells x vertices x channels) (§29).
entry = {
"id": t.id,
"kind": t.kind,
"name": t.name,
"style": style,
"style": self._default_styled(t),
"tier": "direct",
"n_points": t.n_points,
"n_marks": int(len(xv)),
"x_axis": t.x_axis,
"y_axis": t.y_axis,
"x0": pw.ship_values(cx),
"y0": pw.ship_values(cy),
"x1": pw.ship_values(x1),
"y1": pw.ship_values(y1),
"x2": pw.ship_values(x2),
"y2": pw.ship_values(y2),
"x": pw.ship_values(xv),
"y": pw.ship_values(yv),
}
triangle_sel = np.repeat(
np.arange(len(t.x), dtype=np.intp) if sel is None else np.asarray(sel), 6
)
entry["color"], _size = self._ship_channels(t, triangle_sel, pw.ship_scalar, pw.ship_u8)
entry["color"], _size = self._ship_channels(t, sel, pw.ship_scalar, pw.ship_u8)
return entry

def _emit_histogram(
Expand Down
34 changes: 29 additions & 5 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
_step_arrays,
_tick_text,
axis_ticks,
hexbin_ring,
layout,
)

Expand Down Expand Up @@ -586,7 +587,7 @@ def render_raster(
elif kind == "scatter":
_emit_scatter(cmd, t, blob, cols, sx, sy, style, color)
elif kind == "hexbin":
_emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color)
_emit_hexbin(cmd, t, blob, cols, sx, sy, style, color)
elif kind in {"errorbar", "stem", "box_whisker", "box_median", "contour", "segments"}:
_emit_segments(cmd, t, blob, cols, sx, sy, style, color)
elif kind in ("bar", "column") and t.get("bar"):
Expand Down Expand Up @@ -1002,12 +1003,9 @@ def _emit_segments(cmd, t, blob, cols, sx, sy, style, color):
cmd.segments(sx(x0), sy(y0), sx(x1), sy(y1), width, colors)


def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color):
vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")]
n = min(len(values) for values in vertices)
def _mesh_fill_rgba(t, blob, cols, n, style, color):
ch = t.get("color") or {}
fill_op = _fill_opacity(style)
stroke_op = _stroke_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":
Expand All @@ -1019,6 +1017,32 @@ def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color):
fills[:, :3] = palette_rgb[codes % len(palette_rgb)]
else:
fills[:, :3] = _parse_color(_css(ch.get("color"), color))[:3]
return fills


def _emit_hexbin(cmd, t, blob, cols, sx, sy, style, color):
"""Expand shipped cell centers into the six-triangle hexagon fan locally
(the payload carries centers only — see _payload._emit_hexbin)."""
cx = _column(blob, cols[t["x"]])
cy = _column(blob, cols[t["y"]])
n = min(len(cx), len(cy))
ring_x, ring_y = hexbin_ring(style)
ring_x, ring_y = np.append(ring_x, ring_x[0]), np.append(ring_y, ring_y[0])
x0 = np.repeat(sx(cx[:n]), 6)
y0 = np.repeat(sy(cy[:n]), 6)
x1 = np.asarray(sx(cx[:n, None] + ring_x[None, :-1]), dtype=np.float64).reshape(-1)
y1 = np.asarray(sy(cy[:n, None] + ring_y[None, :-1]), dtype=np.float64).reshape(-1)
x2 = np.asarray(sx(cx[:n, None] + ring_x[None, 1:]), dtype=np.float64).reshape(-1)
y2 = np.asarray(sy(cy[:n, None] + ring_y[None, 1:]), dtype=np.float64).reshape(-1)
fills = np.repeat(_mesh_fill_rgba(t, blob, cols, n, style, color), 6, axis=0)
cmd.triangles(x0, y0, x1, y1, x2, y2, fills, 0.0, (0, 0, 0, 0))


def _emit_triangle_mesh(cmd, t, blob, cols, sx, sy, style, color):
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)
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))
Expand Down
71 changes: 60 additions & 11 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1132,7 +1132,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
marks.append(_scatter_marks(t, blob, cols, sx, sy, style, color))

elif kind == "hexbin":
marks.append(_triangle_mesh_marks(t, blob, cols, sx, sy, style, color))
marks.append(_hexbin_marks(t, blob, cols, sx, sy, style, color))

elif kind in {"errorbar", "stem", "box_whisker", "box_median", "contour", "segments"}:
marks.append(_segment_marks(t, blob, cols, sx, sy, style, color))
Expand Down Expand Up @@ -1470,23 +1470,72 @@ def _scatter_marks(
return "".join(out)


def _triangle_mesh_marks(
t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str
) -> str:
vertices = [_column(blob, cols[t[name]]) for name in ("x0", "y0", "x1", "y1", "x2", "y2")]
n = min(len(values) for values in vertices)
# 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
# in sync.
HEX_RING = (
(0.0, -1.0 / 3.0),
(0.5, -1.0 / 6.0),
(0.5, 1.0 / 6.0),
(0.0, 1.0 / 3.0),
(-0.5, 1.0 / 6.0),
(-0.5, -1.0 / 6.0),
)


def hexbin_ring(style: dict) -> tuple[np.ndarray, np.ndarray]:
"""Data-space hexagon vertex offsets (6) for a hexbin trace's cell pitch."""
ring = np.asarray(HEX_RING, dtype=np.float64)
return ring[:, 0] * float(style.get("hex_dx", 0.0)), ring[:, 1] * float(
style.get("hex_dy", 0.0)
)


def _mesh_fills(t: dict, blob: bytes, cols: list, n: int, fallback: str) -> list[str]:
"""Per-mark CSS fill colors from a trace's color channel (n marks)."""
color_ch = t.get("color") or {}
mode = color_ch.get("mode")
if mode == "continuous":
values = _column(blob, cols[color_ch["buf"]])[:n]
rgb = _lut(color_ch.get("colormap", "viridis"), values)
fills = [f"rgb({r},{g},{b})" for r, g, b in rgb]
elif mode == "categorical":
return [f"rgb({r},{g},{b})" for r, g, b in rgb]
if mode == "categorical":
codes = _column(blob, cols[color_ch["buf"]])[:n].astype(int)
palette = color_ch.get("palette") or DEFAULT_PALETTE
fills = [palette[code % len(palette)] for code in codes]
else:
fills = [_css(color_ch.get("color"), fallback)] * n
return [palette[code % len(palette)] for code in codes]
return [_css(color_ch.get("color"), fallback)] * n


def _hexbin_marks(
t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str
) -> str:
"""One hexagon polygon per cell, expanded locally from shipped centers."""
cx = _column(blob, cols[t["x"]])
cy = _column(blob, cols[t["y"]])
n = min(len(cx), len(cy))
fills = _mesh_fills(t, blob, cols, n, fallback)
ring_x, ring_y = hexbin_ring(style)
xs = np.asarray(sx(cx[:n, None] + ring_x[None, :]), dtype=np.float64)
ys = np.asarray(sy(cy[:n, None] + ring_y[None, :]), dtype=np.float64)
fill_op = _fill_opacity(style)
group_attr = f' fill-opacity="{_num(fill_op)}"' if fill_op < 1 else ""
out = [f"<g{group_attr}>"]
for i in range(n):
points = " ".join(
f"{_num(float(x))},{_num(float(y))}" for x, y in zip(xs[i], ys[i], strict=True)
)
out.append(f'<polygon points="{points}" fill="{escape(fills[i])}"/>')
out.append("</g>")
return "".join(out)


def _triangle_mesh_marks(
t: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, fallback: str
) -> 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)
Expand Down
31 changes: 31 additions & 0 deletions python/xy/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,38 @@ def _css_property_name(key: str) -> str:
return key.replace("_", "-")


# Validated style dicts, keyed by their exact items. Chart chrome ships the
# same few style dicts on every build (theme fonts, rc-derived title/tick
# slots), and validating one is pure — so each distinct dict validates once
# per process, not once per chart build (the pyplot perf guardrail counts on
# this). Only successful validations are cached; the type names keep e.g.
# True from hitting a cached 1 (equal, same hash, different verdict).
_STYLE_MAPPING_CACHE: dict[tuple, dict[str, str | int | float]] = {}


def style_mapping(value: dict[str, Any], label: str) -> dict[str, str | int | float]:
if isinstance(value, dict):
try:
key = (
label,
tuple((k, v, type(v).__name__) for k, v in value.items()),
)
cached = _STYLE_MAPPING_CACHE.get(key)
except TypeError: # unhashable value: validate (and likely raise) below
key = cached = None
if cached is not None:
return dict(cached)
else:
key = None
out = _validate_style_mapping(value, label)
if key is not None:
if len(_STYLE_MAPPING_CACHE) > 4096:
_STYLE_MAPPING_CACHE.clear()
_STYLE_MAPPING_CACHE[key] = dict(out)
return out


def _validate_style_mapping(value: dict[str, Any], label: str) -> dict[str, str | int | float]:
from . import kernels

if not isinstance(value, dict):
Expand Down
Loading
Loading