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