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