@@ -142,6 +142,8 @@ class MapEngine implements MapHandle {
142142 y : 0 ,
143143 hasValue : false ,
144144 }
145+ private readonly pointerTrail = new Float32Array ( 192 )
146+ private trailCount = 0
145147 private readonly onActiveMarkerChange ?: ( id : string | null ) => void
146148 private destroyed = false
147149
@@ -172,6 +174,8 @@ class MapEngine implements MapHandle {
172174
173175 this . fullscreenVAO = this . gl . createVertexArray ( ) as WebGLVertexArrayObject
174176 this . uploadMarkerUniforms ( )
177+ this . pointerTrail . fill ( - 1 )
178+ this . trailCount = 0
175179
176180 this . resizeObserver = new ResizeObserver ( ( ) => this . resizeCanvas ( ) )
177181 this . resizeObserver . observe ( this . canvas )
@@ -366,6 +370,62 @@ class MapEngine implements MapHandle {
366370 this . onActiveMarkerChange ( id )
367371 }
368372
373+ private updatePointerTrail ( px : number , py : number ) {
374+ const now = performance . now ( ) * 0.001
375+ const maxGap = this . cellSize * this . pixelRatio * 1.5
376+
377+ if ( this . trailCount === 0 ) {
378+ const idx = 0
379+ this . pointerTrail [ idx ] = px
380+ this . pointerTrail [ idx + 1 ] = py
381+ this . pointerTrail [ idx + 2 ] = now
382+ this . trailCount = 1
383+ return
384+ }
385+
386+ const lastIdx = 0
387+ const lastX = this . pointerTrail [ lastIdx ]
388+ const lastY = this . pointerTrail [ lastIdx + 1 ]
389+ const lastTime = this . pointerTrail [ lastIdx + 2 ]
390+ const dx = px - lastX
391+ const dy = py - lastY
392+ const dist = Math . sqrt ( dx * dx + dy * dy )
393+
394+ if ( dist <= maxGap ) {
395+ this . addTrailPoint ( px , py , now )
396+ } else {
397+ const steps = Math . ceil ( dist / maxGap )
398+ const stepSize = 1.0 / steps
399+ const timeStep = ( now - lastTime ) / steps
400+
401+ for ( let i = 0 ; i < steps ; i ++ ) {
402+ const t = ( i + 1 ) * stepSize
403+ const interpX = lastX + dx * t
404+ const interpY = lastY + dy * t
405+ const interpTime = lastTime + timeStep * ( i + 1 )
406+ this . addTrailPoint ( interpX , interpY , interpTime )
407+ }
408+ }
409+ }
410+
411+ private addTrailPoint ( px : number , py : number , time : number ) {
412+ if ( this . trailCount >= 64 ) {
413+ for ( let i = 189 ; i >= 3 ; i -= 3 ) {
414+ this . pointerTrail [ i ] = this . pointerTrail [ i - 3 ]
415+ this . pointerTrail [ i - 1 ] = this . pointerTrail [ i - 4 ]
416+ this . pointerTrail [ i - 2 ] = this . pointerTrail [ i - 5 ]
417+ }
418+ } else {
419+ for ( let i = this . trailCount * 3 - 1 ; i >= 0 ; i -- ) {
420+ this . pointerTrail [ i + 3 ] = this . pointerTrail [ i ]
421+ }
422+ this . trailCount ++
423+ }
424+ this . pointerTrail [ 0 ] = px
425+ this . pointerTrail [ 1 ] = py
426+ this . pointerTrail [ 2 ] = time
427+ }
428+
369429 private attachEvents ( ) {
370430 this . canvas . style . cursor = "default"
371431 this . canvas . addEventListener ( "pointerdown" , this . handlePointerDown )
@@ -419,6 +479,10 @@ class MapEngine implements MapHandle {
419479 this . hoverPointer . x = event . clientX
420480 this . hoverPointer . y = event . clientY
421481 this . hoverPointer . hasValue = true
482+ const devicePos = this . pointerToDevice ( event . clientX , event . clientY )
483+ if ( devicePos ) {
484+ this . updatePointerTrail ( devicePos [ 0 ] , devicePos [ 1 ] )
485+ }
422486 if ( ! this . pointer . active ) {
423487 this . updateHoveredMarkerFromClient ( event . clientX , event . clientY )
424488 }
@@ -457,6 +521,8 @@ class MapEngine implements MapHandle {
457521 if ( event . type === "pointerleave" || event . type === "pointercancel" ) {
458522 this . hoverPointer . hasValue = false
459523 this . notifyHoverChange ( - 1 )
524+ this . pointerTrail . fill ( - 1 )
525+ this . trailCount = 0
460526 }
461527 if ( ! this . pointer . active || event . pointerId !== this . pointer . id ) return
462528 this . pointer . active = false
@@ -596,11 +662,35 @@ class MapEngine implements MapHandle {
596662 this . fps = this . fps * 0.9 + instantaneous * 0.1
597663 this . applyInertia ( dt )
598664 this . updateActiveMarkers ( dt )
665+ this . removeCooledTrailPositions ( )
599666 this . render ( )
600667 this . loop ( )
601668 } )
602669 }
603670
671+ private removeCooledTrailPositions ( ) {
672+ if ( this . trailCount === 0 ) return
673+ const now = performance . now ( ) * 0.001
674+ const newestTime = this . pointerTrail [ 2 ]
675+ const maxAge = 2.0
676+ const threshold = newestTime - maxAge
677+
678+ let writeIdx = 0
679+ for ( let i = 0 ; i < this . trailCount ; i ++ ) {
680+ const idx = i * 3
681+ const time = this . pointerTrail [ idx + 2 ]
682+ if ( time >= threshold ) {
683+ if ( writeIdx !== i ) {
684+ this . pointerTrail [ writeIdx * 3 ] = this . pointerTrail [ idx ]
685+ this . pointerTrail [ writeIdx * 3 + 1 ] = this . pointerTrail [ idx + 1 ]
686+ this . pointerTrail [ writeIdx * 3 + 2 ] = this . pointerTrail [ idx + 2 ]
687+ }
688+ writeIdx ++
689+ }
690+ }
691+ this . trailCount = writeIdx
692+ }
693+
604694 private render ( ) {
605695 const gl = this . gl
606696 const deviceRatio = getDevicePixelRatio ( )
@@ -663,6 +753,15 @@ class MapEngine implements MapHandle {
663753 )
664754 setUniform1f ( gl , this . dotsProgram , "uHaloMinOpacity" , this . haloMinOpacity )
665755 setUniform1i ( gl , this . dotsProgram , "uMarkerCount" , this . markerCount )
756+ setUniform1f ( gl , this . dotsProgram , "uTime" , performance . now ( ) * 0.001 )
757+ const trailLocation = gl . getUniformLocation (
758+ this . dotsProgram ,
759+ "uPointerTrail" ,
760+ )
761+ if ( trailLocation ) {
762+ gl . uniform3fv ( trailLocation , this . pointerTrail )
763+ }
764+ setUniform1i ( gl , this . dotsProgram , "uTrailCount" , this . trailCount )
666765 gl . activeTexture ( gl . TEXTURE0 )
667766 gl . bindTexture ( gl . TEXTURE_2D , this . landTexture )
668767 setUniform1i ( gl , this . dotsProgram , "uLand" , 0 )
0 commit comments