Skip to content

Commit baa64c5

Browse files
committed
fix(bar): only morph surviving bars on data update; snap the rest
Bar-family update animations were producing "from 0,0" artifacts and floating bars on stacked legend toggles. Root cause: after `getPreviousPath` returned the previous render's full `d` string, the path-builders still appended ~5 line commands to it as padding, so `pathFrom` ended up with more SVG commands than `pathTo` and SVG.js morph produced garbage. The padding was only needed for the initial-mount case (where `pathFrom` is a single `move()` and must be padded to match `pathTo`). Restructure each bar/column/funnel/candlestick/boxplot path-builder to build `pathTo` first (incl. `roundPathCorners`), then split on update vs. initial mount: - Update branch calls `getPreviousPath(realIndex, j, pathTo)`, which returns the captured d only if its SVG command count matches `pathTo` (survivor with stable shape → smooth morph). On mismatch (corner state flipped, e.g. bar became new top of stack) or no capture (re-enabled series), returns `pathTo` itself, making `pathFrom === pathTo` a visual no-op (snap). - Initial-mount branch keeps the existing baseline + padding so the rise-from-baseline animation is unchanged.
1 parent 6f42c52 commit baa64c5

4 files changed

Lines changed: 167 additions & 102 deletions

File tree

src/charts/Bar.js

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -608,15 +608,14 @@ class Bar {
608608
const _zeroW = zeroW ?? 0
609609
zeroW =
610610
_zeroW -
611-
/** @type {number} */ ((
611+
/** @type {number} */ (
612612
/** @type {any} */ (
613613
this.barHelpers.getXForValue(
614614
/** @type {any} */ (this.series)[i][j],
615615
_zeroW,
616616
)
617-
)
618-
) -
619-
_zeroW) /
617+
) - _zeroW
618+
) /
620619
2
621620
}
622621

@@ -807,29 +806,52 @@ class Bar {
807806
}
808807
}
809808

810-
/** getPreviousPath is a common function for bars/columns which is used to get previous paths when data changes.
811-
* @memberof Bar
812-
* @param {number} realIndex - current iterating i
813-
* @param {number} j - current iterating series's j index
814-
* @return {string} pathFrom is the string which will be appended in animations
809+
/**
810+
* Resolve `pathFrom` for a bar on data update. Returns the previous render's
811+
* `d` string for the same `(realIndex, j)` only when its SVG command count
812+
* matches `pathTo` — that's the survivor-with-stable-shape case where SVG.js
813+
* morph produces a smooth resize. When commands mismatch (corner state
814+
* flipped, e.g. bar became new top-of-stack after legend toggle) or the bar
815+
* is genuinely new (no captured previous), returns `pathTo` — which makes
816+
* pathFrom === pathTo so morph is a visual no-op (snap).
817+
*
818+
* @param {number} realIndex - stable series index from `data:realIndex`
819+
* @param {number} j - data-point index within the series
820+
* @param {string} pathTo - the freshly-built path for this bar (post-roundPathCorners)
821+
* @returns {string}
815822
**/
816-
getPreviousPath(realIndex, j) {
823+
getPreviousPath(realIndex, j, pathTo) {
817824
const w = this.w
818-
let pathFrom = 'M 0 0'
825+
let oldD = null
819826
for (let pp = 0; pp < w.globals.previousPaths.length; pp++) {
820827
const gpp = w.globals.previousPaths[pp]
821-
822828
if (
823829
gpp.paths &&
824830
gpp.paths.length > 0 &&
825831
parseInt(gpp.realIndex, 10) === parseInt(String(realIndex), 10)
826832
) {
827-
if (typeof w.globals.previousPaths[pp].paths[j] !== 'undefined') {
828-
pathFrom = w.globals.previousPaths[pp].paths[j].d
833+
if (typeof gpp.paths[j] !== 'undefined') {
834+
oldD = gpp.paths[j].d
829835
}
830836
}
831837
}
832-
return pathFrom
838+
if (oldD && Bar.pathCommandCount(oldD) === Bar.pathCommandCount(pathTo)) {
839+
return oldD
840+
}
841+
return pathTo
842+
}
843+
844+
/**
845+
* Count SVG path commands (M, L, C, Q, Z, etc.). Used to detect whether
846+
* two paths can be morphed safely — SVG.js requires matching command counts.
847+
*
848+
* @param {string} d
849+
* @returns {number}
850+
*/
851+
static pathCommandCount(d) {
852+
if (!d) return 0
853+
const matches = d.match(/[A-Za-z]/g)
854+
return matches ? matches.length : 0
833855
}
834856
}
835857

src/charts/BoxCandleStick.js

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,6 @@ class BoxCandleStick extends Bar {
283283
}
284284

285285
let pathTo
286-
let pathFrom = graphics.move(barXPosition + barWidth / 2, y1)
287-
if (w.globals.previousPaths.length > 0) {
288-
pathFrom = this.getPreviousPath(realIndex, j)
289-
}
290-
291286
if (this.isOHLC) {
292287
const centerX = barXPosition + barWidth / 2
293288
const openY = zeroH - ohlc.o / yRatio
@@ -344,7 +339,16 @@ class BoxCandleStick extends Bar {
344339
]
345340
}
346341

347-
pathFrom = pathFrom + graphics.move(barXPosition, y1)
342+
let pathFrom
343+
if (w.globals.previousPaths.length > 0) {
344+
// Update: survivor with matching command count → animate; else snap.
345+
pathFrom = this.getPreviousPath(realIndex, j, pathTo[0])
346+
} else {
347+
// Initial mount: baseline collapsed at the bar's center.
348+
pathFrom =
349+
graphics.move(barXPosition + barWidth / 2, y1) +
350+
graphics.move(barXPosition, y1)
351+
}
348352

349353
if (!w.axisFlags.isXNumeric) {
350354
x = x + xDivision
@@ -435,11 +439,6 @@ class BoxCandleStick extends Bar {
435439
m = zeroW + ohlc.m / yRatio
436440
}
437441

438-
let pathFrom = graphics.move(x1, barYPosition + barHeight / 2)
439-
if (w.globals.previousPaths.length > 0) {
440-
pathFrom = this.getPreviousPath(realIndex, j)
441-
}
442-
443442
const pathTo = [
444443
graphics.move(x1, barYPosition) +
445444
graphics.line(x1, barYPosition + barHeight / 2) +
@@ -466,7 +465,16 @@ class BoxCandleStick extends Bar {
466465
'z',
467466
]
468467

469-
pathFrom = pathFrom + graphics.move(x1, barYPosition)
468+
let pathFrom
469+
if (w.globals.previousPaths.length > 0) {
470+
// Update: survivor with matching command count → animate; else snap.
471+
pathFrom = this.getPreviousPath(realIndex, j, pathTo[0])
472+
} else {
473+
// Initial mount: baseline collapsed at the bar's center.
474+
pathFrom =
475+
graphics.move(x1, barYPosition + barHeight / 2) +
476+
graphics.move(x1, barYPosition)
477+
}
470478

471479
if (!w.axisFlags.isXNumeric) {
472480
y = y + yDivision

src/charts/common/bar/Helpers.js

Lines changed: 58 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -485,47 +485,44 @@ export default class Helpers {
485485
y1 += 0.001 - strokeCenter * direction
486486
y2 += 0.001 + strokeCenter * direction
487487

488-
let pathTo = graphics.move(x1, y1)
489-
let pathFrom = graphics.move(x1, y1)
490-
491488
const sl = graphics.line(x2, y1)
492-
if (w.globals.previousPaths.length > 0) {
493-
pathFrom = this.barCtx.getPreviousPath(realIndex, j, false)
494-
}
495-
496-
pathTo =
497-
pathTo +
498-
graphics.line(x1, y2) +
499-
graphics.line(x2, y2) +
500-
sl +
501-
(w.config.plotOptions.bar.borderRadiusApplication === 'around' ||
489+
const closing =
490+
w.config.plotOptions.bar.borderRadiusApplication === 'around' ||
502491
this.arrBorderRadius[realIndex][j] === 'both'
503492
? ' Z'
504-
: ' z')
493+
: ' z'
505494

506-
// the lines in pathFrom are repeated to equal it to the points of pathTo
507-
// this is to avoid weird animation (bug in svg.js)
508-
pathFrom =
509-
pathFrom +
510-
graphics.line(x1, y1) +
511-
sl +
512-
sl +
513-
sl +
514-
sl +
495+
let pathTo =
496+
graphics.move(x1, y1) +
497+
graphics.line(x1, y2) +
498+
graphics.line(x2, y2) +
515499
sl +
516-
graphics.line(x1, y1) +
517-
(w.config.plotOptions.bar.borderRadiusApplication === 'around' ||
518-
this.arrBorderRadius[realIndex][j] === 'both'
519-
? ' Z'
520-
: ' z')
521-
500+
closing
522501
if (this.arrBorderRadius[realIndex][j] !== 'none') {
523502
pathTo = graphics.roundPathCorners(
524503
pathTo,
525504
w.config.plotOptions.bar.borderRadius
526505
)
527506
}
528507

508+
let pathFrom
509+
if (w.globals.previousPaths.length > 0) {
510+
// Update: survivor with matching command count → animate; else snap.
511+
pathFrom = this.barCtx.getPreviousPath(realIndex, j, pathTo)
512+
} else {
513+
// Initial mount: rise from baseline; pad command count to match pathTo.
514+
pathFrom =
515+
graphics.move(x1, y1) +
516+
graphics.line(x1, y1) +
517+
sl +
518+
sl +
519+
sl +
520+
sl +
521+
sl +
522+
graphics.line(x1, y1) +
523+
closing
524+
}
525+
529526
if (w.config.chart.stacked) {
530527
let _ctx = this.barCtx
531528
_ctx = this.barCtx[seriesGroup]
@@ -600,11 +597,12 @@ export default class Helpers {
600597
graphics.line(bottomLeftX, y2) +
601598
' Z'
602599

603-
let pathFrom = graphics.move(center, y1)
600+
let pathFrom
604601
if (w.globals.previousPaths.length > 0) {
605-
pathFrom = this.barCtx.getPreviousPath(realIndex, j, false)
602+
// Update: survivor with matching command count → animate; else snap.
603+
pathFrom = this.barCtx.getPreviousPath(realIndex, j, pathTo)
606604
} else {
607-
// Start collapsed at the centerline so the trapezoid expands outward.
605+
// Initial mount: collapsed at the centerline so the trapezoid expands outward.
608606
pathFrom =
609607
graphics.move(center, y1) +
610608
graphics.line(center, y1) +
@@ -672,46 +670,45 @@ export default class Helpers {
672670
const isFunnel = this.barCtx.isFunnel
673671
const fromX = isFunnel ? (x1 + x2) / 2 : x1
674672

675-
let pathTo = graphics.move(x1, y1)
676-
let pathFrom = graphics.move(fromX, y1)
677-
678-
if (w.globals.previousPaths.length > 0) {
679-
pathFrom = this.barCtx.getPreviousPath(realIndex, j, false)
680-
}
681-
682673
const sl = graphics.line(x1, y2)
683-
pathTo =
684-
pathTo +
685-
graphics.line(x2, y1) +
686-
graphics.line(x2, y2) +
687-
sl +
688-
(w.config.plotOptions.bar.borderRadiusApplication === 'around' ||
674+
const closing =
675+
w.config.plotOptions.bar.borderRadiusApplication === 'around' ||
689676
this.arrBorderRadius[realIndex][j] === 'both'
690677
? ' Z'
691-
: ' z')
692-
693-
const slFrom = isFunnel ? graphics.line(fromX, y2) : sl
694-
pathFrom =
695-
pathFrom +
696-
graphics.line(fromX, y1) +
697-
slFrom +
698-
slFrom +
699-
slFrom +
700-
slFrom +
701-
slFrom +
702-
graphics.line(fromX, y1) +
703-
(w.config.plotOptions.bar.borderRadiusApplication === 'around' ||
704-
this.arrBorderRadius[realIndex][j] === 'both'
705-
? ' Z'
706-
: ' z')
678+
: ' z'
707679

680+
let pathTo =
681+
graphics.move(x1, y1) +
682+
graphics.line(x2, y1) +
683+
graphics.line(x2, y2) +
684+
sl +
685+
closing
708686
if (this.arrBorderRadius[realIndex][j] !== 'none') {
709687
pathTo = graphics.roundPathCorners(
710688
pathTo,
711689
w.config.plotOptions.bar.borderRadius
712690
)
713691
}
714692

693+
let pathFrom
694+
if (w.globals.previousPaths.length > 0) {
695+
// Update: survivor with matching command count → animate; else snap.
696+
pathFrom = this.barCtx.getPreviousPath(realIndex, j, pathTo)
697+
} else {
698+
// Initial mount: rise from baseline; pad command count to match pathTo.
699+
const slFrom = isFunnel ? graphics.line(fromX, y2) : sl
700+
pathFrom =
701+
graphics.move(fromX, y1) +
702+
graphics.line(fromX, y1) +
703+
slFrom +
704+
slFrom +
705+
slFrom +
706+
slFrom +
707+
slFrom +
708+
graphics.line(fromX, y1) +
709+
closing
710+
}
711+
715712
if (w.config.chart.stacked) {
716713
let _ctx = this.barCtx
717714
_ctx = this.barCtx[seriesGroup]

0 commit comments

Comments
 (0)