VisActor / VisActor/VChart

[Bug] point mark keeps hover-state size after updateState() during hover (state animation not reverted)

Open
#4,639 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
1.8k
Forks
221
Avg merge
1d 6h
Merged PRs (30d)
26

Description

Version

@visactor/vchart@2.1.5

Description

When a series declares a state animation on the point mark (animationState.point), and a mark's state is changed via updateState() while that mark is in the hover state, the point permanently keeps the hover state's interpolated attribute values after the pointer leaves.

The state itself is removed correctly — graphic.currentStates no longer contains hover — but graphic.attribute.size stays at the hover value instead of returning to the idle value. So this is not a stale-pixel / dirty-rect issue: forcing a full stage.render() does not clear it, because the wrong value is genuinely in the scene graph.

Points that are invisible at rest (idle size resolves to 0) therefore stay permanently visible after the user has moved the mouse away.

The bug only occurs when instance-level animation is enabled. With { animation: false } the same interaction is clean, because state animations never run.

Reproduction

const spec = {
  type: 'common',
  seriesField: 'group',
  animation: true,
  hover: { dimension: {} },
  data: [{ id: 'allData', values: [
    { group: 'cost',    index: 0, value: 400119 }, { group: 'cost',    index: 1, value: 425357 },
    { group: 'cost',    index: 2, value: 339819 }, { group: 'cost',    index: 3, value: 430718 },
    { group: 'revenue', index: 0, value: 553817 }, { group: 'revenue', index: 1, value: 493905 },
    { group: 'revenue', index: 2, value: 446204 }, { group: 'revenue', index: 3, value: 573945 },
  ] }],
  series: [{
    type: 'line',
    dataId: 'allData',
    xField: 'index',
    yField: 'value',
    seriesField: 'group',
    hover: { dimension: {} },
    animation: true,

    // (1) state animation on the point mark
    animationState: { point: { duration: 1000 }, line: false, area: false },

    point: {
      // (2) idle size resolves to 0 -> points are invisible at rest
      style: { size: () => 0, fill: '#fff', lineWidth: () => 1 },
      state: {
        dimension_hover: { globalZIndex: 400, size: () => 8, lineWidth: () => 2, fill: '#fff' },
        hover: { size: () => 10, lineWidth: () => 2, fill: '#fff' },
        // (3) custom states toggled through updateState()
        sel_series: { outerBorder: { stroke: '#4e83fd', lineWidth: 1, distance: 3 } },
        sel_point:  { outerBorder: { stroke: '#4e83fd', lineWidth: 1, distance: 3 } },
      },
    },
  }],
  axes: [{ orient: 'left', type: 'linear' }, { orient: 'bottom', type: 'band' }],
};

const vchart = new VChart(spec, { dom: CONTAINER_ID, animation: true });
vchart.renderSync();

// (4) toggle custom states while the pointer is over a point.
// Both keys are always passed: one filter matches, the other never matches,
// i.e. every call simultaneously adds one state and removes another.
const NEVER = () => false;
let hasSelection = false;
vchart.on('dblclick', e => {
  const datum = Array.isArray(e?.datum) ? e.datum[0] : e?.datum;
  if (!datum?.group) return;
  let seriesFilter = NEVER;
  let pointFilter = NEVER;
  if (hasSelection) {
    pointFilter = d => d?.group === datum.group && d?.index === datum.index;
  } else {
    seriesFilter = d => d?.group === datum.group;
  }
  hasSelection = true;
  vchart.updateState({ sel_series: { filter: seriesFilter }, sel_point: { filter: pointFilter } });
});

Steps

  1. Hover a data point of series cost and double-click it.
  2. Hover a data point of series revenue and double-click it.
  3. Repeat once more on two further points (4 double-clicks total, alternating series).
  4. Move the mouse completely off the chart and wait.

Expected — every point returns to its idle size 0 and becomes invisible again.

Actual — the points touched in steps 1–3 stay at size: 10 (the hover value) and remain visible, although their currentStates no longer contains hover.

Inspecting the scene graph after step 4:

// walk the stage and collect the point symbols
[
  { size: 0,  states: [] },
  { size: 10, states: ['selected'] },          // <-- leaked, should be 0
  { size: 10, states: ['selected'] },          // <-- leaked, should be 0
  { size: 0,  states: [] },
  { size: 0,  states: [] },
  { size: 10, states: ['selected'] },          // <-- leaked, should be 0
  { size: 10, states: ['sel_point', 'selected'] }, // <-- leaked, should be 0
  { size: 0,  states: [] },
]

Measurements

Driven with Playwright, counting points whose resolved attribute.size > 0 after the pointer has left, 4 runs per cell:

instance animation animationState.point leaked points per run
true { duration: 1000 } 4, 4, 4, 4
false { duration: 1000 } 0, 0, 0, 0
true { duration: 300 } 0, 1, 0, 1
true { duration: 1 } 0, 0, 0, 1
true absent 0, 0, 0, 0

The leak rate scales with the state animation's duration, which suggests the interpolated value is committed when a state animation is interrupted by the next state change and never reconciled against the mark's declared idle style.

Two further observations that may help narrow it down:

  • Hovering points without calling updateState() never leaks — the updateState() call during hover is required.
  • With static values (size: 0 / size: 10 instead of callbacks) the leak was not observed. It reproduces with callback-valued styles.

Workaround

Removing animationState from the series spec entirely avoids the problem. Setting animationState.point = false also works. Note that a very short duration ({ duration: 1 }) does not reliably avoid it — it only narrows the race window.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reproducing the supplied VChart spec and trace updateState() while a point's state animation is running, comparing animated and disabled-animation cases. The fix is complete when interrupted state animations reconcile point attributes with the idle style after the pointer leaves, including callback-valued styles, without regressing the provided duration cases.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
data-visualization, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.