setAttribute( \NumberFormatter::MAX_FRACTION_DIGITS, $decimals ); $out = $formatter->format( $ratio ); if ( false !== $out ) { return $out; } } catch ( \Throwable $error ) { unset( $error ); } } return Locale::number( $ratio * 100, $decimals ) . '%'; } public static function hours( int $minutes ): string { $hours = $minutes / 60; /* translators: %s: number of hours, e.g. 12.5. */ return sprintf( __( '%s h', 'abientot' ), Locale::number( $hours, $hours >= 100 || 0 === $minutes % 60 ? 0 : 1 ) ); } public static function ratio( float $part, float $whole ): float { return $whole > 0 ? $part / $whole : 0.0; } /** * Change against the previous period. Lower-is-better metrics invert the tone. * * @param bool $points Compare ratios in percentage points instead of relative change. */ public static function delta( float $current, float $previous, bool $higher_is_better = true, bool $points = false ): string { if ( $points ) { $change = ( $current - $previous ) * 100; if ( abs( $change ) < 0.5 ) { return self::delta_markup( 'flat', '→', __( 'No change', 'abientot' ) ); } /* translators: %s: change in percentage points, e.g. 4. */ $label = sprintf( __( '%s pts', 'abientot' ), Locale::number( abs( $change ), 0 ) ); } else { if ( $previous <= 0 ) { return $current > 0 ? self::delta_markup( 'new', '✦', __( 'New this period', 'abientot' ) ) : self::delta_markup( 'flat', '→', __( 'No change', 'abientot' ) ); } $change = ( $current - $previous ) / $previous; if ( abs( $change ) < 0.005 ) { return self::delta_markup( 'flat', '→', __( 'No change', 'abientot' ) ); } $label = self::percent( abs( $change ), abs( $change ) < 0.1 ? 1 : 0 ); } $up = $change > 0; $good = $up === $higher_is_better; return self::delta_markup( $good ? 'good' : 'bad', $up ? '▲' : '▼', $label ); } private static function delta_markup( string $tone, string $icon, string $label ): string { return ' ' . esc_html( $label ) . ''; } /** @param array $values */ public static function sparkline( array $values, string $color = '' ): string { $values = array_values( array_map( 'floatval', $values ) ); if ( count( $values ) < 2 || max( $values ) <= 0 ) { return ''; } $max = max( $values ); $last = count( $values ) - 1; $points = array(); foreach ( $values as $index => $value ) { $points[] = round( $index / $last * 100, 2 ) . ',' . round( 28 - $value / $max * 26, 2 ); } $line = implode( ' ', $points ); $fill = '0,30 ' . $line . ' 100,30'; return ''; } /** * Columns for the current period, an optional dashed line for the previous one. * * @param array $columns */ public static function columns( array $columns, callable $format, string $aria, string $color = '', bool $integer = false ): string { if ( ! $columns ) { return self::empty_state(); } $max = 0.0; foreach ( $columns as $column ) { $max = max( $max, (float) $column['value'], (float) ( $column['previous'] ?? 0 ) ); } $tick = self::nice_max( $max / 4 ); $scale = ( $integer ? max( 1.0, ceil( $tick ) ) : $tick ) * 4; $count = count( $columns ); $every = max( 1, (int) ceil( $count / 8 ) ); $out = ''; } /** * @param array $segments */ public static function donut( array $segments, string $center, string $caption, string $aria ): string { $total = array_sum( array_map( static fn( array $segment ): float => (float) $segment['value'], $segments ) ); if ( $total <= 0 ) { return self::empty_state(); } $radius = 15.915; // Circumference 100. $offset = 25.0; $gap = count( array_filter( $segments, static fn( array $segment ): bool => $segment['value'] > 0 ) ) > 1 ? 0.8 : 0; $out = '
'; foreach ( $segments as $segment ) { if ( $segment['value'] <= 0 ) { continue; } $length = $segment['value'] / $total * 100; $out .= ''; $offset -= $length; } return $out . '
'; } /** * Ranked horizontal bars with a direct value label. * * @param array $rows */ public static function hbars( array $rows, string $aria ): string { if ( ! $rows ) { return self::empty_state(); } $max = max( array_map( static fn( array $row ): float => (float) $row['value'], $rows ) ); $out = '
    '; foreach ( $rows as $index => $row ) { $width = $max > 0 && $row['value'] > 0 ? max( 1.5, $row['value'] / $max * 100 ) : 0; $label = esc_html( $row['label'] ); if ( ! empty( $row['href'] ) ) { $label = '' . $label . ''; } $out .= '
  • ' . $label . '' . esc_html( $row['display'] ) . '
    ' . ( ! empty( $row['meta'] ) ? '' . esc_html( $row['meta'] ) . '' : '' ) . '
  • '; } return $out . '
'; } /** * 100% stacked bar with 2px gaps and a legend carrying values (identity is never colour-alone). * * @param array $segments */ public static function stack( array $segments, string $aria ): string { $total = array_sum( array_map( static fn( array $segment ): float => (float) $segment['value'], $segments ) ); if ( $total <= 0 ) { return self::empty_state(); } $bar = '' . $legend . ''; } /** * @param array $rows Row labels (weekdays). * @param array $columns Column labels (hours). * @param array> $values [row][column]. * @param array> $tips [row][column]. */ public static function heatmap( array $rows, array $columns, array $values, array $tips, string $aria ): string { $max = 0.0; foreach ( $values as $row ) { $max = max( $max, $row ? max( $row ) : 0 ); } if ( $max <= 0 ) { return self::empty_state(); } $every = max( 1, (int) ceil( count( $columns ) / 7 ) ); $out = ''; } public static function meter( float $ratio, string $label, string $tone = '' ): string { $tone = '' !== $tone ? $tone : ( $ratio >= 0.85 ? 'high' : ( $ratio >= 0.5 ? 'mid' : 'low' ) ); return ''; } /** * Accessible table view that mirrors a chart. * * @param array $headers * @param array> $rows */ public static function table( array $headers, array $rows, string $caption ): string { $out = ''; } public static function empty_state( string $message = '' ): string { return '

' . esc_html( '' !== $message ? $message : __( 'No data for this period.', 'abientot' ) ) . '

'; } /** Rounded axis maximum: 1, 2, 2.5 or 5 times a power of ten. */ public static function nice_max( float $max ): float { if ( $max <= 0 ) { return 0.0; } $power = 10 ** floor( log10( $max ) ); foreach ( array( 1, 2, 2.5, 5, 10 ) as $step ) { if ( $max <= $step * $power ) { return $step * $power; } } return 10 * $power; } }