'#3b82f6', 'Balanced Mesh' => '#10b981', 'Developed Cluster' => '#0ea5e9', 'Over-Centralized Hub' => '#ef4444', 'Fragmented' => '#f59e0b', 'Weak Structure' => '#8b5cf6', 'Emerging Cluster' => '#64748b', 'Mixed' => '#94a3b8', 'Unclassified' => '#cbd5e1', ]; /** @var array */ private array $r = []; /** Showing the demo-site example (no Pro): no links, nothing to click. */ private bool $example = false; /** Period length in days => label. */ public static function periods(): array { return [ 28 => __( '4 weeks', 'clusterlane' ), 84 => __( '12 weeks', 'clusterlane' ), 182 => __( '6 months', 'clusterlane' ), ]; } public static function period( $days ): int { $days = (int) $days; return isset( self::periods()[ $days ] ) ? $days : 84; } public function render(): void { // A read-only view: the period is the only input. $days = self::period( isset( $_GET['period'] ) ? absint( wp_unslash( $_GET['period'] ) ) : 84 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $this->example = ! Edition::has( 'report' ); $r = $this->example ? ProExamples::report( $days ) : apply_filters( 'clusterlane_report_data', null, $days ); // premium: Report::build() ?>
render_empty(); echo '
'; return; } $this->r = $r; if ( $this->example ) { echo '
' . wp_kses_post( ProExamples::banner_html( __( 'A site report', 'clusterlane' ) ) ) . '
'; } $this->render_header(); ?>
render_trend(); ?> render_work(); ?>
render_clusters(); ?> render_composition(); ?>
render_todo(); ?> render_summary(); ?>
render_footer(); ?>

r; $site = $r['now']['site']; $base = $r['base']['site'] ?? null; $host = (string) ( $r['host'] ?? wp_parse_url( home_url(), PHP_URL_HOST ) ); $act = $r['activity']['total']; $url = admin_url( 'admin.php?page=clusterlane-report' ); if ( ! $r['base'] ) { $compare = __( 'no earlier snapshot to compare with yet', 'clusterlane' ); } elseif ( $r['partial'] ) { /* translators: %s: date */ $compare = sprintf( __( 'compared with %s, when the history starts', 'clusterlane' ), $this->date( $r['base']['date'] ) ); } else { /* translators: %s: date */ $compare = sprintf( __( 'compared with %s', 'clusterlane' ), $this->date( $r['base']['date'] ) ); } ?>

date( $r['start'] ) . ' – ' . $this->date( $r['today'], true ) : $this->date( $r['today'], true ); echo esc_html( $host . ' · ' . $range . ' · ' . $compare ); ?>
kpi( __( 'Average health', 'clusterlane' ), $site['health'] . '%', $base ? $this->delta( $site['health'] - $base['health'], 'up', __( 'pts', 'clusterlane' ) ) : '', /* translators: %s: value */ $base ? sprintf( __( 'was %s', 'clusterlane' ), $base['health'] . '%' ) : __( 'weighted by articles', 'clusterlane' ) ); $this->kpi( __( 'Orphans', 'clusterlane' ), number_format_i18n( $site['orphans'] ), $base ? $this->delta( $site['orphans'] - $base['orphans'], 'down' ) : '', /* translators: %s: value */ $base ? sprintf( __( 'was %s', 'clusterlane' ), number_format_i18n( $base['orphans'] ) ) : __( 'articles nothing links to', 'clusterlane' ) ); $this->kpi( __( 'Links added', 'clusterlane' ), number_format_i18n( (int) ( $act[ Activity::LINK_ADDED ] ?? 0 ) ), '', __( 'through Clusterlane, each approved', 'clusterlane' ) ); $this->kpi( __( 'Avg inbound links', 'clusterlane' ), number_format_i18n( $site['avg_inbound'], 1 ), $base ? $this->delta( round( $site['avg_inbound'] - $base['avg_inbound'], 1 ), 'up', '', 1 ) : '', /* translators: %s: value */ $base ? sprintf( __( 'per article, was %s', 'clusterlane' ), number_format_i18n( $base['avg_inbound'], 1 ) ) : __( 'per article', 'clusterlane' ) ); $this->kpi( __( 'Content', 'clusterlane' ), number_format_i18n( $site['articles'] ), '', $this->content_sub( $site, $base ) ); ?>
signed( $da ) ); $parts[] = $clusters . ( $dc ? ' (' . $this->signed( $dc ) . ')' : '' ); return implode( ' · ', $parts ); } private function render_trend(): void { $r = $this->r; $series = $r['series']; ?>

= 2 ) : ?>
= 2 ) : ?>
line_chart( $series ), self::kses_chart() ); ?>

date( $r['first'] ?: $r['today'], true ) ) ); ?>

r['activity']; $tot = $a['total']; $by = $a['by_tool'][ Activity::LINK_ADDED ] ?? []; $links = (int) ( $tot[ Activity::LINK_ADDED ] ?? 0 ); $rows = [ [ __( 'Meta descriptions written', 'clusterlane' ), (int) ( $tot[ Activity::META_WRITTEN ] ?? 0 ) ], [ __( 'Clusters with a new pillar', 'clusterlane' ), (int) ( $tot[ Activity::PILLAR_CHOSEN ] ?? 0 ) ], [ __( 'Drafts created', 'clusterlane' ), (int) ( $tot[ Activity::DRAFT_CREATED ] ?? 0 ) ], ]; $week = $this->r['links_week']; ?>

0 ) : ?>
bar_chart( $week ), self::kses_chart() ); ?>

date( $this->log_since(), true ) ) ); ?>

r['log_since'] ) ) return (string) $this->r['log_since']; $first = Activity::stats()['first']; $start = $this->r['start']; if ( ! $first ) return $this->r['today']; $local = wp_date( 'Y-m-d', (int) strtotime( $first . ' UTC' ) ); return max( $local, $start ); } private function render_clusters(): void { $r = $this->r; $clusters = $r['now']['clusters']; $then = []; foreach ( (array) ( $r['base']['clusters'] ?? [] ) as $c ) $then[ $c['k'] ] = (int) $c['h']; // Needs attention first: lowest health; empty clusters last. usort( $clusters, static fn( $a, $b ) => [ $a['n'] === 0, $a['h'], $a['l'] ] <=> [ $b['n'] === 0, $b['h'], $b['l'] ] ); $shown = array_slice( $clusters, 0, 10 ); $rest = array_slice( $clusters, 10 ); $ci = admin_url( 'admin.php?page=clusterlane-cluster-inspector' ); ?>

example ) : ?>cluster_row( $c, $then, $ci ); ?> cluster_row( $c, $then, $ci ); ?>
role_label( $role ) ); ?>
a( add_query_arg( 'open', rawurlencode( $c['k'] ), $ci ), $c['l'], 'clusterlane-rp-cl-name' ) ); ?> mix_bar( (array) $c['r'], $n ); ?> delta( $change, 'up', __( 'pts', 'clusterlane' ) ) ); elseif ( $this->r['base'] ) echo '' . esc_html__( 'new', 'clusterlane' ) . ''; // not in the earlier snapshot else echo '—'; ?> example ) : ?> —'; return; } $title = []; echo ''; foreach ( self::ROLE_ORDER as $role ) { $v = (int) ( $roles[ $role ] ?? 0 ); if ( ! $v ) continue; echo ''; } echo ''; } private function render_composition(): void { $s = $this->r['now']['site']; $roles = $s['roles']; $meta = $s['meta']; $arch = (array) $s['archetypes']; arsort( $arch ); $rows = [ [ __( 'Article roles', 'clusterlane' ), /* translators: %s: number */ sprintf( __( '%s articles, site-wide role', 'clusterlane' ), number_format_i18n( array_sum( $roles ) ) ), array_map( fn( $role ) => [ $this->role_label( $role ), (int) $roles[ $role ], 'var(--clusterlane-rp-' . $role . ')' ], self::ROLE_ORDER ), ], [ __( 'Meta descriptions', 'clusterlane' ), __( 'from your SEO plugin or fields', 'clusterlane' ), [ [ __( 'Good', 'clusterlane' ), (int) $meta['good'], '#15803d' ], [ __( 'Weak', 'clusterlane' ), (int) $meta['weak'], '#f59e0b' ], [ __( 'Missing', 'clusterlane' ), (int) $meta['missing'], '#dc2626' ], ], ], [ __( 'Cluster archetypes', 'clusterlane' ), /* translators: %s: number */ sprintf( _n( '%s cluster', '%s clusters', array_sum( $arch ), 'clusterlane' ), number_format_i18n( array_sum( $arch ) ) ), array_map( fn( $name, $n ) => [ $name, (int) $n, self::ARCHETYPE_COLORS[ $name ] ?? '#94a3b8' ], array_keys( $arch ), array_values( $arch ) ), ], ]; ?>

' . esc_html( round( $n / $total * 100 ) . '%' ) . '' : ''; ?>
r; $s = $r['now']['site']; $limit = $r['thresholds']['orange']; $ai = admin_url( 'admin.php?page=clusterlane-article-inspector' ); $ci = admin_url( 'admin.php?page=clusterlane-cluster-inspector' ); $weak = array_values( array_filter( $r['now']['clusters'], fn( $c ) => $c['n'] > 0 && $c['h'] < $limit ) ); usort( $weak, fn( $a, $b ) => $a['h'] <=> $b['h'] ); $items = []; if ( $s['orphans'] > 0 ) { $items[] = [ /* translators: %s: number */ sprintf( _n( 'Link to %s orphan article', 'Link to %s orphan articles', $s['orphans'], 'clusterlane' ), number_format_i18n( $s['orphans'] ) ), array_map( fn( $o ) => [ $o['title'], add_query_arg( 'post_id', $o['id'], $ai ) ], $r['orphan_list'] ), add_query_arg( 'role', 'orphan', $ai ), ]; } if ( $weak ) { $items[] = [ /* translators: 1: number, 2: percentage */ sprintf( _n( 'Lift %1$s cluster above %2$s health', 'Lift %1$s clusters above %2$s health', count( $weak ), 'clusterlane' ), number_format_i18n( count( $weak ) ), $limit . '%' ), array_map( fn( $c ) => [ $c['l'] . ' (' . $c['h'] . '%)', add_query_arg( 'open', rawurlencode( $c['k'] ), $ci ) ], array_slice( $weak, 0, 4 ) ), $ci, ]; } if ( $s['meta']['missing'] > 0 || $s['meta']['weak'] > 0 ) { $title = $s['meta']['missing'] > 0 /* translators: %s: number */ ? sprintf( _n( 'Write %s missing meta description', 'Write %s missing meta descriptions', $s['meta']['missing'], 'clusterlane' ), number_format_i18n( $s['meta']['missing'] ) ) /* translators: %s: number */ : sprintf( _n( 'Improve %s weak meta description', 'Improve %s weak meta descriptions', $s['meta']['weak'], 'clusterlane' ), number_format_i18n( $s['meta']['weak'] ) ); $note = ( $s['meta']['missing'] > 0 && $s['meta']['weak'] > 0 ) /* translators: %s: number */ ? sprintf( _n( 'and improve %s that is too short or too long', 'and improve %s that are too short or too long', $s['meta']['weak'], 'clusterlane' ), number_format_i18n( $s['meta']['weak'] ) ) : ''; // To SEO Titles, the page where descriptions are written (0.98.185; was Article Inspector). $items[] = [ $title, $note, add_query_arg( 'meta', $s['meta']['missing'] > 0 ? 'missing' : 'weak', admin_url( 'admin.php?page=clusterlane-seo-titles' ) ) ]; } ?>

    [ $title, $detail, $link ] ) : ?>
  1. a( $link, $title, 'clusterlane-rp-todo__title' ) ); ?>
    $this->a( $d[1], $d[0] ), $detail ); echo wp_kses_post( implode( ' · ', $links ) ); ?>
example && Edition::has( 'ai' ) && Edition::premium_code(); ?>

example ) echo wp_kses_post( ProExamples::banner_html( __( 'A report summary', 'clusterlane' ) ) ); $ex = ProExamples::data()['report_summary'] ?? []; ?>

r['days']; $start = new \DateTimeImmutable( $this->r['start'], wp_timezone() ); $x0 = 64; $x1 = 760; $y0 = 24; $y1 = 194; $hs = array_column( $series, 'health' ); $os = array_column( $series, 'orphans' ); $hmin = max( 0, (int) floor( ( min( $hs ) - 5 ) / 10 ) * 10 ); $hmax = min( 100, (int) ceil( ( max( $hs ) + 5 ) / 10 ) * 10 ); // A span of 20, 40, 60 … so the four gridlines land on round numbers (5, 10, 15 … apart). $span = max( 20, (int) ceil( ( $hmax - $hmin ) / 20 ) * 20 ); $hmax = min( 100, $hmin + $span ); $hmin = $hmax - $span; $omax = max( 4, (int) ceil( max( $os ) * 1.2 ) ); $x = function ( string $date ) use ( $start, $days, $x0, $x1 ): float { $d = ( new \DateTimeImmutable( $date, wp_timezone() ) )->getTimestamp() - $start->getTimestamp(); return round( $x0 + max( 0, min( 1, $d / ( $days * DAY_IN_SECONDS ) ) ) * ( $x1 - $x0 ), 1 ); }; $yh = fn( $v ) => round( $y1 - ( $v - $hmin ) / ( $hmax - $hmin ) * ( $y1 - $y0 ), 1 ); $yo = fn( $v ) => round( $y1 - $v / $omax * ( $y1 - $y0 ), 1 ); $hp = []; $op = []; foreach ( $series as $p ) { $px = $x( $p['snap_date'] ); $hp[] = $px . ',' . $yh( $p['health'] ); $op[] = $px . ',' . $yo( $p['orphans'] ); } $first = reset( $series ); $last = end( $series ); $lx = $x( $last['snap_date'] ); $label = sprintf( /* translators: 1: percentage, 2: percentage, 3: number, 4: number */ __( 'Average health from %1$s to %2$s; orphans from %3$s to %4$s.', 'clusterlane' ), $first['health'] . '%', $last['health'] . '%', $first['orphans'], $last['orphans'] ); $svg = ''; // Grid + health scale. $svg .= ''; for ( $i = 0; $i <= 4; $i++ ) { $v = $hmin + ( $hmax - $hmin ) * $i / 4; $y = $yh( $v ); $svg .= ''; $svg .= '' . esc_html( round( $v ) . '%' ) . ''; } $svg .= ''; // Dates along the bottom: five ticks across the period. $svg .= ''; for ( $i = 0; $i <= 4; $i++ ) { $t = $start->modify( '+' . (int) round( $days * $i / 4 ) . ' days' ); $tx = round( $x0 + ( $x1 - $x0 ) * $i / 4, 1 ); $anchor = $i === 0 ? 'start' : ( $i === 4 ? 'end' : 'middle' ); $svg .= '' . esc_html( wp_date( 'j M', $t->getTimestamp() ) ) . ''; } $svg .= ''; // Calculation changed: metrics_version differs from the previous point. $prev = null; foreach ( $series as $p ) { if ( $prev !== null && (int) $p['metrics_version'] !== $prev ) { $vx = $x( $p['snap_date'] ); $svg .= ''; $svg .= '' . esc_html__( 'Calculation changed', 'clusterlane' ) . ''; } $prev = (int) $p['metrics_version']; } $fx = $x( $first['snap_date'] ); $svg .= ''; $svg .= ''; $svg .= ''; $svg .= ''; $svg .= ''; // End labels; the orphans label moves below its dot when the two would collide. $hy = $yh( $last['health'] ); $oy = $yo( $last['orphans'] ); $svg .= '' . esc_html( $last['health'] . '%' ) . ''; /* translators: %s: number */ $olabel = sprintf( _n( '%s orphan', '%s orphans', (int) $last['orphans'], 'clusterlane' ), number_format_i18n( (int) $last['orphans'] ) ); $oty = abs( $oy - $hy ) < 22 ? $oy + 18 : $oy - 8; $svg .= '' . esc_html( $olabel ) . ''; return $svg . ''; } /** Small bars, one per week. */ private function bar_chart( array $values ): string { $n = count( $values ); $w = 340; $h = 84; $base = 70; $max = max( 1, max( $values ) ); $slot = $w / $n; $bw = max( 3, min( 20, $slot * 0.62 ) ); /* translators: 1: number, 2: number */ $label = sprintf( __( 'Links added per week, from %1$s to %2$s.', 'clusterlane' ), min( $values ), max( $values ) ); $svg = ''; $svg .= ''; foreach ( $values as $i => $v ) { if ( ! $v ) continue; $bh = round( $v / $max * ( $base - 12 ), 1 ); $bx = round( $i * $slot + ( $slot - $bw ) / 2, 1 ); $svg .= ''; if ( $v === $max ) { $svg .= '' . (int) $v . ''; } } $start = new \DateTimeImmutable( $this->r['start'], wp_timezone() ); $svg .= '' . esc_html( wp_date( 'j M', $start->getTimestamp() ) ) . ''; $svg .= '' . esc_html__( 'this week', 'clusterlane' ) . ''; return $svg . ''; } /** wp_kses allowlist for the charts. */ private static function kses_chart(): array { $shape = [ 'class' => true, 'x' => true, 'y' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'cx' => true, 'cy' => true, 'r' => true, 'rx' => true, 'width' => true, 'height' => true, 'points' => true ]; return [ 'svg' => [ 'viewbox' => true, 'role' => true, 'aria-label' => true, 'class' => true ], 'g' => [ 'class' => true ], 'line' => $shape, 'polyline' => $shape, 'polygon' => $shape, 'rect' => $shape, 'circle' => $shape, 'text' => [ 'class' => true, 'x' => true, 'y' => true, 'text-anchor' => true ], ]; } /* ------------------------------------------------------------------ Small helpers ------------------------------------------------------------------ */ /** A link — or plain text in the example (the demo site's articles and clusters don't exist here). */ private function a( string $url, string $text, string $class = '' ): string { $c = $class !== '' ? ' class="' . esc_attr( $class ) . '"' : ''; return $this->example ? '' . esc_html( $text ) . '' : '' . esc_html( $text ) . ''; } private function kpi( string $label, string $value, string $delta, string $sub ): void { ?>
±0' . ( $unit ? ' ' . esc_html( $unit ) : '' ) . ''; } $good = ( $d > 0 ) === ( $better === 'up' ); return '' . esc_html( $this->signed( $d, $decimals ) . ( $unit ? ' ' . $unit : '' ) ) . ''; } private function signed( float $n, int $decimals = 0 ): string { $s = number_format_i18n( abs( $n ), $decimals ); return ( $n > 0 ? '+' : ( $n < 0 ? '−' : '' ) ) . $s; } private function band( int $h ): string { $t = $this->r['thresholds']; return $h >= $t['green'] ? 'good' : ( $h >= $t['orange'] ? 'fair' : 'weak' ); } /** A local Y-m-d date as "7 Jul" (or "7 Jul 2026"). */ private function date( string $ymd, bool $year = false ): string { $t = ( new \DateTimeImmutable( $ymd, wp_timezone() ) )->getTimestamp(); return wp_date( $year ? 'j M Y' : 'j M', $t ); } private function role_label( string $role ): string { return [ 'pillar' => __( 'Pillar', 'clusterlane' ), 'support' => __( 'Support', 'clusterlane' ), 'light' => __( 'Light', 'clusterlane' ), 'orphan' => __( 'Orphan', 'clusterlane' ), ][ $role ] ?? $role; } }