Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -24,6 +25,7 @@ All notable changes to this project will be documented in this file.
- "Top referrers" and "Search terms" breakdowns are rendered side by side with other "Sources" tabs instead of replacing them
- 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
- Changed graph tooltip positioning logic: it now aligns at the top of the chart to the right of the indicator line. Indicator line follows cursor, tooltip follows indicator line.

### 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>
66 changes: 47 additions & 19 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 Down Expand Up @@ -93,6 +95,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 +104,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 +263,40 @@ 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,
y: 0,
persistent: true
})
}
}
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,
y: 0,
persistent: false
})
},
[]
[tooltip.persistent]
)

const onGotPointerCapture = useCallback((event: unknown) => {
Expand All @@ -280,8 +312,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 +355,7 @@ export const MainGraph = ({
return setTooltip({
selectedIndex: closestPoint.index,
x: closestPoint.x,
y: Math.min(...closestPoint.values.filter((y) => y !== null)),
y: 0,
persistent: true
})
}
Expand All @@ -334,7 +370,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 @@ -429,17 +468,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