diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db870448e6f..28f96cf6902e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx index 630b78a4fe48..94524ac9dc13 100644 --- a/assets/js/dashboard/components/graph-tooltip.tsx +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -23,12 +23,24 @@ export const GraphTooltipWrapper = ({ transition?: TransitionClasses & TransitionEvents }) => { const ref = useRef(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) { @@ -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} diff --git a/assets/js/dashboard/components/graph.tsx b/assets/js/dashboard/components/graph.tsx index c781a63c06aa..85431805e73c 100644 --- a/assets/js/dashboard/components/graph.tsx +++ b/assets/js/dashboard/components/graph.tsx @@ -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 @@ -75,6 +77,8 @@ export function Graph({ ) } +const highlightIndicatorGroupId = 'highlight-indicator' + function InnerGraph({ className, width, @@ -199,7 +203,7 @@ function InnerGraph({ d3 .axisBottom(x) .tickValues(xTickValues) - .tickSize(4) + .tickSize(X_TICK_LENGTH_PX) .tickFormat(getXTickFormat(data)) ) .call((g) => g.select('.domain').remove()) @@ -226,6 +230,9 @@ function InnerGraph({ }) } + // must be on top of gradients, but under lines and points + svg.append('g').attr('id', highlightIndicatorGroupId) + const points: Point[] = [] for (const [seriesIndex, series] of settings.entries()) { if (series.underline) { @@ -239,7 +246,9 @@ function InnerGraph({ y1Accessor: (d) => y(d.values[seriesIndex]!) }) } + } + for (const [seriesIndex, series] of settings.entries()) { if (series.lines) { for (const line of series.lines) { drawLine({ @@ -275,7 +284,9 @@ function InnerGraph({ }) points[i] = { ...point, - dots: [...point.dots, dotForSeries] as { [K in keyof T]: SelectedDot } + dots: [...point.dots, dotForSeries] as { + [K in keyof T]: SelectedGroup + } } } } @@ -459,15 +470,45 @@ function InnerGraph({ }, [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( + `#${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 ( ({ ) } +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' @@ -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 @@ -834,7 +877,7 @@ type XPos = number type Point = { x: XPos values: T - dots: { [K in keyof T]: SelectedDot } + dots: { [K in keyof T]: SelectedGroup } } export type SeriesConfig = { @@ -857,4 +900,4 @@ export type PointerHandler = (opts: { }) => void type SelectedSVG = d3.Selection -type SelectedDot = d3.Selection +type SelectedGroup = d3.Selection diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index d1a2d6f51a16..28a288db3a90 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, + useRef, useState } from 'react' import { UIMode, useTheme } from '../../theme-context' @@ -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 @@ -93,6 +95,7 @@ export const MainGraph = ({ const [isTouchDevice, setIsTouchDevice] = useState(null) const [tooltip, setTooltip] = useState(initialTooltipState) const { selectedIndex } = tooltip + const panGestureStartTimeRef = useRef(null) const metric = data.query.metrics[0] as Metric const interval = data.interval const period = data.period @@ -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, @@ -249,9 +263,27 @@ export const MainGraph = ({ ) const onPointerMove = useCallback>( - ({ 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) { @@ -259,12 +291,12 @@ export const MainGraph = ({ } 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) => { @@ -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, @@ -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 }) } @@ -334,7 +370,10 @@ export const MainGraph = ({ return ( - className={showZoomToPeriod && selectedDatum ? 'cursor-pointer' : ''} + className={classNames( + showZoomToPeriod && selectedDatum ? 'cursor-pointer' : '', + tooltip.persistent ? 'touch-pan-y' : '' + )} highlightedIndex={selectedIndex} width={width} height={height} @@ -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' - } - : {} - } >