Compare commits

..

53 Commits

Author SHA1 Message Date
50e6016710 chore: release v8.0.0-beta.2 2025-09-21 20:42:20 +08:00
351661f870 chore: update changelog 2025-09-21 20:38:44 +08:00
3faa3755cf chore(deps): update minors after 7 days 2025-09-21 01:32:32 +08:00
c4ec8ebfee chore: run vitest with rolldown-vite 2025-09-21 00:02:07 +08:00
9f9fa13a2c chore(deps): update patches after 7 days (#887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 14:53:35 +00:00
52938f4a03 docs: update readme 2025-09-20 22:35:54 +08:00
79932b65c0 chore: add test step to workflows 2025-09-20 22:35:54 +08:00
087be22721 test: setup vitest, add unit tests for smart-update 2025-09-20 22:35:54 +08:00
468f7dbfbd feat: add smart update 2025-09-20 22:35:54 +08:00
f5e17356e8 chore: add agents.md 2025-09-20 22:35:54 +08:00
178084da01 chore(deps): update dependency @types/node to v22.18.3 (#886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 05:22:02 +00:00
bf94db6ca3 chore(deps): update dependency vue-tsc to v3.0.7 (#885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-19 13:54:00 +00:00
9dce0bbde6 chore(deps): update dependency @types/node to v22.18.1 (#882)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 22:11:53 +08:00
c80c659dbb chore(deps): update actions/setup-node action to v5 (#881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 16:11:11 +08:00
948e55f6a7 chore(deps): update dependency @typescript-eslint/utils to v8.42.0 (#880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 16:10:51 +08:00
e9eac88a62 chore(deps): update dependency @vueuse/core to v13.9.0 (#879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 21:58:04 +08:00
b4b6b4a8ad chore(deps): update actions/checkout action to v5 (#877)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 21:57:00 +08:00
4458586d38 chore(deps): update patches after 7 days (#878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 10:20:43 +00:00
bb03b07ed0 chore(deps): update dependency vue-tsc to v3.0.6 (#875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 18:19:10 +08:00
2a5d34583e chore(deps): update minors after 7 days (#876)
* chore(deps): update minors after 7 days

* chore: revert breaking option for now

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Justineo <justice360@gmail.com>
2025-09-08 18:18:20 +08:00
bf23d6c7e9 chore: use renovate app, improve configuration (#871)
* chore: use renovate app instead

* chore: config renovate
2025-09-08 17:04:58 +08:00
4f52c7d0da chore(deps): update dependency vue to v3.5.21 (#864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 10:05:55 +08:00
6defb4c331 chore(deps): update dependency unplugin-raw to v0.6.1 (#862)
Co-authored-by: Renovate Bot <renovate@whitesourcesoftware.com>
2025-09-08 02:00:27 +08:00
9b5bf63277 chore(deps): update dependency tsdown to v0.14.2 (#861)
Co-authored-by: Renovate Bot <renovate@whitesourcesoftware.com>
2025-09-08 01:44:37 +08:00
325ef58964 chore: add renovate repo env 2025-09-08 01:40:52 +08:00
bf04b30dfa chore: add renovate (#860)
* chore: add renovate

* chore: add permissions
2025-09-07 23:27:00 +08:00
47368d4833 docs: remove online editor badges and add logo 2025-08-14 23:44:53 +08:00
b804dcf982 chore: add permissions in workflow 2025-08-14 22:40:29 +08:00
c1035a9e79 chore: remove esbuild from devDependencies 2025-08-14 11:34:15 +08:00
cf1e60c9c5 docs: add link to setTheme, simplify readme (#854)
* docs: add link to `setTheme`, simplify readme

* docs: add improved favicon

Co-authored-by: Yue JIN <40021217+kingyue737@users.noreply.github.com>
2025-08-12 11:46:00 +08:00
62cf9e921c chore: update docs after version bump but before commit 2025-08-12 00:20:11 +08:00
33fe1e1794 chore: add codeowners (#852) 2025-08-11 11:10:23 +08:00
3349c8a0eb chore: rebrand to "Vue ECharts", improve ci.yml (#851) 2025-08-11 10:45:01 +08:00
fe7e2afd09 chore: release and publish from github actions (#850) 2025-08-10 23:36:36 +08:00
a51e2db6d1 chore: add echarts 6 features in codegen
* chore: add echarts 6 features in codegen

* update according to 772cf01859
2025-08-10 23:36:36 +08:00
654fdc98f9 docs: update version, deps and docs for 8.0 beta (#849)
* chore: up version and deps

* chore: use pnpm CLI to get versions

* add note for echarts 6 upgrade guide

* remove docs script

let cdn to redirect for us

* Revert "remove docs script"

This reverts commit 3bc237db9100864f2813249ac1693735a658e646.

* update demo links
2025-08-10 23:36:36 +08:00
7e49190f95 feat!: inject style via constructable CSSStyleSheet and remove CSP entry (#847)
* chore: not inject inline css on server

* feat!: remove csp entry

* keep csp title in readme

* chore: switch to rolldown and tsdown

* update

* dedupe

* update according to review

* emphasize "both" in csp section

* load css with unplugin-raw

* change tsdown entry
2025-08-10 23:36:36 +08:00
a6ad4e70a2 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>
2025-08-10 23:36:36 +08:00
4beaa9bce9 chore: remove large mode for flight example (#845) 2025-08-10 23:36:36 +08:00
26c991ea81 feat: dynamically update the theme (#841)
Co-authored-by: GU Yiling <justice360@gmail.com>
2025-08-10 23:36:36 +08:00
55c187ec64 chore: fix warnings and errors in demo (#839)
* chore: suppress warning in demo

* chore: prevent multiple intializations of esbuild-wasm in demo HMR
2025-08-10 23:36:36 +08:00
6ef3fd8f52 refactor: use Web Components without native class support detection (#836) 2025-08-10 23:36:36 +08:00
5b39c47102 refactor: switch to generated .d.ts (#835)
* build: generate d.ts

* fix: preserve PublicMethods

* fix: avoid exposing types of attrs

* refactor: use existing setoption type

* fix: expose root and chart

* feat: use symbol as injection key

* chore: add comment for the type casting of the exposed
2025-08-10 23:36:36 +08:00
d3bff26307 chore: ESLint Flat Config (#834)
* chore: eslint flat config

* chore: format

* update according to review

* chore: remove prettier config and format

* fix: move handler to script to bypass eslint

* chore: config eslint for lang=js block

* docs: add surrounding empty lines for code block

* chore: also minify css in csp build

* chore: publint
2025-08-10 23:36:36 +08:00
8fbc68a010 build: migrate demo from webpack to Vite (#832) 2025-08-10 23:36:36 +08:00
440285dabf chore: remove @vue/runtime-core from peerDependencies
@vue/runtime-core was added here for supporting typescript in vue < 2.7
2025-08-10 23:36:36 +08:00
9dd86d0a8d refactor: change listeners from object to Map 2025-08-10 23:36:36 +08:00
0aec2ecbd6 refactor: rename realListeners to listeners 2025-08-10 23:36:36 +08:00
756aa363f0 docs: update provide/inject section 2025-08-10 23:36:36 +08:00
05bd137cce refactor: simplify render function 2025-08-10 23:36:36 +08:00
f81fd99c1a feat: support getter in provide/inject 2025-08-10 23:36:36 +08:00
349644f913 docs: remove vue 2 related content 2025-08-10 23:36:36 +08:00
503094de70 feat!: remove vue 2 2025-08-10 23:36:36 +08:00
27 changed files with 2332 additions and 875 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @ecomfe/vue-echarts

View File

@ -1,5 +1,5 @@
name: "🐞 Bug Report"
description: Create a bug report for Vue-ECharts
description: Create a bug report for Vue ECharts
body:
- type: markdown
attributes:
@ -9,14 +9,14 @@ body:
id: confirmation
attributes:
label: Confirmation
description: Before submitting this issue, please make sure that the problem only occurs in Vue-ECharts and is not related to ECharts itself.
description: Before submitting this issue, please make sure that the problem only occurs in Vue ECharts and is not related to ECharts itself.
options:
- label: I can confirm this problem is not reproducible with ECharts itself.
required: true
- type: dropdown
id: integration
attributes:
label: How are you introducing Vue-ECharts into your project?
label: How are you introducing Vue ECharts into your project?
options:
- ES Module imports
- "<script> tag"

View File

@ -1,5 +1,5 @@
name: "🐞 Bug 报告"
description: 给 Vue-ECharts 报告 bug
description: 给 Vue ECharts 报告 bug
body:
- type: markdown
attributes:
@ -9,14 +9,14 @@ body:
id: confirmation
attributes:
label: 请确认
description: 在提交此问题前,请确认问题仅在 Vue-ECharts 中发生,而与 ECharts 本身无关。
description: 在提交此问题前,请确认问题仅在 Vue ECharts 中发生,而与 ECharts 本身无关。
options:
- label: 我可以确认这个问题无法在 ECharts 项目本身中复现。
required: true
- type: dropdown
id: integration
attributes:
label: 您是如何将 Vue-ECharts 引入项目的?
label: 您是如何将 Vue ECharts 引入项目的?
options:
- 通过 ES 模块 import
- "<script> 标签"

View File

@ -1,5 +1,5 @@
name: "✨ Feature Request"
description: Create a feature request for Vue-ECharts
description: Create a feature request for Vue ECharts
body:
- type: markdown
attributes:

View File

@ -1,5 +1,5 @@
name: "✨ 新功能建议"
description: 给 Vue-ECharts 提交新功能建议
description: 给 Vue ECharts 提交新功能建议
body:
- type: markdown
attributes:

View File

@ -1,26 +1,29 @@
name: CI
permissions:
contents: read
on:
pull_request:
push:
branches:
- main
- "8.0" # remove this after 8.0 is merged into main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: "pnpm"
cache: pnpm
- name: Install dependencies
run: pnpm install
@ -31,6 +34,9 @@ jobs:
- name: Typecheck
run: pnpm run typecheck && pnpm run dev:typecheck
- name: Test
run: pnpm run test
- name: Build
run: pnpm run build

View File

@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
@ -41,6 +41,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Test
run: pnpm run test
- name: Build
run: pnpm run build

21
AGENTS.md Normal file
View File

@ -0,0 +1,21 @@
# Repository Guidelines
## Project Structure & Module Organization
Core source lives in `src/`, implemented in TypeScript with the Vue 3 Composition API. Key entry modules include `src/index.ts` for exports, `src/ECharts.ts` for the main component, and utilities under `src/composables/` and `src/utils.ts`. Demo site assets sit in `demo/` (Vite-powered) and should be kept in sync with new features. Bundled artifacts in `dist/` are generated by `pnpm build`; avoid editing them manually. Shared build helpers reside in `scripts/`.
## Build, Test, and Development Commands
Use `pnpm install` to set up dependencies. `pnpm dev` serves the demo playground at `http://localhost:5173` for interactive testing. Run `pnpm build` (tsdown) to produce distributable output under `dist/`, and `pnpm dev:build` or `pnpm dev:preview` when you need the Vite bundle for the demo. `pnpm typecheck` validates the librarys TypeScript contracts, `pnpm lint` applies ESLint with autofix, `pnpm format` runs Prettier, and `pnpm test` executes the Vitest suite. Use `pnpm publint` before releases to confirm the published surface.
## Coding Style & Naming Conventions
This project targets Vue 3 + TypeScript with ECMAScript modules. Follow the existing 2-space indentation, trailing commas where valid, and single quotes for strings unless interpolation is required. Components and exported composables use PascalCase (e.g., `VChart`), while local helpers remain camelCase. Honor `eslint.config.ts` and Prettier defaults; always run `pnpm lint && pnpm format` before sending patches. Keep public exports centralized in `src/index.ts` and add accompanying CSS changes to `src/style.css`.
## Testing Guidelines
There is no standalone unit-test runner yet; rely on TypeScript, linting, and manual QA in the demo. Before opening a PR, run `pnpm lint`, `pnpm typecheck`, and `pnpm build`. Exercise relevant demos in `demo/src/` and add or update examples that showcase new behaviors. For major fixes, include reproduction and verification steps in the PR description so reviewers can follow along.
## Commit & Pull Request Guidelines
Commit history follows Conventional Commits (`type(scope): summary`), e.g., `feat(runtime): add renderer option` or `chore(deps): update vue`. Use concise, imperative summaries and group related changes together. PRs should describe user-facing effects, list verification commands, and link issues with `Fixes #123` when applicable. Include screenshots or GIFs for visual updates to the demo, and note any doc changes (`README.md`, `demo/`) in the description. Ensure CI checks mirror local commands: `pnpm lint`, `pnpm typecheck`, and `pnpm build`.

View File

@ -1,3 +1,7 @@
## 8.0.0-beta.2
- Added [smart update](https://github.com/ecomfe/vue-echarts/blob/main/README.md#smart-update) strategy to make updates more effortless.
## 8.0.0-beta.1
### Breaking changes
@ -285,7 +289,7 @@
- Update peer dependency for `echarts` to `^5.0.2`.
- Update peer dependency for `vue` to `^2.6.11 || ^3.0.0`.
- Now `@vue/composition-api` is required to be installed to use Vue-ECharts with Vue 2.
- Now `@vue/composition-api` is required to be installed to use Vue ECharts with Vue 2.
- `options` is renamed to **`option`** to align with ECharts itself.
- Updating `option` will respect **`update-options`** configs instead of checking reference change.
- `watch-shallow` is removed. Use **`manual-update`** for performance critical scenarios.

View File

@ -1,10 +1,8 @@
<h1 align="center">Vue-ECharts</h1>
<p align="center"><a href="https://vue-echarts.dev/"><img alt="Vue ECharts" src="https://raw.githubusercontent.com/ecomfe/vue-echarts/refs/heads/main/demo/public/favicon.svg" width="96"></a></p>
<h1 align="center">Vue ECharts</h1>
<p align="center">Vue.js component for Apache ECharts™.</p>
<p align="center"><a href="https://npmjs.com/package/vue-echarts"><img alt="npm version" src="https://img.shields.io/npm/v/vue-echarts"></a> <a href="https://vue-echarts.dev/"><img src="https://img.shields.io/badge/Demo%20%C2%BB-20c3aa" alt="View demo"></a> <a href="./README.zh-Hans.md"><img src="https://img.shields.io/badge/%E4%B8%AD%E6%96%87%E7%89%88%20%C2%BB-000" alt="前往中文版"></a></p>
<p align="center"><a href="https:///pr.new/ecomfe/vue-echarts"><img alt="Open in Codeflow" src="https://developer.stackblitz.com/img/open_in_codeflow.svg" height="28"></a> <a href="https://codesandbox.io/p/github/ecomfe/vue-echarts"><img alt="Edit in CodeSandbox" src="https://assets.codesandbox.io/github/button-edit-lime.svg" height="28"></a></p>
---
> Still using Vue 2? Read v7 docs [here →](https://github.com/ecomfe/vue-echarts/tree/7.x)
@ -19,7 +17,7 @@ npm install echarts vue-echarts
#### Example
<details>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
```vue
<template>
@ -114,17 +112,17 @@ import "echarts";
Drop `<script>` inside your HTML file and access the component via `window.VueECharts`.
<details>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<!-- vue3Scripts:start -->
<!-- scripts:start -->
```html
<script src="https://cdn.jsdelivr.net/npm/echarts@6.0.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.18"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.1"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.21"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.2"></script>
```
<!-- vue3Scripts:end -->
<!-- scripts:end -->
```js
const app = Vue.createApp(...)
@ -153,14 +151,17 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
- `option: object`
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 triggers Vue ECharts to compute an update plan and call `setOption`. Read more [here →](https://echarts.apache.org/en/option.html)
> [!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.
#### Smart Update
- If you supply `update-options` (via prop or injection), Vue ECharts forwards it directly to `setOption` and skips the planner.
- Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in.
- Otherwise, Vue ECharts analyses the change: removed objects become `null`, removed arrays become `[]` with `replaceMerge`, ID/anonymous deletions trigger `replaceMerge`, and risky changes fall back to `notMerge: true`.
- `update-options: object`
Options for updating chart option. See `echartsInstance.setOption`'s `opts` parameter [here →](https://echarts.apache.org/en/api.html#echartsInstance.setOption)
Options for updating chart option. If supplied (or injected), Vue ECharts forwards it directly to `setOption`, skipping the [smart update](#smart-update). See `echartsInstance.setOption`'s `opts` parameter [here →](https://echarts.apache.org/en/api.html#echartsInstance.setOption)
Injection key: `UPDATE_OPTIONS_KEY`.
@ -184,7 +185,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
- `manual-update: boolean` (default: `false`)
For performance critical scenarios (having a large dataset) we'd better bypass Vue's reactivity system for `option` prop. By specifying `manual-update` prop with `true` and not providing `option` prop, the dataset won't be watched any more. After doing so, you need to retrieve the component instance with `ref` and manually call `setOption` method to update the chart.
For performance critical scenarios (having a large dataset) we'd better bypass Vue's reactivity system for `option` prop. By specifying `manual-update` prop with `true` and not providing `option` prop, the dataset won't be watched any more. After doing so, you need to retrieve the component instance with `ref` and manually call `setOption` method to update the chart (manual `setOption` calls are ignored when `manual-update` is `false`).
### Events
@ -199,7 +200,7 @@ You can bind events with Vue's `v-on` directive.
> [!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:
Vue ECharts support the following events:
- `highlight` [](https://echarts.apache.org/en/api.html#events.highlight)
- `downplay` [](https://echarts.apache.org/en/api.html#events.downplay)
@ -249,7 +250,7 @@ See supported events [here →](https://echarts.apache.org/en/api.html#events)
#### Native DOM Events
As Vue-ECharts binds events to the ECharts instance by default, there is some caveat when using native DOM events. You need to prefix the event name with `native:` to bind native DOM events.
As Vue ECharts binds events to the ECharts instance by default, there is some caveat when using native DOM events. You need to prefix the event name with `native:` to bind native DOM events.
```vue
<template>
@ -259,7 +260,7 @@ As Vue-ECharts binds events to the ECharts instance by default, there is some ca
### Provide / Inject
Vue-ECharts provides provide/inject API for `theme`, `init-options`, `update-options` and `loading-options` to help configuring contextual options. eg. for `theme` you can use the provide API like this:
Vue ECharts provides provide/inject API for `theme`, `init-options`, `update-options` and `loading-options` to help configuring contextual options. eg. for `theme` you can use the provide API like this:
<details>
<summary>Composition API</summary>
@ -333,11 +334,11 @@ export default {
> The following ECharts instance methods aren't exposed because their functionality is already provided by component [props](#props):
>
> - [`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`](https://echarts.apache.org/en/api.html#echartsInstance.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.
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**

View File

@ -1,10 +1,8 @@
<h1 align="center">Vue-ECharts</h1>
<p align="center"><a href="https://vue-echarts.dev/"><img alt="Vue ECharts" src="https://raw.githubusercontent.com/ecomfe/vue-echarts/refs/heads/main/demo/public/favicon.svg" width="96"></a></p>
<h1 align="center">Vue ECharts</h1>
<p align="center">Apache ECharts™ 的 Vue.js 组件。</p>
<p align="center"><a href="https://npmjs.com/package/vue-echarts"><img alt="npm 版本" src="https://img.shields.io/npm/v/vue-echarts"></a> <a href="https://vue-echarts.dev/"><img src="https://img.shields.io/badge/%E6%BC%94%E7%A4%BA%20%C2%BB-20c3aa" alt="查看演示"></a> <a href="./README.zh-Hans.md"></p>
<p align="center"><a href="https:///pr.new/ecomfe/vue-echarts"><img alt="Open in Codeflow" src="https://developer.stackblitz.com/img/open_in_codeflow.svg" height="28"></a> <a href="https://codesandbox.io/p/github/ecomfe/vue-echarts"><img alt="Edit in CodeSandbox" src="https://assets.codesandbox.io/github/button-edit-lime.svg" height="28"></a></p>
---
<p align="center"><a href="https://npmjs.com/package/vue-echarts"><img alt="npm 版本" src="https://img.shields.io/npm/v/vue-echarts"></a> <a href="https://vue-echarts.dev/"><img src="https://img.shields.io/badge/%E6%BC%94%E7%A4%BA%20%C2%BB-20c3aa" alt="查看演示"></a></p>
> 还在使用 Vue 2可以继续阅读老版本的文档。[前往 →](https://github.com/ecomfe/vue-echarts/blob/7.x/README.zh-Hans.md)
@ -19,7 +17,7 @@ npm install echarts vue-echarts
#### 示例
<details>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8?file=src%2FApp.vue">Demo →</a></summary>
```vue
<template>
@ -114,17 +112,17 @@ import "echarts";
用如下方式在 HTML 中插入 `<script>` 标签,并且通过 `window.VueECharts` 来访问组件接口:
<details>
<summary>Vue 3 <a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<summary><a href="https://stackblitz.com/edit/vue-echarts-8-global?file=index.html">Demo →</a></summary>
<!-- vue3Scripts:start -->
<!-- scripts:start -->
```html
<script src="https://cdn.jsdelivr.net/npm/echarts@6.0.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.18"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.1"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.21"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-echarts@8.0.0-beta.2"></script>
```
<!-- vue3Scripts:end -->
<!-- scripts:end -->
```js
const app = Vue.createApp(...)
@ -155,12 +153,15 @@ app.component('v-chart', VueECharts)
ECharts 的万能接口。修改这个 prop 会触发 ECharts 实例的 `setOption` 方法。查看[详情 →](https://echarts.apache.org/zh/option.html)
> [!TIP]
> 在没有指定 `update-options` 时,如果直接修改 `option` 对象而引用保持不变,`setOption` 方法调用时将默认指定 `notMerge: false`;否则,如果为 `option` 绑定一个新的引用,将指定 `notMerge: true`。
#### 智能更新
- 如果提供了 `update-options`(或通过 inject 注入Vue ECharts 会直接把它传给 `setOption`,不会执行智能计划。
- 手动调用 `setOption`(仅当 `manual-update``true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数。
- 其他情况下Vue ECharts 会分析差异:删除的对象写入 `null`,删除的数组写入 `[]` 并加入 `replaceMerge`ID/匿名项减少时追加 `replaceMerge`,风险较高的变更会退回 `notMerge: true`
- `update-options: object`
图表更新的配置项。请参考 `echartsInstance.setOption``opts` 参数。[前往 →](https://echarts.apache.org/zh/api.html#echartsInstance.setOption)
图表更新的配置项。一旦提供(或通过 inject 注入Vue ECharts 会直接把它传给 `setOption` 并跳过智能更新。请参考 `echartsInstance.setOption``opts` 参数。[前往 →](https://echarts.apache.org/zh/api.html#echartsInstance.setOption)
Inject 键名:`UPDATE_OPTIONS_KEY`
@ -184,7 +185,7 @@ app.component('v-chart', VueECharts)
- `manual-update: boolean`(默认值`false`
在性能敏感(数据量很大)的场景下,我们最好对于 `option` prop 绕过 Vue 的响应式系统。当将 `manual-update` prop 指定为 `true` 且不传入 `option` prop 时,数据将不会被监听。然后,需要用 `ref` 获取组件实例以后手动调用 `setOption` 方法来更新图表。
在性能敏感(数据量很大)的场景下,我们最好对于 `option` prop 绕过 Vue 的响应式系统。当将 `manual-update` 指定为 `true` 且不传入 `option` prop 时,数据将不会被监听。此时需要用 `ref` 获取组件实例手动调用 `setOption` 来更新图表(当 `manual-update``false` 时,手动调用 `setOption` 会被忽略)
### 事件
@ -199,7 +200,7 @@ app.component('v-chart', VueECharts)
> [!NOTE]
> 仅支持 `.once` 修饰符,因为其它修饰符都与 DOM 事件机制强耦合。
Vue-ECharts 支持如下事件:
Vue ECharts 支持如下事件:
- `highlight` [](https://echarts.apache.org/zh/api.html#events.highlight)
- `downplay` [](https://echarts.apache.org/zh/api.html#events.downplay)
@ -249,7 +250,7 @@ Vue-ECharts 支持如下事件:
#### 原生 DOM 事件
由于 Vue-ECharts 默认将事件绑定到 ECharts 实例,因此在使用原生 DOM 事件时需要做一些特殊处理。你需要在事件名称前加上 `native:` 前缀来绑定原生 DOM 事件。
由于 Vue ECharts 默认将事件绑定到 ECharts 实例,因此在使用原生 DOM 事件时需要做一些特殊处理。你需要在事件名称前加上 `native:` 前缀来绑定原生 DOM 事件。
```vue
<template>
@ -259,7 +260,7 @@ Vue-ECharts 支持如下事件:
### Provide / Inject
Vue-ECharts 为 `theme``init-options``update-options``loading-options` 提供了 provide/inject API以通过上下文配置选项。例如可以通过如下方式来使用 provide API 为 `theme` 提供上下文配置:
Vue ECharts 为 `theme``init-options``update-options``loading-options` 提供了 provide/inject API以通过上下文配置选项。例如可以通过如下方式来使用 provide API 为 `theme` 提供上下文配置:
<details>
<summary>组合式 API</summary>
@ -333,11 +334,11 @@ export default {
> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [props](#props) 提供了:
>
> - [`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`](https://echarts.apache.org/zh/api.html#echartsInstance.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 模板语法来编写自定义提示框或数据视图中的内容。
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 模板语法来编写自定义提示框或数据视图中的内容。
**插槽命名约定**

View File

@ -54,7 +54,7 @@ watch(codeOpen, (open) => {
<logo-chart />
<h1>
<a href="https://github.com/ecomfe/vue-echarts">Vue-ECharts</a>
<a href="https://github.com/ecomfe/vue-echarts">Vue ECharts</a>
</h1>
<p class="desc">
Vue.js component for Apache ECharts. (<a

View File

@ -4,12 +4,13 @@
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico"/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link
href="https://fonts.googleapis.com/css?family=Inter:300,500;display=swap"
rel="stylesheet"
/>
<title>Vue-ECharts: Vue.js component for Apache ECharts™.</title>
<title>Vue ECharts: Vue.js component for Apache ECharts™.</title>
</head>
<body>
<div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

1
demo/public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><g clip-path="url(#a)"><path fill="#42B883" d="M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24Zm-.792 5.307c2.192-.025 4.366 1.134 5.43 3.304.909 1.852.878 3.61-.098 5.645-.477.995-.487 1.06-.241 1.578.214.452.727.779 1.221.779.454 0 1.15-.586 1.252-1.054.1-.454-.193-1.118-.607-1.377a10.147 10.147 0 0 1-.393-.255c-.129-.1.42-.38.741-.38.687 0 1.247.526 1.375 1.29.055.333.134.422.44.502.859.222 1.297 1.451.755 2.116-.22.27-.23.271-.305.042-.267-.801-.666-1.12-1.403-1.12-.319 0-.572.128-1.098.556-1.006.82-1.866 1.303-2.907 1.632-1.276.384-2.752.478-4.086.156-2.162-.431-4.232-2.11-5.252-4.257C4.758 11.782 5.135 9 7.033 7.077a5.924 5.924 0 0 1 4.175-1.77Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 817 B

View File

@ -3,6 +3,7 @@
"include": ["./**/*", "./**/*.vue"],
"compilerOptions": {
"types": ["vite/client"],
"allowJs": true
"allowJs": true,
"noUncheckedIndexedAccess": false
}
}

View File

@ -1,6 +1,6 @@
{
"name": "vue-echarts",
"version": "8.0.0-beta.1",
"version": "8.0.0-beta.2",
"description": "Vue.js component for Apache ECharts™.",
"license": "MIT",
"repository": {
@ -19,9 +19,11 @@
"dev:preview": "vite preview",
"dev:typecheck": "vue-tsc -p ./demo",
"docs": "jiti ./scripts/docs.ts",
"release": "pnpm run docs && bumpp --all"
"release": "bumpp --execute \"pnpm run docs\" --all",
"test": "vitest run",
"test:watch": "vitest watch"
},
"packageManager": "pnpm@10.14.0",
"packageManager": "pnpm@10.16.0",
"type": "module",
"main": "dist/index.js",
"unpkg": "dist/index.min.js",
@ -40,21 +42,21 @@
},
"devDependencies": {
"@highlightjs/vue-plugin": "^2.1.0",
"@types/node": "^22.17.0",
"@typescript-eslint/utils": "^8.39.0",
"@types/node": "^22.17.1",
"@typescript-eslint/utils": "^8.39.1",
"@vercel/analytics": "^1.5.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.7.0",
"@vue/tsconfig": "^0.8.0",
"@vueuse/core": "^13.6.0",
"bumpp": "^10.2.2",
"bumpp": "^10.2.3",
"comment-mark": "^2.0.1",
"echarts": "^6.0.0",
"echarts-gl": "^2.0.9",
"echarts-liquidfill": "^3.1.0",
"esbuild-wasm": "^0.25.8",
"eslint": "^9.32.0",
"esbuild-wasm": "^0.25.9",
"eslint": "^9.33.0",
"eslint-plugin-vue": "^10.4.0",
"highlight.js": "^11.11.1",
"jiti": "^2.5.1",
@ -63,10 +65,11 @@
"prettier": "^3.6.2",
"publint": "^0.3.12",
"releaselog": "^6.0.3",
"tsdown": "^0.13.3",
"tsdown": "^0.15.0",
"typescript": "^5.9.2",
"unplugin-raw": "^0.5.1",
"vite": "npm:rolldown-vite@^7.1.0",
"unplugin-raw": "^0.6.0",
"vite": "npm:rolldown-vite@latest",
"vitest": "^3.2.4",
"vue": "^3.5.18",
"vue-tsc": "^3.0.5"
},
@ -75,6 +78,9 @@
"allowedVersions": {
"echarts": "6"
}
},
"overrides": {
"vite": "npm:rolldown-vite@latest"
}
}
}

2311
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@
onlyBuiltDependencies:
- esbuild

24
renovate.json Normal file
View File

@ -0,0 +1,24 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict",
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"groupName": "patches after 7 days",
"automerge": true
},
{
"matchUpdateTypes": ["minor"],
"excludePackageNames": ["vue", "echarts"],
"groupName": "minors after 7 days",
"automerge": false
},
{
"matchPackageNames": ["vue", "echarts"],
"matchUpdateTypes": ["minor"],
"automerge": false
}
]
}

View File

@ -42,7 +42,7 @@ README_FILES.forEach((file) => {
writeFileSync(
file,
commentMark(content, {
vue3Scripts: getCodeBlock(getScripts()),
scripts: getCodeBlock(getScripts()),
}),
"utf8",
);

View File

@ -11,8 +11,10 @@ import {
nextTick,
watchEffect,
toValue,
warn,
} from "vue";
import { init as initChart } from "echarts/core";
import type { EChartsOption } from "echarts";
import {
usePublicAPI,
@ -25,6 +27,8 @@ import {
import type { PublicMethods, SlotsTypes } from "./composables";
import { isOn, omitOn } from "./utils";
import { register, TAG_NAME } from "./wc";
import { planUpdate } from "./smart-update";
import type { Signature, UpdatePlan } from "./smart-update";
import type { PropType, InjectionKey } from "vue";
import type {
@ -71,24 +75,19 @@ export default defineComponent({
setup(props, { attrs, expose, slots }) {
const root = shallowRef<EChartsElement>();
const chart = shallowRef<EChartsType>();
const manualOption = shallowRef<Option>();
const defaultTheme = inject(THEME_KEY, null);
const defaultInitOptions = inject(INIT_OPTIONS_KEY, null);
const defaultUpdateOptions = inject(UPDATE_OPTIONS_KEY, null);
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
const realOption = computed(
() => manualOption.value || props.option || null,
);
const realTheme = computed(
() => props.theme || toValue(defaultTheme) || {},
);
const realOption = computed(() => props.option || {});
const realTheme = computed(() => props.theme || toValue(defaultTheme));
const realInitOptions = computed(
() => props.initOptions || toValue(defaultInitOptions) || {},
() => props.initOptions || toValue(defaultInitOptions) || undefined,
);
const realUpdateOptions = computed(
() => props.updateOptions || toValue(defaultUpdateOptions) || {},
() => props.updateOptions || toValue(defaultUpdateOptions),
);
const nonEventAttrs = computed(() => omitOn(attrs));
const nativeListeners: Record<string, unknown> = {};
@ -98,13 +97,70 @@ export default defineComponent({
const { teleportedSlots, patchOption } = useSlotOption(slots, () => {
if (!manualUpdate.value && props.option && chart.value) {
chart.value.setOption(
patchOption(props.option),
realUpdateOptions.value,
);
applyOption(chart.value, props.option);
}
});
let lastSignature: Signature | undefined;
function resolveUpdateOptions(
plan?: UpdatePlan,
override?: UpdateOptions,
): UpdateOptions {
const base = realUpdateOptions.value;
const result: UpdateOptions = { ...override };
const replacements = [
...(plan?.replaceMerge ?? []),
...(override?.replaceMerge ?? []),
].filter((key): key is string => key != null);
if (replacements.length > 0) {
result.replaceMerge = [...new Set(replacements)];
} else {
delete result.replaceMerge;
}
const notMerge = override?.notMerge ?? plan?.notMerge;
if (notMerge !== undefined) {
result.notMerge = notMerge;
} else {
delete result.notMerge;
}
return base ? { ...base, ...result } : result;
}
function applyOption(
instance: EChartsType,
option: Option,
override?: UpdateOptions,
manual = false,
) {
const patched = patchOption(option);
if (manual) {
instance.setOption(patched, override ?? {});
lastSignature = undefined;
return;
}
if (realUpdateOptions.value) {
const updateOptions = override ?? realUpdateOptions.value;
instance.setOption(patched, updateOptions);
lastSignature = undefined;
return;
}
const planned = planUpdate(
lastSignature,
patched as unknown as EChartsOption,
);
const updateOptions = resolveUpdateOptions(planned.plan, override);
instance.setOption(planned.option, updateOptions);
lastSignature = planned.signature;
}
// 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
@ -140,7 +196,7 @@ export default defineComponent({
listeners.set({ event, zr, once }, attrs[key]);
});
function init(option?: Option) {
function init(option?: Option, manual = false, override?: UpdateOptions) {
if (!root.value) {
return;
}
@ -186,7 +242,8 @@ export default defineComponent({
function commit() {
const opt = option || realOption.value;
if (opt) {
instance.setOption(patchOption(opt), realUpdateOptions.value);
applyOption(instance, opt, override, manual);
override = undefined;
}
}
@ -206,17 +263,20 @@ export default defineComponent({
notMerge,
lazyUpdate?: boolean,
) => {
if (!props.manualUpdate) {
warn(
"[vue-echarts] setOption is only available when manual-update is true.",
);
return;
}
const updateOptions =
typeof notMerge === "boolean" ? { notMerge, lazyUpdate } : notMerge;
if (props.manualUpdate) {
manualOption.value = option;
}
if (!chart.value) {
init(option);
init(option, true, updateOptions ?? undefined);
} else {
chart.value.setOption(patchOption(option), updateOptions);
applyOption(chart.value, option, updateOptions ?? undefined, true);
}
};
@ -225,6 +285,7 @@ export default defineComponent({
chart.value.dispose();
chart.value = undefined;
}
lastSignature = undefined;
}
let unwatchOption: (() => void) | null = null;
@ -239,19 +300,15 @@ export default defineComponent({
if (!manualUpdate) {
unwatchOption = watch(
() => props.option,
(option, oldOption) => {
(option) => {
if (!option) {
lastSignature = undefined;
return;
}
if (!chart.value) {
init();
} else {
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,
...realUpdateOptions.value,
});
applyOption(chart.value, option);
}
},
{ deep: true },
@ -277,7 +334,7 @@ export default defineComponent({
watch(
realTheme,
(theme) => {
chart.value?.setTheme(theme);
chart.value?.setTheme(theme || {});
},
{
deep: true,

243
src/smart-update.ts Normal file
View File

@ -0,0 +1,243 @@
import type { EChartsOption } from "echarts";
import { isPlainObject } from "./utils";
export interface UpdatePlan {
notMerge: boolean;
replaceMerge?: string[];
}
/** Summary of a top-level array key for deletion detection. */
export interface ArraySummary {
/** Unique, sorted string ids extracted from items' `id` field. */
idsSorted: string[];
/** Count of items without an `id` field. */
noIdCount: number;
}
/** Minimal signature of an option used to decide setOption behavior. */
export interface Signature {
/** Lengths of `option.options` and `option.media` (0 if not arrays). */
optionsLength: number;
mediaLength: number;
/** Map of array-typed top-level keys to their summaries. */
arrays: Record<string, ArraySummary | undefined>;
/** Sorted list of object-typed top-level keys. */
objects: string[];
/** Sorted list of scalar-typed top-level keys (string|number|boolean|null). */
scalars: string[];
}
/**
* Read an item's `id` as a string.
* Only accept string or number. Other types are ignored to surface inconsistent data early.
*/
function readId(item: unknown): string | undefined {
if (!isPlainObject(item)) {
return undefined;
}
const raw = (item as { id?: unknown }).id;
if (typeof raw === "string") {
return raw;
}
if (typeof raw === "number" && Number.isFinite(raw)) {
return String(raw);
}
return undefined;
}
/**
* Build a minimal signature from a full ECharts option.
* Only top-level keys are inspected.
*/
export function buildSignature(option: EChartsOption): Signature {
const opt = option as Record<string, unknown>;
const optionsLength = Array.isArray(opt.options)
? (opt.options as unknown[]).length
: 0;
const mediaLength = Array.isArray(opt.media)
? (opt.media as unknown[]).length
: 0;
const arrays: Record<string, ArraySummary | undefined> = Object.create(null);
const objects: string[] = [];
const scalars: string[] = [];
for (const key of Object.keys(opt)) {
if (key === "options" || key === "media") {
continue;
}
const value = opt[key];
if (Array.isArray(value)) {
const items = value as unknown[];
const ids = new Set<string>();
let noIdCount = 0;
for (let i = 0; i < items.length; i++) {
const id = readId(items[i]);
if (id !== undefined) {
ids.add(id);
} else {
noIdCount++;
}
}
const idsSorted = ids.size > 0 ? Array.from(ids).sort() : [];
arrays[key] = { idsSorted, noIdCount };
} else if (isPlainObject(value)) {
objects.push(key);
} else {
// scalar: string | number | boolean | null (undefined is treated as "absent")
if (value !== undefined) {
scalars.push(key);
}
}
}
if (objects.length > 1) {
objects.sort();
}
if (scalars.length > 1) {
scalars.sort();
}
return { optionsLength, mediaLength, arrays, objects, scalars };
}
function diffKeys(
prevKeys: readonly string[],
nextKeys: readonly string[],
): string[] {
if (prevKeys.length === 0) {
return [];
}
if (nextKeys.length === 0) {
return prevKeys.slice();
}
const nextSet = new Set(nextKeys);
const missing: string[] = [];
for (let i = 0; i < prevKeys.length; i++) {
const key = prevKeys[i];
if (!nextSet.has(key)) {
missing.push(key);
}
}
return missing;
}
function hasMissingIds(
prevIds: readonly string[],
nextIds: readonly string[],
): boolean {
if (prevIds.length === 0) {
return false;
}
if (nextIds.length === 0) {
return true;
}
const nextSet = new Set(nextIds);
for (let i = 0; i < prevIds.length; i++) {
if (!nextSet.has(prevIds[i])) {
return true;
}
}
return false;
}
export interface PlannedUpdate {
option: EChartsOption;
signature: Signature;
plan: UpdatePlan;
}
/**
* Produce an update plan plus a normalized option that encodes common deletions.
* Falls back to `notMerge: true` when the change looks complex.
*/
export function planUpdate(
prev: Signature | undefined,
option: EChartsOption,
): PlannedUpdate {
const next = buildSignature(option);
if (!prev) {
return { option, signature: next, plan: { notMerge: false } };
}
if (next.optionsLength < prev.optionsLength) {
return { option, signature: next, plan: { notMerge: true } };
}
if (next.mediaLength < prev.mediaLength) {
return { option, signature: next, plan: { notMerge: true } };
}
if (diffKeys(prev.scalars, next.scalars).length > 0) {
return { option, signature: next, plan: { notMerge: true } };
}
const replace = new Set<string>();
const overrides = new Map<string, null | []>();
const missingObjects = diffKeys(prev.objects, next.objects);
for (let i = 0; i < missingObjects.length; i++) {
overrides.set(missingObjects[i], null);
}
for (const key of Object.keys(prev.arrays)) {
const prevArray = prev.arrays[key];
if (!prevArray) {
continue;
}
const nextArray = next.arrays[key];
if (!nextArray) {
if (prevArray.idsSorted.length > 0 || prevArray.noIdCount > 0) {
overrides.set(key, []);
replace.add(key);
}
continue;
}
if (hasMissingIds(prevArray.idsSorted, nextArray.idsSorted)) {
replace.add(key);
continue;
}
if (nextArray.noIdCount < prevArray.noIdCount) {
replace.add(key);
}
}
let normalizedOption = option;
let signature = next;
if (overrides.size > 0) {
const clone = { ...(option as Record<string, unknown>) };
overrides.forEach((value, key) => {
clone[key] = value;
});
normalizedOption = clone as EChartsOption;
signature = buildSignature(normalizedOption);
}
const replaceMerge =
replace.size > 0 ? Array.from(replace).sort() : undefined;
const plan = replaceMerge
? { notMerge: false, replaceMerge }
: { notMerge: false };
return {
option: normalizedOption,
signature,
plan,
};
}

View File

@ -38,3 +38,7 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
return true;
}
export function isPlainObject(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === "object" && !Array.isArray(v);
}

298
tests/smart-update.test.ts Normal file
View File

@ -0,0 +1,298 @@
import { describe, it, expect } from "vitest";
import { buildSignature, planUpdate } from "../src/smart-update";
import type { EChartsOption } from "echarts";
describe("smart-update", () => {
describe("buildSignature", () => {
it("collects scalars, objects, and array summaries", () => {
const option: EChartsOption = {
title: { text: "foo" },
tooltip: { show: true },
color: "#000",
dataset: [{ id: "ds1", source: [] }, { source: [] }],
series: [
{ id: "a", type: "bar" },
{ type: "line" },
],
};
const signature = buildSignature(option);
expect(signature.objects).toEqual(["title", "tooltip"]);
expect(signature.scalars).toEqual(["color"]);
expect(signature.arrays.dataset?.idsSorted).toEqual(["ds1"]);
expect(signature.arrays.dataset?.noIdCount).toBe(1);
expect(signature.arrays.series?.idsSorted).toEqual(["a"]);
expect(signature.arrays.series?.noIdCount).toBe(1);
});
});
describe("planUpdate", () => {
describe("bootstrap & neutral cases", () => {
it("returns neutral plan when previous signature missing", () => {
const option: EChartsOption = {
legend: { show: true },
series: [{ type: "bar", data: [1, 2, 3] }],
};
const result = planUpdate(undefined, option);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
expect(result.option).toEqual(option);
});
it("returns neutral plan when signatures match", () => {
const option: EChartsOption = {
title: { text: "foo" },
series: [{ id: "a" }],
};
const prev = buildSignature(option);
const next = planUpdate(prev, option);
expect(next.plan.notMerge).toBe(false);
expect(next.plan.replaceMerge).toBeUndefined();
expect(next.option).toEqual(option);
});
it("keeps merge when scalar value changes", () => {
const prev = buildSignature({ color: "red" });
const next = planUpdate(prev, { color: "blue" });
expect(next.plan.notMerge).toBe(false);
expect(next.plan.replaceMerge).toBeUndefined();
expect(next.option.color).toBe("blue");
});
it("keeps merge when new series IDs are added", () => {
const base: EChartsOption = {
series: [{ id: "latte", type: "bar", data: [10, 20] }],
};
const update: EChartsOption = {
series: [
{ id: "latte", type: "bar", data: [12, 24] },
{ id: "mocha", type: "bar", data: [14, 28] },
],
};
const result = planUpdate(buildSignature(base), update);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
expect(result.option.series).toEqual(update.series);
});
});
describe("shrink detection", () => {
it("forces rebuild when options shrink", () => {
const prev = buildSignature({ options: [{}, {}] });
const { plan } = planUpdate(prev, { options: [{}] });
expect(plan.notMerge).toBe(true);
});
it("forces rebuild when scalars disappear", () => {
const prev = buildSignature({ color: "red", title: { text: "foo" } });
const { plan } = planUpdate(prev, { title: { text: "foo" } });
expect(plan.notMerge).toBe(true);
});
it("injects null for removed objects", () => {
const prev = buildSignature({ legend: { show: true } });
const next = planUpdate(prev, {});
expect(next.option.legend).toBeNull();
expect(next.plan.notMerge).toBe(false);
});
it("injects empty array and replaceMerge when array removed", () => {
const prev = buildSignature({ series: [{ id: "a" }, {}] });
const next = planUpdate(prev, {});
expect(next.option.series).toEqual([]);
expect(next.plan.replaceMerge).toEqual(["series"]);
});
it("adds replaceMerge when ids shrink", () => {
const prev = buildSignature({ series: [{ id: "a" }, { id: "b" }] });
const next = planUpdate(prev, { series: [{ id: "a" }] });
expect(next.plan.replaceMerge).toEqual(["series"]);
});
it("adds replaceMerge when anonymous count shrinks", () => {
const prev = buildSignature({ series: [{}, {}] });
const next = planUpdate(prev, { series: [{}] });
expect(next.plan.replaceMerge).toEqual(["series"]);
});
});
describe("real data scenarios", () => {
it("handles legend removal and series shrink", () => {
const base: EChartsOption = {
legend: { show: true },
dataset: [
{
id: "sales",
source: [
["product", "2015", "2016"],
["Matcha Latte", 43.3, 85.8],
],
},
],
series: [
{ id: "2015", type: "bar", datasetId: "sales" },
{ id: "2016", type: "bar", datasetId: "sales" },
],
};
const update: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["product", "2015"],
["Matcha Latte", 55.1],
],
},
],
series: [{ id: "2015", type: "bar", datasetId: "sales" }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.legend).toBeNull();
expect(result.option.series).toEqual(update.series);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toEqual(["series"]);
});
it("clears dataset when removed entirely", () => {
const base: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["product", "2015"],
["Latte", 30],
],
},
],
series: [{ id: "sales-series", type: "bar", datasetId: "sales" }],
};
const update: EChartsOption = {
series: [{ id: "sales-series", type: "bar", data: [35] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.dataset).toEqual([]);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toContain("dataset");
});
it("tracks multiple array shrink operations", () => {
const base: EChartsOption = {
legend: { show: true },
dataset: [
{
id: "2015",
source: [
["Latte", 30],
["Mocha", 24],
],
},
{
id: "2016",
source: [
["Latte", 40],
["Mocha", 35],
],
},
],
series: [
{ id: "latte", type: "bar", datasetId: "2015" },
{ id: "mocha", type: "bar", datasetId: "2016" },
],
};
const update: EChartsOption = {
series: [{ id: "latte", type: "bar", datasetId: "2015" }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.legend).toBeNull();
expect(result.option.dataset).toEqual([]);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toEqual(["dataset", "series"]);
});
it("injects null for tooltip removal while keeping explicit arrays", () => {
const base: EChartsOption = {
tooltip: { trigger: "axis" },
xAxis: [{ type: "category", data: ["Jan", "Feb"] }],
series: [{ type: "line", data: [10, 20] }],
};
const update: EChartsOption = {
xAxis: [{ type: "category", data: ["Jan", "Feb"] }],
series: [{ type: "line", data: [12, 18] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.tooltip).toBeNull();
expect(result.option.xAxis).toEqual(update.xAxis);
expect(result.plan.notMerge).toBe(false);
expect(result.plan.replaceMerge).toBeUndefined();
});
it("handles dataset to series data migration", () => {
const base: EChartsOption = {
dataset: [
{
id: "sales",
source: [
["Latte", 30],
["Mocha", 40],
],
},
],
series: [{ id: "sales", type: "bar", datasetId: "sales" }],
};
const update: EChartsOption = {
series: [{ id: "sales", type: "bar", data: [35, 44] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.dataset).toEqual([]);
expect(result.option.series).toEqual(update.series);
expect(result.plan.replaceMerge).toEqual(["dataset"]);
});
it("tracks series ID removal while keeping modifications", () => {
const base: EChartsOption = {
series: [
{ id: "latte", type: "bar", data: [10, 20] },
{ id: "mocha", type: "bar", data: [15, 25] },
],
};
const update: EChartsOption = {
series: [{ id: "latte", type: "line", data: [11, 22] }],
};
const result = planUpdate(buildSignature(base), update);
expect(result.option.series).toEqual(update.series);
expect(result.plan.replaceMerge).toEqual(["series"]);
});
});
});
});

8
tsconfig.vitest.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals", "node"],
"noEmit": true
},
"include": ["tests/**/*.ts", "src/**/*.ts"]
}

12
vitest.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
coverage: {
reporter: ["text", "lcov"],
},
},
});