summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph/animation.go
blob: 103d43bf7297dc056b4504e83c59ca1f71fa824a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
package flamegraph

import (
	"math"

	"github.com/charmbracelet/harmonica"
)

const springEpsilon = 0.01

type frameSpring struct {
	path        string
	base        tuiFrame
	widthSpring harmonica.Spring
	colSpring   harmonica.Spring

	currentW    float64
	currentCol  float64
	velocityW   float64
	velocityCol float64

	targetW   float64
	targetCol float64
}

// AnimationState stores per-frame spring interpolation state.
type AnimationState struct {
	springs []frameSpring
	frames  []tuiFrame
	settled bool

	fps             int
	angularVelocity float64
	damping         float64
}

// NewAnimationState builds a spring animation state with the provided parameters.
func NewAnimationState(fps int, angularVelocity, damping float64) AnimationState {
	if fps <= 0 {
		fps = 30
	}
	return AnimationState{
		fps:             fps,
		angularVelocity: angularVelocity,
		damping:         damping,
		settled:         true,
	}
}

// SetTargets sets new frame targets, preserving spring motion for matching paths.
func (a *AnimationState) SetTargets(targets []tuiFrame) {
	existing := make(map[string]frameSpring, len(a.springs))
	for _, spring := range a.springs {
		existing[spring.path] = spring
	}

	next := make([]frameSpring, 0, len(targets))
	for _, target := range targets {
		spring, ok := existing[target.Path]
		if !ok {
			spring = frameSpring{
				path:       target.Path,
				currentW:   float64(target.Width),
				currentCol: float64(target.Col),
			}
		}
		spring.base = target
		spring.targetW = float64(target.Width)
		spring.targetCol = float64(target.Col)
		spring.widthSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
		spring.colSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
		next = append(next, spring)
	}
	a.springs = next
	if cap(a.frames) < len(a.springs) {
		a.frames = make([]tuiFrame, len(a.springs))
	} else {
		a.frames = a.frames[:len(a.springs)]
	}
	a.settled = len(a.springs) == 0
	for _, spring := range a.springs {
		if !isSpringSettled(spring) {
			a.settled = false
			break
		}
	}
}

// Tick advances springs by delta seconds and returns true while animation is active.
func (a *AnimationState) Tick(delta float64) bool {
	if len(a.springs) == 0 {
		a.settled = true
		return false
	}
	baseDelta := harmonica.FPS(a.fps)
	if delta <= 0 {
		delta = baseDelta
	}

	active := false
	for idx := range a.springs {
		spring := &a.springs[idx]
		if delta != baseDelta {
			spring.widthSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
			spring.colSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
		}
		spring.currentW, spring.velocityW = spring.widthSpring.Update(spring.currentW, spring.velocityW, spring.targetW)
		spring.currentCol, spring.velocityCol = spring.colSpring.Update(spring.currentCol, spring.velocityCol, spring.targetCol)
		if !isSpringSettled(*spring) {
			active = true
		}
	}
	a.settled = !active
	return active
}

// CurrentFrames returns interpolated frames for the current animation step.
func (a *AnimationState) CurrentFrames() []tuiFrame {
	for idx, spring := range a.springs {
		frame := spring.base
		frame.Col = maxInt(0, int(math.Round(spring.currentCol)))
		frame.Width = maxInt(1, int(math.Round(spring.currentW)))
		a.frames[idx] = frame
	}
	return a.frames
}

// Settled reports whether all active springs are at rest.
func (a AnimationState) Settled() bool {
	return a.settled
}

func isSpringSettled(s frameSpring) bool {
	return math.Abs(s.currentW-s.targetW) < springEpsilon &&
		math.Abs(s.currentCol-s.targetCol) < springEpsilon &&
		math.Abs(s.velocityW) < springEpsilon &&
		math.Abs(s.velocityCol) < springEpsilon
}

func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}