Skip to content

Commit a2c8765

Browse files
authored
Add highlighted vertical line to graph (with tooltip sticky to the line) (#6293)
* Add highlighted vertical line to graph * Make tooltip stick to highlight line * Format * Update changelog * Center tooltip over top if it fits neither left or right * Clamp tooltip if it fits neither left or right * Make it possible to drag on the chart to change selected period on mobile * Fix edge case with tooltip flashing on L-shaped y pans * Remove y position from state
1 parent 6c19786 commit a2c8765

4 files changed

Lines changed: 122 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
1414
- Add "Unknown" option to Countries shield, for when the country code is unrecognized
1515
- Add "Last 24 Hours" to dashboard time range picker and Stats API v2
1616
- Always compare against the same time range in comparisons with "Today"
17+
- Added vertical indicator line to graph to make it easier to see what's hovered / selected
1718

1819
### Removed
1920

@@ -25,6 +26,7 @@ All notable changes to this project will be documented in this file.
2526
- Improved top bar and top stats UI/styling
2627
- Moved graph interval picker, export button, imported data toggle and notices out of the graph and into a new options menu in the top bar
2728
- Standardised and improved segment and filter modals styling
29+
- Changed graph tooltip positioning logic: it now aligns to the top of the chart, to the right of the hovered data point
2830

2931
### Fixed
3032

assets/js/dashboard/components/graph-tooltip.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,24 @@ export const GraphTooltipWrapper = ({
2323
transition?: TransitionClasses & TransitionEvents
2424
}) => {
2525
const ref = useRef<HTMLDivElement>(null)
26-
const xOffsetFromCursor = 12
27-
const yOffsetFromCursor = 24
26+
27+
const xOffset = 12
28+
2829
const [measuredWidth, setMeasuredWidth] = useState(minWidth)
29-
// clamp to prevent left/right overflow
30-
const rawLeft = x + xOffsetFromCursor
31-
const tooltipLeft = Math.max(0, Math.min(rawLeft, maxX - measuredWidth))
30+
31+
const leftByAlignment = {
32+
alignedRight: x + xOffset,
33+
alignedLeft: x - xOffset - measuredWidth,
34+
alignedRightClamped: Math.max(0, Math.min(x, maxX - measuredWidth))
35+
}
36+
37+
const canFitRight = leftByAlignment.alignedRight + measuredWidth <= maxX
38+
const canFitLeft = leftByAlignment.alignedLeft >= 0
39+
const position = canFitRight
40+
? 'alignedRight'
41+
: canFitLeft
42+
? 'alignedLeft'
43+
: 'alignedRightClamped'
3244

3345
useLayoutEffect(() => {
3446
if (!ref.current) {
@@ -44,9 +56,8 @@ export const GraphTooltipWrapper = ({
4456
className={className}
4557
style={{
4658
minWidth,
47-
left: tooltipLeft,
48-
top: y,
49-
transform: `translateY(-100%) translateY(-${yOffsetFromCursor}px)`
59+
left: leftByAlignment[position],
60+
top: y
5061
}}
5162
>
5263
{children}

assets/js/dashboard/components/graph.tsx

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import classNames from 'classnames'
1010

1111
const IDEAL_Y_TICK_COUNT = 5
1212
const MAX_X_TICK_COUNT = 8
13+
const X_TICK_LENGTH_PX = 4
14+
const HIGHLIGHT_LINE_VERTICAL_SPILL_PX = 4
1315

1416
type GraphYValues = ReadonlyArray<number | null>
1517

@@ -75,6 +77,8 @@ export function Graph<T extends GraphYValues>({
7577
)
7678
}
7779

80+
const highlightIndicatorGroupId = 'highlight-indicator'
81+
7882
function InnerGraph<T extends GraphYValues>({
7983
className,
8084
width,
@@ -199,7 +203,7 @@ function InnerGraph<T extends GraphYValues>({
199203
d3
200204
.axisBottom(x)
201205
.tickValues(xTickValues)
202-
.tickSize(4)
206+
.tickSize(X_TICK_LENGTH_PX)
203207
.tickFormat(getXTickFormat(data))
204208
)
205209
.call((g) => g.select('.domain').remove())
@@ -226,6 +230,9 @@ function InnerGraph<T extends GraphYValues>({
226230
})
227231
}
228232

233+
// must be on top of gradients, but under lines and points
234+
svg.append('g').attr('id', highlightIndicatorGroupId)
235+
229236
const points: Point<T>[] = []
230237
for (const [seriesIndex, series] of settings.entries()) {
231238
if (series.underline) {
@@ -239,7 +246,9 @@ function InnerGraph<T extends GraphYValues>({
239246
y1Accessor: (d) => y(d.values[seriesIndex]!)
240247
})
241248
}
249+
}
242250

251+
for (const [seriesIndex, series] of settings.entries()) {
243252
if (series.lines) {
244253
for (const line of series.lines) {
245254
drawLine({
@@ -275,7 +284,9 @@ function InnerGraph<T extends GraphYValues>({
275284
})
276285
points[i] = {
277286
...point,
278-
dots: [...point.dots, dotForSeries] as { [K in keyof T]: SelectedDot }
287+
dots: [...point.dots, dotForSeries] as {
288+
[K in keyof T]: SelectedGroup
289+
}
279290
}
280291
}
281292
}
@@ -459,15 +470,45 @@ function InnerGraph<T extends GraphYValues>({
459470
}, [onClick, isInHoverableArea, data])
460471

461472
useEffect(() => {
462-
pointsRef.current?.forEach(({ dots }, index) =>
463-
dots.forEach((g) =>
464-
g.attr(
465-
'data-active',
466-
highlightedIndex !== null && index === highlightedIndex ? '' : null
473+
if (pointsRef.current) {
474+
const currentPoints = pointsRef.current
475+
currentPoints.forEach(({ dots }, index) =>
476+
dots.forEach((g) =>
477+
g.attr(
478+
'data-active',
479+
highlightedIndex !== null && index === highlightedIndex ? '' : null
480+
)
467481
)
468482
)
469-
)
470-
}, [highlightedIndex, data])
483+
484+
if (svgRef.current) {
485+
const svg = d3.select(svgRef.current)
486+
let line = svg.select<SVGLineElement>(
487+
`#${highlightIndicatorGroupId} line`
488+
)
489+
const shouldShowLine = typeof highlightedIndex === 'number'
490+
if (shouldShowLine) {
491+
const { x } = currentPoints[highlightedIndex]
492+
if (line.empty()) {
493+
line = svg.select(`#${highlightIndicatorGroupId}`).append('line')
494+
}
495+
line
496+
.attr('x1', 0)
497+
.attr('x2', 0)
498+
.attr('y1', marginTop - HIGHLIGHT_LINE_VERTICAL_SPILL_PX)
499+
.attr(
500+
'y2',
501+
height - marginBottom + HIGHLIGHT_LINE_VERTICAL_SPILL_PX
502+
)
503+
.attr('class', currentlySelectedLineClass)
504+
.attr('transform', `translate(${x}, 0)`)
505+
}
506+
if (!shouldShowLine && !line.empty()) {
507+
line.remove()
508+
}
509+
}
510+
}
511+
}, [highlightedIndex, data, height, marginBottom, marginTop])
471512

472513
return (
473514
<svg
@@ -478,6 +519,8 @@ function InnerGraph<T extends GraphYValues>({
478519
)
479520
}
480521

522+
const currentlySelectedLineClass =
523+
'stroke-1 stroke-gray-300 dark:stroke-gray-700' // maybe add 'transition-transform duration-75'
481524
const yTickLineClass =
482525
'stroke-gray-150 dark:stroke-gray-800/75 group-first:stroke-gray-300 dark:group-first:stroke-gray-700'
483526
const tickTextClass = 'fill-gray-500 dark:fill-gray-400 text-xs select-none'
@@ -777,7 +820,7 @@ function drawDot({
777820
series: SeriesConfig
778821
x: number
779822
y: number | null
780-
}): SelectedDot {
823+
}): SelectedGroup {
781824
const group = svg.append('g').attr('class', 'group')
782825
if (series.dot && y !== null) {
783826
group
@@ -834,7 +877,7 @@ type XPos = number
834877
type Point<T extends GraphYValues> = {
835878
x: XPos
836879
values: T
837-
dots: { [K in keyof T]: SelectedDot }
880+
dots: { [K in keyof T]: SelectedGroup }
838881
}
839882

840883
export type SeriesConfig = {
@@ -857,4 +900,4 @@ export type PointerHandler<T extends GraphYValues> = (opts: {
857900
}) => void
858901

859902
type SelectedSVG = d3.Selection<SVGSVGElement, unknown, null, undefined>
860-
type SelectedDot = d3.Selection<SVGGElement, unknown, null, undefined>
903+
type SelectedGroup = d3.Selection<SVGGElement, unknown, null, undefined>

assets/js/dashboard/stats/graph/main-graph.tsx

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, {
33
useCallback,
44
useEffect,
55
useMemo,
6+
useRef,
67
useState
78
} from 'react'
89
import { UIMode, useTheme } from '../../theme-context'
@@ -51,6 +52,7 @@ const marginRight = 4
5152
const marginBottom = 32
5253
const defaultMarginLeft = 16 // this is adjusted by the Graph component based on y-axis label width
5354
const hoverBuffer = 4
55+
const HORIZONTAL_PAN_DELAY_MS = 100
5456

5557
type MainGraphData = MainGraphResponse & {
5658
period: DashboardPeriod
@@ -68,13 +70,11 @@ type MainGraphYValues = Readonly<
6870

6971
type TooltipState = {
7072
x: number
71-
y: number
7273
selectedIndex: number | null
7374
persistent: boolean
7475
}
7576
const initialTooltipState: TooltipState = {
7677
x: 0,
77-
y: 0,
7878
selectedIndex: null,
7979
persistent: false
8080
}
@@ -93,6 +93,7 @@ export const MainGraph = ({
9393
const [isTouchDevice, setIsTouchDevice] = useState<null | boolean>(null)
9494
const [tooltip, setTooltip] = useState<TooltipState>(initialTooltipState)
9595
const { selectedIndex } = tooltip
96+
const panGestureStartTimeRef = useRef<number | null>(null)
9697
const metric = data.query.metrics[0] as Metric
9798
const interval = data.interval
9899
const period = data.period
@@ -101,6 +102,17 @@ export const MainGraph = ({
101102
setTooltip(initialTooltipState)
102103
}, [data])
103104

105+
useEffect(() => {
106+
const onPointerCancel = (e: PointerEvent) => {
107+
if (e.pointerType === 'touch') {
108+
panGestureStartTimeRef.current = null
109+
setTooltip(initialTooltipState)
110+
}
111+
}
112+
document.addEventListener('pointercancel', onPointerCancel)
113+
return () => document.removeEventListener('pointercancel', onPointerCancel)
114+
}, [])
115+
104116
const {
105117
remappedData,
106118
yMax,
@@ -249,22 +261,38 @@ export const MainGraph = ({
249261
)
250262

251263
const onPointerMove = useCallback<PointerHandler<MainGraphYValues>>(
252-
({ inHoverableArea, closestPoint, xPointer, yPointer, event }) => {
264+
({ inHoverableArea, closestPoint, event }) => {
253265
if (event instanceof PointerEvent && event.pointerType === 'touch') {
254-
return setIsTouchDevice(true)
266+
setIsTouchDevice(true)
267+
if (tooltip.persistent && inHoverableArea && closestPoint) {
268+
const now = Date.now()
269+
// move the tooltip only when it is certain it's a y-pan
270+
if (panGestureStartTimeRef.current === null) {
271+
panGestureStartTimeRef.current = now
272+
} else if (
273+
now - panGestureStartTimeRef.current >=
274+
HORIZONTAL_PAN_DELAY_MS
275+
) {
276+
setTooltip({
277+
selectedIndex: closestPoint.index,
278+
x: closestPoint.x,
279+
persistent: true
280+
})
281+
}
282+
}
283+
return
255284
}
256285
setIsTouchDevice(false)
257286
if (!inHoverableArea || !closestPoint) {
258287
return setTooltip(initialTooltipState)
259288
}
260289
return setTooltip({
261290
selectedIndex: closestPoint.index,
262-
x: Math.floor(xPointer),
263-
y: Math.floor(yPointer),
291+
x: closestPoint.x,
264292
persistent: false
265293
})
266294
},
267-
[]
295+
[tooltip.persistent]
268296
)
269297

270298
const onGotPointerCapture = useCallback((event: unknown) => {
@@ -280,8 +308,12 @@ export const MainGraph = ({
280308
}, [])
281309

282310
const onPointerLeave = useCallback(() => {
311+
panGestureStartTimeRef.current = null
312+
if (tooltip.persistent) {
313+
return
314+
}
283315
setTooltip(initialTooltipState)
284-
}, [])
316+
}, [tooltip.persistent])
285317

286318
const showZoomToPeriod = canZoomToPeriod(
287319
interval,
@@ -319,7 +351,6 @@ export const MainGraph = ({
319351
return setTooltip({
320352
selectedIndex: closestPoint.index,
321353
x: closestPoint.x,
322-
y: Math.min(...closestPoint.values.filter((y) => y !== null)),
323354
persistent: true
324355
})
325356
}
@@ -334,7 +365,10 @@ export const MainGraph = ({
334365

335366
return (
336367
<Graph<MainGraphYValues>
337-
className={showZoomToPeriod && selectedDatum ? 'cursor-pointer' : ''}
368+
className={classNames(
369+
showZoomToPeriod && selectedDatum ? 'cursor-pointer' : '',
370+
tooltip.persistent ? 'touch-pan-y' : ''
371+
)}
338372
highlightedIndex={selectedIndex}
339373
width={width}
340374
height={height}
@@ -365,7 +399,8 @@ export const MainGraph = ({
365399
interval={interval}
366400
metric={metric}
367401
x={tooltip.x}
368-
y={tooltip.y}
402+
// aligned to top of graph
403+
y={0}
369404
datum={selectedDatum}
370405
bucketIndex={selectedIndex}
371406
totalBuckets={remappedData.length}
@@ -429,17 +464,6 @@ const MainGraphTooltip = ({
429464
'absolute select-none bg-gray-800 dark:bg-gray-950 py-3 px-4 rounded-md shadow shadow-gray-200 dark:shadow-gray-850',
430465
typeof onClick !== 'function' && 'pointer-events-none'
431466
)}
432-
transition={
433-
persistent
434-
? {
435-
// enter delay on mobile is needed to prevent the tooltip from entering when the user starts to y-pan
436-
// but the y-pan is not yet certain
437-
enter: 'transition-opacity duration-0 delay-150',
438-
enterFrom: 'opacity-0',
439-
enterTo: 'opacity-100'
440-
}
441-
: {}
442-
}
443467
>
444468
<aside className="text-sm font-normal text-gray-100 flex flex-col gap-1.5">
445469
<div className="flex justify-between items-center rounded-sm">

0 commit comments

Comments
 (0)