= 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() ); ?>
$top + $plot_h - ( $v / $scale ) * $plot_h;
ob_start();
?>
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();
?>
$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();
?>
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();
?>
$value ) : ?>
-
' . esc_html( $notes[ $label ] ) . '' : ''; ?>
$cell ) : ?>
| > |
$cell ) : ?>
| > |