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:
calcturns your input columns into the columns you want to draw. It runs once, on arrays, before anything is built.buildreturns ordinary XY marks. Not shaders, not draw calls — the samexy.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 reuses the built-in rendering, picking, and export paths — including native PNG and SVG, which have no browser — rather than reimplementing them.
A worked example
ExpandCollapse
Use it like any other mark:
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
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 what lets it reuse the engine's existing paths. A plugin carrying its own shader would reuse none of them and would have to reimplement decimation, picking, and three export paths itself. See §24 of the design dossier.
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 whatxy.scattermeans. - Silently replacing another plugin. Two libraries registering
"candlestick"is a conflict their user needs to see, not a race that import order settles. Passreplace=Truewhen 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.