Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds a tooltip to the campaign details chart #97075

Merged
merged 10 commits into from
Dec 6, 2024
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -1294,3 +1294,38 @@ $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;
}

&__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;
}
},
},
};
}
Loading