<?php
declare(strict_types=1);
namespace ItccaAllievi;
if (!defined('ABSPATH')) {
exit;
}
final class Dashboard
{
/**
* Palette ciclica usata per gli animali (uno per ognuno dei 12 segni,
* mantiene lo stesso colore indipendentemente dall'elemento). Per
* "Elemento / El Sx / El Dx" si usano invece i colori canonici di
* Fields::element_color() così Acqua è sempre azzurro, ecc.
*
* @var array<string, string>
*/
private const ANIMAL_PALETTE = [
'Topo' => '#6b7280',
'Bue' => '#92400e',
'Tigre' => '#f59e0b',
'Coniglio' => '#d1bcaf',
'Drago' => '#dc2626',
'Serpente' => '#16a34a',
'Cavallo' => '#7c2d12',
'Capra' => '#94a3b8',
'Scimmia' => '#a16207',
'Gallo' => '#ea580c',
'Cane' => '#365314',
'Maiale' => '#be185d',
];
public static function register(): void
{
add_action('admin_enqueue_scripts', [self::class, 'enqueue_assets']);
add_action('wp_ajax_itcca_geocode_batch_start', [self::class, 'ajax_batch_start']);
}
public static function enqueue_assets(string $hook): void
{
if (strpos($hook, 'itcca-allievi-dashboard') === false) {
return;
}
// Leaflet (mappa) — CDN ufficiale.
wp_enqueue_style(
'leaflet',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
[],
'1.9.4'
);
wp_enqueue_script(
'leaflet',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
[],
'1.9.4',
true
);
wp_enqueue_script(
'itcca-dashboard-map',
ITCCA_URL . 'assets/js/dashboard-map.js',
['leaflet'],
ITCCA_VERSION,
true
);
$view = self::current_view();
wp_localize_script('itcca-dashboard-map', 'itccaMap', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('itcca_geocode_batch'),
'view' => $view,
'sedi' => self::map_sedi_payload(),
// La mappa mostra sempre entrambi i gruppi (attivi + inattivi)
// su layer separati: la view filtra solo KPI/torte/contatori.
'allievi' => self::map_allievi_payload('all'),
'strings' => [
'no_data' => __('Nessuna posizione disponibile.', 'itcca-allievi'),
'geocoding' => __('Geocodifica in corso…', 'itcca-allievi'),
'done' => __('Completata.', 'itcca-allievi'),
'sedi' => __('Sedi attive', 'itcca-allievi'),
'sedi_aband' => __('Sedi abbandonate', 'itcca-allievi'),
'allievi_active' => __('Allievi attivi', 'itcca-allievi'),
'allievi_inactive'=> __('Allievi inattivi', 'itcca-allievi'),
'abandoned' => __('abbandonata', 'itcca-allievi'),
'inactive' => __('inattivo', 'itcca-allievi'),
],
]);
}
/**
* @return array<int, array{name:string, address:string, lat:float, lng:float, abandoned:bool}>
*/
private static function map_sedi_payload(): array
{
$out = [];
foreach (Sedi::all() as $s) {
if ($s['lat'] === null || $s['lng'] === null) continue;
$out[] = [
'name' => $s['name'],
'address' => $s['address'],
'lat' => (float) $s['lat'],
'lng' => (float) $s['lng'],
'abandoned' => !empty($s['abandoned']),
];
}
return $out;
}
/**
* @return array<int, array{name:string, address:string, lat:float, lng:float, active:bool}>
*/
private static function map_allievi_payload(string $view = 'all'): array
{
global $wpdb;
$sql = $wpdb->prepare(
"SELECT u.ID, u.display_name,
MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS cognome,
MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS nome,
MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS comune,
MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS indirizzo,
MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS lat,
MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS lng,
MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS active_flag
FROM {$wpdb->users} u
INNER JOIN {$wpdb->usermeta} m ON m.user_id = u.ID
WHERE m.meta_key IN (%s,%s,%s,%s,%s,%s,%s)
GROUP BY u.ID
HAVING lat IS NOT NULL AND lat <> '' AND lng IS NOT NULL AND lng <> ''",
ITCCA_META_PREFIX . 'cognome',
ITCCA_META_PREFIX . 'nome',
ITCCA_META_PREFIX . 'comune',
ITCCA_META_PREFIX . 'indirizzo',
ITCCA_META_PREFIX . Geocoder::META_LAT,
ITCCA_META_PREFIX . Geocoder::META_LNG,
ITCCA_META_PREFIX . 'active',
ITCCA_META_PREFIX . 'cognome',
ITCCA_META_PREFIX . 'nome',
ITCCA_META_PREFIX . 'comune',
ITCCA_META_PREFIX . 'indirizzo',
ITCCA_META_PREFIX . Geocoder::META_LAT,
ITCCA_META_PREFIX . Geocoder::META_LNG,
ITCCA_META_PREFIX . 'active'
);
$rows = $wpdb->get_results($sql, ARRAY_A) ?: [];
$out = [];
foreach ($rows as $r) {
$active_flag = (string) ($r['active_flag'] ?? '');
$is_active = $active_flag === '' || $active_flag === '1';
if ($view === 'active' && !$is_active) continue;
if ($view === 'inactive' && $is_active) continue;
$name = trim(((string) $r['cognome']) . ' ' . ((string) $r['nome']));
if ($name === '') $name = (string) $r['display_name'];
$out[] = [
'name' => $name,
'address' => trim(((string) $r['indirizzo']) . ' — ' . ((string) $r['comune']), ' —'),
'lat' => (float) $r['lat'],
'lng' => (float) $r['lng'],
'active' => $is_active,
];
}
return $out;
}
public static function ajax_batch_start(): void
{
if (!current_user_can('edit_users')) {
wp_send_json_error(['msg' => 'forbidden'], 403);
}
check_ajax_referer('itcca_geocode_batch', 'nonce');
$pending = Geocoder::pending_user_ids();
wp_send_json_success(['total' => count($pending)]);
}
public static function render_page(): void
{
if (!current_user_can('edit_users')) {
wp_die(__('Permessi insufficienti.', 'itcca-allievi'));
}
$view = self::current_view();
$counts = self::count_by_active();
$ids = self::user_ids_for_view($view);
$total = count($ids);
$counts_animale = self::tally_meta($ids, 'animale');
$counts_elem = self::tally_meta($ids, 'elem');
$counts_el_sx = self::tally_meta($ids, 'el_sx');
$counts_el_dx = self::tally_meta($ids, 'el_dx');
echo '<div class="wrap itcca-dashboard">';
echo '<h1>' . esc_html__('Dashboard Allievi ITCCA', 'itcca-allievi') . '</h1>';
self::render_notice();
self::render_view_switcher($view, $counts);
$label = match ($view) {
'inactive' => __('Distribuzione su %d allievi inattivi.', 'itcca-allievi'),
'all' => __('Distribuzione su %d allievi totali (attivi + inattivi).', 'itcca-allievi'),
default => __('Distribuzione su %d allievi attivi.', 'itcca-allievi'),
};
echo '<p class="description">' . sprintf(esc_html($label), $total) . '</p>';
if ($total === 0) {
echo '<p>' . esc_html__('Nessun allievo corrispondente alla vista selezionata.', 'itcca-allievi') . '</p></div>';
return;
}
$kpi = self::compute_age_kpi($ids);
$births = self::compute_births_per_month($ids);
self::render_kpi_section($kpi, $births);
echo '<div class="itcca-dashboard-grid">';
self::render_chart_card(
__('Animale (Zodiaco cinese)', 'itcca-allievi'),
$counts_animale,
'animale'
);
self::render_chart_card(
__('Elemento', 'itcca-allievi'),
$counts_elem,
'element'
);
self::render_chart_card(
__('Elemento sinistro', 'itcca-allievi'),
$counts_el_sx,
'element'
);
self::render_chart_card(
__('Elemento destro', 'itcca-allievi'),
$counts_el_dx,
'element'
);
echo '</div>'; // .itcca-dashboard-grid
self::render_map_section($view);
echo '</div>'; // .wrap
}
private static function render_notice(): void
{
$notice = sanitize_key((string) ($_GET['itcca_notice'] ?? ''));
if ($notice === 'geocode_retry') {
$n = max(0, (int) ($_GET['cleared'] ?? 0));
printf(
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
esc_html(sprintf(
/* translators: %d numero di utenti reimmessi in coda */
_n(
'%d utente rimesso in coda per la geocodifica.',
'%d utenti rimessi in coda per la geocodifica.',
$n,
'itcca-allievi'
),
$n
))
);
}
}
public static function current_view(): string
{
$v = sanitize_key((string) ($_GET['view'] ?? 'active'));
return in_array($v, ['active', 'inactive', 'all'], true) ? $v : 'active';
}
/**
* @return array<int, int>
*/
private static function user_ids_for_view(string $view): array
{
$args = ['role' => ITCCA_ROLE, 'fields' => ['ID']];
if ($view === 'active') {
$args['meta_query'] = [[
'relation' => 'OR',
['key' => ITCCA_META_PREFIX . 'active', 'value' => '1', 'compare' => '='],
['key' => ITCCA_META_PREFIX . 'active', 'compare' => 'NOT EXISTS'],
]];
} elseif ($view === 'inactive') {
$args['meta_query'] = [[
'key' => ITCCA_META_PREFIX . 'active', 'value' => '0', 'compare' => '=',
]];
}
$users = get_users($args);
return array_map(static fn ($u) => (int) $u->ID, $users);
}
/**
* Conteggio degli allievi pending alla geocodifica, separati per stato.
*
* @return array{active:int, inactive:int}
*/
private static function pending_split_by_active(): array
{
$pending = array_map('intval', Geocoder::pending_user_ids());
$out = ['active' => 0, 'inactive' => 0];
if (empty($pending)) return $out;
// Prime user meta cache: una query, evita N letture singole.
update_meta_cache('user', $pending);
foreach ($pending as $uid) {
$flag = (string) get_user_meta($uid, ITCCA_META_PREFIX . 'active', true);
if ($flag === '0') $out['inactive']++;
else $out['active']++;
}
return $out;
}
/**
* @return array{active:int, inactive:int, all:int}
*/
private static function count_by_active(): array
{
$all = (int) (new \WP_User_Query([
'role' => ITCCA_ROLE, 'count_total' => true, 'fields' => 'ID', 'number' => 1,
]))->get_total();
$inactive = (int) (new \WP_User_Query([
'role' => ITCCA_ROLE,
'count_total' => true,
'fields' => 'ID',
'number' => 1,
'meta_query' => [
['key' => ITCCA_META_PREFIX . 'active', 'value' => '0', 'compare' => '='],
],
]))->get_total();
return [
'active' => $all - $inactive,
'inactive' => $inactive,
'all' => $all,
];
}
/**
* @param array{active:int, inactive:int, all:int} $counts
*/
private static function render_view_switcher(string $current, array $counts): void
{
$base = admin_url('admin.php?page=itcca-allievi-dashboard');
$items = [
'active' => [__('Attivi', 'itcca-allievi'), $counts['active']],
'inactive' => [__('Inattivi', 'itcca-allievi'), $counts['inactive']],
'all' => [__('Tutti', 'itcca-allievi'), $counts['all']],
];
echo '<ul class="subsubsub itcca-dashboard-views">';
$links = [];
foreach ($items as $view => $info) {
$href = $view === 'active' ? $base : add_query_arg('view', $view, $base);
$class = $current === $view ? ' class="current"' : '';
$links[] = sprintf(
'<li><a href="%s"%s>%s <span class="count">(%d)</span></a></li>',
esc_url($href),
$class,
esc_html((string) $info[0]),
(int) $info[1]
);
}
echo implode(' | ', $links);
echo '</ul><br class="clear" />';
}
private static function render_map_section(string $view = 'active'): void
{
$pending_total = count(Geocoder::pending_user_ids());
$pending_split = self::pending_split_by_active();
$allievi_payload = self::map_allievi_payload('all');
$geocoded_active = 0;
$geocoded_inactive = 0;
foreach ($allievi_payload as $a) {
if (!empty($a['active'])) $geocoded_active++;
else $geocoded_inactive++;
}
$sedi_count = count(self::map_sedi_payload());
$failed = Geocoder::failed_counts();
echo '<div class="itcca-map-card">';
echo '<h2>' . esc_html__('Distribuzione geografica', 'itcca-allievi') . '</h2>';
echo '<p class="description">'
. sprintf(
esc_html__('%1$d sedi · %2$d allievi attivi e %3$d inattivi posizionati. %4$d ancora da geocodificare (%5$d attivi + %6$d inattivi).', 'itcca-allievi'),
$sedi_count,
$geocoded_active,
$geocoded_inactive,
$pending_total,
$pending_split['active'],
$pending_split['inactive']
) . '</p>';
if ($failed['transient'] > 0 || $failed['permanent'] > 0) {
echo '<p class="description"><strong>';
printf(
esc_html__('Geocodifiche fallite: %1$d transitorie (es. errori di rete/SSL), %2$d permanenti (indirizzo non trovato).', 'itcca-allievi'),
$failed['transient'],
$failed['permanent']
);
echo '</strong></p>';
}
echo '<div class="itcca-map-toolbar">';
echo '<button type="button" class="button button-secondary" id="itcca-geocode-batch-start"';
if ($pending_total === 0) echo ' disabled';
echo '>' . esc_html__('Geocodifica indirizzi mancanti', 'itcca-allievi') . '</button>';
if ($failed['transient'] > 0) {
$retry_url = wp_nonce_url(
admin_url('admin-post.php?action=itcca_geocode_retry_failed&mode=transient'),
'itcca_geocode_retry_failed'
);
echo ' <a href="' . esc_url($retry_url) . '" class="button button-secondary">'
. esc_html__('Riprova falliti transitori', 'itcca-allievi') . '</a>';
}
if ($failed['permanent'] > 0 || $failed['transient'] > 0) {
$retry_all_url = wp_nonce_url(
admin_url('admin-post.php?action=itcca_geocode_retry_failed&mode=all'),
'itcca_geocode_retry_failed'
);
echo ' <a href="' . esc_url($retry_all_url) . '" class="button">'
. esc_html__('Riprova tutti i falliti', 'itcca-allievi') . '</a>';
}
echo '<span id="itcca-geocode-progress" class="description" style="margin-left:1rem"></span>';
echo '</div>';
echo '<div id="itcca-map" style="height:480px;border-radius:6px;margin-top:.75rem"></div>';
echo '<p class="description" style="margin-top:.5rem">'
. esc_html__('Mappa © OpenStreetMap contributors. Posizioni ricavate via Nominatim, geocodifica limitata a 1 chiamata/sec.', 'itcca-allievi')
. '</p>';
echo '</div>';
}
/**
* @param array<int> $user_ids
* @return array{
* count:int,
* avg:?float,
* min:?array{age:int,user_id:int,name:string},
* max:?array{age:int,user_id:int,name:string},
* }
*/
private static function compute_age_kpi(array $user_ids): array
{
if (empty($user_ids)) {
return ['count' => 0, 'avg' => null, 'min' => null, 'max' => null];
}
global $wpdb;
$placeholders = implode(',', array_fill(0, count($user_ids), '%d'));
$key = ITCCA_META_PREFIX . 'nascita_data';
$sql = $wpdb->prepare(
"SELECT user_id, meta_value FROM {$wpdb->usermeta}
WHERE meta_key = %s AND user_id IN ($placeholders) AND meta_value <> ''",
array_merge([$key], $user_ids)
);
$rows = $wpdb->get_results($sql, ARRAY_A) ?: [];
$today = new \DateTimeImmutable('today');
$ages = [];
$min = null;
$max = null;
foreach ($rows as $r) {
try {
$d = new \DateTimeImmutable((string) $r['meta_value']);
} catch (\Exception) {
continue;
}
if ($d > $today) continue;
$age = (int) $today->diff($d)->y;
if ($age < 0 || $age > 130) continue;
$uid = (int) $r['user_id'];
$ages[$uid] = $age;
if ($min === null || $age < $min['age']) $min = ['age' => $age, 'user_id' => $uid, 'name' => ''];
if ($max === null || $age > $max['age']) $max = ['age' => $age, 'user_id' => $uid, 'name' => ''];
}
if (empty($ages)) {
return ['count' => 0, 'avg' => null, 'min' => null, 'max' => null];
}
if ($min !== null) $min['name'] = self::display_name((int) $min['user_id']);
if ($max !== null) $max['name'] = self::display_name((int) $max['user_id']);
return [
'count' => count($ages),
'avg' => array_sum($ages) / count($ages),
'min' => $min,
'max' => $max,
];
}
/**
* @param array<int> $user_ids
* @return array<int, int> index 1..12 → count
*/
private static function compute_births_per_month(array $user_ids): array
{
$out = array_fill(1, 12, 0);
if (empty($user_ids)) return $out;
global $wpdb;
$placeholders = implode(',', array_fill(0, count($user_ids), '%d'));
$key = ITCCA_META_PREFIX . 'nascita_data';
$sql = $wpdb->prepare(
"SELECT meta_value FROM {$wpdb->usermeta}
WHERE meta_key = %s AND user_id IN ($placeholders) AND meta_value <> ''",
array_merge([$key], $user_ids)
);
$rows = $wpdb->get_col($sql) ?: [];
foreach ($rows as $v) {
try {
$d = new \DateTimeImmutable((string) $v);
} catch (\Exception) {
continue;
}
$m = (int) $d->format('n');
if ($m >= 1 && $m <= 12) $out[$m]++;
}
return $out;
}
private static function display_name(int $user_id): string
{
$cognome = (string) get_user_meta($user_id, ITCCA_META_PREFIX . 'cognome', true);
$nome = (string) get_user_meta($user_id, ITCCA_META_PREFIX . 'nome', true);
$full = trim($cognome . ' ' . $nome);
if ($full !== '') return $full;
$u = get_user_by('id', $user_id);
return $u instanceof \WP_User ? (string) $u->display_name : '';
}
/**
* @param array{count:int, avg:?float, min:?array, max:?array} $kpi
* @param array<int, int> $births
*/
private static function render_kpi_section(array $kpi, array $births): void
{
echo '<div class="itcca-kpi-grid">';
// Card età media
echo '<div class="itcca-kpi-card">';
echo '<div class="itcca-kpi-label">' . esc_html__('Età media', 'itcca-allievi') . '</div>';
if ($kpi['avg'] !== null) {
echo '<div class="itcca-kpi-value">' . esc_html(number_format($kpi['avg'], 1, ',', '')) . '</div>';
echo '<div class="itcca-kpi-note">' . sprintf(
esc_html__('su %d allievi con data di nascita', 'itcca-allievi'),
(int) $kpi['count']
) . '</div>';
} else {
echo '<div class="itcca-kpi-value itcca-kpi-value--muted">—</div>';
echo '<div class="itcca-kpi-note">' . esc_html__('nessuna data di nascita disponibile', 'itcca-allievi') . '</div>';
}
echo '</div>';
// Card età minima
echo '<div class="itcca-kpi-card itcca-kpi-card--accent-blue">';
echo '<div class="itcca-kpi-label">' . esc_html__('Più giovane', 'itcca-allievi') . '</div>';
if (!empty($kpi['min'])) {
$edit = get_edit_user_link((int) $kpi['min']['user_id']);
echo '<div class="itcca-kpi-value">' . (int) $kpi['min']['age'] . '<span class="itcca-kpi-unit">' . esc_html__('anni', 'itcca-allievi') . '</span></div>';
echo '<div class="itcca-kpi-note"><a href="' . esc_url($edit) . '">' . esc_html($kpi['min']['name']) . '</a></div>';
} else {
echo '<div class="itcca-kpi-value itcca-kpi-value--muted">—</div>';
}
echo '</div>';
// Card età massima
echo '<div class="itcca-kpi-card itcca-kpi-card--accent-red">';
echo '<div class="itcca-kpi-label">' . esc_html__('Più anziano', 'itcca-allievi') . '</div>';
if (!empty($kpi['max'])) {
$edit = get_edit_user_link((int) $kpi['max']['user_id']);
echo '<div class="itcca-kpi-value">' . (int) $kpi['max']['age'] . '<span class="itcca-kpi-unit">' . esc_html__('anni', 'itcca-allievi') . '</span></div>';
echo '<div class="itcca-kpi-note"><a href="' . esc_url($edit) . '">' . esc_html($kpi['max']['name']) . '</a></div>';
} else {
echo '<div class="itcca-kpi-value itcca-kpi-value--muted">—</div>';
}
echo '</div>';
// Card grafico nascite per mese
echo '<div class="itcca-kpi-card itcca-kpi-card--chart">';
echo '<div class="itcca-kpi-label">' . esc_html__('Nascite per mese', 'itcca-allievi') . '</div>';
echo self::render_births_bar_chart($births);
echo '</div>';
echo '</div>'; // .itcca-kpi-grid
}
/**
* @param array<int, int> $births
*/
private static function render_births_bar_chart(array $births): string
{
$months = ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'];
$max = max(1, max($births));
$w = 280; $h = 110;
$bar_w = $w / 12;
$top_pad = 16; $bottom_pad = 18;
$svg = '<svg class="itcca-bars" viewBox="0 0 ' . $w . ' ' . $h . '" preserveAspectRatio="none" role="img" aria-label="' . esc_attr__('Nascite per mese', 'itcca-allievi') . '">';
for ($m = 1; $m <= 12; $m++) {
$count = (int) $births[$m];
$bh = $count > 0 ? max(2, ($count / $max) * ($h - $top_pad - $bottom_pad)) : 0;
$x = ($m - 1) * $bar_w + 1.5;
$bw = max(1, $bar_w - 3);
$y = $h - $bottom_pad - $bh;
$svg .= sprintf(
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="2" fill="#2271b1"><title>%s: %d</title></rect>',
$x,
$y,
$bw,
$bh,
esc_html($months[$m - 1]),
$count
);
if ($count > 0) {
$svg .= sprintf(
'<text x="%.2f" y="%.2f" text-anchor="middle" font-size="8" fill="#1d2327">%d</text>',
$x + $bw / 2,
$y - 2,
$count
);
}
$svg .= sprintf(
'<text x="%.2f" y="%.2f" text-anchor="middle" font-size="9" fill="#50575e">%s</text>',
$x + $bw / 2,
$h - 4,
esc_html($months[$m - 1])
);
}
$svg .= '</svg>';
return $svg;
}
/**
* @param array<int> $user_ids
* @return array<string, int>
*/
private static function tally_meta(array $user_ids, string $meta_suffix): array
{
if (empty($user_ids)) return [];
global $wpdb;
$key = ITCCA_META_PREFIX . $meta_suffix;
$placeholders = implode(',', array_fill(0, count($user_ids), '%d'));
$params = array_merge([$key], $user_ids);
$sql = $wpdb->prepare(
"SELECT meta_value, COUNT(*) AS n
FROM {$wpdb->usermeta}
WHERE meta_key = %s AND user_id IN ($placeholders) AND meta_value <> ''
GROUP BY meta_value",
$params
);
$rows = $wpdb->get_results($sql, ARRAY_A) ?: [];
$out = [];
foreach ($rows as $r) {
$val = trim((string) $r['meta_value']);
if ($val === '') continue;
$out[$val] = (int) $r['n'];
}
arsort($out);
return $out;
}
/**
* @param array<string, int> $counts
* @param 'animale'|'element' $palette
*/
private static function render_chart_card(string $title, array $counts, string $palette): void
{
$total = array_sum($counts);
echo '<div class="itcca-chart-card">';
echo '<h2>' . esc_html($title) . '</h2>';
if ($total === 0) {
echo '<p class="description">'
. esc_html__('Nessun dato per questo campo.', 'itcca-allievi')
. '</p></div>';
return;
}
$slices = self::build_slices($counts, $total, $palette);
echo '<div class="itcca-chart-row">';
echo self::render_pie_svg($slices);
echo '<ol class="itcca-chart-legend">';
foreach ($slices as $slice) {
printf(
'<li><span class="itcca-swatch" style="background:%s"></span>'
. '<span class="itcca-chart-legend__label">%s</span>'
. '<span class="itcca-chart-legend__value">%d (%s%%)</span></li>',
esc_attr($slice['color']),
esc_html($slice['label']),
$slice['count'],
esc_html(number_format($slice['pct'], 1, ',', ''))
);
}
echo '</ol>';
echo '</div></div>';
}
/**
* @param array<string, int> $counts
* @param 'animale'|'element' $palette
* @return array<int, array{label:string, count:int, pct:float, color:string}>
*/
private static function build_slices(array $counts, int $total, string $palette): array
{
$slices = [];
$fallback_palette = ['#6366f1', '#0ea5e9', '#14b8a6', '#84cc16', '#eab308', '#f97316', '#ec4899', '#a855f7'];
$i = 0;
foreach ($counts as $value => $count) {
$color = '';
if ($palette === 'element') {
$color = Fields::element_color($value);
} elseif ($palette === 'animale') {
$animal = explode(' di ', $value)[0] ?? '';
$color = self::ANIMAL_PALETTE[$animal] ?? '';
}
if ($color === '') {
$color = $fallback_palette[$i % count($fallback_palette)];
}
$i++;
$slices[] = [
'label' => $value,
'count' => $count,
'pct' => $total > 0 ? ($count / $total) * 100 : 0.0,
'color' => $color,
];
}
return $slices;
}
/**
* @param array<int, array{label:string, count:int, pct:float, color:string}> $slices
*/
private static function render_pie_svg(array $slices): string
{
$size = 240;
$r = 110;
$cx = $size / 2;
$cy = $size / 2;
// Caso speciale: singola fetta = cerchio pieno (gli archi a 360° sono degeneri).
if (count($slices) === 1) {
return sprintf(
'<svg class="itcca-pie" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">'
. '<circle cx="%d" cy="%d" r="%d" fill="%s" />'
. '</svg>',
$size,
$size,
$size,
$size,
esc_attr__('Grafico a torta', 'itcca-allievi'),
$cx,
$cy,
$r,
esc_attr($slices[0]['color'])
);
}
$paths = '';
$angle_start = -M_PI / 2; // partiamo in alto (12 in punto)
$total = array_sum(array_column($slices, 'count'));
foreach ($slices as $slice) {
$portion = $total > 0 ? $slice['count'] / $total : 0;
$angle_end = $angle_start + ($portion * 2 * M_PI);
$x1 = $cx + $r * cos($angle_start);
$y1 = $cy + $r * sin($angle_start);
$x2 = $cx + $r * cos($angle_end);
$y2 = $cy + $r * sin($angle_end);
$large_arc = $portion > 0.5 ? 1 : 0;
$d = sprintf(
'M%.3f,%.3f L%.3f,%.3f A%d,%d 0 %d 1 %.3f,%.3f Z',
$cx,
$cy,
$x1,
$y1,
$r,
$r,
$large_arc,
$x2,
$y2
);
$paths .= sprintf(
'<path d="%s" fill="%s"><title>%s — %d (%s%%)</title></path>',
esc_attr($d),
esc_attr($slice['color']),
esc_html($slice['label']),
$slice['count'],
esc_html(number_format($slice['pct'], 1, ',', ''))
);
$angle_start = $angle_end;
}
return sprintf(
'<svg class="itcca-pie" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="%s">%s</svg>',
$size,
$size,
$size,
$size,
esc_attr__('Grafico a torta', 'itcca-allievi'),
$paths
);
}
}