Skip to content

Circle shape writes its label-derived size into node.options.size, leaking into every shape selected afterwards #2516

Description

@peter-villadsen

Version: 10.1.0
Browser: Chromium (WebView2), also reproducible in any browser

Summary

Circle.resize() stores its computed radius on the node's options object:

this.options.size = diameter / 2;

A shape shares the same options object as its Node (new Circle(this.options, …) in Node.updateShape), so this creates an own size property on the node that shadows the value from nodes.size / group / global defaults. It is never cleared.

As a result, once a node has been rendered as a circle, switching nodes.shape to any label-outside shape sizes that node from the leftover value — dot, star, square etc. compute width = height = 2 * options.size. Every node ends up with the radius of the label it happened to have while it was a circle, so a formerly-circle graph renders as dots of wildly differing sizes with no per-node size anywhere in the data.

Steps to reproduce

const nodes = new vis.DataSet([
  { id: 1, label: 'A' },
  { id: 2, label: 'a very much longer label' },
]);
const network = new vis.Network(container, { nodes, edges: new vis.DataSet([]) }, {
  nodes: { shape: 'dot', size: 25 },
});

// Both nodes are 25 here, as configured.
console.log(network.body.nodes[1].options.size,
            network.body.nodes[2].options.size);   // 25 25

network.setOptions({ nodes: { shape: 'circle', size: 25 } });
// (allow a frame to render, e.g. in network.once('afterDrawing', …))

network.setOptions({ nodes: { shape: 'dot', size: 25 } });

console.log(network.body.nodes[1].options.size,
            network.body.nodes[2].options.size);   // e.g. 21 78 - label-derived, not 25

Expected: both nodes return to size: 25 and render identically, since neither has a size in its data.

Actual: each node keeps half the diameter of the circle it was, and the dot rendering reflects that. The graph never recovers until the node data is reloaded.

Why it does not self-correct

  • NodesHandler.setOptions updates only the handler's own options. For shape it calls node.updateShape(), and for size it calls node.needsRefresh() — neither rewrites node.options.size, so the shadowing own property survives and the forced re-measure just reads it back.
  • The only restore path is Node.setValueRange, whose else-branch does this.options.size = this.baseSize. That runs when node data is (re)loaded, not when options change.

Suggested fix

options.size is being used as a scratch field to hand the derived radius to Circle.updateBoundingBox, which reads it for all four bounds:

updateBoundingBox(x, y) {
    this.boundingBox.top    = y - this.options.size;
    this.boundingBox.left   = x - this.options.size;
    this.boundingBox.right  = x + this.options.size;
    this.boundingBox.bottom = y + this.options.size;
}

resize() already sets this.radius = this.width / 2 alongside it, so updateBoundingBox can use this.radius (or this.width / 2) and the write to this.options.size can be dropped entirely. That keeps the derived radius on the shape, where it belongs, instead of mutating shared node options.

Note the write cannot simply be removed without that change — updateBoundingBox would then read a stale size and break hit-testing, fit() and rubber-band selection for circle nodes.

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions