@@ -17,6 +17,7 @@ interface Article {
1717  tags ?: string [ ] ; 
1818  x : number ; 
1919  y : number ; 
20+   cluster : number ; 
2021} 
2122
2223export  default  function  KnowledgeMap ( { 
@@ -30,13 +31,10 @@ export default function KnowledgeMap({
3031  const  [ loading ,  setLoading ]  =  useState ( true ) ; 
3132  const  [ error ,  setError ]  =  useState < string  |  null > ( null ) ; 
3233  const  [ hoveredArticle ,  setHoveredArticle ]  =  useState < Article  |  null > ( null ) ; 
33-   const  [ selectedArticle ,  setSelectedArticle ]  =  useState < Article  |  null > ( null ) ; 
3434  const  [ searchQuery ,  setSearchQuery ]  =  useState ( "" ) ; 
3535  const  [ transform ,  setTransform ]  =  useState ( {  k : 1 ,  x : 0 ,  y : 0  } ) ; 
36-   const  [ neighbors ]  =  useState ( 4 ) ; 
37-   const  [ minDist ]  =  useState ( 0.05 ) ; 
38-   const  [ spread ]  =  useState ( 6.0 ) ; 
3936  const  {  theme }  =  useTheme ( ) ; 
37+   const  zoomBehaviorRef  =  useRef < any > ( null ) ; 
4038
4139  // Fetch data from static JSON file 
4240  const  fetchData  =  async  ( )  =>  { 
@@ -76,10 +74,26 @@ export default function KnowledgeMap({
7674    return  dot  /  ( magA  *  magB ) ; 
7775  } ,  [ ] ) ; 
7876
79-   // Render 
77+   // Generate color palette for clusters 
78+   const  getClusterColor  =  useCallback ( 
79+     ( cluster : number ,  isDark : boolean ) : string  =>  { 
80+       if  ( cluster  ===  - 1 )  { 
81+         // Noise points - use default color 
82+         return  isDark  ? "#91989C"  : "#595857" ; 
83+       } 
84+ 
85+       // HSL color palette with good contrast 
86+       const  hue  =  ( cluster  *  137.5 )  %  360 ;  // Golden angle for good distribution 
87+       const  saturation  =  isDark  ? 60  : 55 ; 
88+       const  lightness  =  isDark  ? 60  : 50 ; 
89+       return  `hsl(${ hue }  , ${ saturation }  %, ${ lightness }  %)` ; 
90+     } , 
91+     [ ] 
92+   ) ; 
93+ 
94+   // Render (drawing only, no dimension changes) 
8095  useEffect ( ( )  =>  { 
81-     if  ( ! canvasRef . current  ||  ! containerRef . current  ||  filtered . length  ===  0 ) 
82-       return ; 
96+     if  ( ! canvasRef . current  ||  ! containerRef . current )  return ; 
8397
8498    const  canvas  =  canvasRef . current ; 
8599    const  container  =  containerRef . current ; 
@@ -88,15 +102,18 @@ export default function KnowledgeMap({
88102
89103    const  dpr  =  window . devicePixelRatio  ||  1 ; 
90104    const  rect  =  container . getBoundingClientRect ( ) ; 
91-     canvas . width  =  rect . width  *  dpr ; 
92-     canvas . height  =  rect . height  *  dpr ; 
93-     canvas . style . width  =  `${ rect . width }  px` ; 
94-     canvas . style . height  =  `${ rect . height }  px` ; 
95-     ctx . scale ( dpr ,  dpr ) ; 
96- 
97105    const  width  =  rect . width ; 
98106    const  height  =  rect . height ; 
99107
108+     ctx . setTransform ( 1 ,  0 ,  0 ,  1 ,  0 ,  0 ) ;  // Reset transform 
109+     ctx . scale ( dpr ,  dpr ) ; 
110+ 
111+     // Clear canvas (transparent to show container background with texture) 
112+     ctx . clearRect ( 0 ,  0 ,  width ,  height ) ; 
113+ 
114+     // Early return if no results 
115+     if  ( filtered . length  ===  0 )  return ; 
116+ 
100117    const  xScale  =  scaleLinear ( ) . domain ( [ 0 ,  1000 ] ) . range ( [ 0 ,  width ] ) ; 
101118    const  yScale  =  scaleLinear ( ) . domain ( [ 0 ,  1000 ] ) . range ( [ 0 ,  height ] ) ; 
102119
@@ -116,9 +133,6 @@ export default function KnowledgeMap({
116133      ? "rgba(145, 152, 156, 0.08)" 
117134      : "rgba(89, 88, 87, 0.08)" ; 
118135
119-     // Clear canvas (transparent to show container background with texture) 
120-     ctx . clearRect ( 0 ,  0 ,  width ,  height ) ; 
121- 
122136    ctx . save ( ) ; 
123137    ctx . translate ( transform . x ,  transform . y ) ; 
124138    ctx . scale ( transform . k ,  transform . k ) ; 
@@ -151,13 +165,11 @@ export default function KnowledgeMap({
151165      // Opacity varies by word count (longer posts = more opaque) 
152166      const  baseOpacity  =  Math . min ( 0.8 ,  0.3  +  wordCount  /  2000 ) ; 
153167
154-       let  color  =  dot ; 
168+       // Use cluster color 
169+       let  color  =  getClusterColor ( article . cluster ,  isDark ) ; 
155170      let  opacity  =  baseOpacity ; 
156171
157-       if  ( article . id  ===  selectedArticle ?. id )  { 
158-         color  =  dotSelected ; 
159-         opacity  =  1 ; 
160-       }  else  if  ( article . id  ===  hoveredArticle ?. id )  { 
172+       if  ( article . id  ===  hoveredArticle ?. id )  { 
161173        color  =  dotHover ; 
162174        opacity  =  1 ; 
163175      }  else  if  ( 
@@ -190,17 +202,45 @@ export default function KnowledgeMap({
190202    theme , 
191203    transform , 
192204    hoveredArticle , 
193-     selectedArticle , 
194205    searchQuery , 
195206    similarity , 
207+     getClusterColor , 
196208  ] ) ; 
197209
198-   // Zoom setup 
210+   // Refs to avoid stale closures 
211+   const  transformRef  =  useRef ( transform ) ; 
212+   const  hoveredArticleRef  =  useRef ( hoveredArticle ) ; 
213+   const  filteredRef  =  useRef ( filtered ) ; 
214+ 
199215  useEffect ( ( )  =>  { 
200-     if  ( ! canvasRef . current  ||  ! containerRef . current )  return ; 
216+     transformRef . current  =  transform ; 
217+   } ,  [ transform ] ) ; 
218+ 
219+   useEffect ( ( )  =>  { 
220+     hoveredArticleRef . current  =  hoveredArticle ; 
221+   } ,  [ hoveredArticle ] ) ; 
222+ 
223+   useEffect ( ( )  =>  { 
224+     filteredRef . current  =  filtered ; 
225+   } ,  [ filtered ] ) ; 
226+ 
227+   // Canvas initialization and zoom setup 
228+   useEffect ( ( )  =>  { 
229+     if  ( ! canvasRef . current  ||  ! containerRef . current  ||  articles . length  ===  0 ) 
230+       return ; 
201231
202232    const  canvas  =  canvasRef . current ; 
233+     const  container  =  containerRef . current ; 
234+ 
235+     // Initialize canvas dimensions ONCE 
236+     const  dpr  =  window . devicePixelRatio  ||  1 ; 
237+     const  rect  =  container . getBoundingClientRect ( ) ; 
238+     canvas . width  =  rect . width  *  dpr ; 
239+     canvas . height  =  rect . height  *  dpr ; 
240+     canvas . style . width  =  `${ rect . width }  px` ; 
241+     canvas . style . height  =  `${ rect . height }  px` ; 
203242
243+     // Set up zoom behavior 
204244    const  zoomBehavior  =  d3Zoom ( ) 
205245      . scaleExtent ( [ 0.5 ,  10 ] ) 
206246      . on ( "zoom" ,  ( event )  =>  { 
@@ -211,30 +251,33 @@ export default function KnowledgeMap({
211251        } ) ; 
212252      } ) ; 
213253
254+     zoomBehaviorRef . current  =  zoomBehavior ; 
255+ 
214256    const  selection  =  select ( canvas ) ; 
215257    selection . call ( zoomBehavior  as  any ) ; 
216258
217-     // Prevent default scroll behavior 
218-     const  preventScroll  =  ( e : WheelEvent )  =>  { 
219-       e . preventDefault ( ) ; 
220-     } ; 
221- 
222-     canvas . addEventListener ( "wheel" ,  preventScroll ,  {  passive : false  } ) ; 
259+     // Handle window resize 
260+     const  handleResize  =  ( )  =>  { 
261+       const  newRect  =  container . getBoundingClientRect ( ) ; 
262+       canvas . width  =  newRect . width  *  dpr ; 
263+       canvas . height  =  newRect . height  *  dpr ; 
264+       canvas . style . width  =  `${ newRect . width }  px` ; 
265+       canvas . style . height  =  `${ newRect . height }  px` ; 
223266
224-     return  ( )  =>  { 
225-       selection . on ( ".zoom" ,  null ) ; 
226-       canvas . removeEventListener ( "wheel" ,  preventScroll ) ; 
267+       // Re-apply zoom behavior after canvas reset 
268+       selection . call ( zoomBehavior  as  any ) ; 
227269    } ; 
228-   } ,  [ ] ) ; 
229270
230-   // Mouse hover 
231-   const  handleMouseMove  =  useCallback ( 
232-     ( e : React . MouseEvent < HTMLCanvasElement > )  =>  { 
233-       if  ( ! canvasRef . current  ||  ! containerRef . current )  return ; 
271+     window . addEventListener ( "resize" ,  handleResize ) ; 
234272
235-       const  rect  =  canvasRef . current . getBoundingClientRect ( ) ; 
236-       const  x  =  ( e . clientX  -  rect . left  -  transform . x )  /  transform . k ; 
237-       const  y  =  ( e . clientY  -  rect . top  -  transform . y )  /  transform . k ; 
273+     // Handle mouse move for hover 
274+     const  handleNativeMouseMove  =  ( e : MouseEvent )  =>  { 
275+       const  rect  =  canvas . getBoundingClientRect ( ) ; 
276+       const  currentTransform  =  transformRef . current ; 
277+       const  x  = 
278+         ( e . clientX  -  rect . left  -  currentTransform . x )  /  currentTransform . k ; 
279+       const  y  = 
280+         ( e . clientY  -  rect . top  -  currentTransform . y )  /  currentTransform . k ; 
238281
239282      const  width  =  rect . width ; 
240283      const  height  =  rect . height ; 
@@ -244,7 +287,7 @@ export default function KnowledgeMap({
244287      let  closest : Article  |  null  =  null ; 
245288      let  minDist  =  12 ; 
246289
247-       filtered . forEach ( ( article )  =>  { 
290+       filteredRef . current . forEach ( ( article )  =>  { 
248291        const  dx  =  xScale ( article . x )  -  x ; 
249292        const  dy  =  yScale ( article . y )  -  y ; 
250293        const  dist  =  Math . sqrt ( dx  *  dx  +  dy  *  dy ) ; 
@@ -255,16 +298,26 @@ export default function KnowledgeMap({
255298      } ) ; 
256299
257300      setHoveredArticle ( closest ) ; 
258-     } , 
259-     [ filtered ,  transform ] 
260-   ) ; 
301+     } ; 
261302
262-   // Click to navigate 
263-   const  handleClick  =  useCallback ( ( )  =>  { 
264-     if  ( hoveredArticle )  { 
265-       window . location . href  =  `/posts/${ hoveredArticle . postSlug }  ` ; 
266-     } 
267-   } ,  [ hoveredArticle ] ) ; 
303+     // Handle click for navigation 
304+     const  handleNativeClick  =  ( )  =>  { 
305+       const  currentHovered  =  hoveredArticleRef . current ; 
306+       if  ( currentHovered )  { 
307+         window . location . href  =  `/posts/${ currentHovered . postSlug }  ` ; 
308+       } 
309+     } ; 
310+ 
311+     canvas . addEventListener ( "mousemove" ,  handleNativeMouseMove ) ; 
312+     canvas . addEventListener ( "click" ,  handleNativeClick ) ; 
313+ 
314+     return  ( )  =>  { 
315+       window . removeEventListener ( "resize" ,  handleResize ) ; 
316+       selection . on ( ".zoom" ,  null ) ; 
317+       canvas . removeEventListener ( "mousemove" ,  handleNativeMouseMove ) ; 
318+       canvas . removeEventListener ( "click" ,  handleNativeClick ) ; 
319+     } ; 
320+   } ,  [ articles . length ] ) ; 
268321
269322  if  ( loading )  { 
270323    return  < UMAPLoader  className = { className }  /> ; 
@@ -287,28 +340,26 @@ export default function KnowledgeMap({
287340      } } 
288341    > 
289342      { /* Search */ } 
290-       < div  className = "absolute top-4 left-4 z-20" > 
343+       < div  className = "absolute top-4 left-4 z-20 pointer-events-none " > 
291344        < input 
292345          type = "text" 
293346          placeholder = "search..." 
294347          value = { searchQuery } 
295348          onChange = { ( e )  =>  setSearchQuery ( e . target . value ) } 
296-           className = "w-40 px-2 py-1 text-xs bg-white/90 dark:bg-gray-900/90 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded focus:outline-none placeholder:text-gray-400" 
349+           className = "w-40 px-2 py-1 text-xs bg-white/90 dark:bg-gray-900/90 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded focus:outline-none placeholder:text-gray-400 pointer-events-auto " 
297350        /> 
298351      </ div > 
299352
300353      { /* Canvas */ } 
301354      < canvas 
302355        ref = { canvasRef } 
303-         onMouseMove = { handleMouseMove } 
304-         onClick = { handleClick } 
305-         className = "cursor-pointer w-full h-full block" 
356+         className = "cursor-crosshair w-full h-full block" 
306357        style = { {  background : "transparent"  } } 
307358      /> 
308359
309360      { /* Article Detail on Hover */ } 
310361      { hoveredArticle  &&  ( 
311-         < div  className = "absolute top-4 right-4 z-20 bg-white/95 dark:bg-gray-900/95 p-3 rounded border border-gray-300 dark:border-gray-700 shadow-sm max-w-xs" > 
362+         < div  className = "absolute top-4 right-4 z-20 bg-white/95 dark:bg-gray-900/95 p-3 rounded border border-gray-300 dark:border-gray-700 shadow-sm max-w-xs pointer-events-none " > 
312363          < h3  className = "font-semibold text-sm leading-tight mb-2" > 
313364            { hoveredArticle . postTitle } 
314365          </ h3 > 
@@ -343,7 +394,7 @@ export default function KnowledgeMap({
343394      ) } 
344395
345396      { /* Instructions */ } 
346-       < div  className = "absolute bottom-4 left-4 z-10 bg-white/90 dark:bg-gray-900/90 px-3 py-1 rounded border border-gray-300 dark:border-gray-700 text-xs text-gray-500" > 
397+       < div  className = "absolute bottom-4 left-4 z-10 bg-white/90 dark:bg-gray-900/90 px-3 py-1 rounded border border-gray-300 dark:border-gray-700 text-xs text-gray-500 pointer-events-none " > 
347398        scroll to zoom • drag to pan • click to read
348399      </ div > 
349400    </ div > 
0 commit comments