Skip to content

Commit

Permalink
Adds a tooltip to the campaign details chart (#97075)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
j6ll authored Dec 6, 2024
1 parent c13354e commit 81cb36a
Show file tree
Hide file tree
Showing 3 changed files with 186 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 = {
Expand All @@ -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(
Expand All @@ -42,35 +42,52 @@ 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 );
const values = data.map( ( entry ) => entry.total );

// 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 ),
Expand Down Expand Up @@ -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',
Expand All @@ -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)`;
Expand All @@ -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 (
<div style={ { position: 'relative' } }>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="campaign-item-details__chart-tooltip-date">
<strong>${ formatDate( new Date( date * 1000 ), hourly ) }</strong>
</div>
<div class="campaign-item-details__chart-tooltip-divider"></div>
<div class="campaign-item-details__chart-tooltip-data">
${ formatValue( value ) }
</div>
`;

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;
}
},
},
};
}

0 comments on commit 81cb36a

Please sign in to comment.