diff --git a/README.md b/README.md
index d461cf8..cd990b2 100644
--- a/README.md
+++ b/README.md
@@ -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.
```
-> **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:
@@ -337,6 +337,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.
+
+
+Usage
+
+```vue
+
+
+
+
+
+
+ {{ param.seriesName }}
+ {{ param.value[0] }}
+
+
+
+
+
+ X-Axis : {{ params.value }}
+
+
+
+
+
+
+
+
+ {{ t }}
+
+
+
+
+
+ {{ row[0] }}
+ {{ v }}
+
+
+
+
+
+
+```
+
+[Example →](https://vue-echarts.dev/#line)
+
+
+
+> [!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).
diff --git a/README.zh-Hans.md b/README.zh-Hans.md
index 6fefa2a..dceef5f 100644
--- a/README.zh-Hans.md
+++ b/README.zh-Hans.md
@@ -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)
```
-> **Note**
->
+> [!NOTE]
> 仅支持 `.once` 修饰符,因为其它修饰符都与 DOM 事件机制强耦合。
Vue-ECharts 支持如下事件:
@@ -337,6 +337,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 对象对应回调函数的第一个参数。
+
+
+用法示例
+
+```vue
+
+
+
+
+
+
+ {{ param.seriesName }}
+ {{ param.value[0] }}
+
+
+
+
+
+ X轴: {{ params.value }}
+
+
+
+
+
+
+
+
+ {{ t }}
+
+
+
+
+
+ {{ row[0] }}
+ {{ v }}
+
+
+
+
+
+
+```
+
+[示例 →](https://vue-echarts.dev/#line)
+
+
+
+> [!NOTE]
+> 插槽会优先于 `props.option` 中对应的回调函数。
+
### 静态方法
静态方法请直接通过 [`echarts` 本身](https://echarts.apache.org/zh/api.html#echarts)进行调用。
diff --git a/demo/Demo.vue b/demo/Demo.vue
index 9270f02..9e33e98 100644
--- a/demo/Demo.vue
+++ b/demo/Demo.vue
@@ -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) => {
+
diff --git a/demo/data/line.js b/demo/data/line.js
new file mode 100644
index 0000000..35f1601
--- /dev/null
+++ b/demo/data/line.js
@@ -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" },
+ },
+ ],
+ };
+}
diff --git a/demo/examples/Example.vue b/demo/examples/Example.vue
index 88fa11e..3e36779 100644
--- a/demo/examples/Example.vue
+++ b/demo/examples/Example.vue
@@ -43,7 +43,7 @@ defineProps({
width: fit-content;
margin: 2em auto;
- .echarts {
+ > .echarts {
width: calc(60vw + 4em);
height: 360px;
max-width: 720px;
diff --git a/demo/examples/LineChart.vue b/demo/examples/LineChart.vue
new file mode 100644
index 0000000..30173d4
--- /dev/null
+++ b/demo/examples/LineChart.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+ {{ axis === "xAxis" ? "Year" : "Value" }}:
+ {{ params.name }}
+
+
+
+
+
+
+ {{ t }}
+
+
+
+
+
+ {{ row[0] }}
+ {{ v }}
+
+
+
+
+
+
+
+ Custom tooltip on
+
+ X Axis
+ Y Axis
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 163c7b9..ec7620b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/ECharts.ts b/src/ECharts.ts
index a6b0d88..93739f0 100644
--- a/src/ECharts.ts
+++ b/src/ECharts.ts
@@ -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();
const chart = shallowRef();
const manualOption = shallowRef();
@@ -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` props and collect them into `listeners` so that
// we can bind them to the chart instance later.
// For `onNative:` 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, {
- ...nonEventAttrs.value,
- ...nativeListeners,
- ref: root,
- class: ["echarts", ...(nonEventAttrs.value.class || [])],
- })) as unknown as typeof exposed & PublicMethods;
+ h(
+ TAG_NAME,
+ {
+ ...nonEventAttrs.value,
+ ...nativeListeners,
+ ref: root,
+ class: ["echarts", nonEventAttrs.value.class],
+ },
+ teleportedSlots(),
+ )) as unknown as typeof exposed & PublicMethods;
},
});
diff --git a/src/composables/index.ts b/src/composables/index.ts
index 7708f91..68526de 100644
--- a/src/composables/index.ts
+++ b/src/composables/index.ts
@@ -1,3 +1,4 @@
export * from "./api";
export * from "./autoresize";
export * from "./loading";
+export * from "./slot";
diff --git a/src/composables/loading.ts b/src/composables/loading.ts
index e305fc9..6c76379 100644
--- a/src/composables/loading.ts
+++ b/src/composables/loading.ts
@@ -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,
}));
diff --git a/src/composables/slot.ts b/src/composables/slot.ts
new file mode 100644
index 0000000..b355526
--- /dev/null
+++ b/src/composables/slot.ts
@@ -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 = Partial>;
+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>({});
+ const initialized = shallowReactive>({});
+ const params = shallowReactive>({});
+ 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>
+>;
diff --git a/src/types.ts b/src/types.ts
index 0e44e81..df574f8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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
- | Ref
- | ShallowRef
- | WritableComputedRef;
-export type MaybeRefOrGetter =
- | MaybeRef
- | ComputedRef
- | (() => T);
export type Injection = MaybeRefOrGetter;
type InitType = typeof init;
diff --git a/src/utils.ts b/src/utils.ts
index 6dec3f5..4dc1e11 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,6 +1,3 @@
-import type { MaybeRefOrGetter } from "./types";
-import { unref } from "vue";
-
type Attrs = Record;
// 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";
+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
+ );
+}
-// Copied from
-// https://github.com/vuejs/core/blob/3cb4db21efa61852b0541475b4ddf57fdec4c479/packages/reactivity/src/ref.ts#L246-L248
-export function toValue(source: MaybeRefOrGetter): T {
- return isFunction(source) ? source() : unref(source);
+export function isSameSet(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;
}