prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * RestGraph * * Returns node + edge data for a cluster's vis.js graph. */ class RestGraph { /** Most article nodes one graph draws (0.98.169). */ public const MAX_NODES = 150; public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function register_routes(): void { register_rest_route( 'clusterlane/v1', '/graph/(?P[a-z0-9_\-]+)', [ 'methods' => 'GET,POST', 'callback' => [ $this, 'get_graph' ], 'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(), ] ); } public function get_graph( \WP_REST_Request $request ): \WP_REST_Response { $key = sanitize_key( $request['key'] ); global $wpdb; $cluster = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s", $key ) ); if ( ! $cluster ) { return new \WP_REST_Response( [ 'error' => 'Cluster not found' ], 404 ); } // Get member post IDs. $post_ids = $this->plugin->get( 'cluster_engine' )->get_member_post_ids( $cluster ); if ( empty( $post_ids ) ) { return rest_ensure_response( [ 'nodes' => [], 'edges' => [] ] ); } // Big clusters (0.98.169): a browser cannot lay out thousands of nodes — 2,000 // articles froze the page for ~30 s and drew an unreadable ball. Draw the // MAX_NODES that matter most: pillars, the best-linked articles, and a share // of the orphans (what needs fixing). The response says what was left out. $total_members = count( $post_ids ); if ( $total_members > self::MAX_NODES ) { $post_ids = array_values( array_map( 'intval', $post_ids ) ); $rank = $wpdb->get_results( $wpdb->prepare( "SELECT m.post_id, COALESCE( c.content_class, m.content_class ) AS cls, m.inbound_links AS inb, m.overall_score AS sc FROM {$wpdb->prefix}clusterlane_post_metrics m LEFT JOIN {$wpdb->prefix}clusterlane_cluster_post_class c ON c.post_id = m.post_id AND c.cluster_key = %s WHERE m.post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")", array_merge( [ $key ], $post_ids ) ) ); $orph = array_values( array_filter( $rank, fn( $r ) => $r->cls === 'orphan' ) ); $rest = array_values( array_filter( $rank, fn( $r ) => $r->cls !== 'orphan' ) ); usort( $rest, fn( $a, $b ) => ( ( $b->cls === 'pillar' ) <=> ( $a->cls === 'pillar' ) ) ?: ( (int) $b->inb <=> (int) $a->inb ) ?: ( (int) $b->sc <=> (int) $a->sc ) ); usort( $orph, fn( $a, $b ) => (int) $b->sc <=> (int) $a->sc ); // Links between all members, not just the drawn ones — for the "Links" figure. $links_total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE target_type = 'post' AND source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")", array_merge( $post_ids, $post_ids ) ) ); $n_orph = min( count( $orph ), (int) round( self::MAX_NODES * 0.2 ) ); $keep = array_merge( array_slice( $rest, 0, self::MAX_NODES - $n_orph ), array_slice( $orph, 0, $n_orph ) ); if ( count( $keep ) < self::MAX_NODES ) $keep = array_merge( $keep, array_slice( $orph, $n_orph, self::MAX_NODES - count( $keep ) ) ); $post_ids = array_map( fn( $r ) => (int) $r->post_id, $keep ); } // Get metrics for each member. $metrics = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, inbound_links, outbound_links, overall_score, content_class FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")", $post_ids ) ); $metric_map = []; foreach ( $metrics as $m ) { $metric_map[ (int) $m->post_id ] = $m; } // Get this cluster's own cluster-relative classification // (pillar/support/light/orphan is computed against THIS cluster's // inbound-link distribution — see Cluster_Engine::compute_cluster()). // Falls back to the global content_class on clusterlane_post_metrics only // for clusters that haven't had "Compute Metrics" run yet. $class_rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, content_class FROM {$wpdb->prefix}clusterlane_cluster_post_class WHERE cluster_key = %s AND post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")", array_merge( [ $key ], $post_ids ) ) ); $class_map = []; foreach ( $class_rows as $c ) { $class_map[ (int) $c->post_id ] = $c->content_class; } // Get internal links between cluster members (post-to-post only — // links to the category hub are fetched separately below). $links = $wpdb->get_results( $wpdb->prepare( "SELECT source_id, target_id FROM {$wpdb->prefix}clusterlane_links WHERE source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_type = 'post'", array_merge( $post_ids, $post_ids ) ) ); // Build nodes. $nodes = []; foreach ( $post_ids as $post_id ) { $post = get_post( $post_id ); if ( ! $post ) continue; $m = $metric_map[ $post_id ] ?? null; $inbound = $m ? (int) $m->inbound_links : 0; // Prefer the cluster-relative class; fall back to the global // one (set at scan time) only if "Compute Metrics" hasn't // run for this cluster yet. $class = $class_map[ $post_id ] ?? ( $m ? $m->content_class : 'light' ); $score = $m ? (int) $m->overall_score : 0; // Node size based on inbound links. $size = max( 10, min( 40, 10 + $inbound * 3 ) ); // Color based on content class. $color = match( $class ) { 'pillar' => [ 'background' => '#e76c2e', 'border' => '#b84e1e', 'highlight' => [ 'background' => '#f07840', 'border' => '#b84e1e' ] ], 'support' => [ 'background' => '#f5c842', 'border' => '#c9a020', 'highlight' => [ 'background' => '#ffd54f', 'border' => '#c9a020' ] ], 'light' => [ 'background' => '#90b865', 'border' => '#5d8a30', 'highlight' => [ 'background' => '#a8cc7a', 'border' => '#5d8a30' ] ], 'orphan' => [ 'background' => '#5ba4cf', 'border' => '#2979a8', 'highlight' => [ 'background' => '#72b7e0', 'border' => '#2979a8' ] ], default => [ 'background' => '#cccccc', 'border' => '#999999', 'highlight' => [ 'background' => '#dddddd', 'border' => '#999999' ] ], }; $nodes[] = [ 'id' => $post_id, 'label' => mb_strlen( $ptitle = html_entity_decode( (string) $post->post_title, ENT_QUOTES, 'UTF-8' ) ) > 30 ? mb_substr( $ptitle, 0, 28 ) . '…' : $ptitle, 'title' => $ptitle . "\nInbound: $inbound | Score: $score", 'value' => $size, 'size' => $size, 'color' => $color, 'url' => get_edit_post_link( $post_id, 'raw' ), 'class' => $class, 'inbound' => $inbound, ]; } // Build edges. $edges = []; foreach ( $links as $i => $link ) { $edges[] = [ 'id' => 'e' . $i, 'from' => (int) $link->source_id, 'to' => (int) $link->target_id, 'arrows' => 'to', 'color' => [ 'color' => '#cccccc', 'opacity' => 0.6 ], ]; } // --- Category/tag hub node --- // Taxonomy-backed clusters (not custom ones) have a real archive // page members can link back to. Show it as its own node, with // edges only for posts that ACTUALLY link to it — this is meant // to answer "do our articles link back to the hub?", not just // decorate the graph with cluster membership. if ( $cluster->cluster_type !== 'custom' && $cluster->term_id ) { $term = get_term( (int) $cluster->term_id, $cluster->cluster_type ); if ( $term && ! is_wp_error( $term ) ) { $hub_id = 'term_' . $cluster->term_id; $hub_inbound_rows = $wpdb->get_results( $wpdb->prepare( "SELECT source_id FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = 'term' AND source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")", array_merge( [ (int) $cluster->term_id ], $post_ids ) ) ); $hub_inbound = count( $hub_inbound_rows ); $hub_size = max( 14, min( 44, 14 + $hub_inbound * 3 ) ); $hub_url = get_term_link( $term ); if ( is_wp_error( $hub_url ) ) $hub_url = ''; // Term names are stored HTML-encoded ("&"); the graph draws plain text. $hub_name = html_entity_decode( (string) $term->name, ENT_QUOTES, 'UTF-8' ); $nodes[] = [ 'id' => $hub_id, 'label' => mb_strlen( $hub_name ) > 30 ? mb_substr( $hub_name, 0, 28 ) . '…' : $hub_name, 'title' => $hub_name . " (category hub)" . "\nLinks to: all " . $total_members . " posts (via archive page)" . "\nLinked from: $hub_inbound / " . count( $post_ids ) . ( count( $post_ids ) < $total_members ? ' shown' : '' ) . " posts (in-content)", 'shape' => 'diamond', 'value' => $hub_size, 'size' => $hub_size, 'color' => [ 'background' => '#6f5bb0', 'border' => '#4a3a80', 'highlight' => [ 'background' => '#8a76c9', 'border' => '#4a3a80' ] ], 'url' => $hub_url, 'class' => 'hub', 'inbound' => $hub_inbound, ]; // The archive page itself lists and links to every member // post — that's what a category/tag archive template does, // independent of anything scanned in post content. Drawn // as a distinct, faint dashed edge so it reads as // "structural" rather than an editorial in-content link. // (Assumes a stock archive template; a heavily customized // one that hides the post list would make this untrue.) foreach ( $post_ids as $k => $member_id ) { $edges[] = [ 'id' => 'hubout' . $k, 'from' => $hub_id, 'to' => $member_id, 'arrows' => 'to', 'dashes' => true, 'color' => [ 'color' => '#d8d0ee', 'opacity' => 0.35 ], ]; } foreach ( $hub_inbound_rows as $j => $row ) { $edges[] = [ 'id' => 'hub' . $j, 'from' => (int) $row->source_id, 'to' => $hub_id, 'arrows' => 'to', 'color' => [ 'color' => '#b0a4dc', 'opacity' => 0.8 ], ]; } } } // Thumbnails for the node tooltips (article nodes only; hubs have string IDs). $nodes = \Clusterlane\Core\Thumbs::add( $nodes ); return rest_ensure_response( [ 'nodes' => $nodes, 'edges' => $edges, // Set when the graph shows only part of a big cluster (0.98.169). 'truncated' => count( $post_ids ) < $total_members ? [ 'shown' => count( $post_ids ), 'total' => $total_members, 'links' => $links_total ?? 0 ] : null, 'cluster' => [ 'label' => $cluster->cluster_label, 'type' => $cluster->cluster_type, 'archetype'=> $cluster->archetype, 'health' => $cluster->health_score, 'members' => $total_members, 'entropy' => (int) $cluster->entropy_score, 'gini' => (int) $cluster->gini_score, 'density' => (int) $cluster->density_score, ], ] ); } }