11'use client' ;
22
3- import React , { useState , useRef , ReactNode , useMemo , type ReactElement } from 'react' ;
3+ import React , { useState , useRef , ReactNode , useMemo , useEffect , type ReactElement } from 'react' ;
4+ import { createPortal } from 'react-dom' ;
5+ import { AnimatePresence } from 'framer-motion' ;
6+ import VisualizationTooltip from './dashboard/VisualizationTooltip' ;
47
58// ── Parallax particle configuration ──────────────────────────────────────────
69// Particles are generated deterministically so SSR and client renders match,
@@ -45,6 +48,35 @@ function buildParticles(): ParallaxParticle[] {
4548// container edge. Shallower particles shift proportionally less.
4649const PARALLAX_STRENGTH = 80 ;
4750
51+ interface ActiveTooltipState {
52+ date : string ;
53+ count : number ;
54+ metric : string ;
55+ x : number ;
56+ y : number ;
57+ }
58+
59+ const formatDate = ( dateStr : string ) : string => {
60+ if ( ! dateStr ) return '' ;
61+ const parts = dateStr . split ( '-' ) ;
62+ if ( parts . length !== 3 ) return dateStr ;
63+ const year = parseInt ( parts [ 0 ] , 10 ) ;
64+ const month = parseInt ( parts [ 1 ] , 10 ) ;
65+ const day = parseInt ( parts [ 2 ] , 10 ) ;
66+ if ( isNaN ( year ) || isNaN ( month ) || isNaN ( day ) ) return dateStr ;
67+ try {
68+ const date = new Date ( year , month - 1 , day ) ;
69+ const formatted = date . toLocaleDateString ( 'en-US' , {
70+ month : 'short' ,
71+ day : 'numeric' ,
72+ year : 'numeric' ,
73+ } ) ;
74+ return formatted === 'Invalid Date' ? dateStr : formatted ;
75+ } catch {
76+ return dateStr ;
77+ }
78+ } ;
79+
4880interface InteractiveViewerProps {
4981 children : ReactNode ;
5082 className ?: string ;
@@ -66,6 +98,14 @@ export default function InteractiveViewer({
6698 const isDragging = useRef ( false ) ;
6799 const [ isDraggingState , setIsDraggingState ] = useState ( false ) ;
68100 const lastMousePos = useRef ( { x : 0 , y : 0 } ) ;
101+ const [ activeTooltip , setActiveTooltip ] = useState < ActiveTooltipState | null > ( null ) ;
102+ const activeTooltipRef = useRef < ActiveTooltipState | null > ( null ) ;
103+ const startPointerPos = useRef ( { x : 0 , y : 0 } ) ;
104+ const [ mounted , setMounted ] = useState ( false ) ;
105+ useEffect ( ( ) => {
106+ // eslint-disable-next-line react-hooks/set-state-in-effect
107+ setMounted ( true ) ;
108+ } , [ ] ) ;
69109
70110 // Stable particle list — generated once on mount, never re-shuffled.
71111 const particles = useMemo ( ( ) : ParallaxParticle [ ] => buildParticles ( ) , [ ] ) ;
@@ -131,6 +171,7 @@ export default function InteractiveViewer({
131171 isDragging . current = true ;
132172 setIsDraggingState ( true ) ;
133173 lastMousePos . current = { x : e . clientX , y : e . clientY } ;
174+ startPointerPos . current = { x : e . clientX , y : e . clientY } ;
134175 e . currentTarget . setPointerCapture ( e . pointerId ) ;
135176 } ;
136177
@@ -145,17 +186,80 @@ export default function InteractiveViewer({
145186 }
146187
147188 // Only apply pan logic when actively dragging
148- if ( ! isDragging . current ) return ;
149- const dx = e . clientX - lastMousePos . current . x ;
150- const dy = e . clientY - lastMousePos . current . y ;
151- setPan ( ( p ) => ( { x : p . x + dx , y : p . y + dy } ) ) ;
152- lastMousePos . current = { x : e . clientX , y : e . clientY } ;
189+ if ( isDragging . current ) {
190+ const dx = e . clientX - lastMousePos . current . x ;
191+ const dy = e . clientY - lastMousePos . current . y ;
192+ setPan ( ( p ) => ( { x : p . x + dx , y : p . y + dy } ) ) ;
193+ lastMousePos . current = { x : e . clientX , y : e . clientY } ;
194+ // Hide tooltip during active drag/pan
195+ activeTooltipRef . current = null ;
196+ setActiveTooltip ( null ) ;
197+ return ;
198+ }
199+
200+ // Detect if we are hovering over an interactive tower
201+ const targetElement = e . target as HTMLElement ;
202+ const tower = targetElement . closest ( '.interactive-tower' ) ;
203+ if ( tower ) {
204+ const date = tower . getAttribute ( 'data-date' ) ;
205+ const countStr = tower . getAttribute ( 'data-count' ) ;
206+ const metric = tower . getAttribute ( 'data-metric' ) ;
207+ if ( date && countStr && metric ) {
208+ if ( ! activeTooltipRef . current || activeTooltipRef . current . date !== date ) {
209+ const count = parseInt ( countStr , 10 ) ;
210+ const towerRect = tower . getBoundingClientRect ( ) ;
211+ const newTooltip = {
212+ date,
213+ count,
214+ metric,
215+ x : towerRect . left + towerRect . width / 2 ,
216+ y : towerRect . top ,
217+ } ;
218+ activeTooltipRef . current = newTooltip ;
219+ setActiveTooltip ( newTooltip ) ;
220+ }
221+ }
222+ } else {
223+ if ( activeTooltipRef . current ) {
224+ activeTooltipRef . current = null ;
225+ setActiveTooltip ( null ) ;
226+ }
227+ }
153228 } ;
154229
155230 const handlePointerUp = ( e : React . PointerEvent ) : void => {
156231 isDragging . current = false ;
157232 setIsDraggingState ( false ) ;
158233 e . currentTarget . releasePointerCapture ( e . pointerId ) ;
234+
235+ // If it was a tap (moved very little), show/toggle the tooltip!
236+ const dx = Math . abs ( e . clientX - startPointerPos . current . x ) ;
237+ const dy = Math . abs ( e . clientY - startPointerPos . current . y ) ;
238+ if ( dx < 5 && dy < 5 ) {
239+ const targetElement = document . elementFromPoint ( e . clientX , e . clientY ) as HTMLElement ;
240+ const tower = targetElement ?. closest ( '.interactive-tower' ) ;
241+ if ( tower ) {
242+ const date = tower . getAttribute ( 'data-date' ) ;
243+ const countStr = tower . getAttribute ( 'data-count' ) ;
244+ const metric = tower . getAttribute ( 'data-metric' ) ;
245+ if ( date && countStr && metric ) {
246+ const count = parseInt ( countStr , 10 ) ;
247+ const towerRect = tower . getBoundingClientRect ( ) ;
248+ const newTooltip = {
249+ date,
250+ count,
251+ metric,
252+ x : towerRect . left + towerRect . width / 2 ,
253+ y : towerRect . top ,
254+ } ;
255+ activeTooltipRef . current = newTooltip ;
256+ setActiveTooltip ( newTooltip ) ;
257+ return ;
258+ }
259+ }
260+ }
261+ activeTooltipRef . current = null ;
262+ setActiveTooltip ( null ) ;
159263 } ;
160264
161265 const handlePointerEnter = ( ) : void => setIsHovering ( true ) ;
@@ -164,6 +268,8 @@ export default function InteractiveViewer({
164268 setIsHovering ( false ) ;
165269 // Reset cursor position to center so the glow fades out gracefully from center
166270 setMousePos ( { x : 0.5 , y : 0.5 } ) ;
271+ activeTooltipRef . current = null ;
272+ setActiveTooltip ( null ) ;
167273 } ;
168274
169275 const handleWheel = ( e : React . WheelEvent ) : void => {
@@ -278,6 +384,52 @@ export default function InteractiveViewer({
278384 >
279385 { children }
280386 </ div >
387+
388+ { mounted &&
389+ createPortal (
390+ < AnimatePresence >
391+ { activeTooltip && (
392+ < VisualizationTooltip
393+ title = { formatDate ( activeTooltip . date ) }
394+ x = { activeTooltip . x }
395+ y = { activeTooltip . y }
396+ >
397+ < div className = "flex flex-col gap-1.5 min-w-[140px] p-0.5" >
398+ < div className = "text-[11px] font-semibold text-gray-900 dark:text-zinc-100 flex justify-between items-center" >
399+ < span > Contributions</ span >
400+ < span className = "text-emerald-500 dark:text-emerald-400 font-bold bg-emerald-500/10 px-1.5 py-0.5 rounded text-[10px] min-w-[1.5rem] text-center" >
401+ { activeTooltip . count }
402+ </ span >
403+ </ div >
404+ < div className = "h-px bg-black/5 dark:bg-white/5 w-full" />
405+ < div className = "flex items-center gap-1.5" >
406+ < span
407+ className = { `inline-block w-1.5 h-1.5 rounded-full ${
408+ activeTooltip . metric === 'Peak day'
409+ ? 'bg-emerald-500 shadow-[0_0_6px_#10b981]'
410+ : activeTooltip . metric === 'Active day'
411+ ? 'bg-cyan-500 shadow-[0_0_6px_#06b6d4]'
412+ : 'bg-zinc-400 dark:bg-zinc-500'
413+ } `}
414+ />
415+ < span
416+ className = { `text-[9px] font-bold uppercase tracking-wider ${
417+ activeTooltip . metric === 'Peak day'
418+ ? 'text-emerald-500 dark:text-emerald-400'
419+ : activeTooltip . metric === 'Active day'
420+ ? 'text-cyan-500 dark:text-cyan-400'
421+ : 'text-zinc-500 dark:text-zinc-400'
422+ } `}
423+ >
424+ { activeTooltip . metric }
425+ </ span >
426+ </ div >
427+ </ div >
428+ </ VisualizationTooltip >
429+ ) }
430+ </ AnimatePresence > ,
431+ document . body
432+ ) }
281433 </ div >
282434 ) ;
283435}
0 commit comments