Skip to content

Commit 9dd9b02

Browse files
committed
counts on mouse hover
1 parent 2ff2c0c commit 9dd9b02

2 files changed

Lines changed: 61 additions & 30 deletions

File tree

client/plots/profile/profileForms.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getProfilePlotConfig, profilePlot, getDefaultProfilePlotSettings } from
33
import { fillTermWrapper, fillTwLst } from '#termsetting'
44
import { axisBottom, axisTop } from 'd3-axis'
55
import { scaleLinear as d3Linear } from 'd3-scale'
6+
import { select } from 'd3'
67
import { Tabs } from '../../dom/toggleButtons.js'
78
import { roundValueAuto } from '#shared'
89
import { dofetch3 } from '#common/dofetch'
@@ -191,8 +192,7 @@ export class profileForms extends profilePlot {
191192
module: this.module,
192193
data: this.data,
193194
texts: this.config.impression,
194-
colors: { sc: this.impressionScColor || '#888' },
195-
tip: this.tip
195+
colors: { sc: this.impressionScColor || '#888' }
196196
})
197197
this.filterG.selectAll('*').remove()
198198
this.addFilterLegend()
@@ -425,6 +425,24 @@ export class profileForms extends profilePlot {
425425
}
426426

427427
onMouseOver(event) {
428+
// Impression thermometer: elements carry their tooltip text + hover-highlight descriptor as
429+
// __data__ (bound by renderImpressionThermometer's attachTip). Same delegation pattern as
430+
// polar2/radar2 — show the tip, apply the outline, and animate the POC ball's radius.
431+
if (this.isImpressionDomain) {
432+
const target = event.target
433+
const d = target.__data__
434+
if (d?.tip) {
435+
const menu = this.tip.clear()
436+
menu.d.text(d.tip)
437+
menu.show(event.clientX, event.clientY, true, true)
438+
if (d.on) for (const k in d.on) target.setAttribute(k, d.on[k]) // idempotent on repeated mousemove
439+
if (d.growR && !d._grown) {
440+
d._grown = true
441+
select(target).interrupt().transition().duration(150).attr('r', d.growR)
442+
}
443+
} else this.onMouseOut(event)
444+
return
445+
}
428446
if (event.target.tagName == 'rect') {
429447
const path = event.target
430448
const d = path.__data__
@@ -444,6 +462,21 @@ export class profileForms extends profilePlot {
444462
} else this.onMouseOut(event)
445463
}
446464

465+
onMouseOut(event) {
466+
// Reset the impression thermometer's hover highlight for the element being left; the base
467+
// only hides the tip. Targeted via event.target (mouseout bubbles from the element).
468+
if (this.isImpressionDomain) {
469+
const target = event?.target
470+
const d = target?.__data__
471+
if (d?.off) for (const k in d.off) target.setAttribute(k, d.off[k])
472+
if (d?._grown) {
473+
d._grown = false
474+
select(target).interrupt().transition().duration(150).attr('r', d.baseR)
475+
}
476+
}
477+
this.tip.hide()
478+
}
479+
447480
getColor(key: string) {
448481
return key == 'Yes' ? this.activePlot.color : key == 'No' ? '#aaa' : `url(#${this.id}_diagonalHatch)`
449482
}

client/plots/profile/renderImpressionThermometer.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const FALLBACK_COLOR = '#888'
2525
const POC_MEDIAN_FILL = '#9e9e9e'
2626
const 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+
2832
export 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

Comments
 (0)