Combined Taxonomies Tag Cloud

Header image for Combined Taxonomies Tag Cloud

A tag cloud widget for the rest of your taxonomies.

15 minute reading time of 3138 words (inc code) Code available at github.com Plugin available at WordPress This code is marked as "old" - in other words, while it won't set fire to your kitchen, it's unsupported and hasn't been tested on any latest versions of anything, and so it probably won't work without heavy tweaking. Caveat emptor, here be dragons etc...

It’s rare that I’ve made a WP site and not used custom taxonomies somewhere - and the standard WP tag cloud widget only works on the standard ones, and only individually at that. This plugin lets me display a cloud made up of as many taxonomies and post types as needed.

add_action('widgets_init', function() { register_widget('CombinedTaxonomiesTagCloudWidget'); });
add_filter('pre_get_posts', array($this, 'add_post_types_to_the_loop'));

...

// make sure that the taxonomies are added to the relevant tag archive pages
public function add_post_types_to_the_loop($query) {
    if ($query->is_main_query() AND ! is_admin()) {

        // we're on an archive page, so make sure we include all the possible post types in the loop
        if (is_tag() OR is_category()) { // shouldn't need is_tax() as that already pulls in the others

            $looking_for = array();
            foreach ($query->tax_query->queries as $tax) {
                $looking_for[] = $tax['taxonomy'];
            }

            $all_post_types = get_post_types(array(
                'show_ui' => true,
            ));

            $include = array(); foreach ($all_post_types as $type) {
                $post_type_has_these = get_object_taxonomies($type);
                if (array_intersect($looking_for, $post_type_has_these)) $include[] = $type;
            }

            $query->set('post_type', $include);
        }
    }
}

The plugin constructor this time is really only here to fix a semi-related problem I have with the standard tag and category archive pages, in that they don’t show custom post types even when tagged with standard tags. As the tag cloud links to these archives a lot, it’s best to make sure your posts will show up in them!

parent::__construct(false, __('Combined Tag Cloud'),
    \ array('description' => __('More adaptable version of the basic WP tag cloud widget.',
    'CombinedTaxonomiesTagCloud'), 'classname' => 'widget_tag_cloud'));

// only load if we're using the widget
if (is_admin() OR is_active_widget(false, false, $this->id_base, true)) {
    add_action('wp_loaded', array($this, 'make_default_selections'));
    add_action('admin_enqueue_scripts', function() {
        wp_enqueue_style('wp-color-picker'); 
        wp_enqueue_script('combined-taxonomies-tag-cloud-script', plugins_url('admin.js', __FILE__), 
            \ array('wp-color-picker'), false, true);
    });

    // only need our stylesheet on the front end, hence the wp_ hooks
    add_action('wp_head', function() { wp_register_style('combined-taxonomies-tag-cloud-style',
        \ plugins_url('style.css', __FILE__), false, '0.1'); });
    add_action('wp_footer', function() { wp_enqueue_style('combined-taxonomies-tag-cloud-style'); });
}

In the widget class itself is where I load the CSS and JS files; again, register the extra files in the head, but write them into the footer after the main page load.

Same as my Plain Custom Login plugin, the admin.js file associates the WP colour picker widget with the right form fields and fixes a bug.

public function make_default_selections() {
    $this->choices   = array(
        'taxonomies' => get_taxonomies(array('show_ui' => true), 'objects'),
        'post_types' => get_post_types(array('show_ui' => true), 'objects'),
        'unit'       => array('rem', 'em', 'pt', 'px'),
        'orderby'    => array(
                            'name' => __('Alphabetically'),
                            'count' => __('By Count'),
                            'random' => __('Randomly')
                    ),
        'single'     => array(
                            'leave' => __('Leave Alone'),
                            'remove' => __('Remove'),
                            'link' => __('Link to Entry')
                    ),
        'display'    => array(
                            'diy' => __('Your Own Stylesheet'),
                            'flat' => __('Flat List'),
                            'ulist' => __('Block List'),
                            'olist' => __('Numbered List'),
                            'boxes' => __('Box Tags'),
                    ),
        'textcase'   => array(
                            '' => __('Leave Alone'),
                            'lower' => __('lowercase'),
                            'upper' => __('UPPERCASE')
                    ),
        'save'       => array(0, 1, 2, 4, 8, 12, 24, 48, 96), // hours
    );
    sort($this->choices['taxonomies']);
    sort($this->choices['post_types']);

    $this->defaults = array(
        // list of name/value options
    );
}

If the get_taxonomies/post_types functions were real database calls, then I’d be skipping this function on the front end.

// we can avoid all the following db queries if we have some saved output already
$this->transient = 'combined_taxonomies_tag_cloud_'.$args['widget_id'];
$output = get_transient($this->transient);

if (! $output OR $instance['save'] == 0) {

    // need wpdb for the query and wp_post_types to get the labels (names to use in the post counts)
    global $wpdb, $wp_post_types;

    // our sql to retrieve the combined CPT/taxes
    $statement = "
        SELECT	t.term_id, t.name, tt.taxonomy, t.slug, p.post_type, COUNT(*) AS post_type_count
        FROM	{$wpdb->prefix}posts p
                INNER JOIN {$wpdb->prefix}term_relationships tr ON p.ID = tr.object_id
                INNER JOIN {$wpdb->prefix}term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
                INNER JOIN {$wpdb->prefix}terms t ON tt.term_id = t.term_id
        WHERE	p.post_type IN (".implode(', ', array_fill(0, count($args['post_types']), '%s')).")
                AND tt.taxonomy IN (".implode(', ', array_fill(0, count($args['taxonomies']), '%s')).")
                AND t.term_id NOT IN (".implode(', ', array_fill(0, count($args['exclude']), '%d')).")
        GROUP BY t.name, p.post_type
        ORDER BY t.name
    ";

    // nifty array_fill prep by DaveRandom @ https://stackoverflow.com/a/10634225
    $sql = call_user_func_array(array($wpdb, 'prepare'), array_merge(array($statement),
        \ $args['post_types'], $args['taxonomies'], $args['exclude']));
    
    // what the above did was turn our sql statement with a variable number of clauses into something
    // that could run through wpdb->prepare and be made safe enough to run the next line:
    $data = $wpdb->get_results($sql);

    // need this as I'm not getting both the grand total and individual post type counts in the sql
    $tags = array(); foreach ($data as $tag) {
        if (! isset($tags[$tag->term_id])) {
            $tags[$tag->term_id] = array(
                'term_id'  => $tag->term_id,
                'name'     => $tag->name,
                'taxonomy' => $tag->taxonomy,
                'slug'     => $tag->slug,
                'count'    => 0,
                'types'    => array(),
            );
        }
        // uses this as the main font size determination
        $tags[$tag->term_id]['count']+= $tag->post_type_count;

        // and this to make a better link title
        $tags[$tag->term_id]['types'][$tag->post_type] = $tag->post_type_count;
    }

    // mess with the default alphabetical ordering if needed
    if ('random' == $args['orderby']) {
        shuffle($tags);
    } elseif ('count' == $args['orderby']) {
        usort($tags, function($a, $b) {
            return $a['count'] - $b['count'];
        });
    }

    // 0 = ascending, 1 = desc
    if (1 == $args['order']) {
        $tags = array_reverse($tags);
    }

    // and potentially remove orphaned tags *before* trimming
    if ('remove' == $args['single']) {
        foreach ($tags as $i => $tag) if (1 == $tag['count']) unset($tags[$i]);
    }

    // trim to the maximum amount we'll be showing
    $tags = array_slice($tags, 0, $args['maximum']);

    // skip the next bit if we have no tags left
    if (empty($tags)) return false;

    // work out the difference between our highest and lowest tag counts etc
    $weights = array(); foreach ($tags as $tag) $weights[] = $tag['count'];
    $max_qty = max(array_values($weights)); $min_qty = min(array_values($weights));
    $spread = $max_qty - $min_qty;

    // we don't want to divide by zero...
    if ($spread == 0) $spread = 1;

    // set the font-size increment
    $step = ($args['largest'] - $args['smallest']) / $spread;
    
    // halfway point used to alter CSS effects
    $midway = round($args['smallest'] + (($args['largest'] - $args['smallest']) / 2), 2);

    // are we marking the tag links as nofollow?
    $nofollow = (1 == $args['nofollow']) ? ' rel="nofollow"' : '';

    // build our cloud
    $cloud = array();
    foreach ($tags as $i => $tag) {

        // do we link directly to the post?
        if ('link' == $args['single'] AND 1 == $tag['count']) {
            $objs = get_objects_in_term($tag['term_id'], $tag['taxonomy']);
            $link = get_permalink(array_pop($objs));

        // or go to the archive page?
        } else {
            $link = get_term_link((int) $tag['term_id'], $tag['taxonomy']);
        }

        // in case we setup the cloud and then delete a taxonomy without updating the widget choices
        if (is_wp_error($link)) continue;

        // calculate the size of this tag - find the $value in excess of $min_qty, multiply by the
        // font-size increment ($step) and add the $args['smallest'] set above
        $size = round($args['smallest'] + (($tag['count'] - $min_qty) * $step), 2);

        // style tags with class names
        $classes = $tag['taxonomy'].' '.$args['textcase'].' ';
        $classes.= ($midway >= $size) ? 'smaller' : 'larger';

        // build our link title from the component post type counts
        $link_title = array();
        foreach ($tag['types'] as $type => $count) {
            $link_title[]= (1 == $count) ? '1 '.$wp_post_types[$type]->labels->singular_name : 
                \ $count.' '.$wp_post_types[$type]->labels->name;
        }
        $link_title = strtolower(implode(', ', $link_title));

        $cloud[] = '<a href="'.$link.'" style="font-size:'.$size.$args['unit'].'" class="'.$classes.'"
            \ title="'.$link_title.'"'.$nofollow.'>'.$tag['name'].'</a>';
    }


    // that was the hard part, now we wrap the cloud with the desired HTML tag and the
    // usual widget before/after filters

    $title = apply_filters('widget_title', $instance['title']);
    if ('' != $title) $title = $args['before_title'].$title.$args['after_title'];

    switch ($args['display']) {
        case 'diy':			
        case 'flat':  $cloud = '<div class="ctc">'.implode('', $cloud).'</div>'; break;
        case 'olist': $cloud = '<ol class="ctc list"><li>'.implode('</li><li>', $cloud).'</li></ol>'; break;
        case 'ulist': $cloud = '<ul class="ctc list"><li>'.implode('</li><li>', $cloud).'</li></ul>'; break;
        case 'boxes': $cloud = '<div class="ctc boxes">'.implode('', $cloud).'</div>'; break;
        default:      $cloud = '';
    }

    $output = $args['before_widget']."\n"
            . $title."\n".$cloud."\n"
            . $args['after_widget']."\n";

    // save the resulting html so we don't need to do this again for a while
    if (0 < $args['save']) set_transient($this->transient, $output, $args['save'] * HOUR_IN_SECONDS);
}

The main display function - we then add a bit of inline CSS, depending on the widget options, and echo the $output.

$all_terms = get_terms($instance['taxonomies'], array(
    'hide_empty'			 => false,
    'update_term_meta_cache' => false,
));
$sorted = array(); $this->sort_terms_hierarchically($all_terms, $sorted);

$this->selected = $instance['exclude'];
$select['excluded'] = '<select class="widefat"  id="'.esc_attr($this->get_field_id('exclude')).'[]" 
        \ name="'.esc_attr($this->get_field_name('exclude')).'[]" size="10" multiple="true">'
    . $this->display_options_recursively($sorted)
    . '</select>';


...

private function display_options_recursively($terms = array(), $level = 0) {
    $output = '';
    foreach ($terms as $i => $term) {
        $selected = (in_array($term->term_id, $this->selected)) ? ' selected="selected"' : '';
        $padded_name = str_repeat('-- ', $level).$term->name;
        $output.= '<option class="level-'.$level.'" value="'.$term->term_id.'"'.$selected.'>'.
            \ $padded_name.' ('.$term->count.')</option>';
        if (isset($term->children) AND sizeof($term->children)) $output.= 
            \ $this->display_options_recursively($term->children, $level+1);
    }
    return $output;
}

// by pospi @ https://wordpress.stackexchange.com/a/99516
private function sort_terms_hierarchically(array &$cats, array &$into, $parentId = 0) {
    foreach ($cats as $i => $cat) {
        if ($cat->parent == $parentId) {
            $into[$cat->term_id] = $cat;
            unset($cats[$i]);
        }
    }
    foreach ($into as $topCat) {
        $topCat->children = array();
        $this->sort_terms_hierarchically($cats, $topCat->children, $topCat->term_id);
    }
}

A snippet from within the construction of all the different form fields in the widget admin - two different recursive functions (one by me, one by popsi) to sort all the terms for the chosen taxonomies and display them, no matter if they’re hierarchical categories or plain tags.

The rest of the widget code is the normal form display and update after validation.


Reply via email