mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-10-27 19:13:59 +08:00
feat: add smart update
This commit is contained in:
19
README.md
19
README.md
@ -151,13 +151,11 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
|
|||||||
|
|
||||||
- `option: object`
|
- `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](#smart-updates) and call `setOption`. 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.
|
|
||||||
|
|
||||||
- `update-options: object`
|
- `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 plan](#smart-updates). See `echartsInstance.setOption`'s `opts` parameter [here →](https://echarts.apache.org/en/api.html#echartsInstance.setOption)
|
||||||
|
|
||||||
Injection key: `UPDATE_OPTIONS_KEY`.
|
Injection key: `UPDATE_OPTIONS_KEY`.
|
||||||
|
|
||||||
@ -181,7 +179,18 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
|
|||||||
|
|
||||||
- `manual-update: boolean` (default: `false`)
|
- `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`).
|
||||||
|
|
||||||
|
#### Smart updates
|
||||||
|
|
||||||
|
Vue ECharts analyses each change to `option` before invoking `setOption`:
|
||||||
|
|
||||||
|
- Removed config objects (`legend`, `tooltip`, etc.) are replaced with `null` automatically so ECharts clears them.
|
||||||
|
- Removed arrays (`series`, `dataset`, …) are converted to empty arrays and marked via `replaceMerge` so old data disappears.
|
||||||
|
- If the diff looks risky (shrinking `options`/`media`, dropping scalar values), the component falls back to `notMerge: true` to rebuild the chart safely.
|
||||||
|
- Supplying `update-options` (or providing it via inject) bypasses the planner and uses your configuration as-is.
|
||||||
|
|
||||||
|
Reactive updates use this logic by default. Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts (apart from slot patching) and honour only the per-call override you provide.
|
||||||
|
|
||||||
### Events
|
### Events
|
||||||
|
|
||||||
|
|||||||
@ -153,11 +153,9 @@ 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`。
|
|
||||||
|
|
||||||
- `update-options: object`
|
- `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`。
|
Inject 键名:`UPDATE_OPTIONS_KEY`。
|
||||||
|
|
||||||
@ -181,7 +179,18 @@ app.component('v-chart', VueECharts)
|
|||||||
|
|
||||||
- `manual-update: boolean`(默认值`false`)
|
- `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` 会被忽略)。
|
||||||
|
|
||||||
|
### 智能更新
|
||||||
|
|
||||||
|
Vue ECharts 在调用 `setOption` 之前会分析每次 `option` 变化:
|
||||||
|
|
||||||
|
- 删除顶层对象(如 `legend`、`tooltip` 等)时,会自动写入 `null`,让 ECharts 清空旧配置。
|
||||||
|
- 移除数组(如 `series`、`dataset` 等)时,会写入空数组并通过 `replaceMerge` 清除旧数据。
|
||||||
|
- 如果检测到风险较高的变更(`options`/`media` 缩小、标量被删除等),会退回到 `notMerge: true` 以保证安全。
|
||||||
|
- 如果提供了 `update-options`(或注入了默认值),会直接使用该配置并跳过智能计划。
|
||||||
|
|
||||||
|
默认情况下响应式更新都会使用这套逻辑。手动调用 `setOption`(仅当 `manual-update` 为 `true` 时可用)与原生 ECharts 一致,仅应用你在本次调用中传入的更新参数。
|
||||||
|
|
||||||
### 事件
|
### 事件
|
||||||
|
|
||||||
|
|||||||
115
src/ECharts.ts
115
src/ECharts.ts
@ -11,8 +11,10 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
watchEffect,
|
watchEffect,
|
||||||
toValue,
|
toValue,
|
||||||
|
warn,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { init as initChart } from "echarts/core";
|
import { init as initChart } from "echarts/core";
|
||||||
|
import type { EChartsOption } from "echarts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
usePublicAPI,
|
usePublicAPI,
|
||||||
@ -25,6 +27,8 @@ import {
|
|||||||
import type { PublicMethods, SlotsTypes } from "./composables";
|
import type { PublicMethods, SlotsTypes } from "./composables";
|
||||||
import { isOn, omitOn } from "./utils";
|
import { isOn, omitOn } from "./utils";
|
||||||
import { register, TAG_NAME } from "./wc";
|
import { register, TAG_NAME } from "./wc";
|
||||||
|
import { planUpdate } from "./merge";
|
||||||
|
import type { Signature, UpdatePlan } from "./merge";
|
||||||
|
|
||||||
import type { PropType, InjectionKey } from "vue";
|
import type { PropType, InjectionKey } from "vue";
|
||||||
import type {
|
import type {
|
||||||
@ -71,24 +75,21 @@ export default defineComponent({
|
|||||||
setup(props, { attrs, expose, slots }) {
|
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 defaultTheme = inject(THEME_KEY, null);
|
const defaultTheme = inject(THEME_KEY, null);
|
||||||
const defaultInitOptions = inject(INIT_OPTIONS_KEY, null);
|
const defaultInitOptions = inject(INIT_OPTIONS_KEY, null);
|
||||||
const defaultUpdateOptions = inject(UPDATE_OPTIONS_KEY, null);
|
const defaultUpdateOptions = inject(UPDATE_OPTIONS_KEY, null);
|
||||||
|
|
||||||
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
|
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
|
||||||
|
|
||||||
const realOption = computed(
|
const realOption = computed(() => props.option || undefined);
|
||||||
() => manualOption.value || props.option || null,
|
|
||||||
);
|
|
||||||
const realTheme = computed(
|
const realTheme = computed(
|
||||||
() => props.theme || toValue(defaultTheme) || {},
|
() => props.theme || toValue(defaultTheme) || undefined,
|
||||||
);
|
);
|
||||||
const realInitOptions = computed(
|
const realInitOptions = computed(
|
||||||
() => props.initOptions || toValue(defaultInitOptions) || {},
|
() => props.initOptions || toValue(defaultInitOptions) || undefined,
|
||||||
);
|
);
|
||||||
const realUpdateOptions = computed(
|
const realUpdateOptions = computed(
|
||||||
() => props.updateOptions || toValue(defaultUpdateOptions) || {},
|
() => props.updateOptions || toValue(defaultUpdateOptions) || undefined,
|
||||||
);
|
);
|
||||||
const nonEventAttrs = computed(() => omitOn(attrs));
|
const nonEventAttrs = computed(() => omitOn(attrs));
|
||||||
const nativeListeners: Record<string, unknown> = {};
|
const nativeListeners: Record<string, unknown> = {};
|
||||||
@ -98,13 +99,72 @@ export default defineComponent({
|
|||||||
|
|
||||||
const { teleportedSlots, patchOption } = useSlotOption(slots, () => {
|
const { teleportedSlots, patchOption } = useSlotOption(slots, () => {
|
||||||
if (!manualUpdate.value && props.option && chart.value) {
|
if (!manualUpdate.value && props.option && chart.value) {
|
||||||
chart.value.setOption(
|
applyOption(chart.value, props.option);
|
||||||
patchOption(props.option),
|
|
||||||
realUpdateOptions.value,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 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
|
||||||
@ -140,7 +200,7 @@ export default defineComponent({
|
|||||||
listeners.set({ event, zr, once }, attrs[key]);
|
listeners.set({ event, zr, once }, attrs[key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
function init(option?: Option) {
|
function init(option?: Option, manual = false, override?: UpdateOptions) {
|
||||||
if (!root.value) {
|
if (!root.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -186,7 +246,8 @@ export default defineComponent({
|
|||||||
function commit() {
|
function commit() {
|
||||||
const opt = option || realOption.value;
|
const opt = option || realOption.value;
|
||||||
if (opt) {
|
if (opt) {
|
||||||
instance.setOption(patchOption(opt), realUpdateOptions.value);
|
applyOption(instance, opt, override, manual);
|
||||||
|
override = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,17 +267,20 @@ export default defineComponent({
|
|||||||
notMerge,
|
notMerge,
|
||||||
lazyUpdate?: boolean,
|
lazyUpdate?: boolean,
|
||||||
) => {
|
) => {
|
||||||
|
if (!props.manualUpdate) {
|
||||||
|
warn(
|
||||||
|
"[vue-echarts] setOption is only available when manual-update is true.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updateOptions =
|
const updateOptions =
|
||||||
typeof notMerge === "boolean" ? { notMerge, lazyUpdate } : notMerge;
|
typeof notMerge === "boolean" ? { notMerge, lazyUpdate } : notMerge;
|
||||||
|
|
||||||
if (props.manualUpdate) {
|
|
||||||
manualOption.value = option;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chart.value) {
|
if (!chart.value) {
|
||||||
init(option);
|
init(option, true, updateOptions ?? undefined);
|
||||||
} else {
|
} else {
|
||||||
chart.value.setOption(patchOption(option), updateOptions);
|
applyOption(chart.value, option, updateOptions ?? undefined, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -225,6 +289,7 @@ export default defineComponent({
|
|||||||
chart.value.dispose();
|
chart.value.dispose();
|
||||||
chart.value = undefined;
|
chart.value = undefined;
|
||||||
}
|
}
|
||||||
|
lastSignature = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let unwatchOption: (() => void) | null = null;
|
let unwatchOption: (() => void) | null = null;
|
||||||
@ -239,19 +304,15 @@ export default defineComponent({
|
|||||||
if (!manualUpdate) {
|
if (!manualUpdate) {
|
||||||
unwatchOption = watch(
|
unwatchOption = watch(
|
||||||
() => props.option,
|
() => props.option,
|
||||||
(option, oldOption) => {
|
(option) => {
|
||||||
if (!option) {
|
if (!option) {
|
||||||
|
lastSignature = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!chart.value) {
|
if (!chart.value) {
|
||||||
init();
|
init();
|
||||||
} else {
|
} else {
|
||||||
chart.value.setOption(patchOption(option), {
|
applyOption(chart.value, option);
|
||||||
// mutating `option` will lead to `notMerge: false` and
|
|
||||||
// replacing it with new reference will lead to `notMerge: true`
|
|
||||||
notMerge: option !== oldOption,
|
|
||||||
...realUpdateOptions.value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
@ -277,7 +338,7 @@ export default defineComponent({
|
|||||||
watch(
|
watch(
|
||||||
realTheme,
|
realTheme,
|
||||||
(theme) => {
|
(theme) => {
|
||||||
chart.value?.setTheme(theme);
|
chart.value?.setTheme(theme || {});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
deep: true,
|
deep: true,
|
||||||
|
|||||||
243
src/merge.ts
Normal file
243
src/merge.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -38,3 +38,7 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return v != null && typeof v === "object" && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user