@@ -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
5160private 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 {
0 commit comments