feat: rendering tooltips and dataView with slots (#838)

* feat: experimental component rendered tooltip

* revert slot in VChart

* feat: use tooltip composable

* feat: try createApp

* feat: use pie chart as tooltip

* feat: switch to createVNode

The limitation is that the tooltip detached from the current component tree, not provide/inject

will try teleport next

* feat: try component with teleport

* wip

* add xAxis example

* refactor with shallowReactive

* Support dynamic slot

* fix: fill empty elements with object in array

* shallow copy option along the path

* ssr friendly

* vibe docs

* typo

* update according to the review

* add dataView slot

* chore: fix warnings and errors in demo (#839)

* chore: suppress warning in demo

* chore: prevent multiple intializations of esbuild-wasm in demo HMR

* feat: dynamically update the theme (#841)

Co-authored-by: GU Yiling <justice360@gmail.com>

* feat: add dataView slot

* vibe docs

---------

Co-authored-by: GU Yiling <justice360@gmail.com>

* fix docs typo

* update according to the review

* small fix

* remove wrapper around slotProp

* update comments

* remove anys

* add tooltip slot prop type

* target to vue 3.3

* move slot related codes to slot.ts

---------

Co-authored-by: GU Yiling <justice360@gmail.com>
This commit is contained in:
Yue JIN
2025-07-26 01:30:58 +08:00
committed by Justineo
parent df640ebce6
commit 570a26c262
13 changed files with 513 additions and 45 deletions

View File

@ -155,7 +155,8 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
ECharts' universal interface. Modifying this prop will trigger ECharts' `setOption` method. Read more [here →](https://echarts.apache.org/en/option.html)
> 💡 When `update-options` is not specified, `notMerge: false` will be specified by default when the `setOption` method is called if the `option` object is modified directly and the reference remains unchanged; otherwise, if a new reference is bound to `option`, ` notMerge: true` will be specified.
> [!TIP]
> When `update-options` is not specified, `notMerge: false` will be specified by default when the `setOption` method is called if the `option` object is modified directly and the reference remains unchanged; otherwise, if a new reference is bound to `option`, `notMerge: true` will be specified.
- `update-options: object`
@ -195,8 +196,7 @@ You can bind events with Vue's `v-on` directive.
</template>
```
> **Note**
>
> [!NOTE]
> Only the `.once` event modifier is supported as other modifiers are tightly coupled with the DOM event system.
Vue-ECharts support the following events:
@ -335,6 +335,76 @@ export default {
> - [`showLoading`](https://echarts.apache.org/en/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/en/api.html#echartsInstance.hideLoading): use the `loading` and `loading-options` props instead.
> - `setTheme`: use the `theme` prop instead.
### Slots
Vue-ECharts allows you to define ECharts option's [`tooltip.formatter`](https://echarts.apache.org/en/option.html#tooltip.formatter) and [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/en/option.html#toolbox.feature.dataView.optionToContent) callbacks via Vue slots instead of defining them in your `option` object. This simplifies custom HTMLElement rendering using familiar Vue templating.
**Slot Naming Convention**
- Slot names begin with `tooltip`/`dataView`, followed by hyphen-separated path segments to the target.
- Each segment corresponds to an `option` property name or an array index (for arrays, use the numeric index).
- The constructed slot name maps directly to the nested callback it overrides.
**Example mappings**:
- `tooltip``option.tooltip.formatter`
- `tooltip-baseOption``option.baseOption.tooltip.formatter`
- `tooltip-xAxis-1``option.xAxis[1].tooltip.formatter`
- `tooltip-series-2-data-4``option.series[2].data[4].tooltip.formatter`
- `dataView``option.toolbox.feature.dataView.optionToContent`
- `dataView-media-1-option``option.media[1].option.toolbox.feature.dataView.optionToContent`
The slot props correspond to the first parameter of the callback function.
<details>
<summary>Usage</summary>
```vue
<template>
<v-chart :option="chartOptions">
<!-- Global `tooltip.formatter` -->
<template #tooltip="params">
<div v-for="(param, i) in params" :key="i">
<span v-html="param.marker" />
<span>{{ param.seriesName }}</span>
<span>{{ param.value[0] }}</span>
</div>
</template>
<!-- Tooltip on xAxis -->
<template #tooltip-xAxis="params">
<div>X-Axis : {{ params.value }}</div>
</template>
<!-- Data View Content -->
<template #dataView="option">
<table>
<thead>
<tr>
<th v-for="(t, i) in option.dataset[0].source[0]" :key="i">
{{ t }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in option.dataset[0].source.slice(1)" :key="i">
<th>{{ row[0] }}</th>
<td v-for="(v, i) in row.slice(1)" :key="i">{{ v }}</td>
</tr>
</tbody>
</table>
</template>
</v-chart>
</template>
```
[Example →](https://vue-echarts.dev/#line)
</details>
> [!NOTE]
> Slots take precedence over the corresponding callback defined in `props.option`.
### Static Methods
Static methods can be accessed from [`echarts` itself](https://echarts.apache.org/en/api.html#echarts).

View File

@ -155,7 +155,8 @@ app.component('v-chart', VueECharts)
ECharts 的万能接口。修改这个 prop 会触发 ECharts 实例的 `setOption` 方法。查看[详情 →](https://echarts.apache.org/zh/option.html)
> 💡 在没有指定 `update-options` 时,如果直接修改 `option` 对象而引用保持不变,`setOption` 方法调用时将默认指定 `notMerge: false`;否则,如果为 `option` 绑定一个新的引用,将指定 `notMerge: true`。
> [!TIP]
> 在没有指定 `update-options` 时,如果直接修改 `option` 对象而引用保持不变,`setOption` 方法调用时将默认指定 `notMerge: false`;否则,如果为 `option` 绑定一个新的引用,将指定 `notMerge: true`。
- `update-options: object`
@ -195,8 +196,7 @@ app.component('v-chart', VueECharts)
</template>
```
> **Note**
>
> [!NOTE]
> 仅支持 `.once` 修饰符,因为其它修饰符都与 DOM 事件机制强耦合。
Vue-ECharts 支持如下事件:
@ -335,6 +335,76 @@ export default {
> - [`showLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.hideLoading):请使用 `loading` 和 `loading-options` prop。
> - `setTheme`:请使用 `theme` prop。
### 插槽Slots
Vue-ECharts 允许你通过 Vue 插槽来定义 ECharts 配置中的 [`tooltip.formatter`](https://echarts.apache.org/zh/option.html#tooltip.formatter) 和 [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/zh/option.html#toolbox.feature.dataView.optionToContent) 回调,而无需在 `option` 对象中定义它们。你可以使用熟悉的 Vue 模板语法来编写自定义提示框或数据视图中的内容。
**插槽命名约定**
- 插槽名称以 `tooltip`/`dataView` 开头,后面跟随用连字符分隔的路径片段,用于定位目标。
- 每个路径片段对应 `option` 对象的属性名或数组索引(数组索引使用数字形式)。
- 拼接后的插槽名称直接映射到要覆盖的嵌套回调函数。
**示例映射**
- `tooltip``option.tooltip.formatter`
- `tooltip-baseOption``option.baseOption.tooltip.formatter`
- `tooltip-xAxis-1``option.xAxis[1].tooltip.formatter`
- `tooltip-series-2-data-4``option.series[2].data[4].tooltip.formatter`
- `dataView``option.toolbox.feature.dataView.optionToContent`
- `dataView-media-1-option``option.media[1].option.toolbox.feature.dataView.optionToContent`
插槽的 props 对象对应回调函数的第一个参数。
<details>
<summary>用法示例</summary>
```vue
<template>
<v-chart :option="chartOptions">
<!-- 全局 `tooltip.formatter` -->
<template #tooltip="params">
<div v-for="(param, i) in params" :key="i">
<span v-html="param.marker" />
<span>{{ param.seriesName }}</span>
<span>{{ param.value[0] }}</span>
</div>
</template>
<!-- x轴 tooltip -->
<template #tooltip-xAxis="params">
<div>X轴: {{ params.value }}</div>
</template>
<!-- 数据视图内容 -->
<template #dataView="option">
<table>
<thead>
<tr>
<th v-for="(t, i) in option.dataset[0].source[0]" :key="i">
{{ t }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in option.dataset[0].source.slice(1)" :key="i">
<th>{{ row[0] }}</th>
<td v-for="(v, i) in row.slice(1)" :key="i">{{ v }}</td>
</tr>
</tbody>
</table>
</template>
</v-chart>
</template>
```
[示例 →](https://vue-echarts.dev/#line)
</details>
> [!NOTE]
> 插槽会优先于 `props.option` 中对应的回调函数。
### 静态方法
静态方法请直接通过 [`echarts` 本身](https://echarts.apache.org/zh/api.html#echarts)进行调用。

View File

@ -8,6 +8,7 @@ import { track } from "@vercel/analytics";
import LogoChart from "./examples/LogoChart.vue";
import BarChart from "./examples/BarChart.vue";
import LineChart from "./examples/LineChart.vue";
import PieChart from "./examples/PieChart.vue";
import PolarChart from "./examples/PolarChart.vue";
import ScatterChart from "./examples/ScatterChart.vue";
@ -74,6 +75,7 @@ watch(codeOpen, (open) => {
</p>
<bar-chart />
<line-chart />
<pie-chart />
<polar-chart />
<scatter-chart />

56
demo/data/line.js Normal file
View File

@ -0,0 +1,56 @@
export default function getData() {
return {
textStyle: {
fontFamily: 'Inter, "Helvetica Neue", Arial, sans-serif',
fontWeight: 300,
},
legend: { top: 20 },
tooltip: {
trigger: "axis",
},
dataset: {
source: [
["product", "2012", "2013", "2014", "2015", "2016", "2017"],
["Milk Tea", 56.5, 82.1, 88.7, 70.1, 53.4, 85.1],
["Matcha Latte", 51.1, 51.4, 55.1, 53.3, 73.8, 68.7],
["Cheese Cocoa", 40.1, 62.2, 69.5, 36.4, 45.2, 32.5],
["Walnut Brownie", 25.2, 37.1, 41.2, 18, 33.9, 49.1],
],
},
xAxis: {
type: "category",
triggerEvent: true,
tooltip: { show: true, formatter: "" },
},
yAxis: {
triggerEvent: true,
tooltip: { show: true, formatter: "" },
},
series: [
{
type: "line",
smooth: true,
seriesLayoutBy: "row",
emphasis: { focus: "series" },
},
{
type: "line",
smooth: true,
seriesLayoutBy: "row",
emphasis: { focus: "series" },
},
{
type: "line",
smooth: true,
seriesLayoutBy: "row",
emphasis: { focus: "series" },
},
{
type: "line",
smooth: true,
seriesLayoutBy: "row",
emphasis: { focus: "series" },
},
],
};
}

View File

@ -43,7 +43,7 @@ defineProps({
width: fit-content;
margin: 2em auto;
.echarts {
> .echarts {
width: calc(60vw + 4em);
height: 360px;
max-width: 720px;

108
demo/examples/LineChart.vue Normal file
View File

@ -0,0 +1,108 @@
<script setup>
import { use } from "echarts/core";
import { LineChart, PieChart } from "echarts/charts";
import {
GridComponent,
DatasetComponent,
LegendComponent,
TooltipComponent,
ToolboxComponent,
} from "echarts/components";
import { shallowRef } from "vue";
import VChart from "../../src/ECharts";
import VExample from "./Example.vue";
import getData from "../data/line";
use([
DatasetComponent,
GridComponent,
LegendComponent,
LineChart,
TooltipComponent,
ToolboxComponent,
PieChart,
]);
const option = shallowRef(getData());
const axis = shallowRef("xAxis");
function getPieOption(params) {
const option = {
dataset: { source: [params[0].dimensionNames, params[0].data] },
series: [
{
type: "pie",
radius: ["60%", "100%"],
seriesLayoutBy: "row",
itemStyle: {
borderRadius: 5,
borderColor: "#fff",
borderWidth: 2,
},
label: {
position: "center",
formatter: params[0].name,
fontFamily: 'Inter, "Helvetica Neue", Arial, sans-serif',
fontWeight: 300,
},
},
],
};
return option;
}
</script>
<template>
<v-example
id="line"
title="Line chart"
desc="(with tooltip and dataView slots)"
>
<v-chart :option="option" autoresize>
<template #tooltip="params">
<v-chart
:style="{ width: '100px', height: '100px' }"
:option="getPieOption(params)"
autoresize
/>
</template>
<template #[`tooltip-${axis}`]="params">
{{ axis === "xAxis" ? "Year" : "Value" }}:
<b>{{ params.name }}</b>
</template>
<template #dataView="option">
<table style="margin: 20px auto">
<thead>
<tr>
<th v-for="(t, i) in option.dataset[0].source[0]" :key="i">
{{ t }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in option.dataset[0].source.slice(1)" :key="i">
<th>{{ row[0] }}</th>
<td v-for="(v, i) in row.slice(1)" :key="i">{{ v }}</td>
</tr>
</tbody>
</table>
</template>
</v-chart>
<template #extra>
<p class="actions">
Custom tooltip on
<select v-model="axis">
<option value="xAxis">X Axis</option>
<option value="yAxis">Y Axis</option>
</select>
</p>
</template>
</v-example>
</template>
<style scoped>
th,
td {
padding: 4px 8px;
}
</style>

View File

@ -37,7 +37,7 @@
],
"peerDependencies": {
"echarts": "^6.0.0-beta.1",
"vue": "^3.1.1"
"vue": "^3.3.0"
},
"devDependencies": {
"@highlightjs/vue-plugin": "^2.1.0",

View File

@ -10,6 +10,7 @@ import {
h,
nextTick,
watchEffect,
toValue,
} from "vue";
import { init as initChart } from "echarts/core";
@ -19,9 +20,10 @@ import {
autoresizeProps,
useLoading,
loadingProps,
type PublicMethods,
useSlotOption,
} from "./composables";
import { isOn, omitOn, toValue } from "./utils";
import type { PublicMethods, SlotsTypes } from "./composables";
import { isOn, omitOn } from "./utils";
import { register, TAG_NAME } from "./wc";
import type { PropType, InjectionKey } from "vue";
@ -64,8 +66,9 @@ export default defineComponent({
...loadingProps,
},
emits: {} as unknown as Emits,
slots: Object as SlotsTypes,
inheritAttrs: false,
setup(props, { attrs, expose }) {
setup(props, { attrs, expose, slots }) {
const root = shallowRef<EChartsElement>();
const chart = shallowRef<EChartsType>();
const manualOption = shallowRef<Option>();
@ -93,6 +96,15 @@ export default defineComponent({
const listeners: Map<{ event: string; once?: boolean; zr?: boolean }, any> =
new Map();
const { teleportedSlots, patchOption } = useSlotOption(slots, () => {
if (!manualUpdate.value && props.option && chart.value) {
chart.value.setOption(
patchOption(props.option),
realUpdateOptions.value,
);
}
});
// We are converting all `on<Event>` props and collect them into `listeners` so that
// we can bind them to the chart instance later.
// For `onNative:<event>` props, we just strip the `Native:` part and collect them into
@ -174,7 +186,7 @@ export default defineComponent({
function commit() {
const opt = option || realOption.value;
if (opt) {
instance.setOption(opt, realUpdateOptions.value);
instance.setOption(patchOption(opt), realUpdateOptions.value);
}
}
@ -204,7 +216,7 @@ export default defineComponent({
if (!chart.value) {
init(option);
} else {
chart.value.setOption(option, updateOptions || {});
chart.value.setOption(patchOption(option), updateOptions);
}
};
@ -234,7 +246,7 @@ export default defineComponent({
if (!chart.value) {
init();
} else {
chart.value.setOption(option, {
chart.value.setOption(patchOption(option), {
// mutating `option` will lead to `notMerge: false` and
// replacing it with new reference will lead to `notMerge: true`
notMerge: option !== oldOption,
@ -312,11 +324,15 @@ export default defineComponent({
// This type casting ensures TypeScript correctly types the exposed members
// that will be available when using this component.
return (() =>
h(TAG_NAME, {
h(
TAG_NAME,
{
...nonEventAttrs.value,
...nativeListeners,
ref: root,
class: ["echarts", ...(nonEventAttrs.value.class || [])],
})) as unknown as typeof exposed & PublicMethods;
class: ["echarts", nonEventAttrs.value.class],
},
teleportedSlots(),
)) as unknown as typeof exposed & PublicMethods;
},
});

View File

@ -1,3 +1,4 @@
export * from "./api";
export * from "./autoresize";
export * from "./loading";
export * from "./slot";

View File

@ -1,5 +1,4 @@
import { inject, computed, watchEffect } from "vue";
import { toValue } from "../utils";
import { inject, computed, watchEffect, toValue } from "vue";
import type { Ref, InjectionKey, PropType } from "vue";
import type {
@ -18,7 +17,7 @@ export function useLoading(
): void {
const defaultLoadingOptions = inject(LOADING_OPTIONS_KEY, {});
const realLoadingOptions = computed(() => ({
...(toValue(defaultLoadingOptions) || {}),
...toValue(defaultLoadingOptions),
...loadingOptions?.value,
}));

146
src/composables/slot.ts Normal file
View File

@ -0,0 +1,146 @@
import {
h,
Teleport,
onUpdated,
onUnmounted,
onMounted,
shallowRef,
shallowReactive,
warn,
} from "vue";
import type { Slots, SlotsType } from "vue";
import type { Option } from "../types";
import { isValidArrayIndex, isSameSet } from "../utils";
import type { TooltipComponentFormatterCallbackParams } from "echarts";
const SLOT_OPTION_PATHS = {
tooltip: ["tooltip", "formatter"],
dataView: ["toolbox", "feature", "dataView", "optionToContent"],
} as const;
type SlotPrefix = keyof typeof SLOT_OPTION_PATHS;
type SlotName = SlotPrefix | `${SlotPrefix}-${string}`;
type SlotRecord<T> = Partial<Record<SlotName, T>>;
const SLOT_PREFIXES = Object.keys(SLOT_OPTION_PATHS) as SlotPrefix[];
function isValidSlotName(key: string): key is SlotName {
return SLOT_PREFIXES.some(
(slotPrefix) => key === slotPrefix || key.startsWith(slotPrefix + "-"),
);
}
export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
const detachedRoot =
typeof window !== "undefined" ? document.createElement("div") : undefined;
const containers = shallowReactive<SlotRecord<HTMLElement>>({});
const initialized = shallowReactive<SlotRecord<boolean>>({});
const params = shallowReactive<SlotRecord<unknown>>({});
const isMounted = shallowRef(false);
// Teleport the slots to a detached root
const teleportedSlots = () => {
// Make slots client-side only to avoid SSR hydration mismatch
return isMounted.value
? h(
Teleport,
{ to: detachedRoot },
Object.entries(slots)
.filter(([key]) => isValidSlotName(key))
.map(([key, slot]) => {
const slotName = key as SlotName;
const slotContent = initialized[slotName]
? slot?.(params[slotName])
: undefined;
return h(
"div",
{
ref: (el) => (containers[slotName] = el as HTMLElement),
style: { display: "contents" },
},
slotContent,
);
}),
)
: undefined;
};
// Shallow-clone the option along the path and override the target callback
function patchOption(src: Option): Option {
const root = { ...src };
Object.keys(slots)
.filter((key) => {
const isValidSlot = isValidSlotName(key);
if (!isValidSlot) {
warn(`Invalid vue-echarts slot name: ${key}`);
}
return isValidSlot;
})
.forEach((key) => {
const path = key.split("-");
const prefix = path.shift() as SlotPrefix;
path.push(...SLOT_OPTION_PATHS[prefix]);
let cur: any = root;
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i];
const next = cur[seg];
// Shallow-clone the link; create empty shell if missing
cur[seg] = next
? Array.isArray(next)
? [...next]
: { ...next }
: isValidArrayIndex(seg)
? []
: {};
cur = cur[seg];
}
cur[path[path.length - 1]] = (p: unknown) => {
initialized[key] = true;
params[key] = p;
return containers[key];
};
});
return root;
}
// `slots` is not reactive, so we need to watch it manually
let slotNames: SlotName[] = [];
onUpdated(() => {
const newSlotNames = Object.keys(slots).filter(isValidSlotName);
if (!isSameSet(newSlotNames, slotNames)) {
// Clean up states for removed slots
slotNames.forEach((key) => {
if (!newSlotNames.includes(key)) {
delete params[key];
delete initialized[key];
delete containers[key];
}
});
slotNames = newSlotNames;
onSlotsChange();
}
});
onMounted(() => {
isMounted.value = true;
});
onUnmounted(() => {
detachedRoot?.remove();
});
return {
teleportedSlots,
patchOption,
};
}
export type SlotsTypes = SlotsType<
Record<
"tooltip" | `tooltip-${string}`,
TooltipComponentFormatterCallbackParams
> &
Record<"dataView" | `dataView-${string}`, Option>
>;

View File

@ -1,17 +1,8 @@
import { init } from "echarts/core";
import type { SetOptionOpts, ECElementEvent, ElementEvent } from "echarts/core";
import type { Ref, ShallowRef, WritableComputedRef, ComputedRef } from "vue";
import type { MaybeRefOrGetter } from "vue";
export type MaybeRef<T = any> =
| T
| Ref<T>
| ShallowRef<T>
| WritableComputedRef<T>;
export type MaybeRefOrGetter<T = any> =
| MaybeRef<T>
| ComputedRef<T>
| (() => T);
export type Injection<T> = MaybeRefOrGetter<T | null>;
type InitType = typeof init;

View File

@ -1,6 +1,3 @@
import type { MaybeRefOrGetter } from "./types";
import { unref } from "vue";
type Attrs = Record<string, any>;
// Copied from
@ -19,13 +16,25 @@ export function omitOn(attrs: Attrs): Attrs {
return result;
}
// Copied from
// https://github.com/vuejs/core/blob/3cb4db21efa61852b0541475b4ddf57fdec4c479/packages/shared/src/general.ts#L49-L50
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const isFunction = (val: unknown): val is Function => typeof val === "function";
// Copied from
// https://github.com/vuejs/core/blob/3cb4db21efa61852b0541475b4ddf57fdec4c479/packages/reactivity/src/ref.ts#L246-L248
export function toValue<T>(source: MaybeRefOrGetter<T>): T {
return isFunction(source) ? source() : unref(source);
export function isValidArrayIndex(key: string): boolean {
const num = Number(key);
return (
Number.isInteger(num) &&
num >= 0 &&
num < Math.pow(2, 32) - 1 &&
String(num) === key
);
}
export function isSameSet<T>(a: T[], b: T[]): boolean {
const setA = new Set(a);
const setB = new Set(b);
if (setA.size !== setB.size) return false;
for (const val of setA) {
if (!setB.has(val)) return false;
}
return true;
}