From 593bc979b4429257f15dbdfd168378ad8ed94a7e Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 15:39:34 +0300 Subject: [PATCH 1/8] Add highlighted vertical line to graph --- assets/js/dashboard/components/graph.tsx | 67 +++++++++++++++++++----- 1 file changed, 55 insertions(+), 12 deletions(-) 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 From e31c8ce45367b38936265f058b1acd677ccb668b Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 16:29:31 +0300 Subject: [PATCH 2/8] Make tooltip stick to highlight line --- .../js/dashboard/components/graph-tooltip.tsx | 21 +++++++++++++------ .../js/dashboard/stats/graph/main-graph.tsx | 6 +++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx index 630b78a4fe48..ac8eb1a4533f 100644 --- a/assets/js/dashboard/components/graph-tooltip.tsx +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -23,12 +23,22 @@ 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 leftIfAlignedToRight = x + xOffset + const leftIfAlignedToLeft = x - xOffset - measuredWidth + + const canFitRight = leftIfAlignedToRight + measuredWidth <= maxX + const canFitLeft = leftIfAlignedToLeft >= 0 + + const tooltipLeft = canFitRight + ? leftIfAlignedToRight + : canFitLeft + ? leftIfAlignedToLeft + : Math.max(0, Math.min(leftIfAlignedToRight, maxX - measuredWidth)) useLayoutEffect(() => { if (!ref.current) { @@ -46,7 +56,6 @@ export const GraphTooltipWrapper = ({ minWidth, left: tooltipLeft, top: y, - transform: `translateY(-100%) translateY(-${yOffsetFromCursor}px)` }} > {children} diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index d1a2d6f51a16..4ece14d7325a 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -249,7 +249,7 @@ export const MainGraph = ({ ) const onPointerMove = useCallback>( - ({ inHoverableArea, closestPoint, xPointer, yPointer, event }) => { + ({ inHoverableArea, closestPoint, event }) => { if (event instanceof PointerEvent && event.pointerType === 'touch') { return setIsTouchDevice(true) } @@ -259,8 +259,8 @@ export const MainGraph = ({ } return setTooltip({ selectedIndex: closestPoint.index, - x: Math.floor(xPointer), - y: Math.floor(yPointer), + x: closestPoint.x, + y: 0, persistent: false }) }, From ba7132f0c703543b91e59da3c74e158245852a12 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 16:38:49 +0300 Subject: [PATCH 3/8] Format --- assets/js/dashboard/components/graph-tooltip.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx index ac8eb1a4533f..545ff78b227b 100644 --- a/assets/js/dashboard/components/graph-tooltip.tsx +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -33,7 +33,7 @@ export const GraphTooltipWrapper = ({ const canFitRight = leftIfAlignedToRight + measuredWidth <= maxX const canFitLeft = leftIfAlignedToLeft >= 0 - + const tooltipLeft = canFitRight ? leftIfAlignedToRight : canFitLeft @@ -55,7 +55,7 @@ export const GraphTooltipWrapper = ({ style={{ minWidth, left: tooltipLeft, - top: y, + top: y }} > {children} From 55e3a250a3d042bea9ff921121cad646be25770d Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 16:42:17 +0300 Subject: [PATCH 4/8] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 0868d19eb6d9e41ef3613c6bd057d97bee0ee540 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 17:16:12 +0300 Subject: [PATCH 5/8] Center tooltip over top if it fits neither left or right --- .../js/dashboard/components/graph-tooltip.tsx | 20 +++++++++++++------ .../js/dashboard/stats/graph/main-graph.tsx | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx index 545ff78b227b..52a5ae3d5a4e 100644 --- a/assets/js/dashboard/components/graph-tooltip.tsx +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -30,15 +30,21 @@ export const GraphTooltipWrapper = ({ const leftIfAlignedToRight = x + xOffset const leftIfAlignedToLeft = x - xOffset - measuredWidth + const leftIfCentered = Math.max(0, x - measuredWidth / 2) const canFitRight = leftIfAlignedToRight + measuredWidth <= maxX const canFitLeft = leftIfAlignedToLeft >= 0 - - const tooltipLeft = canFitRight - ? leftIfAlignedToRight + const position = canFitRight + ? 'right' : canFitLeft - ? leftIfAlignedToLeft - : Math.max(0, Math.min(leftIfAlignedToRight, maxX - measuredWidth)) + ? 'left' + : 'centered-over-top' + + const tooltipLeft = { + right: leftIfAlignedToRight, + left: leftIfAlignedToLeft, + 'centered-over-top': leftIfCentered + }[position] useLayoutEffect(() => { if (!ref.current) { @@ -55,7 +61,9 @@ export const GraphTooltipWrapper = ({ style={{ minWidth, left: tooltipLeft, - top: y + top: y, + transform: + position === 'centered-over-top' ? 'translateY(-100%)' : undefined }} > {children} diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index 4ece14d7325a..38f5a1d81048 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -319,7 +319,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 }) } From e976f0b9253ca37e35d28b82a68ca8417fe5c137 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 18:31:50 +0300 Subject: [PATCH 6/8] Clamp tooltip if it fits neither left or right --- .../js/dashboard/components/graph-tooltip.tsx | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx index 52a5ae3d5a4e..94524ac9dc13 100644 --- a/assets/js/dashboard/components/graph-tooltip.tsx +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -28,23 +28,19 @@ export const GraphTooltipWrapper = ({ const [measuredWidth, setMeasuredWidth] = useState(minWidth) - const leftIfAlignedToRight = x + xOffset - const leftIfAlignedToLeft = x - xOffset - measuredWidth - const leftIfCentered = Math.max(0, x - measuredWidth / 2) + const leftByAlignment = { + alignedRight: x + xOffset, + alignedLeft: x - xOffset - measuredWidth, + alignedRightClamped: Math.max(0, Math.min(x, maxX - measuredWidth)) + } - const canFitRight = leftIfAlignedToRight + measuredWidth <= maxX - const canFitLeft = leftIfAlignedToLeft >= 0 + const canFitRight = leftByAlignment.alignedRight + measuredWidth <= maxX + const canFitLeft = leftByAlignment.alignedLeft >= 0 const position = canFitRight - ? 'right' + ? 'alignedRight' : canFitLeft - ? 'left' - : 'centered-over-top' - - const tooltipLeft = { - right: leftIfAlignedToRight, - left: leftIfAlignedToLeft, - 'centered-over-top': leftIfCentered - }[position] + ? 'alignedLeft' + : 'alignedRightClamped' useLayoutEffect(() => { if (!ref.current) { @@ -60,10 +56,8 @@ export const GraphTooltipWrapper = ({ className={className} style={{ minWidth, - left: tooltipLeft, - top: y, - transform: - position === 'centered-over-top' ? 'translateY(-100%)' : undefined + left: leftByAlignment[position], + top: y }} > {children} From 5d209ae9834f8e9a05b85837c2c25f4c5bbda325 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 19:36:20 +0300 Subject: [PATCH 7/8] Make it possible to drag on the chart to change selected period on mobile --- .../js/dashboard/stats/graph/main-graph.tsx | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index 38f5a1d81048..4509876ab0df 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -101,6 +101,16 @@ export const MainGraph = ({ setTooltip(initialTooltipState) }, [data]) + useEffect(() => { + const onPointerCancel = (e: PointerEvent) => { + if (e.pointerType === 'touch') { + setTooltip(initialTooltipState) + } + } + window.addEventListener('pointercancel', onPointerCancel) + return () => window.removeEventListener('pointercancel', onPointerCancel) + }, []) + const { remappedData, yMax, @@ -251,7 +261,16 @@ export const MainGraph = ({ const onPointerMove = useCallback>( ({ inHoverableArea, closestPoint, event }) => { if (event instanceof PointerEvent && event.pointerType === 'touch') { - return setIsTouchDevice(true) + setIsTouchDevice(true) + if (tooltip.persistent && inHoverableArea && closestPoint) { + setTooltip({ + selectedIndex: closestPoint.index, + x: closestPoint.x, + y: 0, + persistent: true + }) + } + return } setIsTouchDevice(false) if (!inHoverableArea || !closestPoint) { @@ -264,7 +283,7 @@ export const MainGraph = ({ persistent: false }) }, - [] + [tooltip.persistent] ) const onGotPointerCapture = useCallback((event: unknown) => { @@ -280,8 +299,11 @@ export const MainGraph = ({ }, []) const onPointerLeave = useCallback(() => { + if (tooltip.persistent) { + return + } setTooltip(initialTooltipState) - }, []) + }, [tooltip.persistent]) const showZoomToPeriod = canZoomToPeriod( interval, @@ -334,7 +356,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} From f804ee3d992471fdd603fa2f62daeca08d0b36f7 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 29 Apr 2026 20:24:51 +0300 Subject: [PATCH 8/8] Fix edge case with tooltip flashing on L-shaped y pans --- .../js/dashboard/stats/graph/main-graph.tsx | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index 4509876ab0df..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 @@ -104,11 +107,12 @@ export const MainGraph = ({ useEffect(() => { const onPointerCancel = (e: PointerEvent) => { if (e.pointerType === 'touch') { + panGestureStartTimeRef.current = null setTooltip(initialTooltipState) } } - window.addEventListener('pointercancel', onPointerCancel) - return () => window.removeEventListener('pointercancel', onPointerCancel) + document.addEventListener('pointercancel', onPointerCancel) + return () => document.removeEventListener('pointercancel', onPointerCancel) }, []) const { @@ -263,12 +267,21 @@ export const MainGraph = ({ if (event instanceof PointerEvent && event.pointerType === 'touch') { setIsTouchDevice(true) if (tooltip.persistent && inHoverableArea && closestPoint) { - setTooltip({ - selectedIndex: closestPoint.index, - x: closestPoint.x, - y: 0, - persistent: true - }) + 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 } @@ -299,6 +312,7 @@ export const MainGraph = ({ }, []) const onPointerLeave = useCallback(() => { + panGestureStartTimeRef.current = null if (tooltip.persistent) { return } @@ -454,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' - } - : {} - } >