HTML Overlay Slot

Author: JogaiCreated May 30, 2026Updated May 30, 2026

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/overlay module, 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

  1. Provide a declarative way to render reactive Vue DOM at data coordinates.
  2. Keep the chart fully interactive underneath the overlay.
  3. Require no hardcoded element/prop schemas (no maintenance coupling to ECharts releases).
  4. Ship as an optional module so the core bundle is unaffected.
  5. Support multiple coordinate systems (grid, geo, polar, single, by series).

Non-goals

  1. Replacing or extending the canvas graphic overlay.
  2. Server-side rendering of overlay content (client-only by design).
  3. 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:

xml
<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):

xml
<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:

xml
<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

typescript
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 own transform/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.
  • finder defaults 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 to chart.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:

  1. 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 calling chart.convertToPixel in their own computed gets 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.
  2. Init-safety. Inside the slot the chart is guaranteed initialized (the slot renders only when isReady), and convert returns { display: "none" } before init and for off-chart points; a userland ref is undefined during setup.
  3. Baked-in defaults (default finder, the display:none fallback) 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.

typescript
type OverlayActive = ECElementEvent | null; // the mouseover params; null on mouseout/globalout
  • active is the mouseover event params: componentType, seriesIndex, dataIndex, name, value, data, color, and the source event (with offsetX/offsetY pixel coordinates).
  • It is null initially and after mouseout/globalout, so v-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 passing active.value back through convert (scenario 2 above). The two differ on zoom/roam: convert keeps 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.

typescript
// 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, magictypechanged

Resize 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: construct useHtmlOverlay({ chart, slots }); in the render children assembly, after the graphic push and gated on isReady, push renderOverlay() 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:

    css
    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 (via VChartSlotsExtension, matching how graphic augments 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

  1. Listener leaks across instance re-init (manualUpdate/initOptions change disposes and recreates the chart).
    • Control: watch(chart, …, { immediate: true }) with onCleanup unbinds on every instance swap and on unmount.
  2. Animation following: annotations snap to final positions rather than gliding with an animating series, because finished fires once at settle.
    • Control: ship with finished + interaction events; expose rendered-based per-frame following as an opt-in flag (throttled) rather than paying that cost by default.
  3. Stale positions during continuous roam/zoom.
    • Control: include datazoom/georoam/graphroam in the reposition set.
  4. Positioning context regression from the new position:relative on the root.
    • Control: visual/browser tests for overlay alignment and existing canvas border-radius rules.
  5. SSR: overlay must not emit markup server-side.
    • Control: isBrowser() gate in render, consistent with useSlotOption.

Alternatives considered

  1. Component-per-marker (<v-overlay-item :point="…">), mirroring graphic's <g-*> components. This is the coherent alternative to passing convert: vue-echarts reads the coordinate from a prop and positions a wrapper itself, so user markup never sees convert. 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, whereas convert applies position to the user's own element; (b) it is unary — a single :point prop cannot express geometry spanning two data points (a connector line, a band between A and B), which convert expresses by being called twice; (c) it is non-minimal — a :point component can be built on top of convert, but convert cannot be reconstructed from the component.
  2. Extend useSlotOption. Rejected: that mechanism is keyed on ECharts returning an HTMLElement from a formatter field; overlays are positioned by us via convertToPixel, a different lifecycle (event-driven reposition vs. formatter callback).
  3. 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 lint
  • pnpm typecheck
  • pnpm test:node
  • pnpm test:browser

Additional tests:

  • convert returns pixel position for in-range grid/geo/polar coordinates
  • convert returns display:none before init and for off-chart points
  • overlay repositions on datazoom and 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
  • active is the hovered datum on mouseover and resets to null on mouseout/globalout
  • active listeners unbind on instance re-init and on unmount (no leak)

Rollout

  1. Land src/composables/overlay.ts and the optional vue-echarts/overlay registration.
  2. Wire renderOverlay() into ECharts.ts and add the style.css positioning rules.
  3. Add a demo example mirroring demo/examples/GraphicOverlay.vue (DOM annotations at data coordinates) and a coordinate-system showcase.
  4. Land docs updates in both README.md and README.zh-Hans.md, including the animation-following limitation and the opt-in rendered flag.