includes/graphs/helpers.php

<?php

/* ==========================================================================
   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,
    };
}


/*
|--------------------------------------------------------------------------
| 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, ' . $column . ' AS value
        FROM hits_daily
        WHERE ' . $rangeSql . '
        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 {
    [$rows, $labelMode, $fallback] = graphPeriodRows($column, $rangeSql, $rangeParams, $view);

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

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

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

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

    $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;

    $plotLeft = 0;
    $plotRight = 980;
    $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>
        ';
    }

    $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 . '
        <polyline fill="none" points="' . implode(' ', $points) . '" stroke="var(--color-graph-line)" stroke-linecap="round" stroke-linejoin="round" stroke-width="5"/>
        ' . $markers . '
        ' . $labelsSvg . '
    </svg>
    ';
}


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

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;
}

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];
}

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;

    $hasIcon = false;
    $labelWidth = 0;

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

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

    $iconWidth = $hasIcon ? 28 : 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
    )));

    $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;

        $tooltip = (string)($row['tooltip'] ?? '');
        $tooltipAttr = $tooltip !== ''
            ? ' data-tooltip="' . h($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="20" height="20"></image>
            ';

            $labelX = 28;
        }

        $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>
    ';
}

function renderBarGraph(string $table, string $labelColumn, string $valueColumn, string $title, string $rangeSql, array $rangeParams, string $periodTitle, ?callable $labelFormatter = null, ?callable $iconFormatter = null): 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);

    $prepared = [];

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

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

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

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' => $label,
            'tooltipPlacement' => 'right',
            'value' => (int)$row['value'],
            'icon' => '',
        ];
    }

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


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

function renderPieGraph(string $table, string $labelColumn, string $valueColumn, string $title, string $rangeSql, array $rangeParams, string $periodTitle, ?callable $labelFormatter = null, ?callable $iconFormatter = null): 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');
    }

    $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'
        );

        $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>
    ';
}


function referrerCategoryGraphRows(string $category, string $rangeSql, array $rangeParams, int $limit): array {
    $db = brivaciaDb();
    $rawRows = fetchAll($db, '
        SELECT referrer, SUM(views) AS views
        FROM referrers_daily
        WHERE ' . $rangeSql . '
        GROUP BY referrer
        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),
            ];
        }

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

    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
    );
}

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'
        );

        $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
|--------------------------------------------------------------------------
*/

function renderCountriesMapGraph(string $title, string $periodTitle): string {
    $db = brivaciaDb();

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

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

    $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);
        $path->setAttribute('data-tooltip', countryName($country) . ' · ' . $value);
        $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>
    ';
}