Skip to content

Commit b277130

Browse files
jsflaxclaude
andcommitted
Add visualizer node lifecycle animations and snap-back physics
Golden-orange arrival glow for new memories, red-to-black death glow for deleted memories, and rubber-band snap-back on drag release. Reduced alpha reheat values to minimize disruption from topology changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 47ff437 commit b277130

4 files changed

Lines changed: 212 additions & 18 deletions

File tree

Sources/MemoryVisualizer/ForceSimulation.swift

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,28 @@ final class ForceSimulation: ObservableObject {
7171
func unpinNode(_ id: Int64) {
7272
guard let i = idToIndex[id] else { return }
7373
pinned[i] = false
74-
forceAge = 0 // apply stored forces at full strength for immediate snap-back
74+
75+
// Rubber-band snap: compute velocity impulse toward connected-neighbor centroid
76+
var homeX: CGFloat = 0, homeY: CGFloat = 0, count: CGFloat = 0
77+
for (si, ti) in edgeIndices {
78+
if si == i { homeX += x[ti]; homeY += y[ti]; count += 1 }
79+
else if ti == i { homeX += x[si]; homeY += y[si]; count += 1 }
80+
}
81+
if count > 0 {
82+
homeX /= count; homeY /= count
83+
let dx = homeX - x[i], dy = homeY - y[i]
84+
let dist = sqrt(dx * dx + dy * dy)
85+
if dist > 1 {
86+
// Target ~80% of distance in initial impulse: v₀ / (1-damping) ≈ 0.8*dist
87+
let snapSpeed = min(dist * 0.2, 50)
88+
vx[i] = (dx / dist) * snapSpeed
89+
vy[i] = (dy / dist) * snapSpeed
90+
}
91+
}
92+
93+
forceAge = 0
7594
if !tickInFlight {
76-
dispatchForceComputation() // get fresh forces for the new unpinned state
95+
dispatchForceComputation()
7796
}
7897
}
7998

@@ -139,7 +158,7 @@ final class ForceSimulation: ObservableObject {
139158
nodeTopic[id] = topic
140159
prevTopicGroup = topicGroup
141160

142-
alpha = max(alpha, 0.4)
161+
alpha = max(alpha, 0.05)
143162
topologyVersion &+= 1
144163
rebuildPositions()
145164
}
@@ -179,7 +198,7 @@ final class ForceSimulation: ObservableObject {
179198
nodeTopic.removeValue(forKey: id)
180199
prevTopicGroup = topicGroup
181200

182-
alpha = max(alpha, 0.4)
201+
alpha = max(alpha, 0.05)
183202
topologyVersion &+= 1
184203
rebuildPositions()
185204
}
@@ -188,14 +207,14 @@ final class ForceSimulation: ObservableObject {
188207
guard let si = idToIndex[sourceId], let ti = idToIndex[targetId] else { return }
189208
if edgeIndices.contains(where: { $0 == (si, ti) }) { return }
190209
edgeIndices.append((si, ti))
191-
alpha = max(alpha, 0.3)
210+
alpha = max(alpha, 0.03)
192211
topologyVersion &+= 1
193212
}
194213

195214
func removeEdge(from sourceId: Int64, to targetId: Int64) {
196215
guard let si = idToIndex[sourceId], let ti = idToIndex[targetId] else { return }
197216
edgeIndices.removeAll { $0 == (si, ti) }
198-
alpha = max(alpha, 0.2)
217+
alpha = max(alpha, 0.03)
199218
topologyVersion &+= 1
200219
}
201220

@@ -321,7 +340,7 @@ final class ForceSimulation: ObservableObject {
321340
// Reheat simulation if topology or topic groupings changed
322341
let topicGroupsChanged = topicGroup != prevTopicGroup
323342
if topologyChanged || topicGroupsChanged {
324-
alpha = max(alpha, topologyChanged ? 0.4 : 0.3)
343+
alpha = max(alpha, topologyChanged ? 0.05 : 0.03)
325344
prevTopicGroup = topicGroup
326345
}
327346

@@ -391,7 +410,6 @@ final class ForceSimulation: ObservableObject {
391410
private struct SimState: Sendable {
392411
let n: Int
393412
let x: [CGFloat], y: [CGFloat]
394-
let pinned: [Bool]
395413
let projectGroup: [Int], topicGroup: [Int]
396414
let edgeIndices: [(Int, Int)]
397415
let topicProjectGroup: [Int]
@@ -417,7 +435,7 @@ final class ForceSimulation: ObservableObject {
417435

418436
let state = SimState(
419437
n: n, x: x, y: y,
420-
pinned: pinned, projectGroup: projectGroup,
438+
projectGroup: projectGroup,
421439
topicGroup: topicGroup, edgeIndices: edgeIndices,
422440
topicProjectGroup: topicProjectGroup,
423441
alpha: alpha, center: center,
@@ -452,7 +470,6 @@ final class ForceSimulation: ObservableObject {
452470
let n = s.n
453471
let x = s.x.map(Double.init)
454472
let y = s.y.map(Double.init)
455-
let pinned = s.pinned
456473
let projectGroup = s.projectGroup, topicGroup = s.topicGroup
457474
let alpha = Double(s.alpha)
458475

Sources/MemoryVisualizer/GraphCanvas.swift

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ struct EdgeData {
4646
let relation: String
4747
}
4848

49+
struct DyingNode {
50+
let id: Int64
51+
let position: CGPoint
52+
let project: String
53+
let isHub: Bool
54+
let importance: Int
55+
let startTime: Date
56+
}
57+
4958
// MARK: - Perf tracking (writable from Canvas closure)
5059

5160
private final class PerfStats: @unchecked Sendable {
@@ -67,6 +76,8 @@ struct GraphCanvas: View {
6776
let edges: [EdgeData]
6877
let hubs: Set<Int64>
6978
let glowingNodes: [Int64: Date]
79+
let newNodes: [Int64: Date]
80+
let dyingNodes: [Int64: DyingNode]
7081
let searchMatchIds: Set<Int64>
7182
let isSearchActive: Bool
7283
let viewport: ViewportState
@@ -157,6 +168,8 @@ struct GraphCanvas: View {
157168
simulation.tick()
158169
let needsRedraw = simulation.isActive
159170
|| !glowingNodes.isEmpty
171+
|| !newNodes.isEmpty
172+
|| !dyingNodes.isEmpty
160173
|| viewport.draggedNode != nil
161174
|| viewport.isAnimatingPan
162175
|| (isSearchActive && !searchMatchIds.isEmpty)
@@ -531,6 +544,24 @@ struct GraphCanvas: View {
531544
if ri > 0 { recallIntensities[node.id] = ri }
532545
}
533546

547+
// Precompute arrival (new node) intensities — golden-orange glow
548+
var arrivalIntensities: [Int64: CGFloat] = [:]
549+
for node in allOrdered {
550+
guard let arrivalTime = newNodes[node.id] else { continue }
551+
let elapsed = now.timeIntervalSince(arrivalTime)
552+
let fadeIn: CGFloat = 0.5, hold: CGFloat = 2.0, fadeOut: CGFloat = 3.0
553+
let total = fadeIn + hold + fadeOut
554+
let ai: CGFloat
555+
if elapsed < Double(fadeIn) {
556+
let t = CGFloat(elapsed) / fadeIn; ai = t * t
557+
} else if elapsed < Double(fadeIn + hold) {
558+
ai = 1.0
559+
} else if elapsed < Double(total) {
560+
let t = 1.0 - (CGFloat(elapsed) - fadeIn - hold) / fadeOut; ai = t * t
561+
} else { ai = 0 }
562+
if ai > 0 { arrivalIntensities[node.id] = ai }
563+
}
564+
534565
// Pass 0: Bloom halos behind all nodes
535566
for node in allOrdered {
536567
guard let pos = positions[node.id] else { continue }
@@ -540,6 +571,15 @@ struct GraphCanvas: View {
540571
let isSelected = pk == selectedNode
541572
let searchMatched = isSearchActive && searchMatchIds.contains(pk)
542573

574+
if let ai = arrivalIntensities[pk] {
575+
let pulse = 1.0 + sin(Double(frameCount) * 0.06) * 0.2 * ai
576+
let bloomRadius = (radius + 24) * pulse
577+
let bloomRect = CGRect(
578+
x: pos.x - bloomRadius, y: pos.y - bloomRadius,
579+
width: bloomRadius * 2, height: bloomRadius * 2
580+
)
581+
context.fill(Circle().path(in: bloomRect), with: .color(Color(red: 1.0, green: 0.7, blue: 0.2).opacity(0.35 * ai)))
582+
}
543583
if let ri = recallIntensities[pk] {
544584
let pulse = 1.0 + sin(Double(frameCount) * 0.08) * 0.15 * ri
545585
let bloomRadius = (radius + 20) * pulse
@@ -606,7 +646,14 @@ struct GraphCanvas: View {
606646
let baseLineWidth: CGFloat = isSelected ? 3 : (isHub ? 2.5 : 1)
607647
context.fill(Circle().path(in: rect), with: .color(color.opacity(nodeOpacity)))
608648
context.stroke(Circle().path(in: rect), with: .color(baseStroke.opacity(baseStrokeOpacity)), lineWidth: baseLineWidth)
609-
// Overlay hot white-blue blended by recallIntensity (fades smoothly)
649+
// Overlay golden-orange for newly arrived nodes (fades to project color)
650+
let ai = arrivalIntensities[pk] ?? 0
651+
if ai > 0 {
652+
let golden = Color(red: 1.0, green: 0.7, blue: 0.2)
653+
context.fill(Circle().path(in: rect), with: .color(golden.opacity(0.8 * ai)))
654+
context.stroke(Circle().path(in: rect), with: .color(Color(red: 1.0, green: 0.8, blue: 0.3).opacity(0.9 * ai)), lineWidth: 2)
655+
}
656+
// Overlay hot white-blue blended by recallIntensity (fades smoothly, takes priority over arrival)
610657
if ri > 0 {
611658
let hotWhite = Color(red: 0.9, green: 0.95, blue: 1.0)
612659
context.fill(Circle().path(in: rect), with: .color(hotWhite.opacity(0.85 * ri)))
@@ -635,6 +682,48 @@ struct GraphCanvas: View {
635682
}
636683
}
637684

685+
// --- Pass 1a: Dying nodes (ghost rendering — red to black fade-out) ---
686+
for (_, dying) in dyingNodes {
687+
let pos = dying.position
688+
let importance = max(1, dying.importance)
689+
let radius = baseRadius * (dying.isHub ? hubScale : 1.0) * (1.0 + CGFloat(importance - 1) * 0.08)
690+
let elapsed = now.timeIntervalSince(dying.startTime)
691+
let flashIn: CGFloat = 0.3, hold: CGFloat = 1.2, fadeOut: CGFloat = 1.5
692+
let total = flashIn + hold + fadeOut
693+
guard elapsed < Double(total) else { continue }
694+
695+
let di: CGFloat // death intensity
696+
if elapsed < Double(flashIn) {
697+
let t = CGFloat(elapsed) / flashIn; di = t * t
698+
} else if elapsed < Double(flashIn + hold) {
699+
di = 1.0
700+
} else {
701+
let t = 1.0 - (CGFloat(elapsed) - flashIn - hold) / fadeOut; di = t * t
702+
}
703+
704+
// Shrink during fade-out phase
705+
let shrink: CGFloat = elapsed > Double(flashIn + hold)
706+
? 1.0 - (1.0 - di) * 0.5
707+
: 1.0
708+
let r = radius * shrink
709+
710+
// Red bloom halo
711+
let pulse = 1.0 + sin(Double(frameCount) * 0.1) * 0.2 * di
712+
let bloomRadius = (r + 20) * pulse
713+
let bloomRect = CGRect(
714+
x: pos.x - bloomRadius, y: pos.y - bloomRadius,
715+
width: bloomRadius * 2, height: bloomRadius * 2
716+
)
717+
context.fill(Circle().path(in: bloomRect), with: .color(Color(red: 0.9, green: 0.15, blue: 0.1).opacity(0.4 * di)))
718+
719+
// Node fill: blend from red → dark/black
720+
let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2)
721+
let darkening = elapsed > Double(flashIn) ? min(1.0, (CGFloat(elapsed) - flashIn) / (hold + fadeOut)) : 0.0
722+
let red = Color(red: 0.9 * (1.0 - darkening * 0.8), green: 0.15 * (1.0 - darkening), blue: 0.1 * (1.0 - darkening))
723+
context.fill(Circle().path(in: rect), with: .color(red.opacity(di)))
724+
context.stroke(Circle().path(in: rect), with: .color(Color(red: 1.0, green: 0.2, blue: 0.1).opacity(0.7 * di)), lineWidth: 2)
725+
}
726+
638727
// --- Pass 1b: Topic centroid labels ---
639728
if labelVisible(tier: .topic, scale: viewport.scale) {
640729
for group in topicGroups {

Sources/MemoryVisualizer/GraphOverlays.swift

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,8 @@ struct MinimapView: View {
607607
let filteredNodes: [NodeData]
608608
let hubs: Set<Int64>
609609
let glowingNodes: [Int64: Date]
610+
let newNodes: [Int64: Date]
611+
let dyingNodes: [Int64: DyingNode]
610612
let simulation: ForceSimulation
611613
let viewport: ViewportState
612614
let viewportSize: CGSize
@@ -659,7 +661,7 @@ struct MinimapView: View {
659661
.shadow(color: .black.opacity(isFloating ? 0.5 : 0), radius: 12)
660662
.onHover { isHovered = $0 }
661663
.onReceive(timer) { _ in
662-
if simulation.isActive || !glowingNodes.isEmpty {
664+
if simulation.isActive || !glowingNodes.isEmpty || !newNodes.isEmpty || !dyingNodes.isEmpty {
663665
frameCount &+= 1
664666
}
665667
}
@@ -676,10 +678,11 @@ struct MinimapView: View {
676678
let mapScale = min(size.width / bounds.width, size.height / bounds.height)
677679

678680
let glows = glowingNodes
681+
let arrivals = newNodes
679682
let now = Date()
680683
struct MiniDot {
681684
let mx: CGFloat; let my: CGFloat; let dotSize: CGFloat
682-
let color: Color; let ri: CGFloat
685+
let color: Color; let ri: CGFloat; let ai: CGFloat
683686
}
684687
var dots: [MiniDot] = []
685688
let dotScale: CGFloat = min(size.width, size.height) / 120 // scale dots proportionally
@@ -703,9 +706,29 @@ struct MinimapView: View {
703706
let t = 1.0 - (CGFloat(elapsed) - fadeIn - hold) / fadeOut; ri = t * t
704707
} else { ri = 0 }
705708
} else { ri = 0 }
706-
dots.append(MiniDot(mx: mx, my: my, dotSize: dotSize, color: color, ri: ri))
709+
let ai: CGFloat
710+
if let arrivalTime = arrivals[node.id] {
711+
let elapsed = now.timeIntervalSince(arrivalTime)
712+
let fadeIn: CGFloat = 0.5, hold: CGFloat = 2.0, fadeOut: CGFloat = 3.0
713+
let total = fadeIn + hold + fadeOut
714+
if elapsed < Double(fadeIn) {
715+
let t = CGFloat(elapsed) / fadeIn; ai = t * t
716+
} else if elapsed < Double(fadeIn + hold) {
717+
ai = 1.0
718+
} else if elapsed < Double(total) {
719+
let t = 1.0 - (CGFloat(elapsed) - fadeIn - hold) / fadeOut; ai = t * t
720+
} else { ai = 0 }
721+
} else { ai = 0 }
722+
dots.append(MiniDot(mx: mx, my: my, dotSize: dotSize, color: color, ri: ri, ai: ai))
707723
}
708724

725+
// Arrival bloom (golden-orange)
726+
for d in dots where d.ai > 0 {
727+
let bloomSize = d.dotSize + 8
728+
let bloomRect = CGRect(x: d.mx - bloomSize / 2, y: d.my - bloomSize / 2, width: bloomSize, height: bloomSize)
729+
context.fill(Circle().path(in: bloomRect), with: .color(Color(red: 1.0, green: 0.7, blue: 0.2).opacity(0.45 * d.ai)))
730+
}
731+
// Recall bloom (white-blue)
709732
for d in dots where d.ri > 0 {
710733
let bloomSize = d.dotSize + 6
711734
let bloomRect = CGRect(x: d.mx - bloomSize / 2, y: d.my - bloomSize / 2, width: bloomSize, height: bloomSize)
@@ -715,12 +738,46 @@ struct MinimapView: View {
715738
for d in dots {
716739
let rect = CGRect(x: d.mx - d.dotSize / 2, y: d.my - d.dotSize / 2, width: d.dotSize, height: d.dotSize)
717740
context.fill(Circle().path(in: rect), with: .color(d.color.opacity(0.8)))
741+
if d.ai > 0 {
742+
let golden = Color(red: 1.0, green: 0.7, blue: 0.2)
743+
context.fill(Circle().path(in: rect), with: .color(golden.opacity(0.8 * d.ai)))
744+
}
718745
if d.ri > 0 {
719746
let hotWhite = Color(red: 0.9, green: 0.95, blue: 1.0)
720747
context.fill(Circle().path(in: rect), with: .color(hotWhite.opacity(0.85 * d.ri)))
721748
}
722749
}
723750

751+
// Dying node ghosts (red to black fade-out)
752+
let dying = dyingNodes
753+
for (_, ghost) in dying {
754+
let elapsed = now.timeIntervalSince(ghost.startTime)
755+
let flashIn: CGFloat = 0.3, hold: CGFloat = 1.2, fadeOutD: CGFloat = 1.5
756+
let total = flashIn + hold + fadeOutD
757+
guard elapsed < Double(total) else { continue }
758+
let di: CGFloat
759+
if elapsed < Double(flashIn) {
760+
let t = CGFloat(elapsed) / flashIn; di = t * t
761+
} else if elapsed < Double(flashIn + hold) {
762+
di = 1.0
763+
} else {
764+
let t = 1.0 - (CGFloat(elapsed) - flashIn - hold) / fadeOutD; di = t * t
765+
}
766+
let mx = (ghost.position.x - bounds.minX) * mapScale
767+
let my = (ghost.position.y - bounds.minY) * mapScale
768+
let dotSize: CGFloat = (ghost.isHub ? 4 : 2.5) * dotScale
769+
// Red bloom
770+
let bloomSize = dotSize + 8
771+
let bloomRect = CGRect(x: mx - bloomSize / 2, y: my - bloomSize / 2, width: bloomSize, height: bloomSize)
772+
context.fill(Circle().path(in: bloomRect), with: .color(Color(red: 0.9, green: 0.15, blue: 0.1).opacity(0.5 * di)))
773+
// Red-to-dark dot
774+
let darkening = elapsed > Double(flashIn) ? min(1.0, (CGFloat(elapsed) - flashIn) / (hold + fadeOutD)) : 0.0
775+
let r = dotSize * (elapsed > Double(flashIn + hold) ? 1.0 - (1.0 - di) * 0.5 : 1.0)
776+
let rect = CGRect(x: mx - r / 2, y: my - r / 2, width: r, height: r)
777+
let red = Color(red: 0.9 * (1.0 - darkening * 0.8), green: 0.15 * (1.0 - darkening), blue: 0.1 * (1.0 - darkening))
778+
context.fill(Circle().path(in: rect), with: .color(red.opacity(di)))
779+
}
780+
724781
let vpWorldX = -viewport.offset.x / viewport.scale
725782
let vpWorldY = -viewport.offset.y / viewport.scale
726783
let vpWorldW = viewportSize.width / viewport.scale

0 commit comments

Comments
 (0)