= 1000000; $money = Format::money( $millions ? round( $amount / 1000000, 1 ) : round( $amount / 1000 ), $currency ); // "80 €" becomes "80K €" and "€80" becomes "€80K", as the language of the site writes it. return (string) preg_replace( '/(\d)(?!.*\d)/u', '$1' . ( $millions ? 'M' : 'K' ), $money ); } /** * Numbers of a mark for the tooltip. * * @param string $title Day, channel or cell. * @param array $rows Each colour, label and value. * @return string JSON, to escape as an attribute. */ private static function tip( $title, array $rows ) { return (string) wp_json_encode( array( 't' => (string) $title, 'r' => array_values( $rows ), ) ); } /** * Title of a day, week or month in the tooltip. * * @param array $row Row of Chart_Data::series(). * @return string */ private static function title( array $row ) { if ( 'now' === $row['partial'] ) { /* translators: %s: day, week or month, e.g. "Week of 6 October 2026" */ return sprintf( __( '%s (so far)', 'dealroot' ), $row['long'] ); } /* translators: %s: week or month, e.g. "Week of 6 October 2026" */ return 'cut' === $row['partial'] ? sprintf( __( '%s (only partly in the period)', 'dealroot' ), $row['long'] ) : $row['long']; } /** * Width of a chart over time: the usual width, or wider when there are so many days that * their columns would be too thin, so the chart scrolls sideways instead. * * @param int $count Days, weeks or months. * @param int $each Width each of them needs at least. * @return int */ private static function width( $count, $each ) { return max( 1000, 60 + $count * $each ); } /** * Size of a wide chart, which keeps its width and scrolls. * * @param int $width Width. * @return string Value of the style attribute. */ private static function wide( $width ) { return 'width: ' . (int) $width . 'px; max-width: none'; } /** * The scale of a wide chart, kept in view at the left while the chart scrolls. * * @param int $width Width of the chart. * @param int $height Height of the chart. * @param int $left Width of the scale. * @param float[] $ticks Steps of the scale. * @param callable $y Position of a value. * @param callable $label Text of a step. * @return string HTML. */ private static function axis( $width, $height, $left, array $ticks, callable $y, callable $label ) { if ( $width <= 1000 ) { return ''; } $html = ''; } /** * An hour of the day as the site writes times, e.g. "14:00" or "2:00 pm". * * @param int $hour Hour, 0 to 24. * @return string */ public static function clock( $hour ) { return wp_date( (string) get_option( 'time_format' ), ( (int) $hour % 24 ) * HOUR_IN_SECONDS, new \DateTimeZone( 'UTC' ) ); } /** * Every n-th label, so at most about $most fit below an axis. * * @param int $count Labels. * @param int $most Labels that fit. * @return int */ private static function every( $count, $most ) { return max( 1, (int) ceil( $count / max( 1, $most ) ) ); } /** * Revenue and ad costs per day, week or month as pairs of columns on one money axis: the * gray one what the ads cost, the green one what the customers bought for. Above them the * sums of the period and what each unit spent on ads brought back. * * @param array $series Chart_Data::series(). * @return string HTML. */ public static function money( array $series ) { $rows = array_values( $series['rows'] ); $count = count( $rows ); $currency = $series['currency']; $costs = ! empty( $series['costs'] ); if ( $count < 2 ) { return ''; } $revenue = array_sum( wp_list_pluck( $rows, 'revenue' ) ); $spent = array_sum( wp_list_pluck( $rows, 'cost' ) ); $width = self::width( $count, 34 ); $height = 260; $left = 76; $right = 16; $top = 14; $bottom = 30; $plot_w = $width - $left - $right; $plot_h = $height - $top - $bottom; $most = max( 1, (float) max( array_merge( array( 0 ), array_values( wp_list_pluck( $rows, 'revenue' ) ), $costs ? array_values( wp_list_pluck( $rows, 'cost' ) ) : array() ) ) ); $ticks = self::ticks( $most ); $scale = end( $ticks ); $band = min( 110, $plot_w / $count ); $start = $left + ( $plot_w - $band * $count ) / 2; $bar = $costs ? min( 22, max( 2, ( $band - 6 ) / 2 ) ) : min( 24, max( 3, $band - 6 ) ); $base = $top + $plot_h; $y = static fn( $v ) => $top + $plot_h - ( $v / $scale ) * $plot_h; ob_start(); ?>
0 ) : ?>
self::short_money( $tick, $currency ) ), Kses::screen() ); ?>
1000 ? ' style="' . esc_attr( self::wide( $width ) ) . '"' : ''; ?> role="img" aria-label=""> $row ) : $cx = $start + $band * ( $i + 0.5 ); $tip = array( array( self::MONEY, __( 'Revenue', 'dealroot' ), Format::money( $row['revenue'], $currency ) ) ); if ( $costs ) { $tip[] = array( self::COST, __( 'Ad costs', 'dealroot' ), Format::money( $row['cost'], $currency ) ); $tip[] = array( 'transparent', __( 'Left after the ads', 'dealroot' ), Format::money( $row['revenue'] - $row['cost'], $currency ) ); } $money_x = $costs ? $cx + 1 : $cx - $bar / 2; $money_h = $row['revenue'] / $scale * $plot_h; $cost_h = $row['cost'] / $scale * $plot_h; ?> 0 ) : ?> 0 ) : ?>

$top + $plot_h - ( $v / $scale ) * $plot_h; ob_start(); ?>
1000 ? ' style="' . esc_attr( self::wide( $width ) ) . '"' : ''; ?> role="img" aria-label=""> $row ) : $cx = $start + $band * ( $i + 0.5 ); $x0 = $cx - $bar / 2; $won_h = $row['won'] / $scale * $plot_h; $all_h = $row['leads'] / $scale * $plot_h; $base = $top + $plot_h; $gap = $row['won'] && $row['won'] < $row['leads'] ? 2 : 0; ?> $row['won'] ) : ?>
0; } ) ); $total = array_sum( wp_list_pluck( $parts, 'value' ) ); if ( ! $parts || $total <= 0 ) { return ''; } $size = 220; $c = $size / 2; $outer = 100; $inner = 68; $angle = -M_PI / 2; $gap = count( $parts ) > 1 ? 0.025 : 0; ob_start(); ?>
M_PI ? 1 : 0; $p = static fn( $r, $a ) => round( $c + $r * cos( $a ), 2 ) . ',' . round( $c + $r * sin( $a ), 2 ); return 'M' . $p( $outer, $from ) . ' A' . $outer . ',' . $outer . ' 0 ' . $large . ' 1 ' . $p( $outer, $to ) . ' L' . $p( $inner, $to ) . ' A' . $inner . ',' . $inner . ' 0 ' . $large . ' 0 ' . $p( $inner, $from ) . ' Z'; } /** * A whole ring, for a single share. * * @param float $c Centre. * @param float $outer Outer radius. * @param float $inner Inner radius. * @return string Path. */ private static function full_ring( $c, $outer, $inner ) { return 'M' . ( $c - $outer ) . ',' . $c . ' a' . $outer . ',' . $outer . ' 0 1,0 ' . ( 2 * $outer ) . ',0 a' . $outer . ',' . $outer . ' 0 1,0 ' . ( -2 * $outer ) . ',0' . ' M' . ( $c - $inner ) . ',' . $c . ' a' . $inner . ',' . $inner . ' 0 1,1 ' . ( 2 * $inner ) . ',0 a' . $inner . ',' . $inner . ' 0 1,1 ' . ( -2 * $inner ) . ',0 Z'; } /** * What became of the enquiries of each channel: one bar per channel, as long as its * enquiries, split into customers, still open and lost. * * @param array $channels Chart_Data::channels(). * @param string $currency Currency. * @return string HTML. */ public static function outcomes( array $channels, $currency ) { $channels = array_filter( $channels, static function ( $row ) { return $row['leads'] > 0; } ); if ( ! $channels ) { return ''; } // The channel that turns the most enquiries into customers comes first. uasort( $channels, static function ( $a, $b ) { // "Other" and channels with very few enquiries go last, as their share says little. $small = ( ! $a['slot'] || $a['leads'] < 5 ) <=> ( ! $b['slot'] || $b['leads'] < 5 ); return 0 !== $small ? $small : $b['won'] / $b['leads'] <=> $a['won'] / $a['leads']; } ); $most = max( wp_list_pluck( $channels, 'leads' ) ); $parts = array( 'won' => array( __( 'Customers', 'dealroot' ), self::MONEY ), 'open' => array( __( 'Still open', 'dealroot' ), self::LIGHT ), 'lost' => array( __( 'Lost', 'dealroot' ), self::QUIET ), ); ob_start(); ?>
$part ) : if ( $row[ $key ] < 1 ) { continue; } /* translators: 1: channel, 2: outcome, e.g. "Paid search: Customers" */ $title = sprintf( __( '%1$s: %2$s', 'dealroot' ), $row['label'], $part[0] ); $tip = array( array( $part[1], __( 'Enquiries', 'dealroot' ), number_format_i18n( $row[ $key ] ) . ' · ' . Format::percent( $row[ $key ], $row['leads'] ) ) ); if ( 'won' === $key && $row['revenue'] > 0 ) { $tip[] = array( self::MONEY, __( 'Revenue', 'dealroot' ), Format::money( $row['revenue'], $currency ) ); } ?> 0 ? Format::money( $row['revenue'], $currency ) : '—' ); ?>
__( 'Night', 'dealroot' ), 6 => __( 'Morning', 'dealroot' ), 12 => __( 'Afternoon', 'dealroot' ), 18 => __( 'Evening', 'dealroot' ), ); $most = max( 1, $hours['most'] ); $low = $most; $days = array(); // The steps go from the quietest hour with an enquiry to the busiest one. foreach ( $hours['cells'] as $cells ) { foreach ( $cells as $n ) { $low = $n > 0 ? min( $low, $n ) : $low; } } $low = $low >= $most ? 0 : $low; // Monday to Sunday in the language of the site. for ( $day = 1; $day <= 7; $day++ ) { $days[ $day ] = wp_date( 'D', strtotime( 'monday this week +' . ( $day - 1 ) . ' days' ) ); } ob_start(); ?>
$part ) : ?> $name ) : ?>

$day ) : $cx = $band * ( $i + 0.5 ); $now_h = $day['now'] / $most * $plot_h; $prev_h = $day['before'] / $most * $plot_h; ?>
$left + ( $day - 1 ) / ( $days - 1 ) * $plot_w; $y = static fn( $v ) => $top + $plot_h - $v / $most * $plot_h; $path = static function ( array $values ) use ( $x, $y ) { $points = array(); foreach ( array_values( $values ) as $i => $value ) { $points[] = round( $x( $i + 1 ), 1 ) . ',' . round( $y( $value ), 1 ); } return 'M' . implode( ' L', $points ); }; $end = (float) end( $pace['now'] ); $then = (float) ( $pace['before'][ $today - 1 ] ?? end( $pace['before'] ) ); ob_start(); ?>
1 ) : ?> 1 ) : ?> $value ) : $day = $i + 1; $tip = array( array( self::MONEY, __( 'This month', 'dealroot' ), Format::money( $value, $currency ) ) ); if ( isset( $pace['before'][ $i ] ) ) { $tip[] = array( self::QUIET, __( 'Last month', 'dealroot' ), Format::money( $pace['before'][ $i ], $currency ) ); } /* translators: %s: day of the month */ $title = sprintf( __( 'Up to day %s', 'dealroot' ), number_format_i18n( $day ) ); ?> 1
value. * @param string $color Colour. * @param array $notes Label => a short note after the value, optional. * @return string HTML. */ public static function bars( array $rows, $color, array $notes = array() ) { $rows = array_filter( $rows ); if ( ! $rows ) { return ''; } $most = max( $rows ); ob_start(); ?>
$cell ) : ?> $cell ) : ?> >
>