import React, { ReactNode, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { UIMode, useTheme } from '../../theme-context' import { MetricFormatterShort } from '../reports/metric-formatter' import { DashboardPeriod } from '../../dashboard-time-periods' import { formatMonthYYYY, formatDayShort, formatTime, is12HourClock, parseNaiveDate, formatDay, isThisYear } from '../../util/date' import classNames from 'classnames' import { ChangeArrow } from '../reports/change-arrow' import { useAppNavigate } from '../../navigation/use-app-navigate' import { Graph, PointerHandler, SeriesConfig } from '../../components/graph' import { useSiteContext, PlausibleSite } from '../../site-context' import { GraphTooltipWrapper } from '../../components/graph-tooltip' import { MetricValue, RevenueMetricValue } from '../../api' import { MainGraphResponse } from './fetch-main-graph' import { remapAndFillData, getLineSegments, GraphDatum, METRICS_WITH_CHANGE_IN_PERCENTAGE_POINTS, getChangeInPercentagePoints, getRelativeChange, REVENUE_METRICS, getFirstAndLastTimeLabels, MainGraphSeriesName, normalizeGraphTimeLabel } from './main-graph-data' import { Metric, getMetricLabel } from '../metrics' import { extractIntervalFromDimensions, Interval } from './intervals' import { useRoutelessModalsContext } from '../../navigation/routeless-modals-context' import { Annotation, AnnotationType, canShowAddAnnotationButton, getAnnotationGranularity, groupAnnotationsByDatetime } from '../../annotations/annotations' import { useUserContext } from '../../user-context' import { Button } from '../../components/button' import { HoverAnnotationsList } from '../../annotations/hover-annotations-list' import { InteractiveAnnotationsList } from '../../annotations/interactive-annotations-list' import { tooltipClassName } from '../../util/tooltip' const height = 368 const marginTop = 16 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 MainGraphYValues = Readonly< [ // first element is comparison series number | null, // second element is main series number | null ] > type TooltipState = { x: number selectedIndex: number | null persistent: boolean } const initialTooltipState: TooltipState = { x: 0, selectedIndex: null, persistent: false } export const MainGraph = ({ width, data, annotations }: { width: number data: MainGraphResponse annotations: Annotation[] }) => { const site = useSiteContext() const user = useUserContext() const { mode } = useTheme() const navigate = useAppNavigate() const { primaryGradient, secondaryGradient } = paletteByTheme[mode] const [isTouchDevice, setIsTouchDevice] = useState(null) const [tooltip, setTooltip] = useState(initialTooltipState) const canAddAnnotation = canShowAddAnnotationButton({ user, siteAnnotationsAvailable: site.siteAnnotationsAvailable }) const closeTooltip = useCallback(() => { setTooltip(initialTooltipState) }, []) useEffect(() => { setTooltip(initialTooltipState) }, [width]) const tooltipRef = useRef(null) const { selectedIndex } = tooltip const panGestureStartTimeRef = useRef(null) const metric = data.query.metrics[0] as Metric const interval = extractIntervalFromDimensions(data.query.dimensions) const isRealtime = data.extraContext.isRealtime const annotationsByDatetime = useMemo( () => groupAnnotationsByDatetime(annotations, interval), [annotations, interval] ) useEffect(() => { setTooltip(initialTooltipState) }, [data]) useEffect(() => { const onClickOutside = (event: MouseEvent) => { if (!tooltipRef.current?.contains(event.target as Node)) { setTooltip(initialTooltipState) } } if (tooltip.persistent && isTouchDevice === false) { document.addEventListener('click', onClickOutside) } return () => { document.removeEventListener('click', onClickOutside) } }, [tooltip.persistent, isTouchDevice]) useEffect(() => { const onPointerCancel = (event: PointerEvent) => { if (event.pointerType === 'touch') { panGestureStartTimeRef.current = null if (tooltipRef.current?.contains(event.target as Node)) { return } setTooltip(initialTooltipState) } } document.addEventListener('pointercancel', onPointerCancel) return () => document.removeEventListener('pointercancel', onPointerCancel) }, []) const { remappedData, yMax, dateIsUnambiguous, yearIsUnambiguous, mainPeriodLengthInDays, mainPeriodLengthInMonths, settings, remappedDataInGraphFormat, gradients } = useMemo(() => { const mainSeriesStartEndLabels = getFirstAndLastTimeLabels( data, MainGraphSeriesName.main ) const comparisonSeriesStartEndLabels = getFirstAndLastTimeLabels( data, MainGraphSeriesName.comparison ) const remappedData = remapAndFillData({ getValue: (item) => item.metrics[0], getNumericValue: REVENUE_METRICS.includes(metric) ? (v) => (v as RevenueMetricValue).value : (v) => ((v as number | null) === null ? 0 : (v as number)), getChange: METRICS_WITH_CHANGE_IN_PERCENTAGE_POINTS.includes(metric) ? getChangeInPercentagePoints : getRelativeChange, data }) let yMax = 1 // can't be done in a single pass with remapAndFillData // because we need the xLabels formatting parameters to be known const remappedDataInGraphFormat = remappedData.map( ({ main, comparison }, bucketIndex) => { const dataPoint = { values: [ comparison.isDefined ? comparison.numericValue : null, main.isDefined ? main.numericValue : null ] as const, xLabel: main.isDefined ? getBucketLabel(main.timeLabel, { shouldShowDate: !isDateUnambiguous({ startEndLabels: mainSeriesStartEndLabels }), shouldShowYear: !isYearUnambiguous({ site, startEndLabels: mainSeriesStartEndLabels }), interval, isRealtime, bucketIndex, totalBuckets: remappedData.length }) : '' } if (main.isDefined && main.numericValue > yMax) { yMax = main.numericValue } if (comparison.isDefined && comparison.numericValue > yMax) { yMax = comparison.numericValue } return dataPoint } ) const gradients = [primaryGradient, secondaryGradient] const mainLineSegments = getLineSegments(remappedData.map((d) => d.main)) const comparisonLineSegments = getLineSegments( remappedData.map((d) => d.comparison) ) const mainSeries: SeriesConfig = { lines: mainLineSegments.map(({ type, ...rest }) => ({ lineClassName: classNames( sharedPathClass, mainPathClass, { current: dashedPathClass, full: roundedPathClass }[type] ), ...rest })), underline: { gradientId: primaryGradient.id }, dot: { dotClassName: classNames(sharedDotClass, mainDotClass) } } const comparisonSeries: SeriesConfig = { lines: comparisonLineSegments.map(({ type, ...rest }) => ({ lineClassName: classNames( sharedPathClass, comparisonPathClass, roundedPathClass ), ...rest })), underline: { gradientId: secondaryGradient.id }, dot: { dotClassName: classNames(sharedDotClass, comparisonDotClass) } } const settings: [SeriesConfig, SeriesConfig] = [ comparisonSeries, mainSeries ] const yearIsUnambiguous = isYearUnambiguous({ site, startEndLabels: [ ...mainSeriesStartEndLabels, ...comparisonSeriesStartEndLabels ] }) const dateIsUnambiguous = isDateUnambiguous({ startEndLabels: [ ...mainSeriesStartEndLabels, ...comparisonSeriesStartEndLabels ] }) const mainPeriodStart = parseNaiveDate(data.query.date_range[0]) const mainPeriodEnd = parseNaiveDate(data.query.date_range[1]) const mainPeriodLengthInDays = mainPeriodEnd.diff(mainPeriodStart, 'days') const mainPeriodLengthInMonths = mainPeriodEnd .startOf('month') .diff(mainPeriodStart.startOf('month'), 'months') return { remappedData, remappedDataInGraphFormat, yMax, dateIsUnambiguous, yearIsUnambiguous, mainPeriodLengthInDays, mainPeriodLengthInMonths, settings, gradients } }, [ site, data, interval, isRealtime, primaryGradient, secondaryGradient, metric ]) const annotationsByIndex = useMemo( () => remappedData.map((datum) => { const annotationsOnDatum = datum.main.isDefined ? (annotationsByDatetime[ normalizeGraphTimeLabel(datum.main.timeLabel) ] ?? []) : [] return { count: annotationsOnDatum.length } }), [remappedData, annotationsByDatetime] ) const getFormattedValue = useCallback( (value: MetricValue) => MetricFormatterShort[metric](value), [metric] ) const yFormat = useCallback( (numericValue: d3.NumberValue) => MetricFormatterShort[metric](numericValue), [metric] ) const onPointerMove = useCallback>( ({ inHoverableArea, closestPoint, event }) => { if (event instanceof PointerEvent && event.pointerType === 'touch') { setIsTouchDevice(true) if (tooltip.persistent && inHoverableArea && closestPoint) { const now = Date.now() // move the tooltip only when it is certain it's not 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, persistent: true }) } } return } setIsTouchDevice(false) setTooltip((currentState): TooltipState => { const currentlyPersistent = currentState.persistent if (currentlyPersistent) { return currentState } if (!inHoverableArea || !closestPoint) { return initialTooltipState } return { persistent: false, selectedIndex: closestPoint.index, x: closestPoint.x } }) }, [tooltip.persistent] ) const onGotPointerCapture = useCallback((event: unknown) => { if (isTouchEvent(event)) { return setIsTouchDevice(true) } }, []) const onPointerEnter = useCallback((event: unknown) => { if (isTouchEvent(event)) { return setIsTouchDevice(true) } }, []) const onPointerLeave = useCallback(() => { panGestureStartTimeRef.current = null if (tooltip.persistent) { return } setTooltip(initialTooltipState) }, [tooltip.persistent]) const showZoomToPeriod = canZoomToPeriod( interval, mainPeriodLengthInDays, mainPeriodLengthInMonths ) const selectedDatum = selectedIndex !== null && remappedData[selectedIndex] const zoomDate = showZoomToPeriod && selectedDatum && selectedDatum.main.isDefined ? selectedDatum.main.timeLabel : null const annotationDatetime = selectedDatum && selectedDatum.main.isDefined ? normalizeGraphTimeLabel(selectedDatum.main.timeLabel) : null const annotationsForSelectedBucket = annotationDatetime ? annotationsByDatetime[annotationDatetime] : undefined const zoomToPeriod = useCallback( (date: string) => { setTooltip(initialTooltipState) navigate({ search: (currentSearch) => ({ ...currentSearch, date, period: interval === Interval.month ? DashboardPeriod.month : DashboardPeriod.day }) }) }, [navigate, interval] ) const onChartClick = useCallback>( ({ inHoverableArea, closestPoint, event }) => { // the first tap / click can happen when // isTouchDevice isn't determined yet // (no preceding pointerenter, gotpointercapture, pointermove) const shouldHandleAsTouch = isTouchDevice ?? isTouchEvent(event) if (isTouchDevice === null) { setIsTouchDevice(shouldHandleAsTouch) } if (shouldHandleAsTouch) { if (inHoverableArea && closestPoint) { return setTooltip({ selectedIndex: closestPoint.index, x: closestPoint.x, persistent: true }) } return setTooltip(initialTooltipState) } if (tooltip.persistent) { return } if (typeof zoomDate === 'string') { return zoomToPeriod(zoomDate) } }, [isTouchDevice, zoomDate, zoomToPeriod, tooltip.persistent] ) const onContextMenu = useCallback>( ({ event }) => { if (selectedDatum) { ;(event as Event).preventDefault() return setTooltip((current) => ({ ...current, persistent: true })) } }, [selectedDatum] ) return ( className={classNames({ 'cursor-pointer': selectedDatum && showZoomToPeriod, 'touch-pan-y': tooltip.persistent })} highlightedIndex={selectedIndex} width={width} height={height} hoverBuffer={hoverBuffer} marginTop={marginTop} marginRight={marginRight} marginBottom={marginBottom} defaultMarginLeft={defaultMarginLeft} settings={settings} data={remappedDataInGraphFormat} yMax={yMax} onPointerEnter={onPointerEnter} onGotPointerCapture={onGotPointerCapture} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} onClick={onChartClick} onContextMenu={onContextMenu} yFormat={yFormat} gradients={gradients} annotationsByIndex={annotationsByIndex} > {!!selectedDatum && isTouchDevice !== null && ( {tooltip.persistent ? ( ) : ( )} )} ) } type TooltipContentsProps = { annotationDatetime: string | null annotations: Annotation[] | undefined interval: Interval zoomDate: string | null canAddAnnotation: boolean } const PersistentTooltipContents = ({ annotationDatetime, annotations, isTouchDevice, interval, zoomDate, onZoomToPeriod, canAddAnnotation, closeTooltip }: { isTouchDevice: boolean onZoomToPeriod: (date: string) => void closeTooltip: () => void } & TooltipContentsProps) => { const { setModal } = useRoutelessModalsContext() const hasActions = !!zoomDate || (!!annotationDatetime && canAddAnnotation) return ( <> {!!annotationDatetime && !!annotations?.length && ( )} {hasActions && (
{!!annotationDatetime && canAddAnnotation && ( )} {!!zoomDate && ( )}
)} ) } const HoveredTooltipContents = ({ annotationDatetime, annotations, interval, zoomDate, canAddAnnotation }: TooltipContentsProps) => { return ( <> {!!annotationDatetime && !!annotations?.length && ( )}
{!!zoomDate && (
{`Click to view ${interval}`}
)}
{canAddAnnotation ? 'Right click for more actions' : 'Right click to pin tooltip'}
) } const isTouchEvent = (event: unknown) => event instanceof PointerEvent && event.pointerType === 'touch' const mainGraphTooltipClassName = tooltipClassName({ size: 'sm', theme: 'darker', className: 'absolute w-max max-w-[220px] sm:max-w-[300px]' }) type MainGraphTooltipProps = { metric: Metric getFormattedValue: (value: MetricValue) => string interval: Interval isRealtime: boolean hasConversionGoalFilter: boolean shouldShowYear: boolean shouldShowDate: boolean x: number datum: GraphDatum showZoomToPeriod?: boolean bucketIndex: number totalBuckets: number maxX: number persistent: boolean onClick?: () => void children?: ReactNode tooltipRef: RefObject isTouchDevice: boolean } const MainGraphTooltip = ({ metric, getFormattedValue, interval, hasConversionGoalFilter, isRealtime, shouldShowDate, shouldShowYear, maxX, x, datum, bucketIndex, totalBuckets, persistent, children, tooltipRef, isTouchDevice }: MainGraphTooltipProps) => { const metricLabel = getMetricLabel(metric, { hasConversionGoalFilter }) const { main, comparison, change } = datum return ( ) } export const MainGraphContainer = React.forwardRef< HTMLDivElement, { children: ReactNode } >((props, ref) => { return (
{props.children}
) }) type BucketLabelParams = { shouldShowYear: boolean shouldShowDate: boolean interval: Interval isRealtime: boolean bucketIndex: number totalBuckets: number } const getBucketLabel = ( // in the format "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS" xValue: string, { shouldShowYear, shouldShowDate, isRealtime, interval, bucketIndex, totalBuckets }: BucketLabelParams ) => { const parsedDate = parseNaiveDate(xValue) switch (interval) { case Interval.month: return formatMonthYYYY(parsedDate) case Interval.week: case Interval.day: return formatDayShort(parsedDate, shouldShowYear) case Interval.hour: { const time = formatTime(parsedDate, { use12HourClock: is12HourClock(), includeMinutes: false }) if (shouldShowDate) { return `${formatDayShort(parsedDate, shouldShowYear)}, ${time}` } return time } case Interval.minute: { if (isRealtime) { const minutesAgo = totalBuckets - bucketIndex return `-${minutesAgo}m` } const time = formatTime(parsedDate, { use12HourClock: is12HourClock(), includeMinutes: true }) if (shouldShowDate) { return `${formatDayShort(parsedDate, shouldShowYear)}, ${time}` } return time } } } const getFullBucketLabel = ( // in the format "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS" xValue: string, { shouldShowYear, shouldShowDate, isRealtime, interval, bucketIndex, totalBuckets, isPartial }: BucketLabelParams & { isPartial: boolean } ) => { const parsedDate = parseNaiveDate(xValue) switch (interval) { case Interval.month: { const month = getBucketLabel(xValue, { shouldShowYear, shouldShowDate, interval, isRealtime, bucketIndex, totalBuckets }) return isPartial ? `Partial of ${month}` : month } case Interval.week: { const date = getBucketLabel(xValue, { shouldShowYear, shouldShowDate, interval, isRealtime, bucketIndex, totalBuckets }) return isPartial ? `Partial week of ${date}` : `Week of ${date}` } case Interval.day: return formatDay(parsedDate, shouldShowYear) case Interval.hour: { const time = formatTime(parsedDate, { use12HourClock: is12HourClock(), includeMinutes: false }) if (shouldShowDate) { return `${formatDay(parsedDate, shouldShowYear)}, ${time}` } return time } case Interval.minute: { if (isRealtime) { const minutesAgo = totalBuckets - bucketIndex return minutesAgo === 1 ? `1 minute ago` : `${minutesAgo} minutes ago` } const time = formatTime(parsedDate, { use12HourClock: is12HourClock(), includeMinutes: true }) if (shouldShowDate) { return `${formatDay(parsedDate, shouldShowYear)}, ${time}` } return time } } } function isYearUnambiguous({ site, startEndLabels }: { site: PlausibleSite startEndLabels: (string | null)[] }): boolean { return startEndLabels .filter((item) => typeof item === 'string') .every( (item, _index, items) => parseNaiveDate(items[0]).isSame(parseNaiveDate(item), 'year') && isThisYear(site, parseNaiveDate(items[0])) ) } function isDateUnambiguous({ startEndLabels }: { startEndLabels: (string | null)[] }): boolean { return startEndLabels .filter((item) => typeof item === 'string') .every((item, _index, items) => parseNaiveDate(items[0]).isSame(parseNaiveDate(item), 'day') ) } function canZoomToPeriod( interval: Interval, mainPeriodLengthInDays: number, mainPeriodLengthInMonths: number ) { return ( (interval === Interval.day && mainPeriodLengthInDays > 0) || (interval === Interval.month && mainPeriodLengthInMonths > 0) ) } const paletteByTheme = { [UIMode.dark]: { primaryGradient: { id: 'primary-gradient', stopTop: { color: '#4f46e5', opacity: 0.15 }, stopBottom: { color: '#4f46e5', opacity: 0 } }, secondaryGradient: { id: 'secondary-gradient', stopTop: { color: '#4f46e5', opacity: 0.05 }, stopBottom: { color: '#4f46e5', opacity: 0 } } }, [UIMode.light]: { primaryGradient: { id: 'primary-gradient', stopTop: { color: '#4f46e5', opacity: 0.15 }, stopBottom: { color: '#4f46e5', opacity: 0 } }, secondaryGradient: { id: 'secondary-gradient', stopTop: { color: '#4f46e5', opacity: 0.05 }, stopBottom: { color: '#4f46e5', opacity: 0 } } } } const sharedPathClass = 'fill-none stroke-2' const mainPathClass = 'stroke-indigo-500 dark:stroke-indigo-400' const comparisonPathClass = 'stroke-[rgb(222,221,255)] dark:stroke-[rgb(45,46,76)]' const roundedPathClass = '[stroke-linecap:round] [stroke-linejoin:round]' const dashedPathClass = '[stroke-dasharray:3,3]' const sharedDotClass = 'opacity-0 group-data-active:opacity-100 transition-opacity duration-100' const mainDotClass = 'fill-indigo-500 dark:fill-indigo-400' const comparisonDotClass = 'fill-[rgb(222,221,255)] dark:fill-[rgb(45,46,76)]' export function useMainGraphWidth( mainGraphContainer: React.RefObject ): { width: number } { const [width, setWidth] = useState(0) useEffect(() => { const resizeObserver = new ResizeObserver(([e]) => { setWidth(e.contentRect.width) }) if (mainGraphContainer.current) { resizeObserver.observe(mainGraphContainer.current) } return () => { resizeObserver.disconnect() } }, [mainGraphContainer]) return { width } }