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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ in the README).
## [Unreleased]

### Added
- **Mark plugins**: `xy.register_mark(xy.MarkPlugin(...))` adds a chart kind XY
does not ship, and `xy.mark("name", ...)` uses it. A plugin supplies a `calc`
over its declared columns and a `build` that returns *built-in* marks — the
composition half of dossier §24, with the custom-shader half deliberately
deferred. Because a plugin's output is ordinary traces it inherits decimation,
hover, picking, the a11y summary, and every export path including the two with
no browser; `build` never sees the `Figure`, the trace list, or the column
store, so it cannot draw anything the engine could not already draw. The
registry refuses to shadow a built-in kind and refuses to silently replace
another plugin.
- 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
Expand Down
110 changes: 110 additions & 0 deletions docs/advanced/custom-marks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
title: Custom Marks
description: Add a chart kind XY does not ship by composing its built-in marks, without forking the renderer.
---

# Custom Marks

XY ships twenty mark kinds. When you need one it does not have — a candlestick,
a high-low band, a ribbon, a dumbbell — you can register it instead of waiting
for it or forking the renderer.

A mark plugin is two functions and a name:

- **`calc`** turns your input columns into the columns you want to draw. It runs
once, on arrays, before anything is built.
- **`build`** returns ordinary XY marks. Not shaders, not draw calls — the same
`xy.segments(...)`, `xy.scatter(...)`, `xy.line(...)` you would write by hand.

That second constraint is the point rather than a limitation. Because a plugin's
output is ordinary traces, it gets decimation on large inputs, hover and
picking, the accessibility summary, and every export path — including native PNG
and SVG, which have no browser — without writing a line of code for any of them.

## A worked example

~~~python
import numpy as np
import xy


def _calc(columns):
"""Columns in, columns out. Add whatever `build` needs to draw."""
return {**columns, "mid": (columns["low"] + columns["high"]) / 2.0}


def _build(ctx):
"""Return built-in marks. `ctx.columns` is `_calc`'s output."""
return [
xy.segments(
x0=ctx.columns["t"],
x1=ctx.columns["t"],
y0=ctx.columns["low"],
y1=ctx.columns["high"],
name=ctx.name,
style=ctx.style,
),
xy.scatter(
x=ctx.columns["t"],
y=ctx.columns["mid"],
size=ctx.options.get("mid_size", 6),
),
]


xy.register_mark(
xy.MarkPlugin(
name="hilo",
columns=("t", "low", "high"),
calc=_calc,
build=_build,
doc="A high-low band with a midpoint marker.",
)
)
~~~

Use it like any other mark:

~~~python
chart = xy.chart(
xy.mark("hilo", t="day", low="low", high="high", data=frame, name="Range"),
xy.y_axis(label="price"),
)
~~~

Fields you named in `columns` behave exactly like a built-in mark's `x` and `y`:
a string names a column in `data=`, anything else is used as values directly,
and they reach `calc` as arrays. Every other keyword — `mid_size` above — arrives
in `ctx.options` untouched.

## What a plugin can and cannot do

| Can | Cannot |
| --- | --- |
| Compute new columns from its inputs | Reach the `Figure`, the trace list, or the column store |
| Emit any number of built-in marks | Emit another plugin's mark (composition is one level deep) |
| Read the caller's `style`, `name`, and options | Ship its own GLSL or WGSL |
| Draw on a named axis via `xy.mark(..., y_axis="y2")` | Add a new GPU primitive |

The last two are the real boundary, and it is deliberate rather than temporary
scaffolding. A plugin that composes built-in marks cannot draw anything the
engine could not already draw, which is exactly why it inherits everything the
engine already guarantees. A plugin carrying its own shader would inherit none
of it and would have to re-implement decimation, picking, accessibility, and
three export paths correctly on its own. See
[§24 of the design dossier](https://github.com/reflex-dev/xy/blob/main/spec/design-dossier.md)
for the full argument.

## Registry rules

`register_mark` refuses two things outright, both because the alternative is a
bug someone debugs at runtime:

- **Shadowing a built-in.** `xy.register_mark(MarkPlugin(name="scatter", ...))`
raises. A plugin cannot change what `xy.scatter` means.
- **Silently replacing another plugin.** Two libraries registering
`"candlestick"` is a conflict their user needs to see, not a race that import
order settles. Pass `replace=True` when that is genuinely what you want.

`xy.registered_marks()` lists what is contributed from outside;
`xy.unregister_mark(name)` removes one, which is mostly useful in tests.
1 change: 1 addition & 0 deletions docs/app/xy_docs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"network",
(
("XY Architecture", "/advanced/"),
("Custom Marks", "/advanced/custom-marks/"),
(
"Runtime and Deployment",
"/advanced/runtime-and-deployment/",
Expand Down
12 changes: 12 additions & 0 deletions python/xy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"Interaction": ".components",
"Legend": ".components",
"Mark": ".components",
"MarkContext": ".plugins",
"MarkPlugin": ".plugins",
"Modebar": ".components",
"Selection": "._figure",
"Spring": ".components",
Expand Down Expand Up @@ -73,6 +75,7 @@
"hexbin": ".components",
"hexbin_chart": ".components",
"heatmap": ".components",
"mark": ".components",
"heatmap_chart": ".components",
"hline": ".components",
"hist": ".components",
Expand All @@ -81,6 +84,9 @@
"interaction_config": ".components",
"label": ".components",
"legend": ".components",
"register_mark": ".plugins",
"registered_marks": ".plugins",
"unregister_mark": ".plugins",
"line": ".components",
"line_chart": ".components",
"marker": ".components",
Expand Down Expand Up @@ -129,6 +135,8 @@
"Interaction",
"Legend",
"Mark",
"MarkContext",
"MarkPlugin",
"Modebar",
"Selection",
"Spring",
Expand Down Expand Up @@ -172,8 +180,11 @@
"legend",
"line",
"line_chart",
"mark",
"marker",
"modebar",
"register_mark",
"registered_marks",
"scatter",
"scatter_chart",
"segments",
Expand All @@ -192,6 +203,7 @@
"tooltip",
"triangle_mesh",
"triangle_mesh_chart",
"unregister_mark",
"violin",
"violin_chart",
"vline",
Expand Down
135 changes: 133 additions & 2 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@

import numpy as np

from . import _validate, channels, export, styles
from . import _validate, channels, export, plugins, styles
from ._figure import Figure, Selection
from ._typing import ArrayLike, ColorLike, Scalar, TableLike
from .dom import CHART_DOM_SLOTS, validate_dom_slots
Expand Down Expand Up @@ -113,6 +113,7 @@
"legend",
"line",
"line_chart",
"mark",
"marker",
"modebar",
"scatter",
Expand Down Expand Up @@ -3260,7 +3261,7 @@ def figure(self) -> Figure:
colorbar_candidates: list[dict[str, Any]] = []
for m in marks:
data = m.data if m.data is not None else self.data
applier = _MARK_APPLIERS.get(m.kind)
applier = _MARK_APPLIERS.get(m.kind) or _plugin_applier(m.kind)
if applier is None:
raise TypeError(f"no applier registered for mark kind {m.kind!r}")
x_axis_id, y_axis_id = _mark_axis_ids(m, axes)
Expand Down Expand Up @@ -5199,6 +5200,85 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None:
}


def _plugin_column(values: Any) -> Any:
"""A plugin's declared column, as an array when it can be one."""
if values is None or isinstance(values, np.ndarray):
return values
try:
return np.asarray(values)
except (TypeError, ValueError): # pragma: no cover - exotic column objects
return values


def _plugin_applier(kind: str) -> Optional[Callable[[Figure, Mark, Any], None]]:
"""An applier for a registered mark plugin, or None if `kind` is unknown.

Resolved per compile rather than folded into `_MARK_APPLIERS` so a plugin
can never shadow a built-in, and so `registered_marks()` stays the single
answer to what is contributed from outside.
"""
resolved = plugins.get_mark_plugin(kind)
if resolved is None:
return None
plugin = resolved

def apply(fig: Figure, m: Mark, data: Any) -> None:
# Declared columns arrive as arrays, never as the raw list a caller
# happened to pass: a calc function is arithmetic over columns (§24),
# and making every plugin author write np.asarray first would be a
# papercut that shows up as a TypeError in their code, not ours.
columns = {
column: _plugin_column(_resolve(data, m.props.get(column), context=f"{kind}.{column}"))
for column in plugin.columns
}
if plugin.calc is not None:
calculated = plugin.calc(columns)
if not isinstance(calculated, Mapping):
raise TypeError(
f"mark plugin {kind!r} calc must return a mapping of columns, "
f"got {type(calculated).__name__}"
)
columns = dict(calculated)
# Axis ids are chart plumbing, already applied by the compile loop.
options = {
k: v
for k, v in m.props.items()
if k not in plugin.columns and k not in {"x_axis", "y_axis"}
}
built = plugin.build(
plugins.MarkContext(
columns=columns,
options=options,
name=m.name,
style=dict(m.style or {}),
class_name=m.class_name,
)
)
if isinstance(built, (Mark, str, bytes)) or not isinstance(built, Sequence):
raise TypeError(
f"mark plugin {kind!r} build must return a sequence of marks, "
f"got {type(built).__name__}"
)
for child in built:
if not isinstance(child, Mark):
raise TypeError(
f"mark plugin {kind!r} build returned {type(child).__name__}, "
"expected marks built by xy's mark constructors"
)
child_applier = _MARK_APPLIERS.get(child.kind)
if child_applier is None:
# One level only: a plugin composes built-in primitives. Letting
# plugins compose each other turns a registry into a dependency
# graph with cycles, for a case nothing has asked for yet.
raise TypeError(
f"mark plugin {kind!r} build returned mark kind {child.kind!r}, "
f"which is not a built-in; plugins compose built-in marks only"
)
child_applier(fig, child, child.data if child.data is not None else data)

return apply


_ANNOTATION_APPLIERS: dict[str, Callable[[Figure, Annotation], None]] = {
"arrow": _apply_arrow_annotation,
"band": _apply_band_annotation,
Expand All @@ -5209,6 +5289,57 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None:
}


def mark(
kind: str,
*,
data: TableLike = None,
name: Optional[str] = None,
style: Optional[dict[str, StyleValue]] = None,
class_name: Optional[str] = None,
key: Any = None,
animation: "Animation | bool | None" = None,
x_axis: str = "x",
y_axis: str = "y",
**fields: Any,
) -> Mark:
"""A mark contributed by a registered plugin (`xy.register_mark`).

`kind` names the plugin. Fields it declared in `MarkPlugin.columns` accept a
column name resolved from ``data`` or values directly, exactly like a
built-in mark's ``x``/``y``; every other keyword reaches the plugin as an
option untouched.

Args:
kind: Registered plugin name.
data: Table used to resolve column-name inputs.
name: Series label used by legends and tooltips.
style: Mark style overrides, passed to the plugin verbatim.
class_name: Adapter-only trace metadata.
key: Stable row identities, or a column name resolved from ``data``.
animation: Per-mark animation override; ``False`` disables animation.
x_axis: Identifier of the x axis used by this mark.
y_axis: Identifier of the y axis used by this mark.
**fields: The plugin's declared columns and options.
"""
if plugins.get_mark_plugin(kind) is None:
known = ", ".join(plugins.registered_marks()) or "none registered"
raise ValueError(f"unknown mark plugin {kind!r}; registered plugins: {known}")
return Mark(
kind=kind,
data=data,
name=_optional_string(name, "mark name"),
class_name=_optional_string(class_name, "mark class_name"),
style=styles.normalize_css_style(style, "mark style"),
key=key,
animation=animation,
props={
**fields,
"x_axis": _axis_id(x_axis, "mark x_axis"),
"y_axis": _axis_id(y_axis, "mark y_axis"),
},
)


def chart(*children: Component, **props: Any) -> Chart:
"""A neutral single-panel chart for overlays and mixed mark composition."""
return Chart("chart", children, **props)
Expand Down
Loading
Loading