@@ -7,7 +7,7 @@ import { usePlotSettings } from '@/context/PlotSettingsContext';
77import { getColorConfig , ColorByMode } from '@/utils/matrix' ;
88import dynamic from 'next/dynamic' ;
99import { SimpleLinearRegression } from 'ml-regression-simple-linear' ;
10- import { useEffect , useMemo , useState , useCallback } from 'react' ;
10+ import { useEffect , useMemo , useState } from 'react' ;
1111import { ImageModal } from '@/components/ImageModal' ;
1212// import RawDataPlotPanel from '@/components/RawDataPlotPanel';
1313import { reducedChiSquared } from '@/libs/math' ;
@@ -71,6 +71,8 @@ const MatrixPlot = (props: MatrixPlotProps) => {
7171 } | null > ( null ) ;
7272 // keep a handle to the GraphDiv
7373 const [ gd , setGd ] = useState < Plotly . PlotlyHTMLElement | null > ( null ) ;
74+ const [ userAnnotations , setUserAnnotations ] = useState < Partial < Plotly . Annotations > [ ] > ( [ ] ) ;
75+
7476
7577 const { colorBy, focusRangeMax, setSettings, focusRangeMaxManuallySet } = usePlotSettings ( ) ;
7678 if ( ! matrixData || matrixData . length === 0 ) return null ;
@@ -90,6 +92,64 @@ const MatrixPlot = (props: MatrixPlotProps) => {
9092 } , [ matrixData , xKey , yKey , compute ] ) ;
9193 const maxRaw = useMemo ( ( ) => ( rawValues . length ? Math . max ( ...rawValues ) : 0 ) , [ rawValues ] ) ;
9294
95+
96+ function makePointAnnotation ( pt : Plotly . PlotDatum ) {
97+ const xVal = pt . x ;
98+ const yVal = pt . y ;
99+
100+ const fmt = ( v : any ) => {
101+ const n = Number ( v ) ;
102+ return Number . isFinite ( n ) ? n . toFixed ( 2 ) : String ( v ) ;
103+ } ;
104+
105+ // ---------- CASE 1: 2D scatter ----------
106+ if ( ! pt . fullData . dimensions ) {
107+ return [ {
108+ x : xVal ,
109+ y : yVal ,
110+ xref : "x" ,
111+ yref : "y" ,
112+ text : `(${ fmt ( xVal ) } , ${ fmt ( yVal ) } )` ,
113+ showarrow : true ,
114+ arrowhead : 6 ,
115+ ax : 20 ,
116+ ay : - 30 ,
117+ bgcolor : 'rgba(0,240,255,0.7)' ,
118+ bordercolor : 'black' ,
119+ borderwidth : 1 ,
120+ font : { color : 'black' , size : 12 }
121+ } ] ;
122+ }
123+
124+ // ---------- CASE 2: SPLOM ----------
125+ const n = pt . fullData . dimensions . length ;
126+ const anns : any [ ] = [ ] ;
127+
128+ for ( let xi = 1 ; xi <= n ; xi ++ ) {
129+ for ( let yi = 1 ; yi <= n ; yi ++ ) {
130+ if ( yi <= xi ) continue ;
131+
132+ anns . push ( {
133+ x : xVal ,
134+ y : yVal ,
135+ xref : `x${ xi } ` ,
136+ yref : `y${ yi } ` ,
137+ text : `(${ fmt ( xVal ) } , ${ fmt ( yVal ) } )` ,
138+ showarrow : true ,
139+ arrowhead : 6 ,
140+ ax : 20 ,
141+ ay : - 30 ,
142+ bgcolor : 'rgba(0,240,255,0.7)' ,
143+ bordercolor : 'black' ,
144+ borderwidth : 1 ,
145+ font : { color : 'black' , size : 12 }
146+ } ) ;
147+ }
148+ }
149+
150+ return anns ;
151+ }
152+
93153 useEffect ( ( ) => {
94154 if ( ! focusRangeMaxManuallySet ) {
95155 if ( focusRangeMax === 100 || maxRaw > focusRangeMax ) {
@@ -188,19 +248,54 @@ const MatrixPlot = (props: MatrixPlotProps) => {
188248 } ) ;
189249 }
190250
191- const handlePointClick = useCallback ( ( e : Plotly . PlotMouseEvent ) => {
192- const pt = e . points [ 0 ] ;
193- handleOriginalPointClick ( pt ) ;
194- } , [ matrixData , dimensions ] ) ;
251+ useEffect ( ( ) => {
252+ if ( ! gd ) return ;
195253
254+ function domClickHandler ( ev : MouseEvent ) {
255+ // if Plotly captured a click on a point, the 'plotly_click' handler runs first
256+ // so we clear only if NO point was selected
257+
258+ const clickedInsidePlot =
259+ ( ev . target as HTMLElement ) . closest ( '.plotly' ) !== null ;
260+
261+ if ( clickedInsidePlot ) {
262+ // check if last event was a point-click
263+ if ( ( gd as any ) . _lastPlotlyClickWasPoint ) {
264+ ( gd as any ) . _lastPlotlyClickWasPoint = false ;
265+ return ;
266+ }
267+
268+ // empty-space click → clear
269+ setUserAnnotations ( [ ] ) ;
270+ setImageModal ( null ) ;
271+ }
272+ }
273+
274+ gd . addEventListener ( 'click' , domClickHandler ) ;
275+
276+ return ( ) => gd . removeEventListener ( 'click' , domClickHandler ) ;
277+ } , [ gd ] ) ;
196278 useEffect ( ( ) => {
197279 if ( ! gd ) return ;
198- const clickHandler = ( ev : any ) => handlePointClick ( ev ) ;
199- gd . on ?.( 'plotly_click' , clickHandler ) ;
200- return ( ) => {
201- try { gd . removeAllListeners ?.( 'plotly_click' ) ; } catch { }
280+
281+ const handler = ( ev : any ) => {
282+ // mark this click as a point-click so DOM listener won't clear annotations
283+ ( gd as any ) . _lastPlotlyClickWasPoint = true ;
284+
285+ const pt = ev . points ?. [ 0 ] ;
286+ if ( ! pt ) return ;
287+
288+ const anns = makePointAnnotation ( pt ) ;
289+ setUserAnnotations ( anns ) ;
290+ handleOriginalPointClick ( pt ) ;
202291 } ;
203- } , [ gd , handlePointClick ] ) ;
292+
293+ gd . on ( 'plotly_click' , handler ) ;
294+
295+ return ( ) => { try { gd . removeAllListeners ( 'plotly_click' ) ; } catch { } } ;
296+ } , [ gd ] ) ;
297+
298+
204299
205300 // ===== 2D scatter =====
206301 if ( dimensions . length === 2 ) {
@@ -228,12 +323,13 @@ const MatrixPlot = (props: MatrixPlotProps) => {
228323 xaxis : { title : { text : xKey } } ,
229324 yaxis : { title : { text : yKey } } ,
230325 modebar : { orientation : 'v' } ,
326+ annotations : [ ...userAnnotations ]
231327 } }
232328 config = { { responsive : true } }
233329 onInitialized = { ( _ , graphDiv ) => setGd ( graphDiv as Plotly . PlotlyHTMLElement ) }
234330 onUpdate = { ( _ , graphDiv ) => setGd ( graphDiv as Plotly . PlotlyHTMLElement ) }
235- // keep onClick too (nice to have)
236- onClick = { ( ev ) => { console . log ( 'onClick prop' , ev ) ; handlePointClick ( ev ) ; } }
331+ // keep onClick too (nice to have)
332+ // onClick={(ev) => { console.log('onClick prop', ev); handlePointClick(ev); }}
237333 />
238334
239335 { imageModal && (
@@ -400,7 +496,7 @@ const MatrixPlot = (props: MatrixPlotProps) => {
400496 modebar : { orientation : 'v' } ,
401497 height,
402498 font : { size : labelFontSize } ,
403- annotations, // your computed annotations
499+ annotations : [ ... annotations , ... userAnnotations ] ,
404500 updatemenus : [ {
405501 type : 'dropdown' , direction : 'down' , x : 0.75 , y : 1 , showactive : true ,
406502 buttons : [
@@ -413,8 +509,8 @@ const MatrixPlot = (props: MatrixPlotProps) => {
413509 config = { { responsive : true } }
414510 onInitialized = { ( _ , graphDiv ) => setGd ( graphDiv as Plotly . PlotlyHTMLElement ) }
415511 onUpdate = { ( _ , graphDiv ) => setGd ( graphDiv as Plotly . PlotlyHTMLElement ) }
416- // keep onClick too
417- onClick = { ( ev ) => { console . log ( 'onClick prop' , ev ) ; handlePointClick ( ev ) ; } }
512+ // keep onClick too
513+ // onClick={(ev) => { console.log('onClick prop', ev); handlePointClick(ev); }}
418514 />
419515
420516 { imageModal && (
0 commit comments