$atts */ public function render( $atts ): string { $atts = shortcode_atts( [ 'show' => 'courses,projects,people', 'animate' => 'yes', 'columns' => 3, 'duration' => 2000, 'courses_label' => '', 'projects_label' => '', 'people_label' => '', 'courses' => 'all', 'projects' => 'all', 'members_exclude' => '', 'num_size' => '', 'label_size' => '', 'num_color' => '', 'label_color' => '', 'round' => 0, 'suffix' => '+', 'courses_round' => '', 'courses_suffix' => '', 'projects_round' => '', 'projects_suffix' => '', 'people_round' => '', 'people_suffix' => '', 'custom' => '', ], (array) $atts, 'acc_stat_counters' ); $courses_scope = in_array( strtolower( (string) $atts['courses'] ), [ 'all', 'current' ], true ) ? strtolower( (string) $atts['courses'] ) : 'all'; $projects_scope = in_array( strtolower( (string) $atts['projects'] ), [ 'all', 'active', 'past' ], true ) ? strtolower( (string) $atts['projects'] ) : 'all'; $exclude_roles = $this->parse_excluded_roles( (string) $atts['members_exclude'] ); $show_raw = trim( strtolower( (string) $atts['show'] ) ); if ( 'none' === $show_raw ) { $wanted = []; } else { $wanted = array_values( array_unique( array_filter( array_map( 'trim', explode( ',', $show_raw ) ), static fn( $m ) => in_array( $m, self::METRICS, true ) ) ) ); if ( ! $wanted ) { $wanted = self::METRICS; } } $animate = 'no' !== strtolower( (string) $atts['animate'] ); $columns = max( 1, min( 4, (int) $atts['columns'] ) ); $duration = max( 200, min( 10000, (int) $atts['duration'] ) ); $num_size = $this->sanitize_css_size( (string) $atts['num_size'] ); $label_size = $this->sanitize_css_size( (string) $atts['label_size'] ); $num_color = $this->sanitize_css_color( (string) $atts['num_color'] ); $label_color = $this->sanitize_css_color( (string) $atts['label_color'] ); $def_round = max( 0, min( 100000, (int) $atts['round'] ) ); $def_suffix = mb_substr( (string) $atts['suffix'], 0, 8 ); $labels = [ 'courses' => $atts['courses_label'] ?: __( 'Courses', 'academiccrew' ), 'projects' => $atts['projects_label'] ?: __( 'Projects', 'academiccrew' ), 'people' => $atts['people_label'] ?: __( 'People', 'academiccrew' ), ]; $counts = [ 'courses' => $this->count_courses( $courses_scope ), 'projects' => $this->count_projects( $projects_scope ), 'people' => $this->count_members( $exclude_roles ), ]; // Build one flat list of counters — built-ins the caller asked for, // then the hand-entered custom ones — each with its own round/suffix // (falling back to the round="" / suffix="" defaults above). $counters = []; foreach ( $wanted as $metric ) { [ $round, $suffix ] = $this->resolve_round( (string) $atts[ $metric . '_round' ], (string) $atts[ $metric . '_suffix' ], $def_round, $def_suffix ); $counters[] = [ 'key' => $metric, 'label' => $labels[ $metric ], 'raw' => (int) $counts[ $metric ], 'round' => $round, 'suffix' => $suffix, ]; } array_push( $counters, ...$this->parse_custom( (string) $atts['custom'], $def_round, $def_suffix ) ); if ( ! $counters ) { return ''; } $wrap_style = '--acc-stats-cols:' . $columns; if ( '' !== $num_size ) { $wrap_style .= ';--acc-stat-num-size:' . $num_size; } if ( '' !== $label_size ) { $wrap_style .= ';--acc-stat-label-size:' . $label_size; } if ( '' !== $num_color ) { $wrap_style .= ';--acc-stat-num-color:' . $num_color; } if ( '' !== $label_color ) { $wrap_style .= ';--acc-stat-label-color:' . $label_color; } ob_start(); ?>
0 && $c['raw'] >= $c['round'] ) { $value = intdiv( $c['raw'], $c['round'] ) * $c['round']; $with_suffix = $c['suffix']; } else { $value = $c['raw']; $with_suffix = ''; } ?>
>
enqueue_assets_once(); return (string) ob_get_clean(); } /** * A per-counter round/suffix override, falling back to the widget-wide * default when left blank (""). * * @return array{0:int,1:string} */ private function resolve_round( string $round_override, string $suffix_override, int $default_round, string $default_suffix ): array { $round = '' !== trim( $round_override ) ? max( 0, min( 100000, (int) $round_override ) ) : $default_round; $suffix = '' !== $suffix_override ? mb_substr( $suffix_override, 0, 8 ) : $default_suffix; return [ $round, $suffix ]; } /** * Parses the "custom" textarea: one counter per line, * "Label | Value | Round | Suffix" (Round/Suffix optional). * * @return array */ private function parse_custom( string $raw, int $default_round, string $default_suffix ): array { $out = []; $n = 0; foreach ( preg_split( '/\r\n|\r|\n/', $raw ) as $line ) { $line = trim( $line ); if ( '' === $line || $n >= self::MAX_CUSTOM ) { continue; } $parts = array_map( 'trim', explode( '|', $line ) ); $label = sanitize_text_field( $parts[0] ?? '' ); if ( '' === $label ) { continue; } $n++; [ $round, $suffix ] = $this->resolve_round( $parts[2] ?? '', $parts[3] ?? '', $default_round, $default_suffix ); $out[] = [ 'key' => 'custom-' . $n, 'label' => $label, 'raw' => max( 0, (int) preg_replace( '/[^0-9-]/', '', $parts[1] ?? '0' ) ), 'round' => $round, 'suffix' => $suffix, ]; } return $out; } /** * Allow a CSS length / clamp() expression only — digits, units, and the * handful of characters `clamp()`/`calc()` need. Anything else → ''. */ private function sanitize_css_size( string $raw ): string { $raw = trim( $raw ); if ( '' === $raw || strlen( $raw ) > 60 ) { return ''; } return preg_match( '/^[0-9a-z.,%()\s\/*+-]+$/i', $raw ) ? $raw : ''; } /** * Allow a CSS color value only — hex, named colors, rgb()/rgba()/hsl(), * or a var() reference. Anything else (no ':' means no javascript: URLs * can sneak in) → ''. */ private function sanitize_css_color( string $raw ): string { $raw = trim( $raw ); if ( '' === $raw || strlen( $raw ) > 60 ) { return ''; } return preg_match( '/^[#0-9a-z.,%()\s-]+$/i', $raw ) ? $raw : ''; } /** @return int[] published post IDs of a type (unfiltered). */ private function ids( string $post_type ): array { return get_posts( [ 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, ] ); } /** courses="all" | "current" (only the latest academic year). */ private function count_courses( string $scope ): int { if ( 'current' !== $scope ) { return (int) ( wp_count_posts( 'academiccrew_course' )->publish ?? 0 ); } $current_ay = ( new AcademicYearCalculator() )->calculate( 1 ); return count( get_posts( [ 'post_type' => 'academiccrew_course', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'meta_query' => [ [ 'key' => '_acc_academic_year', 'value' => $current_ay, 'compare' => '>=', 'type' => 'NUMERIC', ] ], ] ) ); } /** projects="all" | "active" (ongoing) | "past" (completed). */ private function count_projects( string $scope ): int { if ( 'all' === $scope ) { return (int) ( wp_count_posts( 'acc_collaboration' )->publish ?? 0 ); } $today = current_time( 'Y-m-d' ); $n = 0; foreach ( $this->ids( 'acc_collaboration' ) as $id ) { $end = (string) get_post_meta( (int) $id, '_acc_end_date', true ); $completed = '' !== $end && $end < $today; if ( ( 'past' === $scope ) === $completed ) { $n++; } } return $n; } /** People count, minus any excluded role slugs. */ private function count_members( array $exclude_roles ): int { if ( ! $exclude_roles ) { return (int) ( wp_count_posts( 'academiccrew_member' )->publish ?? 0 ); } $n = 0; foreach ( $this->ids( 'academiccrew_member' ) as $id ) { $role = (string) get_post_meta( (int) $id, '_acc_member_role', true ) ?: 'collaborator'; if ( ! in_array( $role, $exclude_roles, true ) ) { $n++; } } return $n; } /** * "phd-student, former" → ['phd-student', 'former-professor', …, 'alumni']. * Unknown slugs are dropped. * * @return string[] */ private function parse_excluded_roles( string $raw ): array { $tokens = array_filter( array_map( 'trim', explode( ',', strtolower( $raw ) ) ) ); if ( ! $tokens ) { return []; } $valid = RoleRegistry::keys(); $out = []; foreach ( $tokens as $token ) { if ( 'former' === $token ) { foreach ( $valid as $key ) { if ( str_starts_with( $key, 'former-' ) || 'alumni' === $key ) { $out[] = $key; } } } elseif ( in_array( $token, $valid, true ) ) { $out[] = $token; } } return array_values( array_unique( $out ) ); } /** * Enqueues the inline CSS + count-up JS, at most once per request. */ private function enqueue_assets_once(): void { static $done = false; if ( $done ) { return; } $done = true; $css = '.acc-stats{display:grid;grid-template-columns:repeat(var(--acc-stats-cols,3),1fr);gap:24px;margin:1.5rem 0;text-align:center}' . '.acc-stat{display:flex;flex-direction:column;gap:6px;padding:12px 8px}' . '.acc-stat-num{font-family:var(--acc-font-display,"Roboto Slab",Georgia,serif);font-weight:800;font-size:var(--acc-stat-num-size,clamp(2.4rem,5vw,3.6rem));line-height:1;color:var(--acc-stat-num-color,var(--acc-primary,#0d2340));font-variant-numeric:tabular-nums}' . '.acc-stat-label{font-family:var(--acc-font-mono,monospace);font-size:var(--acc-stat-label-size,11px);letter-spacing:.16em;text-transform:uppercase;color:var(--acc-stat-label-color,var(--acc-muted,#8893a6))}' . '@media(max-width:600px){.acc-stats{grid-template-columns:1fr;gap:14px}}' . '@media(prefers-reduced-motion:reduce){.acc-stat-num{transition:none}}'; $js = "(function(){" . "if(window.__accStatsInit)return;window.__accStatsInit=1;" . "var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;" . "function fmt(n){try{return n.toLocaleString();}catch(e){return String(n);}}" . "function animate(el){" . "var target=parseInt(el.getAttribute('data-acc-count'),10)||0;" . "var sfx=el.getAttribute('data-acc-suffix')||'';" . "if(reduce||!el.hasAttribute('data-acc-animate')){el.textContent=fmt(target)+sfx;return;}" . "var dur=parseInt(el.getAttribute('data-acc-duration'),10)||2000,start=null;" . "function step(ts){" . "if(start===null)start=ts;" . "var p=Math.min((ts-start)/dur,1),eased=1-Math.pow(1-p,3);" . "el.textContent=fmt(Math.round(eased*target))+sfx;" . "if(p<1)requestAnimationFrame(step);else el.textContent=fmt(target)+sfx;" . "}" . "requestAnimationFrame(step);" . "}" . "function run(){" . "var nums=document.querySelectorAll('.acc-stat-num[data-acc-count]');" . "if(!nums.length)return;" . "if(!('IntersectionObserver' in window)){Array.prototype.forEach.call(nums,animate);return;}" . "var io=new IntersectionObserver(function(entries){" . "entries.forEach(function(e){if(e.isIntersecting){animate(e.target);io.unobserve(e.target);}});" . "},{threshold:0.4});" . "Array.prototype.forEach.call(nums,function(n){io.observe(n);});" . "}" . "if(document.readyState!=='loading')run();" . "else document.addEventListener('DOMContentLoaded',run);" . "}());"; wp_register_style( 'acc-stat-counters', false, [], ACADEMICCREW_VERSION ); wp_add_inline_style( 'acc-stat-counters', $css ); wp_enqueue_style( 'acc-stat-counters' ); wp_register_script( 'acc-stat-counters', '', [], ACADEMICCREW_VERSION, true ); wp_add_inline_script( 'acc-stat-counters', $js ); wp_enqueue_script( 'acc-stat-counters' ); } }