HTML Overlay Slot
Confirmation
- I can confirm this is a feature request for the Vue component instead of ECharts itself.
Details
RFC: HTML Overlay Slot (convertToPixel-positioned DOM)
- Status: Draft
- Target release: v8 minor
- Scope: new optional
vue-echarts/overlaymodule,src/composables/,src/style.css, docs
Summary
Add an opt-in #overlay scoped slot that renders arbitrary Vue DOM positioned at data-space coordinates. The slot exposes a single convert(value, finder?) helper that maps a data coordinate to a CSS position via chart.convertToPixel, re-running whenever the chart re-renders, zooms, roams, or resizes. This gives real, interactive, accessible DOM annotations layered over the canvas chart — something the canvas graphic overlay cannot provide.
Motivation
ECharts is canvas-first: labels, legends, marks, and custom renderItem series are painted by zrender, not emitted as DOM. The existing HTML-formatter slots (useSlotOption) only work where ECharts itself returns an HTMLElement — i.e. tooltip.formatter and toolbox.feature.dataView.optionToContent. That family is effectively complete; there is no third built-in field to extract.
Users still want real DOM inside the chart: clickable annotations, focusable links, badges, custom HTML callouts at specific data points — with full CSS, accessibility, and event handling. Today this requires manual convertToPixel plumbing and event subscription in userland. This RFC moves that plumbing into vue-echarts behind a declarative slot.
Unlike the graphic overlay, the overlay slot owns no element schema: the user writes the markup. There are no per-element prop lists to maintain, so the module does not forfeit the automatic option/type passthrough that vue-echarts otherwise preserves.
Goals
- Provide a declarative way to render reactive Vue DOM at data coordinates.
- Keep the chart fully interactive underneath the overlay.
- Require no hardcoded element/prop schemas (no maintenance coupling to ECharts releases).
- Ship as an optional module so the core bundle is unaffected.
- Support multiple coordinate systems (grid, geo, polar, single, by series).
Non-goals
- Replacing or extending the canvas
graphicoverlay. - Server-side rendering of overlay content (client-only by design).
- Frame-accurate following of in-progress animations by default.
Public API
A single scoped slot #overlay receiving { convert, active }. There are three meaningful scenarios.
1. convert with your own data — place app-owned coordinates (thresholds, events) at chart positions; active is unused:
<v-chart :option="option">
<template #overlay="{ convert }">
<a
v-for="t in thresholds"
:key="t.id"
class="overlay-anno"
:style="[convert([t.x, t.y]), { transform: 'translate(-50%, -100%)' }]"
>
{{ t.label }}
</a>
</template>
</v-chart>2. active positioned through convert — anchor to the hovered datum, re-projecting its value through convert (keeps it pinned to the data coordinate across zoom/roam):
<v-chart :option="option">
<template #overlay="{ convert, active }">
<div
v-if="active"
class="overlay-callout"
:style="[convert(active.value), { transform: 'translate(-50%, -100%)' }]"
>
{{ active.seriesName }}: {{ active.value }}
</div>
</template>
</v-chart>3. active positioned from its own pixel coordinates — anchor to the hovered datum without convert, using the event's pixel offsets directly:
<v-chart :option="option">
<template #overlay="{ active }">
<div
v-if="active"
class="overlay-callout"
:style="{
position: 'absolute',
left: `${active.event.offsetX}px`,
top: `${active.event.offsetY}px`,
transform: 'translate(-50%, -100%)',
}"
>
{{ active.name }}
</div>
</template>
</v-chart>convert contract
type ConvertFinder = Parameters<EChartsType["convertToPixel"]>[0];
type OverlayConvert = (value: unknown, finder?: ConvertFinder) => Record<string, string>;- Returns
{ position: "absolute", left: "<px>px", top: "<px>px" }anchoring an element's top-left at the data point. Callers compose their owntransform/margin to center or offset. - Returns
{ display: "none" }when the chart is uninitialized or the point is not convertible (off-chart, wrong coordinate system), so out-of-range items vanish without userland guards. finderdefaults to the first cartesian grid ({ gridIndex: 0 }); pass{ geoIndex: 0 },{ polarIndex: 0 },{ seriesIndex: n }, etc. for other coordinate systems. It is a thin pass-through tochart.convertToPixel.
Why the slot receives convert
The chart instance is already exposed (expose({ chart, … })), and convertToPixel is on the public method surface, so a consumer can call chartRef.value.convertToPixel(...) today. convert is justified because it is not a passthrough of that method — it is the reactivity-bound, init-safe projection of it, and the slot is the scope where that projection is valid and where its calls are tracked. Specifically convert adds three things the raw method does not:
- Reactivity binding. It reads
version.value, so calling it in the slot template re-runs on every reposition event (finished/datazoom/roam/resize). A consumer callingchart.convertToPixelin their owncomputedgets a stale value unless they subscribe to those events and force re-evaluation — exactly the event/cleanup boilerplate this RFC exists to absorb. The slot prop is the seam wiring user markup into the reposition loop with no.on()in userland. - Init-safety. Inside the slot the chart is guaranteed initialized (the slot renders only when
isReady), andconvertreturns{ display: "none" }before init and for off-chart points; a userland ref isundefinedduring setup. - Baked-in defaults (default finder, the
display:nonefallback) so the common case is a one-liner.
In a slot-only API convert is also the only channel by which opaque user markup can express a data coordinate back to the positioning system. Dropping it is not "the same slot without a prop" — it forces the component/directive design in Alternatives below.
active contract (current datum)
For overlays anchored to real chart data rather than the user's own coordinates, the slot also receives active — the datum echarts itself hit-tested under the cursor, or null when nothing is hovered. This is the interaction-time answer to "the data point, when applicable": echarts performs the hit-testing and resolves dataset/encode mappings, so active carries the correct datum with no internal-API coupling and no dataset caveat on our side.
type OverlayActive = ECElementEvent | null; // the mouseover params; null on mouseout/globaloutactiveis themouseoverevent params:componentType,seriesIndex,dataIndex,name,value,data,color, and the sourceevent(withoffsetX/offsetYpixel coordinates).- It is
nullinitially and aftermouseout/globalout, sov-if="active"is the natural guard — "when applicable" falls out of the data, no extra flag. - Position an
active-driven element either from its pixel coordinates (active.event.offsetX/offsetY, scenario 3 above) or by passingactive.valueback throughconvert(scenario 2 above). The two differ on zoom/roam:convertkeeps the element pinned to the data coordinate, while raw pixel offsets are a one-shot snapshot of where the cursor was.
This deliberately covers only the hovered datum. Static enumeration of every rendered datum is out of scope, because doing it robustly for dataset/encode charts requires echarts internals, which this design avoids.
Design
Module shape
A new optional module registered the same way as graphic (src/graphic/runtime.ts registerRuntime/useRuntime), imported via vue-echarts/overlay. The composable returns { render } only — it patches no option, because the overlay is pure DOM, not ECharts state.
// src/composables/overlay.ts (sketch)
export function useHtmlOverlay({ chart, slots }: {
chart: Ref<EChartsType | undefined>;
slots: Slots;
}) {
const version = shallowRef(0);
const bump = () => version.value++;
const active = shallowRef<OverlayActive>(null);
const convert: OverlayConvert = (value, finder = { gridIndex: 0 }) => {
const instance = chart.value;
if (!instance) return { display: "none" };
const px = instance.convertToPixel(finder, value as never) as [number, number] | undefined;
if (!px) return { display: "none" };
return { position: "absolute", left: `${px[0]}px`, top: `${px[1]}px` };
};
watch(chart, (instance, _prev, onCleanup) => {
if (!instance) return;
for (const name of REPOSITION_EVENTS) instance.on(name, bump);
const setActive = (params: OverlayActive) => { active.value = params; };
const clearActive = () => { active.value = null; };
instance.on("mouseover", setActive);
instance.on("mouseout", clearActive);
instance.on("globalout", clearActive);
onCleanup(() => {
for (const name of REPOSITION_EVENTS) instance.off(name, bump);
instance.off("mouseover", setActive);
instance.off("mouseout", clearActive);
instance.off("globalout", clearActive);
});
}, { immediate: true });
const render = (): VNodeChild => {
if (!slots.overlay || !isBrowser()) return undefined;
void version.value; // re-run convert calls on bump
// reading active.value here re-renders the slot when the hovered datum changes
return h("div", { class: "echarts-overlay", style: LAYER_STYLE }, slots.overlay({ convert, active: active.value }));
};
return { render };
}Reposition trigger
A version ref bumped on a fixed set of ECharts events:
finished, datazoom, georoam, graphroam, restore, magictypechangedResize is covered transitively: autoresize calls chart.resize(), which emits finished. Reading version.value in render establishes the reactive dependency so the layer — and the convert calls inside the user's slot — re-evaluate on each bump.
Integration points (core)
src/ECharts.ts: constructuseHtmlOverlay({ chart, slots }); in the renderchildrenassembly, after the graphic push and gated onisReady, pushrenderOverlay()so it layers above the canvas.src/style.css: make the root a positioning context (it currently is not) and re-enable pointer events on overlay children:x-vue-echarts{display:block;width:100%;height:100%;min-width:0;position:relative;} .echarts-overlay>*{pointer-events:auto;}Slot types: add
Record<"overlay", { convert: OverlayConvert; active: OverlayActive }>to the slot type surface (viaVChartSlotsExtension, matching howgraphicaugments it from its subpath).
Layer behavior
- The layer is
position:absolute; inset:0; pointer-events:none; overflow:hidden. The chart underneath stays fully interactive; only the user's own elements re-enable pointer events. - DOM order places the layer above the canvas and above tooltips; document the tooltip-overlap case.
Risks and Controls
- Listener leaks across instance re-init (
manualUpdate/initOptionschange disposes and recreates the chart).- Control:
watch(chart, …, { immediate: true })withonCleanupunbinds on every instance swap and on unmount.
- Control:
- Animation following: annotations snap to final positions rather than gliding with an animating series, because
finishedfires once at settle.- Control: ship with
finished+ interaction events; exposerendered-based per-frame following as an opt-in flag (throttled) rather than paying that cost by default.
- Control: ship with
- Stale positions during continuous roam/zoom.
- Control: include
datazoom/georoam/graphroamin the reposition set.
- Control: include
- Positioning context regression from the new
position:relativeon the root.- Control: visual/browser tests for overlay alignment and existing canvas border-radius rules.
- SSR: overlay must not emit markup server-side.
- Control:
isBrowser()gate inrender, consistent withuseSlotOption.
- Control:
Alternatives considered
- Component-per-marker (
<v-overlay-item :point="…">), mirroringgraphic's<g-*>components. This is the coherent alternative to passingconvert: vue-echarts reads the coordinate from a prop and positions a wrapper itself, so user markup never seesconvert. It is more declarative for the place-a-single-point case. Rejected for three concrete reasons, none of which is schema maintenance (the coordinate + finder are not ECharts-coupled, so that argument does not apply here): (a) it must render a positioned wrapper node around the user's content, costing a DOM node and control over the anchor box, whereasconvertapplies position to the user's own element; (b) it is unary — a single:pointprop cannot express geometry spanning two data points (a connector line, a band between A and B), whichconvertexpresses by being called twice; (c) it is non-minimal — a:pointcomponent can be built on top ofconvert, butconvertcannot be reconstructed from the component. - Extend
useSlotOption. Rejected: that mechanism is keyed on ECharts returning anHTMLElementfrom a formatter field; overlays are positioned by us viaconvertToPixel, a different lifecycle (event-driven reposition vs. formatter callback). - Leave in userland. Rejected: every consumer re-implements instance access, event subscription, cleanup, and coordinate conversion; this is exactly the boilerplate a component library should absorb.
Validation
Required checks:
pnpm lintpnpm typecheckpnpm test:nodepnpm test:browser
Additional tests:
convertreturns pixel position for in-range grid/geo/polar coordinatesconvertreturnsdisplay:nonebefore init and for off-chart points- overlay repositions on
datazoomand on resize (finished) - listeners unbind on instance re-init and on unmount (no leak)
- overlay renders nothing under SSR / when slot is absent
- chart remains interactive through the transparent layer; overlay children receive clicks
activeis the hovered datum onmouseoverand resets tonullonmouseout/globaloutactivelisteners unbind on instance re-init and on unmount (no leak)
Rollout
- Land
src/composables/overlay.tsand the optionalvue-echarts/overlayregistration. - Wire
renderOverlay()intoECharts.tsand add thestyle.csspositioning rules. - Add a demo example mirroring
demo/examples/GraphicOverlay.vue(DOM annotations at data coordinates) and a coordinate-system showcase. - Land docs updates in both
README.mdandREADME.zh-Hans.md, including the animation-following limitation and the opt-inrenderedflag.
Source: ecomfe/vue-echarts