includes/graphs/helpers.php

<?php

/*
|--------------------------------------------------------------------------
| Graph rendering helpers
|--------------------------------------------------------------------------
|
| Shared SVG chart generation used by every includes/graphs/*.php target
| file (see api/graphs.php's dispatch table). No client-side charting
| library is used: SVG markup is built directly in PHP from SQL rows, so
| a graph is just an HTML fragment ready to drop into a modal.
|
| Roughly, in the order they appear below:
| - Label helpers (localized month/day/date labels for axes).
| - graphPeriodRows(): picks the right time granularity (hour/day/week/
|   month) for a given view and falls back to a coarser one when the
|   requested one would have too few data points to be readable.
| - renderLineGraph(): line charts (visits, pageviews, unique visitors).
| - renderBarGraph() / renderPieGraph() and their "Prepared"/category
|   variants: bar and pie charts (countries, referrers, top pages).
| - renderCountriesMapGraph(): the interactive world map.
|
*/

/* ==========================================================================
   Internationalization
   ========================================================================== */

function graphDayMonthLabel(string $day): string {
    $timestamp = strtotime($day);

    if ($timestamp === false) {
        return $day;
    }

    $month = graphMonthLabel(date('m', $timestamp));

    return str_replace(
        ['{day}', '{month}'],
        [date('j', $timestamp), $month],
        t('graph.date.day.month')
    );
}


/*
|--------------------------------------------------------------------------
| Graph labels
|--------------------------------------------------------------------------
*/

function graphMonthLabel(string $month): string {
    return match ($month) {
        '01' => t('month.jan.short'),
        '02' => t('month.feb.short'),
        '03' => t('month.mar.short'),
        '04' => t('month.apr.short'),
        '05' => t('month.may.short'),
        '06' => t('month.jun.short'),
        '07' => t('month.jul.short'),
        '08' => t('month.aug.short'),
        '09' => t('month.sep.short'),
        '10' => t('month.oct.short'),
        '11' => t('month.nov.short'),
        '12' => t('month.dec.short'),
        default => $month,
    };
}

function graphDayLabel(string $day): string {
    return match (date('N', strtotime($day))) {
        '1' => t('day.mon.short'),
        '2' => t('day.tue.short'),
        '3' => t('day.wed.short'),
        '4' => t('day.thu.short'),
        '5' => t('day.fri.short'),
        '6' => t('day.sat.short'),
        '7' => t('day.sun.short'),
        default => $day,
    };
}

function graphLabel(string $period, string $mode): string {
    return match ($mode) {
        'month' => graphMonthLabel($period),
        'year' => $period,
        'day_number' => date('j', strtotime($period)),
        'day_month' => graphDayMonthLabel($period),
        'weekday' => graphDayLabel($period),
        default => $period,
    };
}


/*
|--------------------------------------------------------------------------
| Per-site series colors
|--------------------------------------------------------------------------
|
| Each site's line uses the golden angle (~137.508°) to space hues evenly
| around the color wheel, so consecutive sites never end up visually close
| to one another, however many are added. "Global" keeps the theme's
| regular graph color (--color-graph-line) instead of a generated one.
*/

function graphSeriesColor(int $index): string {
    $hue = fmod($index * 137.508, 360);

    return 'hsl(' . round($hue, 1) . ', 70%, 60%)';
}


/*
|--------------------------------------------------------------------------
| Per-site breakdown for line graphs
|--------------------------------------------------------------------------
|
| Used only in the "all sites" view, to draw one line per site alongside
| the Global total. Groups at the same granularity (day/month/year) that
| graphPeriodRows() picked for the Global series, so both line up on the
| same x-axis labels.
|
*/

function graphPeriodRowsBySite(string $column, string $rangeSql, array $rangeParams, string $labelMode): array {
    $db = brivaciaDb();

    $periodExpr = match ($labelMode) {
        'month' => 'strftime("%m", day)',
        'year' => 'strftime("%Y", day)',
        default => 'day',
    };

    $rows = fetchAll($db, '
        SELECT ' . $periodExpr . ' AS period, site, SUM(' . $column . ') AS value
        FROM hits_daily
        WHERE ' . $rangeSql . '
        GROUP BY period, site
        ORDER BY period
    ', $rangeParams);

    $bySite = [];

    foreach ($rows as $row) {
        $bySite[(string)$row['site']][(string)$row['period']] = (int)$row['value'];
    }

    return $bySite;
}


/*
|--------------------------------------------------------------------------
| Per-site breakdown for bar/pie tooltips
|--------------------------------------------------------------------------
|
| Shared across countries, referrers and search engines. A breakdown is
| only worth showing when Brivacia tracks more than one site AND the
| category in question (a country, a referrer...) actually has data from
| more than one of them — otherwise it would just repeat the same number
| already shown on the main line.
*/

function graphMultiSiteEnabled(): bool {
    return count(brivacia_sites()) > 1;
}

// Generic {label => {site => value}} lookup for any (table, label column,
// value column) pair — used to enrich countries_bar/countries_pie tooltips,
// which (unlike referrers/search engines) don't already carry a per-site
// breakdown from their own query.
function graphSiteBreakdown(string $table, string $labelColumn, string $valueColumn, string $rangeSql, array $rangeParams): array {
    $db = brivaciaDb();

    $rows = fetchAll($db, '
        SELECT ' . $labelColumn . ' AS label, site, SUM(' . $valueColumn . ') AS value
        FROM ' . $table . '
        WHERE ' . $rangeSql . '
        GROUP BY ' . $labelColumn . ', site
    ', $rangeParams);

    $bySite = [];

    foreach ($rows as $row) {
        $bySite[(string)$row['label']][(string)$row['site']] = (int)$row['value'];
    }

    return $bySite;
}

// Builds the extra "<br>X% site" lines appended after a tooltip's main
// line. Expects (and produces) already-HTML-escaped text, for callers that
// hand-build a pre-escaped tooltip string (bar/pie charts). $denominator
// is whatever the main line's own percentage was computed against, so the
// breakdown lines always add back up to it.
function graphSiteBreakdownSuffix(array $bySite, int $denominator): string {
    // Note: even when only one site contributed to this particular slice,
    // the breakdown line still matters — it's the only place that reveals
    // *which* site, since the main tooltip line never names one. The outer
    // caller already restricts this to installs tracking 2+ sites overall
    // (graphMultiSiteEnabled()), so $bySite being non-empty here always
    // means there's something worth naming.
    if ($bySite === [] || $denominator <= 0) {
        return '';
    }

    arsort($bySite);

    $suffix = '';

    foreach ($bySite as $site => $value) {
        if ($value <= 0) {
            continue;
        }

        $percentage = round(($value / $denominator) * 100, 1);

        $suffix .= '<br>' . htmlspecialchars(
            (string)$site . ' ' . $percentage . '%',
            ENT_QUOTES,
            'UTF-8'
        );
    }

    return $suffix;
}


/*
|--------------------------------------------------------------------------
| Line graph data
|--------------------------------------------------------------------------
|
| Brivacia stores daily aggregates only.
|
| This function returns:
| - rows: data points used by the SVG renderer
| - label mode: how x-axis labels should be displayed
| - fallback: true when a broader period had too little data and a more
|   detailed view is displayed instead
|
*/


function graphPeriodRows(string $column, string $rangeSql, array $rangeParams, string $view): array {
    $db = brivaciaDb();

    /*
    |--------------------------------------------------------------------------
    | Available granularities
    |--------------------------------------------------------------------------
    */

    $dailyRows = static fn() => fetchAll($db, '
        SELECT day AS period, SUM(' . $column . ') AS value
        FROM hits_daily
        WHERE ' . $rangeSql . '
        GROUP BY day
        ORDER BY day
    ', $rangeParams);

    $monthlyRows = static fn() => fetchAll($db, '
        SELECT strftime("%m", day) AS period, SUM(' . $column . ') AS value
        FROM hits_daily
        WHERE ' . $rangeSql . '
        GROUP BY period
        ORDER BY period
    ', $rangeParams);

    $yearlyRows = static fn() => fetchAll($db, '
        SELECT strftime("%Y", day) AS period, SUM(' . $column . ') AS value
        FROM hits_daily
        WHERE ' . $rangeSql . '
        GROUP BY period
        ORDER BY period
    ', $rangeParams);

    /*
    |--------------------------------------------------------------------------
    | Week view
    |--------------------------------------------------------------------------
    |
    | Week graphs always use daily points.
    |
    */

    if ($view === 'week') {
        return [$dailyRows(), 'weekday', false];
    }

    /*
    |--------------------------------------------------------------------------
    | Month view
    |--------------------------------------------------------------------------
    |
    | Month graphs also use daily points, but labels include the day/month
    | to avoid ambiguous labels such as "10, 11, 12".
    |
    */

    if ($view === 'month') {
        return [$dailyRows(), 'day_month', false];
    }

    /*
    |--------------------------------------------------------------------------
    | Year view
    |--------------------------------------------------------------------------
    |
    | Year graphs should use monthly points.
    | On new installations, there may be only one month of data, so we fallback
    | to daily points and show a notice.
    |
    */

    if ($view === 'year') {
        $rows = $monthlyRows();

        if (count($rows) >= 2) {
            return [$rows, 'month', false];
        }

        return [$dailyRows(), 'day_month', true];
    }

    /*
    |--------------------------------------------------------------------------
    | All-time view
    |--------------------------------------------------------------------------
    |
    | All-time graphs prefer yearly points.
    | If there is not enough history, they fallback to monthly points.
    | If there is still only one month, they fallback again to daily points.
    |
    */

    if ($view === 'all') {
        $rows = $yearlyRows();

        if (count($rows) >= 2) {
            return [$rows, 'year', false];
        }

        $rows = $monthlyRows();

        if (count($rows) >= 2) {
            return [$rows, 'month', true];
        }

        return [$dailyRows(), 'day_month', true];
    }

    /*
    |--------------------------------------------------------------------------
    | Safety fallback
    |--------------------------------------------------------------------------
    */

    return [$dailyRows(), 'day_month', false];
}


/*
|--------------------------------------------------------------------------
| Line graph renderer
|--------------------------------------------------------------------------
*/

function renderLineGraph(string $column, string $title, string $rangeSql, array $rangeParams, string $view, string $periodTitle, string $currentSite = ''): string {
    [$rows, $labelMode, $fallback] = graphPeriodRows($column, $rangeSql, $rangeParams, $view);

    if ($rows === []) {
        return t('graph.no.data');
    }

    $data = [];
    $labels = [];
    $periods = [];

    foreach ($rows as $row) {
        $periods[] = (string)$row['period'];
        $data[] = (int)$row['value'];
        $labels[] = graphLabel((string)$row['period'], $labelMode);
    }

    if (count($data) < 2) {
        return t('graph.no.data');
    }

    /*
    |--------------------------------------------------------------------------
    | Per-site breakdown
    |--------------------------------------------------------------------------
    |
    | Only fetched in the "all sites" view, and only actually used as extra
    | lines when more than one site contributed data in the period — a
    | single site would just redraw the same line as Global.
    */

    $siteSeries = [];

    if ($currentSite === '') {
        $bySite = graphPeriodRowsBySite($column, $rangeSql, $rangeParams, $labelMode);

        if (count($bySite) > 1) {
            foreach ($bySite as $site => $values) {
                $siteSeries[] = [
                    'label' => $site,
                    'data' => array_map(
                        static fn($period) => $values[$period] ?? 0,
                        $periods
                    ),
                ];
            }
        }
    }

    $width = 1000;
    $height = 320;

    $max = max(1, max($data));
    $count = max(1, count($data) - 1);

    $axisGap = 25;
    $axisLabelWidth = strlen((string)$max) * 14 + $axisGap;

    $viewBoxX = -$axisLabelWidth;
    $viewBoxWidth = $width + $axisLabelWidth;

    // When a legend is needed (Global + one line per site), the plot area
    // is narrowed to leave a real column on the right for it -- same idea
    // as the pie chart's legend -- rather than eating into vertical space
    // above the chart. Its width is measured from the actual labels shown
    // (Global + each site's code) rather than a fixed guess, so it stays
    // snug whether sites have 3-letter or 15-letter codes.
    $hasLegend = $siteSeries !== [];
    $legendWidth = 0;

    if ($hasLegend) {
        $legendLabelWidth = graphTextWidth(t('graph.legend.global'));

        foreach ($siteSeries as $series) {
            $legendLabelWidth = max($legendLabelWidth, graphTextWidth((string)$series['label']));
        }

        // swatch (12) + gap after swatch + label + right-side padding.
        // The gap was 7 units — correct in Chrome/Firefox/Brave, but too
        // tight a margin once a browser's own font settings (e.g. Opera's
        // minimum font size) render the label a touch wider than the
        // 15px this was tuned for. 11 gives more slack without visibly
        // changing the normal case.
        $legendSwatchGap = 11;
        $legendWidth = 12 + $legendSwatchGap + $legendLabelWidth + 20;
    }

    $plotLeft = 0;
    $plotRight = $width - 20 - $legendWidth;
    $plotTop = 45;
    $plotBottom = 275;

    $axisTextX = -$axisGap;

    $plotWidth = $plotRight - $plotLeft;
    $plotHeight = $plotBottom - $plotTop;

    $points = [];
    $labelsSvg = '';
    $markers = '';
    $grid = '';

    for ($i = 0; $i <= 4; $i++) {
        $value = (int)round($max - (($max / 4) * $i));
        $y = $plotTop + (($plotHeight / 4) * $i);

        $grid .= '
            <line class="grid" x1="' . $plotLeft . '" y1="' . $y . '" x2="' . $plotRight . '" y2="' . $y . '"></line>
            <text class="axis" x="' . $axisTextX . '" y="' . ($y + 6) . '">' . $value . '</text>
        ';
    }

    foreach ($data as $i => $value) {
        $x = $plotLeft + ($i / $count) * $plotWidth;
        $y = $plotBottom - (($value / $max) * $plotHeight);

        $x = round($x, 1);
        $y = round($y, 1);

        $points[] = "$x,$y";

        $markers .= '
            <circle cx="' . $x . '" cy="' . $y . '" r="5"></circle>
            <text x="' . $x . '" y="' . ($y - 14) . '">' . $value . '</text>
        ';

        $labelsSvg .= '
            <text class="day" x="' . $x . '" y="' . ($height - 10) . '">
                ' . htmlspecialchars($labels[$i] ?? '', ENT_QUOTES, 'UTF-8') . '
            </text>
        ';
    }

    /*
    |--------------------------------------------------------------------------
    | Per-site lines + legend
    |--------------------------------------------------------------------------
    |
    | Site lines are thinner, undotted with numbers (to avoid clutter once
    | several are on screen at once) and colored via graphSeriesColor().
    | The legend sits in its own column to the right of the plot, vertically
    | centered, listing Global first, then each site (by its short code --
    | the same one shown in the site switcher -- not its full domain).
    */

    $siteLines = '';
    $legendSvg = '';

    if ($siteSeries !== []) {
        foreach ($siteSeries as $seriesIndex => $series) {
            $color = graphSeriesColor($seriesIndex);
            $sitePoints = [];

            foreach ($series['data'] as $i => $value) {
                $x = round($plotLeft + ($i / $count) * $plotWidth, 1);
                $y = round($plotBottom - (($value / $max) * $plotHeight), 1);
                $sitePoints[] = "$x,$y";
            }

            $siteLines .= '
                <polyline fill="none" points="' . implode(' ', $sitePoints) . '" stroke="' . $color . '" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" opacity="0.85"/>
            ';

            foreach ($sitePoints as $point) {
                [$px, $py] = explode(',', $point);

                $siteLines .= '<circle cx="' . $px . '" cy="' . $py . '" r="3" fill="' . $color . '"></circle>';
            }
        }

        $legendItems = [
            ['label' => t('graph.legend.global'), 'color' => 'var(--color-graph-line)'],
        ];

        foreach ($siteSeries as $seriesIndex => $series) {
            $legendItems[] = [
                'label' => $series['label'],
                'color' => graphSeriesColor($seriesIndex),
            ];
        }

        $legendRowHeight = 28;
        $legendBlockHeight = count($legendItems) * $legendRowHeight;
        $legendX = $plotRight + 24;
        $legendY = $plotTop + (($plotHeight - $legendBlockHeight) / 2) + 12;

        foreach ($legendItems as $item) {
            $legendSvg .= '
                <rect x="' . $legendX . '" y="' . ($legendY - 11) . '" width="12" height="12" rx="3" fill="' . $item['color'] . '"></rect>
                <text class="legend-label" x="' . ($legendX + 12 + $legendSwatchGap) . '" y="' . $legendY . '">' . htmlspecialchars($item['label'], ENT_QUOTES, 'UTF-8') . '</text>
            ';
            $legendY += $legendRowHeight;
        }
    }

    $notice = $fallback
        ? '<p>' . h(t('graph.fallback.notice')) . '</p>'
        : '';

    return '
    <h2>' . h($title) . ' — ' . h($periodTitle) . '</h2>
    ' . $notice . '

    <svg class="graph-line" viewBox="' . $viewBoxX . ' 0 ' . $viewBoxWidth . ' ' . $height . '" xmlns="http://www.w3.org/2000/svg">
        ' . $grid . '
        ' . $siteLines . '
        <polyline fill="none" points="' . implode(' ', $points) . '" stroke="var(--color-graph-line)" stroke-linecap="round" stroke-linejoin="round" stroke-width="5"/>
        ' . $markers . '
        ' . $labelsSvg . '
        ' . $legendSvg . '
    </svg>
    ';
}


/*
|--------------------------------------------------------------------------
| Horizontal bar graph renderer
|--------------------------------------------------------------------------
*/

// Since this is plain SVG with no browser layout engine to measure real
// text, width is estimated character-by-character using rough buckets by
// letter shape (narrow "il1", wide "mw", capitals, spaces, everything
// else). Good enough to decide when a bar/pie label needs wrapping —
// not meant to be pixel-accurate.
function graphTextWidth(string $text): int {
    $width = 0;

    foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $char) {
        $width += match (true) {
            preg_match('/[ilI1.,:;]/u', $char) => 4,
            preg_match('/[mwMW]/u', $char) => 13,
            preg_match('/[A-ZÀ-Ý]/u', $char) => 10,
            preg_match('/\s/u', $char) => 5,
            default => 9,
        };
    }

    return $width;
}

// Greedy word-wrap using graphTextWidth() as the ruler: keeps adding words
// to the current line until it would exceed $maxWidth, then starts a new
// line.
function graphWrapText(string $text, int $maxWidth): array {
    $text = trim($text);

    if ($text === '') {
        return [''];
    }

    $words = preg_split('~\s+~u', $text) ?: [];
    $lines = [];
    $line = '';

    foreach ($words as $word) {
        $test = $line === '' ? $word : $line . ' ' . $word;

        if ($line !== '' && graphTextWidth($test) > $maxWidth) {
            $lines[] = $line;
            $line = $word;
        } else {
            $line = $test;
        }
    }

    if ($line !== '') {
        $lines[] = $line;
    }

    return $lines ?: [$text];
}

// Renders a horizontal bar chart from already-fetched {label, value, icon?}
// rows. "Prepared" = the caller already ran the SQL and formatted labels;
// this function only handles layout/SVG. renderBarGraph() below is the
// convenience wrapper that queries the DB and calls this.
function renderPreparedBarGraph(array $rows, string $title, string $periodTitle): string {
    if ($rows === []) {
        return t('graph.no.data');
    }

    $width = 1000;
    $rowHeight = 42;
    $top = 30;
    $barHeight = 14;
    $gap = 24;
    $valueWidth = 35;

    // Icon size matches the <image width="20" height="20"> below. The gap
    // is 8 (original spacing) + ~5, i.e. roughly .3em at the 16px SVG
    // font-size used for .bar-label — a bit more breathing room between
    // the flag and the country name than the original fixed 28 offset.
    $iconSize = 20;
    $iconTextGap = 13;

    $hasIcon = false;
    $labelWidth = 0;

    foreach ($rows as $row) {
        $labelWidth = max(
            $labelWidth,
            graphTextWidth((string)$row['label'])
        );

        if ((string)($row['icon'] ?? '') !== '') {
            $hasIcon = true;
        }
    }

    $iconWidth = $hasIcon ? ($iconSize + $iconTextGap) : 0;
    $left = $iconWidth + $labelWidth + $gap;

    $height = $top + (count($rows) * $rowHeight) + 20;
    $barMaxWidth = $width - $left - $gap - $valueWidth;
    $valueX = $left + $barMaxWidth + $gap + $valueWidth;

    $max = max(1, max(array_map(
        static fn($row) => (int)$row['value'],
        $rows
    )));

    $total = array_sum(array_map(
        static fn($row) => (int)$row['value'],
        $rows
    ));

    $bars = '';

    foreach ($rows as $i => $row) {
        $label = (string)$row['label'];
        $value = (int)$row['value'];
        $iconUrl = (string)($row['icon'] ?? '');

        $y = $top + ($i * $rowHeight);
        $barY = $y + 5;
        $barWidth = ($value / $max) * $barMaxWidth;

        // Rows can provide their own ready-made tooltip (e.g. Top Pages,
        // which shows the full untruncated title). Everything else falls
        // back to a generic "label · value (percentage%)" line, plus a
        // per-site breakdown when the row carries one and it's worth
        // showing. Both branches are expected to already be HTML-escaped,
        // since a per-site breakdown needs a literal <br> line separator
        // that a second escaping pass would otherwise turn back into text.
        $explicitTooltip = (string)($row['tooltip'] ?? '');

        if ($explicitTooltip !== '') {
            $tooltip = $explicitTooltip;
        } else {
            $percentageLabel = $total > 0 ? round(($value / $total) * 100, 1) : 0;

            $tooltip = htmlspecialchars(
                $label . ' · ' . $value . ' (' . $percentageLabel . '%)',
                ENT_QUOTES,
                'UTF-8'
            );

            $tooltip .= graphSiteBreakdownSuffix((array)($row['bySite'] ?? []), $total);
        }

        $tooltipAttr = $tooltip !== ''
            ? ' data-tooltip="' . $tooltip . '"
                data-tooltip-class="graph-tooltip"
                data-tooltip-placement="' . h((string)($row['tooltipPlacement'] ?? 'top')) . '"'
            : '';

        $iconSvg = '';
        $labelX = 0;

        if ($iconUrl !== '') {
            $iconSvg = '
                <image class="bar-icon" href="' . h($iconUrl) . '" x="0" y="' . ($y + 1) . '" width="' . $iconSize . '" height="' . $iconSize . '"></image>
            ';

            $labelX = $iconSize + $iconTextGap;
        }

        $bars .= '
            ' . $iconSvg . '
            <text class="bar-label" x="' . $labelX . '" y="' . ($y + 18) . '"' . $tooltipAttr . '>' . h($label) . '</text>

            <rect class="bar-track" x="' . $left . '" y="' . $barY . '" width="' . $barMaxWidth . '" height="' . $barHeight . '" rx="7"></rect>
            <rect class="bar-fill" x="' . $left . '" y="' . $barY . '" width="' . round($barWidth, 1) . '" height="' . $barHeight . '" rx="7"></rect>

            <text class="bar-value" x="' . $valueX . '" y="' . ($y + 18) . '">' . h((string)$value) . '</text>
        ';
    }

    return '
    <h2>' . h($title) . ' — ' . h($periodTitle) . '</h2>

    <svg class="graph-bar" viewBox="0 0 ' . $width . ' ' . $height . '" xmlns="http://www.w3.org/2000/svg">
        ' . $bars . '
    </svg>
    ';
}

// Generic top-10 bar chart for any (table, label column, value column)
// pair — used for countries. $labelFormatter/$iconFormatter turn the raw
// stored value (e.g. a country code) into a display label/flag URL.
function renderBarGraph(string $table, string $labelColumn, string $valueColumn, string $title, string $rangeSql, array $rangeParams, string $periodTitle, ?callable $labelFormatter = null, ?callable $iconFormatter = null, string $currentSite = ''): string {
    $db = brivaciaDb();

    $rows = fetchAll($db, '
        SELECT ' . $labelColumn . ' AS label, SUM(' . $valueColumn . ') AS value
        FROM ' . $table . '
        WHERE ' . $rangeSql . '
        GROUP BY ' . $labelColumn . '
        ORDER BY value DESC
        LIMIT 10
    ', $rangeParams);

    $bySite = ($currentSite === '' && graphMultiSiteEnabled())
        ? graphSiteBreakdown($table, $labelColumn, $valueColumn, $rangeSql, $rangeParams)
        : [];

    $prepared = [];

    foreach ($rows as $row) {
        $rawLabel = (string)$row['label'];

        $prepared[] = [
            'label' => $labelFormatter ? $labelFormatter($rawLabel) : $rawLabel,
            'value' => (int)$row['value'],
            'icon' => $iconFormatter ? (string)$iconFormatter($rawLabel) : '',
            'bySite' => $bySite[$rawLabel] ?? [],
        ];
    }

    return renderPreparedBarGraph($prepared, $title, $periodTitle);
}

// Top-10 pages bar chart. Unlike renderBarGraph(), this is specific to
// pages_daily: it groups by (site, page_key) rather than a single column,
// runs titles through cleanPageTitle() (see includes/rules.php /
// rules_custom.php), and truncates long titles for the bar label while
// keeping the full title available as a tooltip.
function renderTopPagesBarGraph(string $title, string $rangeSql, array $rangeParams, string $periodTitle): string {
    $db = brivaciaDb();

    $rows = fetchAll($db, '
        SELECT
            site,
            page_key,
            MAX(title) AS title,
            MAX(url) AS url,
            SUM(views) AS value
        FROM pages_daily
        WHERE ' . $rangeSql . '
        GROUP BY site, page_key
        ORDER BY value DESC
        LIMIT 10
    ', $rangeParams);

    $prepared = [];

    foreach ($rows as $row) {
        $site = (string)$row['site'];
        $pageKey = (string)$row['page_key'];
        $rawTitle = (string)($row['title'] ?? '');
        $rawUrl = (string)($row['url'] ?? '');

        $label = cleanPageTitle($rawTitle, $site, $pageKey, $rawUrl);

        if ($label === '') {
            $label = $pageKey;
        }

        $prepared[] = [
            'label' => mb_strlen($label, 'UTF-8') > 30
                ? mb_substr($label, 0, 27, 'UTF-8') . '…'
                : $label,
            'tooltip' => htmlspecialchars($label, ENT_QUOTES, 'UTF-8'),
            'tooltipPlacement' => 'right',
            'value' => (int)$row['value'],
            'icon' => '',
        ];
    }

    return renderPreparedBarGraph($prepared, $title, $periodTitle);
}


/*
|--------------------------------------------------------------------------
| Pie graph renderer
|--------------------------------------------------------------------------
*/

// Same idea as renderBarGraph() but as a pie/donut with a legend, capped
// at the top 8 slices (a pie with more than ~8 slices stops being readable).
function renderPieGraph(string $table, string $labelColumn, string $valueColumn, string $title, string $rangeSql, array $rangeParams, string $periodTitle, ?callable $labelFormatter = null, ?callable $iconFormatter = null, string $currentSite = ''): string {
    $db = brivaciaDb();

    $rows = fetchAll($db, '
        SELECT ' . $labelColumn . ' AS label, SUM(' . $valueColumn . ') AS value
        FROM ' . $table . '
        WHERE ' . $rangeSql . '
        GROUP BY ' . $labelColumn . '
        ORDER BY value DESC
        LIMIT 8
    ', $rangeParams);

    if ($rows === []) {
        return t('graph.no.data');
    }

    $total = array_sum(array_map(
        static fn($row) => (int)$row['value'],
        $rows
    ));

    if ($total <= 0) {
        return t('graph.no.data');
    }

    /*
    |--------------------------------------------------------------------------
    | Per-site breakdown, folded into each slice's tooltip
    |--------------------------------------------------------------------------
    |
    | Only fetched when Brivacia tracks more than one site and the "all
    | sites" view is active. Shown even when just one site contributed to
    | this particular slice — the main line never names a site, so this is
    | the only place that reveals which one it was. Each site's percentage
    | uses the same denominator ($total) as the slice's own percentage, so
    | the breakdown lines add up to it exactly.
    */

    $bySite = ($currentSite === '' && graphMultiSiteEnabled())
        ? graphSiteBreakdown($table, $labelColumn, $valueColumn, $rangeSql, $rangeParams)
        : [];

    $width = 1000;
    $height = 360;

    $cx = 310;
    $cy = 185;
    $radius = 150;

    $legendY = 75;
    $legendColorX = 635;
    $legendLabelX = 670;
    $legendValueX = 980;
    $legendGap = 38;

    $colors = [];

    for ($i = 0; $i < count($rows); $i++) {
        $hue = ($i * 137.508) % 360; // Golden angle for well-spaced colors.
        $colors[] = "hsl($hue 75% 55%)";
    }

    $slices = '';
    $legend = '';

    $startAngle = -90;

    foreach ($rows as $i => $row) {
        $rawLabel = (string)$row['label'];
        $value = (int)$row['value'];

        if ($value <= 0) {
            continue;
        }

        $label = $labelFormatter
            ? $labelFormatter($rawLabel)
            : $rawLabel;

        $label = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');

        $percentage = ($value / $total) * 100;
        $percentageLabel = round($percentage, 1);

        $tooltip = htmlspecialchars(
            $label . ' · ' . $value . ' (' . $percentageLabel . '%)',
            ENT_QUOTES,
            'UTF-8'
        );

        $tooltip .= graphSiteBreakdownSuffix((array)($bySite[$rawLabel] ?? []), $total);

        $tooltipIcon = $iconFormatter
            ? h((string)$iconFormatter($rawLabel))
            : '';

        $angle = ($value / $total) * 360;
        $endAngle = $startAngle + $angle;

        $largeArc = $angle > 180 ? 1 : 0;

        $startRad = deg2rad($startAngle);
        $endRad = deg2rad($endAngle);

        $x1 = $cx + ($radius * cos($startRad));
        $y1 = $cy + ($radius * sin($startRad));
        $x2 = $cx + ($radius * cos($endRad));
        $y2 = $cy + ($radius * sin($endRad));

        $color = $colors[$i % count($colors)];

        $slices .= '
            <path
                d="M ' . $cx . ' ' . $cy . '
                   L ' . round($x1, 2) . ' ' . round($y1, 2) . '
                   A ' . $radius . ' ' . $radius . ' 0 ' . $largeArc . ' 1 ' . round($x2, 2) . ' ' . round($y2, 2) . '
                   Z"
                fill="' . $color . '"
                data-tooltip="' . $tooltip . '"
                data-tooltip-class="graph-tooltip"
                data-tooltip-icon="' . $tooltipIcon . '"
            ></path>
        ';

        $legend .= '
            <rect x="' . $legendColorX . '" y="' . ($legendY + ($i * $legendGap) - 12) . '" width="14" height="14" rx="4" fill="' . $color . '"></rect>
            <text class="pie-label" x="' . $legendLabelX . '" y="' . ($legendY + ($i * $legendGap)) . '">' . $label . '</text>
            <text class="pie-value" x="' . $legendValueX . '" y="' . ($legendY + ($i * $legendGap)) . '">' . $value . ' · ' . $percentageLabel . '%</text>
        ';

        $startAngle = $endAngle;
    }

    return '
    <h2>' . h($title) . ' — ' . h($periodTitle) . '</h2>

    <svg class="graph-pie" viewBox="0 0 ' . $width . ' ' . $height . '" xmlns="http://www.w3.org/2000/svg">
        ' . $slices . '
        <circle cx="' . $cx . '" cy="' . $cy . '" r="75" fill="var(--background-card)"></circle>
        <text class="pie-total" x="' . $cx . '" y="' . ($cy - 4) . '">' . h((string)$total) . '</text>
        <text class="pie-total-label" x="' . $cx . '" y="' . ($cy + 24) . '">' . h(t('metric.page.views')) . '</text>
        ' . $legend . '
    </svg>
    ';
}


// Fetches referrers_daily rows for one referrer category (search engine,
// social, referrer site...), re-grouping raw stored referrer hosts into
// their canonical/display form so e.g. "google.co.uk" and "google.com"
// count as one "Google" slice rather than two separate rows.
function referrerCategoryGraphRows(string $category, string $rangeSql, array $rangeParams, int $limit): array {
    $db = brivaciaDb();
    $rawRows = fetchAll($db, '
        SELECT referrer, site, SUM(views) AS views
        FROM referrers_daily
        WHERE ' . $rangeSql . '
        GROUP BY referrer, site
        ORDER BY views DESC
    ', $rangeParams);

    $grouped = [];

    foreach ($rawRows as $row) {
        $referrer = (string)($row['referrer'] ?? '');

        if ($referrer === '') {
            continue;
        }

        $realCategory = referrerCategory($referrer);

        if (
            referrerBlocked($referrer) ||
            $realCategory === BRIVACIA_CATEGORY_BLOCKED
        ) {
            $canonical = BRIVACIA_BLOCKED;
            $label = t('ui.blocked');
            $rowCategory = BRIVACIA_CATEGORY_REFERRER;
        } elseif (strcasecmp($referrer, BRIVACIA_UNKNOWN) === 0) {
            $canonical = BRIVACIA_UNKNOWN;
            $label = t('ui.unknown');
            $rowCategory = BRIVACIA_CATEGORY_REFERRER;
        } else {
            $canonical = referrerCanonical($referrer);
            $label = referrerLabel($referrer);
            $rowCategory = $realCategory;
        }

        if ($rowCategory !== $category) {
            continue;
        }

        if (!isset($grouped[$canonical])) {
            $grouped[$canonical] = [
                'label' => $label,
                'value' => 0,
                'icon' => referrerIconUrl($canonical),
                'bySite' => [],
            ];
        }

        $site = (string)($row['site'] ?? '');
        $views = (int)($row['views'] ?? 0);

        $grouped[$canonical]['value'] += $views;
        $grouped[$canonical]['bySite'][$site] = ($grouped[$canonical]['bySite'][$site] ?? 0) + $views;
    }

    usort($grouped, fn($a, $b) => $b['value'] <=> $a['value']);

    return array_slice($grouped, 0, $limit);
}

function renderReferrerCategoryBarGraph(string $category, string $title, string $rangeSql, array $rangeParams, string $periodTitle): string {
    return renderPreparedBarGraph(
        referrerCategoryGraphRows($category, $rangeSql, $rangeParams, 10),
        $title,
        $periodTitle
    );
}

// Pie-chart counterpart to renderPreparedBarGraph(): renders already-
// fetched {label, value, icon?} rows. renderPieGraph() queries the DB and
// calls this; renderReferrerCategoryPieGraph() feeds it pre-grouped
// referrer category rows instead.
function renderPreparedPieGraph(array $rows, string $title, string $periodTitle): string {
    if ($rows === []) {
        return t('graph.no.data');
    }

    $total = array_sum(array_map(
        static fn($row) => (int)$row['value'],
        $rows
    ));

    if ($total <= 0) {
        return t('graph.no.data');
    }

    $width = 1000;
    $height = 360;

    $cx = 310;
    $cy = 185;
    $radius = 150;

    $legendY = 75;
    $legendColorX = 635;
    $legendLabelX = 670;
    $legendValueX = 980;
    $legendGap = 38;

    $colors = [];

    for ($i = 0; $i < count($rows); $i++) {
        $hue = ($i * 137.508) % 360;
        $colors[] = "hsl($hue 75% 55%)";
    }

    $slices = '';
    $legend = '';
    $startAngle = -90;

    foreach ($rows as $i => $row) {
        $value = (int)$row['value'];

        if ($value <= 0) {
            continue;
        }

        $label = htmlspecialchars((string)$row['label'], ENT_QUOTES, 'UTF-8');
        $percentage = ($value / $total) * 100;
        $percentageLabel = round($percentage, 1);

        $tooltip = htmlspecialchars(
            $label . ' · ' . $value . ' (' . $percentageLabel . '%)',
            ENT_QUOTES,
            'UTF-8'
        );

        $tooltip .= graphSiteBreakdownSuffix((array)($row['bySite'] ?? []), $total);

        $tooltipIcon = h((string)($row['icon'] ?? ''));
        $angle = ($value / $total) * 360;
        $endAngle = $startAngle + $angle;
        $largeArc = $angle > 180 ? 1 : 0;

        $startRad = deg2rad($startAngle);
        $endRad = deg2rad($endAngle);

        $x1 = $cx + ($radius * cos($startRad));
        $y1 = $cy + ($radius * sin($startRad));
        $x2 = $cx + ($radius * cos($endRad));
        $y2 = $cy + ($radius * sin($endRad));
        $color = $colors[$i % count($colors)];

        $slices .= '
            <path
                d="M ' . $cx . ' ' . $cy . '
                   L ' . round($x1, 2) . ' ' . round($y1, 2) . '
                   A ' . $radius . ' ' . $radius . ' 0 ' . $largeArc . ' 1 ' . round($x2, 2) . ' ' . round($y2, 2) . '
                   Z"
                fill="' . $color . '"
                data-tooltip="' . $tooltip . '"
                data-tooltip-class="graph-tooltip"
                data-tooltip-icon="' . $tooltipIcon . '"
            ></path>
        ';

        $legend .= '
            <rect x="' . $legendColorX . '" y="' . ($legendY + ($i * $legendGap) - 12) . '" width="14" height="14" rx="4" fill="' . $color . '"></rect>
            <text class="pie-label" x="' . $legendLabelX . '" y="' . ($legendY + ($i * $legendGap)) . '">' . $label . '</text>
            <text class="pie-value" x="' . $legendValueX . '" y="' . ($legendY + ($i * $legendGap)) . '">' . $value . ' · ' . $percentageLabel . '%</text>
        ';

        $startAngle = $endAngle;
    }

    return '
    <h2>' . h($title) . ' — ' . h($periodTitle) . '</h2>

    <svg class="graph-pie" viewBox="0 0 ' . $width . ' ' . $height . '" xmlns="http://www.w3.org/2000/svg">
        ' . $slices . '
        <circle cx="' . $cx . '" cy="' . $cy . '" r="75" fill="var(--background-card)"></circle>
        <text class="pie-total" x="' . $cx . '" y="' . ($cy - 4) . '">' . h((string)$total) . '</text>
        <text class="pie-total-label" x="' . $cx . '" y="' . ($cy + 24) . '">' . h(t('metric.page.views')) . '</text>
        ' . $legend . '
    </svg>
    ';
}

function renderReferrerCategoryPieGraph(string $category, string $title, string $rangeSql, array $rangeParams, string $periodTitle): string {
    return renderPreparedPieGraph(
        referrerCategoryGraphRows($category, $rangeSql, $rangeParams, 8),
        $title,
        $periodTitle
    );
}


/*
|--------------------------------------------------------------------------
| Map graph renderer
|--------------------------------------------------------------------------
*/

// Choropleth world map: loads the static assets/images/world.svg (one
// <path id="xx"> per country, ISO 3166-1 alpha-2 lowercase), then colors
// each country path by its view count. Countries are colored on a log
// scale (log(value+1) / log(max+1)) rather than linear, since raw
// visitor counts are usually extremely skewed (one or two countries with
// most of the traffic) — a linear scale would make almost every country
// look the same shade. Interactivity (pan/zoom, tooltips) is handled
// client-side once this markup is in the page (see main.js).
function renderCountriesMapGraph(string $title, string $periodTitle, string $currentSite = ''): string {
    $db = brivaciaDb();

    // The map intentionally ignores the selected time period (it always
    // shows all-time totals) but still respects the selected site, same
    // as every other graph.
    $mapRangeSql = '1 = 1';
    $mapRangeParams = [];

    if ($currentSite !== '' && isset(brivacia_sites()[$currentSite])) {
        $mapRangeSql = 'site = ?';
        $mapRangeParams = [$currentSite];
    }

    $rows = fetchAll($db, '
        SELECT country AS label, SUM(views) AS value
        FROM countries_daily
        WHERE ' . $mapRangeSql . '
        GROUP BY country
        ORDER BY value DESC
    ', $mapRangeParams);

    if ($rows === []) {
        return t('graph.no.data');
    }

    $bySite = ($currentSite === '' && graphMultiSiteEnabled())
        ? graphSiteBreakdown('countries_daily', 'country', 'views', $mapRangeSql, $mapRangeParams)
        : [];

    // graphSiteBreakdown() keys its result by the raw stored country code;
    // re-key it through the same normalizeCountryCode()+lowercase pipeline
    // used for $values below, since that's what the per-path lookup uses.
    $bySiteByCountry = [];

    foreach ($bySite as $rawCountry => $siteCounts) {
        $normalized = strtolower(normalizeCountryCode((string)$rawCountry));

        if ($normalized === 'xx') {
            continue;
        }

        $bySiteByCountry[$normalized] = $siteCounts;
    }

    $values = [];
    $max = 1;

    foreach ($rows as $row) {
        $country = strtolower(normalizeCountryCode((string)$row['label']));
        $value = (int)$row['value'];

        if ($country === 'xx' || $value <= 0) {
            continue;
        }

        $values[$country] = $value;
        $max = max($max, $value);
    }

    if ($values === []) {
        return t('graph.no.data');
    }

    $mapFile = dirname(__DIR__, 2) . '/assets/images/world.svg';

    if (!is_file($mapFile)) {
        return t('graph.unavailable');
    }

    $svg = file_get_contents($mapFile);

    if ($svg === false || trim($svg) === '') {
        return t('graph.unavailable');
    }

    $dom = new DOMDocument();

    libxml_use_internal_errors(true);
    $loaded = $dom->loadXML($svg);
    libxml_clear_errors();

    if (!$loaded || !$dom->documentElement) {
        return t('graph.unavailable');
    }

    $root = $dom->documentElement;

    $root->setAttribute('class', trim($root->getAttribute('class') . ' graph-world-map'));
    $width = (float)$root->getAttribute('width');
    $height = (float)$root->getAttribute('height');

    if (!$root->hasAttribute('viewBox') && $width > 0 && $height > 0) {
        $root->setAttribute('viewBox', '0 0 ' . $width . ' ' . $height);
    }

    $root->removeAttribute('width');
    $root->removeAttribute('height');

    foreach ($dom->getElementsByTagName('path') as $path) {
        $id = trim($path->getAttribute('id'));

        if ($id === '') {
            continue;
        }

        $country = strtolower($id);
        $value = $values[$country] ?? 0;

        $path->setAttribute('class', trim($path->getAttribute('class') . ' map-country'));
        $path->removeAttribute('style');
        $path->removeAttribute('fill');

        if ($value <= 0) {
            $path->setAttribute('data-map-empty', '1');
            continue;
        }

        $ratio = log($value + 1) / log($max + 1);
        $lightness = 78 - ($ratio * 38);

        $path->setAttribute('fill', 'hsl(195 80% ' . round($lightness, 1) . '%)');
        $path->setAttribute('data-map-value', (string)$value);

        $tooltip = countryName($country) . ' · ' . $value;
        $siteCounts = $bySiteByCountry[$country] ?? [];

        if (count($siteCounts) > 1) {
            arsort($siteCounts);

            foreach ($siteCounts as $site => $siteValue) {
                if ($siteValue <= 0) {
                    continue;
                }

                $sitePercentage = round(($siteValue / $value) * 100, 1);
                $tooltip .= '<br>' . (string)$site . ' ' . $sitePercentage . '%';
            }
        }

        $path->setAttribute('data-tooltip', $tooltip);
        $path->setAttribute('data-tooltip-class', 'graph-tooltip');
        $path->setAttribute('data-tooltip-icon', countryFlagUrl($country));
    }

    $svg = $dom->saveXML($root) ?: '';

    return '
    <h2>' . h($title) . ' — ' . h($periodTitle) . '</h2>

    <div class="graph-map">
        ' . $svg . '
    </div>
    ';
}

Contribute