@@ -25,6 +25,10 @@ const FALLBACK_COLOR = '#888'
2525const POC_MEDIAN_FILL = '#9e9e9e'
2626const POC_MEDIAN_STROKE = '#616161'
2727
28+ // Hover-highlight descriptor (attribute map toggled on enter/leave by profileForms.onMouseOver).
29+ // The SC bar/bulb get a dark outline on hover.
30+ const SC_HOVER = { on : { stroke : '#222' , 'stroke-width' : '1.5' } , off : { stroke : 'none' } }
31+
2832export type ImpressionTexts = {
2933 titleTemplate : string
3034 subtitle : string [ ]
@@ -55,10 +59,6 @@ export type ImpressionRenderArgs = {
5559 // The POC distribution and rating-swatch legend use a fixed universal red→green palette
5660 // (RATING_COLORS) to encode rating level — same across every module.
5761 colors : { sc : string }
58- // Shared Menu instance from profilePlot. Used to render hover tooltips on the SC bar,
59- // bulb, POC distribution bands, and POC median ball. Optional — if absent, tooltips
60- // are skipped silently.
61- tip ?: { clear : ( ) => any ; hide : ( ) => void ; show : ( x : number , y : number , ...rest : any [ ] ) => void }
6262}
6363
6464// Geometry shared by every thermometer column. Each column keeps the full single-chart
@@ -91,11 +91,12 @@ export function renderImpressionThermometer(a: ImpressionRenderArgs) {
9191 const n : number = data . n ?? 0
9292
9393 const responders : ResponderColumn [ ] = Array . isArray ( data . responders ) ? data . responders : [ ]
94- // SC-only modules (e.g. Patients & Outcomes) carry no responder groups → render a single
95- // POC-less column labeled with the generic frame subtitle.
94+ // Each column's subtitle names both perspectives on the thermometer: the shared SC bar and this
95+ // responder group (frameSubtitle template, {group} = responder label). SC-only modules (e.g.
96+ // Patients & Outcomes) carry no responder group → a single POC-less column labeled just "Site Coordinator".
9697 const columns : { col : ResponderColumn | null ; label : string ; hasPoc : boolean } [ ] = responders . length
97- ? responders . map ( r => ( { col : r , label : r . label , hasPoc : true } ) )
98- : [ { col : null , label : a . texts . frameSubtitle , hasPoc : false } ]
98+ ? responders . map ( r => ( { col : r , label : a . texts . frameSubtitle . replace ( '{group}' , r . label ) , hasPoc : true } ) )
99+ : [ { col : null , label : a . texts . legend . sc , hasPoc : false } ]
99100
100101 // Render directly into the svg with absolute coords; reset mainG transform to identity so
101102 // transforms set up for the Likert/YN paths don't apply.
@@ -109,20 +110,13 @@ export function renderImpressionThermometer(a: ImpressionRenderArgs) {
109110 const root = a . dom . mainG
110111 const rowCenterX = ( FIRST_FRAME_X + lastFrameRight ) / 2
111112
112- // Wires mouseenter/mouseleave on a d3 selection to show `text` in the shared profilePlot
113- // tip. No-op if the tip wasn't passed (defensive — keeps the renderer usable in tests).
114- const tip = a . tip
115- const attachTip = ( sel : any , text : string ) => {
116- if ( ! tip ) return
117- sel
118- . style ( 'cursor' , 'pointer' )
119- . attr ( 'pointer-events' , 'all' )
120- . on ( 'mouseenter' , ( event : MouseEvent ) => {
121- const menu = tip . clear ( )
122- menu . d . text ( text )
123- menu . show ( event . clientX , event . clientY , true , true )
124- } )
125- . on ( 'mouseleave' , ( ) => tip . hide ( ) )
113+ // Bind the tooltip text (and optional hover-highlight descriptor) as the element's datum. The
114+ // shared profilePlot mousemove→onMouseOver delegation (see profileForms.onMouseOver) reads
115+ // `__data__` to show the tip and apply/reset the highlight — same pattern as polar2/radar2,
116+ // rather than per-element mouseenter handlers here. `hover` = { on, off, growR?, baseR? }:
117+ // `on`/`off` are attribute maps toggled on enter/leave; growR/baseR animate a circle's radius.
118+ const attachTip = ( sel : any , text : string , hover ?: any ) => {
119+ sel . datum ( { tip : text , ...( hover || { } ) } ) . style ( 'cursor' , 'pointer' )
126120 }
127121
128122 // Title block (once, centered over the whole row)
@@ -255,22 +249,21 @@ export function renderImpressionThermometer(a: ImpressionRenderArgs) {
255249 const stackG = root . append ( 'g' ) . attr ( 'clip-path' , `url(#${ clipId } )` )
256250 const distByRating : Record < number , { count : number ; pct : number } > = { }
257251 for ( const d of dist ) distByRating [ d . rating ] = { count : d . count || 0 , pct : d . pct || 0 }
258- const pocTotal : number = column . col ?. total || 0
259252 let cum = 0
260253 for ( let r = 1 ; r <= maxScore ; r ++ ) {
261254 const bin = distByRating [ r ]
262255 const pct = bin ?. pct || 0
263256 if ( pct <= 0 ) continue
264257 const yStart = tubeBottom - ( cum / 100 ) * TUBE_H
265258 const yEnd = tubeBottom - ( ( cum + pct ) / 100 ) * TUBE_H
266- const rect = stackG
259+ // Bands are display-only — tooltips are limited to the SC + POC medians.
260+ stackG
267261 . append ( 'rect' )
268262 . attr ( 'x' , tubeX )
269263 . attr ( 'y' , yEnd )
270264 . attr ( 'width' , TUBE_W )
271265 . attr ( 'height' , yStart - yEnd )
272266 . attr ( 'fill' , RATING_COLORS [ r ] )
273- attachTip ( rect , `Rating ${ r } — ${ pct . toFixed ( 1 ) } % (${ bin . count } of ${ pocTotal } staff)` )
274267 cum += pct
275268 }
276269 }
@@ -307,12 +300,12 @@ export function renderImpressionThermometer(a: ImpressionRenderArgs) {
307300 . attr ( 'width' , SC_BAR_W )
308301 . attr ( 'height' , barH )
309302 . attr ( 'fill' , scColor )
310- attachTip ( scBar , `Site Coordinator median: ${ scMedian } (n=${ scTotal } SCs)` )
303+ attachTip ( scBar , `Site Coordinator median: ${ scMedian } (n=${ scTotal } SCs)` , SC_HOVER )
311304 }
312305
313306 // Bulb fill + outline arc
314307 const bulb = root . append ( 'circle' ) . attr ( 'cx' , bulbCx ) . attr ( 'cy' , bulbCy ) . attr ( 'r' , BULB_R ) . attr ( 'fill' , scColor )
315- if ( scMedian != null ) attachTip ( bulb , `Site Coordinator median: ${ scMedian } (n=${ scTotal } SCs)` )
308+ if ( scMedian != null ) attachTip ( bulb , `Site Coordinator median: ${ scMedian } (n=${ scTotal } SCs)` , SC_HOVER )
316309 const halfW = TUBE_W / 2
317310 const intersectY = bulbCy - Math . sqrt ( BULB_R * BULB_R - halfW * halfW )
318311 const bulbOutline = `M ${ tubeX + TUBE_W } ${ intersectY } ` + ` A ${ BULB_R } ${ BULB_R } 0 1 1 ${ tubeX } ${ intersectY } `
@@ -331,7 +324,12 @@ export function renderImpressionThermometer(a: ImpressionRenderArgs) {
331324 . attr ( 'fill' , POC_MEDIAN_FILL )
332325 . attr ( 'stroke' , POC_MEDIAN_STROKE )
333326 . attr ( 'stroke-width' , 1 )
334- attachTip ( ball , `POC median: ${ pocMedian } (n=${ pocTotalForTip } staff responses)` )
327+ attachTip ( ball , `POC median: ${ pocMedian } (n=${ pocTotalForTip } staff responses)` , {
328+ on : { stroke : '#222' , 'stroke-width' : '2' } ,
329+ off : { stroke : POC_MEDIAN_STROKE , 'stroke-width' : '1' } ,
330+ growR : BALL_R + 4 ,
331+ baseR : BALL_R
332+ } )
335333 }
336334
337335 // n indicator (top-right of this frame)
0 commit comments