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

### Removed

Expand All @@ -25,6 +26,7 @@ All notable changes to this project will be documented in this file.
- Improved top bar and top stats UI/styling
- 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
- Standardised and improved segment and filter modals styling
- Changed graph tooltip positioning logic: it now aligns to the top of the chart, to the right of the hovered data point

### Fixed

Expand Down
27 changes: 19 additions & 8 deletions assets/js/dashboard/components/graph-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,24 @@ export const GraphTooltipWrapper = ({
transition?: TransitionClasses & TransitionEvents
}) => {
const ref = useRef<HTMLDivElement>(null)
const xOffsetFromCursor = 12
const yOffsetFromCursor = 24

const xOffset = 12

const [measuredWidth, setMeasuredWidth] = useState(minWidth)
// clamp to prevent left/right overflow
const rawLeft = x + xOffsetFromCursor
const tooltipLeft = Math.max(0, Math.min(rawLeft, maxX - measuredWidth))

const leftByAlignment = {
alignedRight: x + xOffset,
alignedLeft: x - xOffset - measuredWidth,
alignedRightClamped: Math.max(0, Math.min(x, maxX - measuredWidth))
}

const canFitRight = leftByAlignment.alignedRight + measuredWidth <= maxX
const canFitLeft = leftByAlignment.alignedLeft >= 0
const position = canFitRight
? 'alignedRight'
: canFitLeft
? 'alignedLeft'
: 'alignedRightClamped'

useLayoutEffect(() => {
if (!ref.current) {
Expand All @@ -44,9 +56,8 @@ export const GraphTooltipWrapper = ({
className={className}
style={{
minWidth,
left: tooltipLeft,
top: y,
transform: `translateY(-100%) translateY(-${yOffsetFromCursor}px)`
left: leftByAlignment[position],
top: y
}}
>
{children}
Expand Down
67 changes: 55 additions & 12 deletions assets/js/dashboard/components/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import classNames from 'classnames'

const IDEAL_Y_TICK_COUNT = 5
const MAX_X_TICK_COUNT = 8
const X_TICK_LENGTH_PX = 4
const HIGHLIGHT_LINE_VERTICAL_SPILL_PX = 4

type GraphYValues = ReadonlyArray<number | null>

Expand Down Expand Up @@ -75,6 +77,8 @@ export function Graph<T extends GraphYValues>({
)
}

const highlightIndicatorGroupId = 'highlight-indicator'

function InnerGraph<T extends GraphYValues>({
className,
width,
Expand Down Expand Up @@ -199,7 +203,7 @@ function InnerGraph<T extends GraphYValues>({
d3
.axisBottom(x)
.tickValues(xTickValues)
.tickSize(4)
.tickSize(X_TICK_LENGTH_PX)
.tickFormat(getXTickFormat(data))
)
.call((g) => g.select('.domain').remove())
Expand All @@ -226,6 +230,9 @@ function InnerGraph<T extends GraphYValues>({
})
}

// must be on top of gradients, but under lines and points
svg.append('g').attr('id', highlightIndicatorGroupId)

const points: Point<T>[] = []
for (const [seriesIndex, series] of settings.entries()) {
if (series.underline) {
Expand All @@ -239,7 +246,9 @@ function InnerGraph<T extends GraphYValues>({
y1Accessor: (d) => y(d.values[seriesIndex]!)
})
}
}

for (const [seriesIndex, series] of settings.entries()) {
if (series.lines) {
for (const line of series.lines) {
drawLine({
Expand Down Expand Up @@ -275,7 +284,9 @@ function InnerGraph<T extends GraphYValues>({
})
points[i] = {
...point,
dots: [...point.dots, dotForSeries] as { [K in keyof T]: SelectedDot }
dots: [...point.dots, dotForSeries] as {
[K in keyof T]: SelectedGroup
}
}
}
}
Expand Down Expand Up @@ -459,15 +470,45 @@ function InnerGraph<T extends GraphYValues>({
}, [onClick, isInHoverableArea, data])

useEffect(() => {
pointsRef.current?.forEach(({ dots }, index) =>
dots.forEach((g) =>
g.attr(
'data-active',
highlightedIndex !== null && index === highlightedIndex ? '' : null
if (pointsRef.current) {
const currentPoints = pointsRef.current
currentPoints.forEach(({ dots }, index) =>
dots.forEach((g) =>
g.attr(
'data-active',
highlightedIndex !== null && index === highlightedIndex ? '' : null
)
)
)
)
}, [highlightedIndex, data])

if (svgRef.current) {
const svg = d3.select(svgRef.current)
let line = svg.select<SVGLineElement>(
`#${highlightIndicatorGroupId} line`
)
const shouldShowLine = typeof highlightedIndex === 'number'
if (shouldShowLine) {
const { x } = currentPoints[highlightedIndex]
if (line.empty()) {
line = svg.select(`#${highlightIndicatorGroupId}`).append('line')
}
line
.attr('x1', 0)
.attr('x2', 0)
.attr('y1', marginTop - HIGHLIGHT_LINE_VERTICAL_SPILL_PX)
.attr(
'y2',
height - marginBottom + HIGHLIGHT_LINE_VERTICAL_SPILL_PX
)
.attr('class', currentlySelectedLineClass)
.attr('transform', `translate(${x}, 0)`)
}
if (!shouldShowLine && !line.empty()) {
line.remove()
}
}
}
}, [highlightedIndex, data, height, marginBottom, marginTop])

return (
<svg
Expand All @@ -478,6 +519,8 @@ function InnerGraph<T extends GraphYValues>({
)
}

const currentlySelectedLineClass =
'stroke-1 stroke-gray-300 dark:stroke-gray-700' // maybe add 'transition-transform duration-75'
const yTickLineClass =
'stroke-gray-150 dark:stroke-gray-800/75 group-first:stroke-gray-300 dark:group-first:stroke-gray-700'
const tickTextClass = 'fill-gray-500 dark:fill-gray-400 text-xs select-none'
Expand Down Expand Up @@ -777,7 +820,7 @@ function drawDot({
series: SeriesConfig
x: number
y: number | null
}): SelectedDot {
}): SelectedGroup {
const group = svg.append('g').attr('class', 'group')
if (series.dot && y !== null) {
group
Expand Down Expand Up @@ -834,7 +877,7 @@ type XPos = number
type Point<T extends GraphYValues> = {
x: XPos
values: T
dots: { [K in keyof T]: SelectedDot }
dots: { [K in keyof T]: SelectedGroup }
}

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

type SelectedSVG = d3.Selection<SVGSVGElement, unknown, null, undefined>
type SelectedDot = d3.Selection<SVGGElement, unknown, null, undefined>
type SelectedGroup = d3.Selection<SVGGElement, unknown, null, undefined>
68 changes: 46 additions & 22 deletions assets/js/dashboard/stats/graph/main-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { UIMode, useTheme } from '../../theme-context'
Expand Down Expand Up @@ -51,6 +52,7 @@ const marginRight = 4
const marginBottom = 32
const defaultMarginLeft = 16 // this is adjusted by the Graph component based on y-axis label width
const hoverBuffer = 4
const HORIZONTAL_PAN_DELAY_MS = 100

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

type TooltipState = {
x: number
y: number
selectedIndex: number | null
persistent: boolean
}
const initialTooltipState: TooltipState = {
x: 0,
y: 0,
selectedIndex: null,
persistent: false
}
Expand All @@ -93,6 +93,7 @@ export const MainGraph = ({
const [isTouchDevice, setIsTouchDevice] = useState<null | boolean>(null)
const [tooltip, setTooltip] = useState<TooltipState>(initialTooltipState)
const { selectedIndex } = tooltip
const panGestureStartTimeRef = useRef<number | null>(null)
const metric = data.query.metrics[0] as Metric
const interval = data.interval
const period = data.period
Expand All @@ -101,6 +102,17 @@ export const MainGraph = ({
setTooltip(initialTooltipState)
}, [data])

useEffect(() => {
const onPointerCancel = (e: PointerEvent) => {
if (e.pointerType === 'touch') {
panGestureStartTimeRef.current = null
setTooltip(initialTooltipState)
}
}
document.addEventListener('pointercancel', onPointerCancel)
return () => document.removeEventListener('pointercancel', onPointerCancel)
}, [])

const {
remappedData,
yMax,
Expand Down Expand Up @@ -249,22 +261,38 @@ export const MainGraph = ({
)

const onPointerMove = useCallback<PointerHandler<MainGraphYValues>>(
({ inHoverableArea, closestPoint, xPointer, yPointer, event }) => {
({ inHoverableArea, closestPoint, event }) => {
if (event instanceof PointerEvent && event.pointerType === 'touch') {
return setIsTouchDevice(true)
setIsTouchDevice(true)
if (tooltip.persistent && inHoverableArea && closestPoint) {
const now = Date.now()
// move the tooltip only when it is certain it's a y-pan
if (panGestureStartTimeRef.current === null) {
panGestureStartTimeRef.current = now
} else if (
now - panGestureStartTimeRef.current >=
HORIZONTAL_PAN_DELAY_MS
) {
setTooltip({
selectedIndex: closestPoint.index,
x: closestPoint.x,
persistent: true
})
Comment on lines +276 to +280
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick (non-blocking): Looks like the y position of the tooltip is always a fixed 0 (and I think that makes perfect sense with the vertical line!) Did you consider removing the y value from the state?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks and well spotted! I did consider it, but then decided to keep the (x, y) positioning API. With just x, it's slightly less self-explanatory. However, now that you pointed it out, I can see how it may be confusing and a source of bugs. Suggestion applied! c8d8fb0

}
}
return
}
setIsTouchDevice(false)
if (!inHoverableArea || !closestPoint) {
return setTooltip(initialTooltipState)
}
return setTooltip({
selectedIndex: closestPoint.index,
x: Math.floor(xPointer),
y: Math.floor(yPointer),
x: closestPoint.x,
persistent: false
})
},
[]
[tooltip.persistent]
)

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

const onPointerLeave = useCallback(() => {
panGestureStartTimeRef.current = null
if (tooltip.persistent) {
return
}
setTooltip(initialTooltipState)
}, [])
}, [tooltip.persistent])

const showZoomToPeriod = canZoomToPeriod(
interval,
Expand Down Expand Up @@ -319,7 +351,6 @@ export const MainGraph = ({
return setTooltip({
selectedIndex: closestPoint.index,
x: closestPoint.x,
y: Math.min(...closestPoint.values.filter((y) => y !== null)),
persistent: true
})
}
Expand All @@ -334,7 +365,10 @@ export const MainGraph = ({

return (
<Graph<MainGraphYValues>
className={showZoomToPeriod && selectedDatum ? 'cursor-pointer' : ''}
className={classNames(
showZoomToPeriod && selectedDatum ? 'cursor-pointer' : '',
tooltip.persistent ? 'touch-pan-y' : ''
)}
highlightedIndex={selectedIndex}
width={width}
height={height}
Expand Down Expand Up @@ -365,7 +399,8 @@ export const MainGraph = ({
interval={interval}
metric={metric}
x={tooltip.x}
y={tooltip.y}
// aligned to top of graph
y={0}
datum={selectedDatum}
bucketIndex={selectedIndex}
totalBuckets={remappedData.length}
Expand Down Expand Up @@ -429,17 +464,6 @@ const MainGraphTooltip = ({
'absolute select-none bg-gray-800 dark:bg-gray-950 py-3 px-4 rounded-md shadow shadow-gray-200 dark:shadow-gray-850',
typeof onClick !== 'function' && 'pointer-events-none'
)}
transition={
persistent
? {
// enter delay on mobile is needed to prevent the tooltip from entering when the user starts to y-pan
// but the y-pan is not yet certain
enter: 'transition-opacity duration-0 delay-150',
enterFrom: 'opacity-0',
enterTo: 'opacity-100'
}
: {}
}
>
<aside className="text-sm font-normal text-gray-100 flex flex-col gap-1.5">
<div className="flex justify-between items-center rounded-sm">
Expand Down
Loading