From 81cb36a9741b3c024f433b3cf1bf7464e065c3b2 Mon Sep 17 00:00:00 2001 From: James Gill Date: Fri, 6 Dec 2024 15:51:56 +0000 Subject: [PATCH] Adds a tooltip to the campaign details chart (#97075) * Adds a black box tooltip to the graphs * Hide the chart legend * Add points permanently * Move formatting functions to inside the memo * Code refactoring and performance improvements --- .../campaign-item-details/style.scss | 36 +++++++ .../index.tsx/campaign-stats-line-chart.tsx | 83 ++++++++++------ .../index.tsx/tooltip.tsx | 98 +++++++++++++++++++ 3 files changed, 186 insertions(+), 31 deletions(-) create mode 100644 client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/tooltip.tsx diff --git a/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss b/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss index d437795745dfd..b22517b1a1759 100644 --- a/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss +++ b/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss @@ -1294,3 +1294,39 @@ $break-huge-collapsed-menu: $break-huge - 222px; .location-chart__show-more-button { cursor: pointer; } + +.campaign-item-details { + + &__chart-tooltip { + position: absolute; + pointer-events: none; + background: var( --studio-black ); + color: var( --studio-white ); + padding: 8px 10px; + border-radius: 4px; + display: none; + text-align: center; + } + + &__chart-tooltip-date { + color: var(--studio-gray-20); + font-size: 0.75rem; + font-style: normal; + letter-spacing: -0.15px; + } + + &__chart-tooltip-data { + color: var(--color-text-white); + font-size: 0.875rem; + font-weight: 500; + letter-spacing: -0.15px; + } + + &__chart-tooltip-divider { + background: var(--studio-gray-90); + height: 1px; + width: 100%; + display: block; + margin: 4px 0; + } +} diff --git a/client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/campaign-stats-line-chart.tsx b/client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/campaign-stats-line-chart.tsx index c213c6b2c6c50..ead3236f4153b 100644 --- a/client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/campaign-stats-line-chart.tsx +++ b/client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/campaign-stats-line-chart.tsx @@ -1,7 +1,7 @@ import { useLocale } from '@automattic/i18n-utils'; import { hexToRgb } from '@automattic/onboarding'; -import _ from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; +import _, { debounce } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import uPlot from 'uplot'; import UplotReact from 'uplot-react'; import { @@ -10,6 +10,7 @@ import { } from 'calypso/data/promote-post/use-campaign-chart-stats-query'; import { ChartSourceOptions } from 'calypso/my-sites/promote-post-i2/components/campaign-item-details'; import 'uplot/dist/uPlot.min.css'; +import { tooltip } from 'calypso/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/tooltip'; import { formatCents } from 'calypso/my-sites/promote-post-i2/utils'; const DEFAULT_DIMENSIONS = { @@ -26,11 +27,10 @@ type GraphProps = { const CampaignStatsLineChart = ( { data, source, resolution }: GraphProps ) => { const [ width, setWidth ] = useState( DEFAULT_DIMENSIONS.width ); const hourly = resolution === ChartResolution.Hour; + const tooltipRef = useRef< HTMLDivElement | null >( null ); - const accentColour = getComputedStyle( document.body ) - .getPropertyValue( '--color-accent' ) - .trim(); - const primaryRGB = hexToRgb( accentColour ); + const accentColor = getComputedStyle( document.body ).getPropertyValue( '--color-accent' ).trim(); + const chartColor = hexToRgb( accentColor ); const updateWidth = () => { const wrapperElement = document.querySelector( @@ -42,16 +42,18 @@ const CampaignStatsLineChart = ( { data, source, resolution }: GraphProps ) => { } }; + const debouncedUpdateWidth = debounce( updateWidth, 200 ); + useEffect( () => { // Set initial width updateWidth(); - window.addEventListener( 'resize', updateWidth ); + window.addEventListener( 'resize', debouncedUpdateWidth ); return () => { // Remove on unmount - window.removeEventListener( 'resize', updateWidth ); + window.removeEventListener( 'resize', debouncedUpdateWidth ); }; - }, [] ); + }, [ debouncedUpdateWidth ] ); // Convert ISO date string to Unix timestamp const labels = data.map( ( entry ) => new Date( entry.date_utc ).getTime() / 1000 ); @@ -59,18 +61,33 @@ const CampaignStatsLineChart = ( { data, source, resolution }: GraphProps ) => { // Convert to uPlot's AlignedData format const uplotData: uPlot.AlignedData = [ new Float64Array( labels ), new Float64Array( values ) ]; - const locale = useLocale(); - const formatDate = ( date: Date, hourly: boolean ) => { - const options: Intl.DateTimeFormatOptions = hourly - ? { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' } - : { month: 'short', day: 'numeric' }; - return new Intl.DateTimeFormat( locale, options ).format( date ); - }; + const locale = useLocale(); const options = useMemo( () => { + const formatDate = ( date: Date, hourly: boolean ) => { + const options: Intl.DateTimeFormatOptions = hourly + ? { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' } + : { month: 'short', day: 'numeric' }; + return new Intl.DateTimeFormat( locale, options ).format( date ); + }; + + const tooltipPlugin = tooltip( tooltipRef, formatDate, hourly, formatValue ); + + function formatValue( rawValue: number ) { + if ( rawValue == null ) { + return '-'; + } + + if ( source === ChartSourceOptions.Spend ) { + return `$${ formatCents( rawValue, 2 ) }`; + } + + return rawValue.toLocaleString(); + } + return { - class: 'blaze-stats-line-chart', + class: 'campaign-item-details__uplot-chart', width: width, height: DEFAULT_DIMENSIONS.height, tzDate: ( ts: number ) => new Date( ts * 1000 ), @@ -112,6 +129,17 @@ const CampaignStatsLineChart = ( { data, source, resolution }: GraphProps ) => { fill: '#fff', }, }, + legend: { + show: false, // This will hide the legend + }, + scales: { + y: { + range: ( self: uPlot, min: number, max: number ): [ number, number ] => [ + min, + max + ( max - min ) * 0.4, // Increase the scale by 40%, this allows extra space for the tooltip + ], + }, + }, series: [ { label: 'Date', @@ -124,12 +152,12 @@ const CampaignStatsLineChart = ( { data, source, resolution }: GraphProps ) => { }, { label: _.capitalize( source ), - stroke: accentColour, + stroke: accentColor, width: 3, fill: ( self: uPlot ) => { - const { r, g, b } = primaryRGB; + const { r, g, b } = chartColor; - //Get the height so we can create a gradient + // Get the height so we can create a gradient const height = self?.bbox?.height; if ( ! height || ! isFinite( height ) ) { return `rgba(${ r }, ${ g }, ${ b }, 0.1)`; @@ -146,23 +174,16 @@ const CampaignStatsLineChart = ( { data, source, resolution }: GraphProps ) => { return linear?.()( u, seriesIdx, idx0, idx1 ) || null; }, points: { - show: false, + show: true, }, value: ( self: uPlot, rawValue: number ) => { - if ( rawValue == null ) { - return '-'; - } - - if ( source === ChartSourceOptions.Spend ) { - return `$${ formatCents( rawValue, 2 ) }`; - } - - return rawValue.toLocaleString(); + return formatValue( rawValue ); }, }, ], + plugins: [ tooltipPlugin ], }; - }, [ width, source, accentColour, formatDate, hourly, primaryRGB ] ); + }, [ hourly, width, source, accentColor, locale, chartColor ] ); return (
diff --git a/client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/tooltip.tsx b/client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/tooltip.tsx new file mode 100644 index 0000000000000..0b50d97ee6607 --- /dev/null +++ b/client/my-sites/promote-post-i2/components/campaign-stats-line-chart/index.tsx/tooltip.tsx @@ -0,0 +1,98 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import uPlot from 'uplot'; + +export function tooltip( + tooltipRef: React.MutableRefObject< HTMLDivElement | null >, + formatDate: ( date: Date, hourly: boolean ) => string, + hourly: boolean, + formatValue: ( rawValue: number ) => string +) { + return { + hooks: { + init: ( u: uPlot ) => { + // Create the tooltip element + if ( ! tooltipRef.current ) { + tooltipRef.current = document.createElement( 'div' ); + tooltipRef.current.className = 'campaign-item-details__chart-tooltip'; + u.over.parentNode?.appendChild( tooltipRef.current ); + } + + // Wrap mouse move in a Debounce to reduce the number of updates + const handleMouseMove = debounce( ( e ) => { + if ( ! tooltipRef?.current ) { + return; + } + + // Get the mouse position relative to the chart + const { left } = u.over.getBoundingClientRect(); + const mouseLeft = e.clientX - left; + const activePoint = u.posToIdx( mouseLeft ); + + // If a point is active, update the tooltip + if ( activePoint >= 0 && tooltipRef.current ) { + window.requestAnimationFrame( () => { + if ( ! tooltipRef.current ) { + return; + } + + // Get the highlighted point + const xPoint = u.data[ 0 ][ activePoint ]; + const yPoint = u.data[ 1 ][ activePoint ]; + if ( xPoint == null || yPoint == null ) { + tooltipRef.current.style.display = 'none'; + return; + } + + // Find where that is on the page + const xPos = Math.round( u.valToPos( xPoint, 'x', true ) ); + const yPos = Math.round( u.valToPos( yPoint, 'y', true ) ); + + // Get the date / data value + const date = u.data[ 0 ][ activePoint ]; + const value = u.data[ 1 ][ activePoint ]; + + // If we have a value, put the tooltip just above it. + if ( value != null ) { + const tooltip = tooltipRef.current; + tooltip.style.display = 'block'; + tooltip.style.left = `${ xPos - tooltip.offsetWidth / 2 }px`; + tooltip.style.top = `${ yPos - 16 - tooltip.offsetHeight }px`; + + // Only update the content if it has changed to avoid unnecessary reflows + const newTooltipContent = ` +
+ ${ formatDate( new Date( date * 1000 ), hourly ) } +
+
+
+ ${ formatValue( value ) } +
+ `; + + if ( tooltip.innerHTML !== newTooltipContent ) { + tooltip.innerHTML = newTooltipContent; + } + } + } ); + } else if ( tooltipRef.current ) { + tooltipRef.current.style.display = 'none'; + } + }, 8 ); // 120mhz (1000/120 = 8.333ms) + + u.over.addEventListener( 'mousemove', handleMouseMove ); + u.over.addEventListener( 'mouseleave', () => { + if ( tooltipRef.current ) { + tooltipRef.current.style.display = 'none'; + } + } ); + }, + destroy: () => { + if ( tooltipRef.current && tooltipRef.current.parentNode ) { + tooltipRef.current.parentNode.removeChild( tooltipRef.current ); + tooltipRef.current = null; + } + }, + }, + }; +}