mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-08-16 04:31:22 +08:00
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:
76
README.md
76
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)
|
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`
|
- `update-options: object`
|
||||||
|
|
||||||
@ -195,8 +196,7 @@ You can bind events with Vue's `v-on` directive.
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note**
|
> [!NOTE]
|
||||||
>
|
|
||||||
> Only the `.once` event modifier is supported as other modifiers are tightly coupled with the DOM event system.
|
> Only the `.once` event modifier is supported as other modifiers are tightly coupled with the DOM event system.
|
||||||
|
|
||||||
Vue-ECharts support the following events:
|
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.
|
> - [`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.
|
> - `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
|
||||||
|
|
||||||
Static methods can be accessed from [`echarts` itself](https://echarts.apache.org/en/api.html#echarts).
|
Static methods can be accessed from [`echarts` itself](https://echarts.apache.org/en/api.html#echarts).
|
||||||
|
@ -155,7 +155,8 @@ app.component('v-chart', VueECharts)
|
|||||||
|
|
||||||
ECharts 的万能接口。修改这个 prop 会触发 ECharts 实例的 `setOption` 方法。查看[详情 →](https://echarts.apache.org/zh/option.html)
|
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`
|
- `update-options: object`
|
||||||
|
|
||||||
@ -195,8 +196,7 @@ app.component('v-chart', VueECharts)
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note**
|
> [!NOTE]
|
||||||
>
|
|
||||||
> 仅支持 `.once` 修饰符,因为其它修饰符都与 DOM 事件机制强耦合。
|
> 仅支持 `.once` 修饰符,因为其它修饰符都与 DOM 事件机制强耦合。
|
||||||
|
|
||||||
Vue-ECharts 支持如下事件:
|
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。
|
> - [`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。
|
> - `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)进行调用。
|
静态方法请直接通过 [`echarts` 本身](https://echarts.apache.org/zh/api.html#echarts)进行调用。
|
||||||
|
@ -8,6 +8,7 @@ import { track } from "@vercel/analytics";
|
|||||||
|
|
||||||
import LogoChart from "./examples/LogoChart.vue";
|
import LogoChart from "./examples/LogoChart.vue";
|
||||||
import BarChart from "./examples/BarChart.vue";
|
import BarChart from "./examples/BarChart.vue";
|
||||||
|
import LineChart from "./examples/LineChart.vue";
|
||||||
import PieChart from "./examples/PieChart.vue";
|
import PieChart from "./examples/PieChart.vue";
|
||||||
import PolarChart from "./examples/PolarChart.vue";
|
import PolarChart from "./examples/PolarChart.vue";
|
||||||
import ScatterChart from "./examples/ScatterChart.vue";
|
import ScatterChart from "./examples/ScatterChart.vue";
|
||||||
@ -74,6 +75,7 @@ watch(codeOpen, (open) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<bar-chart />
|
<bar-chart />
|
||||||
|
<line-chart />
|
||||||
<pie-chart />
|
<pie-chart />
|
||||||
<polar-chart />
|
<polar-chart />
|
||||||
<scatter-chart />
|
<scatter-chart />
|
||||||
|
56
demo/data/line.js
Normal file
56
demo/data/line.js
Normal 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" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
@ -43,7 +43,7 @@ defineProps({
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
margin: 2em auto;
|
margin: 2em auto;
|
||||||
|
|
||||||
.echarts {
|
> .echarts {
|
||||||
width: calc(60vw + 4em);
|
width: calc(60vw + 4em);
|
||||||
height: 360px;
|
height: 360px;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
|
108
demo/examples/LineChart.vue
Normal file
108
demo/examples/LineChart.vue
Normal 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>
|
@ -37,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"echarts": "^6.0.0-beta.1",
|
"echarts": "^6.0.0-beta.1",
|
||||||
"vue": "^3.1.1"
|
"vue": "^3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@highlightjs/vue-plugin": "^2.1.0",
|
"@highlightjs/vue-plugin": "^2.1.0",
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
h,
|
h,
|
||||||
nextTick,
|
nextTick,
|
||||||
watchEffect,
|
watchEffect,
|
||||||
|
toValue,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { init as initChart } from "echarts/core";
|
import { init as initChart } from "echarts/core";
|
||||||
|
|
||||||
@ -19,9 +20,10 @@ import {
|
|||||||
autoresizeProps,
|
autoresizeProps,
|
||||||
useLoading,
|
useLoading,
|
||||||
loadingProps,
|
loadingProps,
|
||||||
type PublicMethods,
|
useSlotOption,
|
||||||
} from "./composables";
|
} 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 { register, TAG_NAME } from "./wc";
|
||||||
|
|
||||||
import type { PropType, InjectionKey } from "vue";
|
import type { PropType, InjectionKey } from "vue";
|
||||||
@ -64,8 +66,9 @@ export default defineComponent({
|
|||||||
...loadingProps,
|
...loadingProps,
|
||||||
},
|
},
|
||||||
emits: {} as unknown as Emits,
|
emits: {} as unknown as Emits,
|
||||||
|
slots: Object as SlotsTypes,
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
setup(props, { attrs, expose }) {
|
setup(props, { attrs, expose, slots }) {
|
||||||
const root = shallowRef<EChartsElement>();
|
const root = shallowRef<EChartsElement>();
|
||||||
const chart = shallowRef<EChartsType>();
|
const chart = shallowRef<EChartsType>();
|
||||||
const manualOption = shallowRef<Option>();
|
const manualOption = shallowRef<Option>();
|
||||||
@ -93,6 +96,15 @@ export default defineComponent({
|
|||||||
const listeners: Map<{ event: string; once?: boolean; zr?: boolean }, any> =
|
const listeners: Map<{ event: string; once?: boolean; zr?: boolean }, any> =
|
||||||
new Map();
|
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 are converting all `on<Event>` props and collect them into `listeners` so that
|
||||||
// we can bind them to the chart instance later.
|
// we can bind them to the chart instance later.
|
||||||
// For `onNative:<event>` props, we just strip the `Native:` part and collect them into
|
// For `onNative:<event>` props, we just strip the `Native:` part and collect them into
|
||||||
@ -174,7 +186,7 @@ export default defineComponent({
|
|||||||
function commit() {
|
function commit() {
|
||||||
const opt = option || realOption.value;
|
const opt = option || realOption.value;
|
||||||
if (opt) {
|
if (opt) {
|
||||||
instance.setOption(opt, realUpdateOptions.value);
|
instance.setOption(patchOption(opt), realUpdateOptions.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +216,7 @@ export default defineComponent({
|
|||||||
if (!chart.value) {
|
if (!chart.value) {
|
||||||
init(option);
|
init(option);
|
||||||
} else {
|
} else {
|
||||||
chart.value.setOption(option, updateOptions || {});
|
chart.value.setOption(patchOption(option), updateOptions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -234,7 +246,7 @@ export default defineComponent({
|
|||||||
if (!chart.value) {
|
if (!chart.value) {
|
||||||
init();
|
init();
|
||||||
} else {
|
} else {
|
||||||
chart.value.setOption(option, {
|
chart.value.setOption(patchOption(option), {
|
||||||
// mutating `option` will lead to `notMerge: false` and
|
// mutating `option` will lead to `notMerge: false` and
|
||||||
// replacing it with new reference will lead to `notMerge: true`
|
// replacing it with new reference will lead to `notMerge: true`
|
||||||
notMerge: option !== oldOption,
|
notMerge: option !== oldOption,
|
||||||
@ -312,11 +324,15 @@ export default defineComponent({
|
|||||||
// This type casting ensures TypeScript correctly types the exposed members
|
// This type casting ensures TypeScript correctly types the exposed members
|
||||||
// that will be available when using this component.
|
// that will be available when using this component.
|
||||||
return (() =>
|
return (() =>
|
||||||
h(TAG_NAME, {
|
h(
|
||||||
...nonEventAttrs.value,
|
TAG_NAME,
|
||||||
...nativeListeners,
|
{
|
||||||
ref: root,
|
...nonEventAttrs.value,
|
||||||
class: ["echarts", ...(nonEventAttrs.value.class || [])],
|
...nativeListeners,
|
||||||
})) as unknown as typeof exposed & PublicMethods;
|
ref: root,
|
||||||
|
class: ["echarts", nonEventAttrs.value.class],
|
||||||
|
},
|
||||||
|
teleportedSlots(),
|
||||||
|
)) as unknown as typeof exposed & PublicMethods;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./autoresize";
|
export * from "./autoresize";
|
||||||
export * from "./loading";
|
export * from "./loading";
|
||||||
|
export * from "./slot";
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { inject, computed, watchEffect } from "vue";
|
import { inject, computed, watchEffect, toValue } from "vue";
|
||||||
import { toValue } from "../utils";
|
|
||||||
|
|
||||||
import type { Ref, InjectionKey, PropType } from "vue";
|
import type { Ref, InjectionKey, PropType } from "vue";
|
||||||
import type {
|
import type {
|
||||||
@ -18,7 +17,7 @@ export function useLoading(
|
|||||||
): void {
|
): void {
|
||||||
const defaultLoadingOptions = inject(LOADING_OPTIONS_KEY, {});
|
const defaultLoadingOptions = inject(LOADING_OPTIONS_KEY, {});
|
||||||
const realLoadingOptions = computed(() => ({
|
const realLoadingOptions = computed(() => ({
|
||||||
...(toValue(defaultLoadingOptions) || {}),
|
...toValue(defaultLoadingOptions),
|
||||||
...loadingOptions?.value,
|
...loadingOptions?.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
146
src/composables/slot.ts
Normal file
146
src/composables/slot.ts
Normal 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>
|
||||||
|
>;
|
11
src/types.ts
11
src/types.ts
@ -1,17 +1,8 @@
|
|||||||
import { init } from "echarts/core";
|
import { init } from "echarts/core";
|
||||||
|
|
||||||
import type { SetOptionOpts, ECElementEvent, ElementEvent } 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>;
|
export type Injection<T> = MaybeRefOrGetter<T | null>;
|
||||||
|
|
||||||
type InitType = typeof init;
|
type InitType = typeof init;
|
||||||
|
33
src/utils.ts
33
src/utils.ts
@ -1,6 +1,3 @@
|
|||||||
import type { MaybeRefOrGetter } from "./types";
|
|
||||||
import { unref } from "vue";
|
|
||||||
|
|
||||||
type Attrs = Record<string, any>;
|
type Attrs = Record<string, any>;
|
||||||
|
|
||||||
// Copied from
|
// Copied from
|
||||||
@ -19,13 +16,25 @@ export function omitOn(attrs: Attrs): Attrs {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copied from
|
export function isValidArrayIndex(key: string): boolean {
|
||||||
// https://github.com/vuejs/core/blob/3cb4db21efa61852b0541475b4ddf57fdec4c479/packages/shared/src/general.ts#L49-L50
|
const num = Number(key);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
return (
|
||||||
const isFunction = (val: unknown): val is Function => typeof val === "function";
|
Number.isInteger(num) &&
|
||||||
|
num >= 0 &&
|
||||||
// Copied from
|
num < Math.pow(2, 32) - 1 &&
|
||||||
// https://github.com/vuejs/core/blob/3cb4db21efa61852b0541475b4ddf57fdec4c479/packages/reactivity/src/ref.ts#L246-L248
|
String(num) === key
|
||||||
export function toValue<T>(source: MaybeRefOrGetter<T>): T {
|
);
|
||||||
return isFunction(source) ? source() : unref(source);
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user