@@ -142,6 +142,93 @@ let countdownLayer = g
142142 . append ( "g" )
143143 . attr ( "id" , "countdown" )
144144
145+ // context from the last draw, so job updates can refresh just the pool cloud (and
146+ // detect whether a full redraw is actually needed) without re-rendering the blocks.
147+ let miningDrawCtx = null
148+
149+ // invert the current stratum jobs (state_stratum_jobs, one entry per pool, kept up to
150+ // date in main.js) into a map of prev_hash -> { parent, pool_names }: the pools mining
151+ // on each block we recognise. Expired pools are pruned here so the set stays current
152+ // without a separate timer.
153+ function current_mining_by_prev ( header_infos ) {
154+ let result = new Map ( )
155+ if ( typeof state_stratum_jobs === "undefined" || state_stratum_jobs . size === 0 ) {
156+ return result
157+ }
158+ const now = Date . now ( )
159+ // index the real headers by hash so we can resolve a job's parent block
160+ let by_hash = new Map ( )
161+ header_infos . forEach ( h => by_hash . set ( h . hash , h ) )
162+
163+ state_stratum_jobs . forEach ( ( job , pool_name ) => {
164+ // drop pools we haven't heard from in a while
165+ if ( now - job . last_seen > STRATUM_JOB_TTL_MS ) {
166+ state_stratum_jobs . delete ( pool_name )
167+ return
168+ }
169+ // only show ghosts that build on a block we actually know about
170+ let parent = by_hash . get ( job . prev_hash )
171+ if ( parent === undefined ) return
172+ let entry = result . get ( job . prev_hash )
173+ if ( entry === undefined ) {
174+ entry = { parent, pool_names : [ ] }
175+ result . set ( job . prev_hash , entry )
176+ }
177+ entry . pool_names . push ( pool_name )
178+ } )
179+ // stable order, so the labels don't get reshuffled between refreshes
180+ result . forEach ( entry => entry . pool_names . sort ( ) )
181+ return result
182+ }
183+
184+ // build synthetic "being mined" header objects to inject into the tree. One ghost
185+ // block per prev_hash we recognise, aggregating all pools mining on top of it.
186+ function build_mining_headers ( header_infos ) {
187+ let mining_headers = [ ]
188+ current_mining_by_prev ( header_infos ) . forEach ( ( { parent, pool_names } , prev_hash ) => {
189+ mining_headers . push ( {
190+ id : "mining-" + prev_hash ,
191+ prev_id : parent . id ,
192+ height : parent . height + 1 ,
193+ hash : "mining-" + prev_hash ,
194+ prev_blockhash : prev_hash ,
195+ // pool names are shown in a force-positioned cloud around the block, so the
196+ // block itself carries no miner label
197+ miner : "" ,
198+ // not MIN_DIFFICULTY, so it doesn't get the accent-colored stroke
199+ difficulty_int : 0 ,
200+ status : "mining" ,
201+ mining_pools : pool_names ,
202+ } )
203+ } )
204+ return mining_headers
205+ }
206+
207+ // called when new stratum jobs arrive. If the set of being-mined blocks is unchanged
208+ // (the common case: same tip, just a shifting pool set) only the pool cloud is
209+ // refreshed, leaving the block DOM — and its running pulse animation — untouched. A
210+ // full redraw happens only when a ghost block appears or disappears.
211+ function refresh_mining ( ) {
212+ if ( miningDrawCtx === null ) {
213+ draw ( { preserveView : true } )
214+ return
215+ }
216+ let desired = current_mining_by_prev ( miningDrawCtx . header_infos )
217+ let current_keys = miningDrawCtx . ghostByPrev
218+ let same = desired . size === current_keys . size &&
219+ Array . from ( desired . keys ( ) ) . every ( k => current_keys . has ( k ) )
220+ if ( ! same ) {
221+ draw ( { preserveView : true } )
222+ return
223+ }
224+ // same set of ghosts: update their pool lists in place and re-lay-out the cloud only
225+ desired . forEach ( ( { pool_names } , prev_hash ) => {
226+ let node = current_keys . get ( prev_hash )
227+ if ( node ) node . data . data . mining_pools = pool_names
228+ } )
229+ draw_mining_pool_clouds ( miningDrawCtx . root_node , miningDrawCtx . htoi )
230+ }
231+
145232function preprocess_data ( data ) {
146233 let header_infos = data . header_infos ;
147234 let node_infos = data . nodes ;
@@ -166,13 +253,18 @@ function preprocess_data(data) {
166253 header_info . is_tip = status != undefined
167254 } )
168255
256+ // synthetic "being mined" blocks from the stratum jobs feed, injected as children
257+ // of the block each pool builds on. max_height (below) stays based on the real
258+ // headers only, so these ghosts are not treated as the animated newest block.
259+ let mining_headers = build_mining_headers ( header_infos )
260+
169261 var treeData = d3
170262 . stratify ( )
171263 . id ( d => d . id )
172264 . parentId ( function ( d ) {
173265 // d3js requires the first prev block hash to be null
174266 return ( d . prev_id == MAX_USIZE ? null : d . prev_id )
175- } ) ( header_infos ) ;
267+ } ) ( header_infos . concat ( mining_headers ) ) ;
176268
177269 stripUninteresting ( treeData , 4 )
178270
@@ -214,7 +306,8 @@ function preprocess_data(data) {
214306 return [ root_node , max_height , htoi ]
215307}
216308
217- function draw ( ) {
309+ function draw ( opts ) {
310+ opts = opts || { }
218311 let data = state_data
219312
220313 // nothing to draw if there are no headers
@@ -434,6 +527,13 @@ function draw() {
434527 // pool names orbiting each "being mined" block, laid out with a force simulation
435528 draw_mining_pool_clouds ( root_node , htoi )
436529
530+ // remember what we drew so incoming jobs can refresh the cloud in place, without a
531+ // full redraw that would restart the ghost blocks' pulse animation
532+ let ghostByPrev = new Map ( )
533+ root_node . descendants ( ) . filter ( d => d . data . data . status == "mining" )
534+ . forEach ( d => ghostByPrev . set ( d . data . data . prev_blockhash , d ) )
535+ miningDrawCtx = { root_node, htoi, header_infos : data . header_infos , ghostByPrev }
536+
437537 // size the miner background box to fit its (already positioned) text
438538 recalc_miner_boxes ( )
439539
@@ -519,18 +619,21 @@ function draw() {
519619 } )
520620 descLayer . raise ( )
521621
522- // most redraws (a node's data changed) re-render the very same layout, so the block
523- // we anchor on hasn't moved. Panning the camera to where it already is still runs a
524- // zoom transition, and that is visible as a jump — so only re-anchor when there is
525- // something to re-anchor to.
622+ // most redraws (a node's data changed, a new stratum job ) re-render the very same
623+ // layout, so the block we anchor on hasn't moved. Panning the camera to where it
624+ // already is still runs a zoom transition, and that is visible as a jump — so only
625+ // re-anchor when there is something to re-anchor to.
526626 let anchor_moved = offset_x != lastTipPos . x || offset_y != lastTipPos . y
527627 lastTipPos = { x : offset_x , y : offset_y }
528628
529- if ( initialDraw || anchor_moved ) {
629+ // job-triggered redraws (new stratum jobs arriving) pass preserveView so the
630+ // viewport isn't yanked back to the tip while the user is panning around.
631+ if ( ! opts . preserveView && ( initialDraw || anchor_moved ) ) {
530632 zoom . scaleBy ( svg , 1 ) ;
531633 let svgSize = d3 . select ( "#drawing-area" ) . node ( ) . getBoundingClientRect ( ) ;
532634 zoom . translateTo ( svg . transition ( d3 . transition ( ) . duration ( initialDraw ? 0 : 750 ) ) , offset_x , offset_y , o . tip_anchor ( svgSize . width , svgSize . height ) )
533-
635+ // only clear this once the view has actually been anchored, so a job-triggered
636+ // preserveView redraw can't consume it before the first real draw
534637 initialDraw = false
535638 }
536639}
0 commit comments