@@ -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