| Current Path : /home/seto/888mirror.pm/admin/ |
| Current File : /home/seto/888mirror.pm/admin/dashboard.php |
<?php
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/helpers.php';
auth_check();
$db = db();
$total_brands = (int)$db->query("SELECT COUNT(*) FROM brands WHERE status='active'")->fetchColumn();
$today = date('Y-m-d');
$month_start = date('Y-m-01');
// === Глобальные фильтры ===
$period = in_array($_GET['period'] ?? '', ['today','yesterday','7d','30d','all'])
? $_GET['period'] : '30d';
$top_exclude = ($_GET['exclude_cnus'] ?? '1') !== '0';
$excl_all = $top_exclude ? "AND country NOT IN ('cn','us')" : '';
$excl_s = $top_exclude ? "AND s.country NOT IN ('cn','us')" : '';
// Фильтр Hetzner datacenter IPv6 (2a01:4f8:: и 2a01:4f9::) — краулеры с этих диапазонов
$hetzner_excl = "AND (ip NOT LIKE '2a01:4f8:%' AND ip NOT LIKE '2a01:4f9:%')";
$hetzner_excl_s = "AND (s.ip NOT LIKE '2a01:4f8:%' AND s.ip NOT LIKE '2a01:4f9:%')";
$period_where = match($period) {
'today' => "DATE(created_at) = '" . date('Y-m-d') . "'",
'yesterday' => "DATE(created_at) = '" . date('Y-m-d', strtotime('-1 day')) . "'",
'7d' => "created_at >= '" . date('Y-m-d H:i:s', strtotime('-7 days')) . "'",
'all' => "1=1",
default => "created_at >= '" . date('Y-m-d H:i:s', strtotime('-30 days')) . "'",
};
$period_where_s = match($period) {
'today' => "DATE(s.created_at) = '" . date('Y-m-d') . "'",
'yesterday' => "DATE(s.created_at) = '" . date('Y-m-d', strtotime('-1 day')) . "'",
'7d' => "s.created_at >= '" . date('Y-m-d H:i:s', strtotime('-7 days')) . "'",
'all' => "1=1",
default => "s.created_at >= '" . date('Y-m-d H:i:s', strtotime('-30 days')) . "'",
};
$period_label = match($period) {
'today' => 'Сегодня',
'yesterday' => 'Вчера',
'7d' => '7 дней',
'all' => 'Всё время',
default => '30 дней',
};
// Боты
$s = $db->prepare("SELECT COUNT(*) FROM access_log WHERE type='bot' AND DATE(created_at)=?");
$s->execute([$today]);
$bots_today = (int)$s->fetchColumn();
$s = $db->prepare("SELECT COUNT(*) FROM access_log WHERE type='bot' AND created_at>=?");
$s->execute([$month_start . ' 00:00:00']);
$bots_month = (int)$s->fetchColumn();
$openai_key = setting('openai_key');
// Редиректы
$s = $db->prepare("SELECT COUNT(*) FROM redirect_clicks WHERE DATE(created_at)=?");
$s->execute([$today]);
$redirects_today = (int)$s->fetchColumn();
$s = $db->prepare("SELECT COUNT(*) FROM redirect_clicks WHERE created_at>=?");
$s->execute([$month_start . ' 00:00:00']);
$redirects_month = (int)$s->fetchColumn();
// Сводка за выбранный период
$stats_period_raw = $db->query("
SELECT event_type,
CASE WHEN event_type='impression' THEN COUNT(DISTINCT visit_id) ELSE COUNT(*) END as cnt
FROM stats
WHERE $period_where AND country != '-' $excl_all $hetzner_excl
GROUP BY event_type
")->fetchAll();
$period_data = array_column($stats_period_raw, 'cnt', 'event_type');
// Всего за всё время (правая карточка)
$stats_all_raw = $db->query("
SELECT event_type,
CASE WHEN event_type='impression' THEN COUNT(DISTINCT visit_id) ELSE COUNT(*) END as cnt
FROM stats WHERE country != '-' $excl_all $hetzner_excl GROUP BY event_type
")->fetchAll();
$all_data = array_column($stats_all_raw, 'cnt', 'event_type');
// График (следует за периодом)
if (in_array($period, ['today', 'yesterday'])) {
$chart_day = $period === 'today' ? date('Y-m-d') : date('Y-m-d', strtotime('-1 day'));
$chart_raw = $db->query("
SELECT CAST(strftime('%H', created_at) AS INTEGER) as slot,
COUNT(DISTINCT CASE WHEN event_type='impression' THEN visit_id END) as imp,
SUM(CASE WHEN event_type='click' THEN 1 ELSE 0 END) as clk
FROM stats
WHERE DATE(created_at) = '$chart_day' AND country != '-' $excl_all $hetzner_excl
GROUP BY slot ORDER BY slot
")->fetchAll();
$chart_by_slot = array_column($chart_raw, null, 'slot');
$chart_labels = $chart_imp = $chart_clk = [];
for ($h = 0; $h < 24; $h++) {
$chart_labels[] = sprintf('%02d:00', $h);
$chart_imp[] = (int)($chart_by_slot[$h]['imp'] ?? 0);
$chart_clk[] = (int)($chart_by_slot[$h]['clk'] ?? 0);
}
$chart_title = 'По часам — ' . ($period === 'today' ? 'сегодня' : 'вчера');
} elseif ($period === 'all') {
$chart_start = date('Y-m-01', strtotime('-11 months'));
$chart_raw = $db->query("
SELECT strftime('%Y-%m', created_at) as slot,
COUNT(DISTINCT CASE WHEN event_type='impression' THEN visit_id END) as imp,
SUM(CASE WHEN event_type='click' THEN 1 ELSE 0 END) as clk
FROM stats
WHERE created_at >= '$chart_start' AND country != '-' $excl_all $hetzner_excl
GROUP BY slot ORDER BY slot
")->fetchAll();
$chart_by_slot = array_column($chart_raw, null, 'slot');
$chart_labels = $chart_imp = $chart_clk = [];
for ($i = 11; $i >= 0; $i--) {
$m = date('Y-m', strtotime("-$i months"));
$chart_labels[] = date('M y', strtotime($m . '-01'));
$chart_imp[] = (int)($chart_by_slot[$m]['imp'] ?? 0);
$chart_clk[] = (int)($chart_by_slot[$m]['clk'] ?? 0);
}
$chart_title = 'По месяцам — последние 12 мес.';
} else {
$days = $period === '7d' ? 7 : 30;
$chart_raw = $db->query("
SELECT DATE(created_at) as slot,
COUNT(DISTINCT CASE WHEN event_type='impression' THEN visit_id END) as imp,
SUM(CASE WHEN event_type='click' THEN 1 ELSE 0 END) as clk
FROM stats
WHERE created_at >= '" . date('Y-m-d', strtotime('-' . ($days - 1) . ' days')) . "' AND country != '-' $excl_all $hetzner_excl
GROUP BY DATE(created_at) ORDER BY slot
")->fetchAll();
$chart_by_slot = array_column($chart_raw, null, 'slot');
$chart_labels = $chart_imp = $chart_clk = [];
for ($i = $days - 1; $i >= 0; $i--) {
$day = date('Y-m-d', strtotime("-$i days"));
$chart_labels[] = date('d.m', strtotime($day));
$chart_imp[] = (int)($chart_by_slot[$day]['imp'] ?? 0);
$chart_clk[] = (int)($chart_by_slot[$day]['clk'] ?? 0);
}
$chart_title = 'По дням — ' . ($days === 7 ? 'последние 7 дней' : 'последние 30 дней');
}
// Топ брендов
$top_brands = $db->query("
SELECT b.id, b.name, b.slug,
COUNT(DISTINCT CASE WHEN s.event_type='impression' THEN s.visit_id END) as imp,
SUM(CASE WHEN s.event_type='click' THEN 1 ELSE 0 END) as clk
FROM stats s
JOIN brands b ON b.id = s.brand_id
WHERE $period_where_s AND s.country != '-' $excl_s $hetzner_excl_s
GROUP BY s.brand_id
HAVING imp > 0 OR clk > 0
ORDER BY clk DESC
")->fetchAll();
// Language Coverage
$lang_brand = (int)($_GET['lang_brand'] ?? 0);
$lang_brand_filter_st = $lang_brand ? "AND brand_id = $lang_brand" : '';
$lang_excl_zh = $top_exclude ? "AND language != 'zh'" : '';
$lang_brands_list = $db->query("SELECT id, name FROM brands WHERE status='active' ORDER BY name ASC")->fetchAll();
$lang_names_raw = $db->query("SELECT code, name FROM languages")->fetchAll();
$lang_names = array_column($lang_names_raw, 'name', 'code');
$lang_flag_map = [
'en'=>'gb','zh'=>'cn','ko'=>'kr','ja'=>'jp','ar'=>'sa',
'he'=>'il','uk'=>'ua','sv'=>'se','da'=>'dk','fi'=>'fi',
'nb'=>'no','cs'=>'cz','sk'=>'sk','sl'=>'si','sr'=>'rs',
'hr'=>'hr','bs'=>'ba','mk'=>'mk','sq'=>'al','bg'=>'bg',
'ro'=>'ro','hu'=>'hu','et'=>'ee','lv'=>'lv','lt'=>'lt',
'ka'=>'ge','hy'=>'am','az'=>'az','kk'=>'kz','uz'=>'uz',
'tk'=>'tm','mn'=>'mn','vi'=>'vn','th'=>'th','id'=>'id',
'ms'=>'my','tl'=>'ph','bn'=>'bd','hi'=>'in','ur'=>'pk',
'fa'=>'ir','tr'=>'tr','el'=>'gr','pt'=>'pt','pl'=>'pl',
'de'=>'de','fr'=>'fr','it'=>'it','es'=>'es','ru'=>'ru',
'nl'=>'nl','be'=>'by','gl'=>'es-ga',
];
$lang_stats_raw = $db->query("
SELECT language,
COUNT(DISTINCT CASE WHEN event_type='impression' THEN visit_id END) as imp,
SUM(CASE WHEN event_type='click' THEN 1 ELSE 0 END) as clk,
COUNT(DISTINCT CASE WHEN event_type='impression' THEN brand_id END) as brand_count
FROM stats
WHERE $period_where AND language != '-' AND country != '-' $lang_excl_zh $lang_brand_filter_st $hetzner_excl
GROUP BY language
")->fetchAll();
$lang_rows = [];
foreach ($lang_stats_raw as $row) {
$imp = (int)$row['imp']; $clk = (int)$row['clk'];
if ($imp === 0 && $clk === 0) continue;
$lang_rows[] = ['lc' => $row['language'], 'brands' => (int)$row['brand_count'], 'imp' => $imp, 'clk' => $clk];
}
usort($lang_rows, fn($a, $b) => $b['imp'] - $a['imp']);
// Referrer
$ref_brand = (int)($_GET['ref_brand'] ?? 0);
$ref_brand_filter = $ref_brand ? "AND brand_id = $ref_brand" : '';
$ref_rows_raw = $db->query("
SELECT
CASE WHEN referrer IS NULL OR referrer = '' THEN '__direct__' ELSE referrer END as src,
COUNT(DISTINCT visit_id) as visits
FROM stats
WHERE event_type = 'impression' AND visit_id IS NOT NULL AND country != '-'
AND $period_where $ref_brand_filter $hetzner_excl
GROUP BY src
ORDER BY visits DESC
")->fetchAll();
// GEO Coverage
$geo_brand = (int)($_GET['geo_brand'] ?? 0);
$geo_excl_countries = $top_exclude ? "'cn','us','hk'" : "'hk'";
$geo_brand_filter_bc = $geo_brand ? "AND bc.brand_id = $geo_brand" : '';
$geo_brand_filter_st = $geo_brand ? "AND brand_id = $geo_brand" : '';
$geo_brands_list = $db->query("SELECT id, name FROM brands WHERE status='active' ORDER BY name ASC")->fetchAll();
$geo_brands_raw = $db->query("
SELECT bc.country_code, COUNT(DISTINCT bc.brand_id) as brand_count
FROM brand_countries bc
JOIN brands b ON b.id = bc.brand_id
WHERE b.status = 'active' AND bc.country_code NOT IN ($geo_excl_countries) $geo_brand_filter_bc
GROUP BY bc.country_code
")->fetchAll();
$geo_brand_counts = array_column($geo_brands_raw, 'brand_count', 'country_code');
$geo_stats_raw = $db->query("
SELECT country,
COUNT(DISTINCT CASE WHEN event_type='impression' THEN visit_id END) as imp,
SUM(CASE WHEN event_type='click' THEN 1 ELSE 0 END) as clk
FROM stats
WHERE $period_where AND country != '-' AND country NOT IN ($geo_excl_countries) $geo_brand_filter_st $hetzner_excl
GROUP BY country
")->fetchAll();
$geo_stats = array_column($geo_stats_raw, null, 'country');
$geo_all_codes = array_unique(array_merge(array_keys($geo_brand_counts), array_keys($geo_stats)));
$geo_rows = [];
foreach ($geo_all_codes as $cc) {
$brands = (int)($geo_brand_counts[$cc] ?? 0);
$imp = (int)($geo_stats[$cc]['imp'] ?? 0);
$clk = (int)($geo_stats[$cc]['clk'] ?? 0);
if ($brands === 0 && $imp === 0) continue;
$geo_rows[] = compact('cc', 'brands', 'imp', 'clk');
}
usort($geo_rows, fn($a, $b) => $b['imp'] - $a['imp']);
$avg_imp = count($geo_rows) ? array_sum(array_column($geo_rows, 'imp')) / count($geo_rows) : 0;
$avg_brands = count($geo_rows) ? array_sum(array_column($geo_rows, 'brands')) / count($geo_rows) : 0;
function ctr(int $imp, int $clk): string {
if (!$imp) return '0%';
return round($clk / $imp * 100, 1) . '%';
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Дашборд — Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<?= admin_nav('dashboard') ?>
<div class="container-fluid px-2 px-md-4">
<!-- Глобальный фильтр -->
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-3 flex-wrap">
<span class="fw-semibold small text-muted text-nowrap">Период:</span>
<div class="btn-group btn-group-sm" id="globalPeriodBtns">
<?php foreach (['today'=>'Сегодня','yesterday'=>'Вчера','7d'=>'7 дней','30d'=>'30 дней','all'=>'Всё время'] as $pk => $pl): ?>
<button type="button"
class="btn btn-outline-secondary <?= $period === $pk ? 'active' : '' ?>"
data-period="<?= $pk ?>"><?= $pl ?></button>
<?php endforeach ?>
</div>
<div class="vr d-none d-sm-block"></div>
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="excludeCNUS"
<?= $top_exclude ? 'checked' : '' ?>>
<label class="form-check-label small" for="excludeCNUS">Без CN / US / ZH</label>
</div>
</div>
</div>
</div>
<!-- Сводка -->
<div class="row g-2 g-md-3 mb-4">
<div class="col-6 col-md-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-2 py-md-3">
<div class="fw-bold text-primary" style="font-size:clamp(1.1rem,4vw,2rem)"><?= $total_brands ?></div>
<div class="text-muted small">Активных брендов</div>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-2 py-md-3">
<div class="fw-bold text-success" style="font-size:clamp(1rem,3.5vw,2rem)"><?= number_format((int)($period_data['click'] ?? 0)) ?></div>
<div class="text-muted small">Кликов — <?= h($period_label) ?></div>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-2 py-md-3">
<div class="fw-bold text-info" style="font-size:clamp(1rem,3.5vw,2rem)"><?= number_format((int)($period_data['impression'] ?? 0)) ?></div>
<div class="text-muted small">Показов — <?= h($period_label) ?></div>
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-2 py-md-3">
<div class="fw-bold text-warning" style="font-size:clamp(1rem,3.5vw,2rem)">
<?= ctr((int)($period_data['impression'] ?? 0), (int)($period_data['click'] ?? 0)) ?>
</div>
<div class="text-muted small">CTR — <?= h($period_label) ?></div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card text-center shadow-sm border-secondary h-100">
<div class="card-body py-2 py-md-3">
<div class="fw-bold text-secondary" style="font-size:clamp(1.1rem,4vw,2rem)"><?= $bots_today ?> <small class="fs-6 fw-normal">/ <?= $bots_month ?></small></div>
<div class="text-muted small">Ботов сегодня / мес.</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card text-center shadow-sm h-100" style="border-color:#10b981">
<div class="card-body py-2 py-md-3">
<a href="/admin/stats.php?tab=redirects" class="text-decoration-none">
<div class="fw-bold" style="font-size:clamp(1.1rem,4vw,2rem);color:#10b981"><?= $redirects_today ?> <small class="fs-6 fw-normal text-muted">/ <?= $redirects_month ?></small></div>
<div class="text-muted small">↪ Redirects сегодня / мес.</div>
</a>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- Топ брендов -->
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-semibold">Топ брендов</span>
<small class="text-muted"><?= h($period_label) ?><?= $top_exclude ? ' · без CN/US' : '' ?></small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0" id="topBrandsTable">
<thead class="table-light">
<tr>
<th style="width:32px">#</th>
<th>Бренд</th>
<th class="text-end sortable" data-col="imp" style="cursor:pointer" title="Сортировать">
Показы <span class="sort-icon text-muted">↕</span>
</th>
<th class="text-end sortable" data-col="clk" style="cursor:pointer" title="Сортировать">
Клики <span class="sort-icon text-muted">↓</span>
</th>
<th class="text-end sortable" data-col="ctr" style="cursor:pointer" title="Сортировать">
CTR <span class="sort-icon text-muted">↕</span>
</th>
</tr>
</thead>
<tbody>
<?php foreach ($top_brands as $i => $b): ?>
<tr>
<td class="text-muted"><?= $i+1 ?></td>
<td>
<?php
$geo_link = '?' . http_build_query([
'period' => $period,
'exclude_cnus' => $top_exclude ? '1' : '0',
'geo_brand' => $b['id'],
]) . '#geo-coverage';
?>
<a href="<?= h($geo_link) ?>" class="text-decoration-none text-body">
<?= h($b['name']) ?>
</a>
</td>
<td class="text-end" data-imp="<?= (int)$b['imp'] ?>"><?= number_format((int)$b['imp']) ?></td>
<td class="text-end" data-clk="<?= (int)$b['clk'] ?>"><?= number_format((int)$b['clk']) ?></td>
<?php $ctr_val = $b['imp'] > 0 ? round($b['clk'] / $b['imp'] * 100, 1) : 0; ?>
<td class="text-end" data-ctr="<?= $ctr_val ?>"><?= $ctr_val ?>%</td>
</tr>
<?php endforeach ?>
<?php if (empty($top_brands)): ?>
<tr><td colspan="5" class="text-center text-muted py-3">Нет данных</td></tr>
<?php endif ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Правая колонка -->
<div class="col-md-5">
<?php if (!$openai_key): ?>
<div class="alert alert-warning">
⚠ OpenAI API ключ не задан.
<a href="/admin/settings.php" class="alert-link">Добавить в настройках →</a>
</div>
<?php endif ?>
<!-- World Map -->
<div class="card shadow-sm mb-3" id="world-map-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2"
style="cursor:pointer;user-select:none" onclick="toggleMap(event)">
<span class="fw-semibold">🌍 География</span>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary active" id="map-mode-imp"
onclick="event.stopPropagation();setMapMode('imp')">Показы</button>
<button class="btn btn-outline-secondary" id="map-mode-clk"
onclick="event.stopPropagation();setMapMode('clk')">Клики</button>
</div>
<span class="badge bg-success" id="map-live" style="font-size:.68rem;letter-spacing:.3px">● LIVE</span>
<i class="bi bi-chevron-down" id="map-chevron" style="transition:transform .3s;font-size:.9rem"></i>
</div>
</div>
<div style="background:#eef1f7;border-radius:0 0 .375rem .375rem;overflow:hidden">
<div id="world-map-mini" style="width:100%;height:460px;overflow:hidden;cursor:pointer" onclick="toggleMap(event)"></div>
<div id="world-map-full" style="display:none">
<div id="world-map" style="width:100%;height:520px;position:relative;overflow:hidden"></div>
</div>
</div>
</div>
<!-- Всего за всё время -->
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center justify-content-between">
<span>Всего за всё время</span>
<?php if ($top_exclude): ?>
<small class="text-muted fw-normal">без CN/US</small>
<?php endif ?>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td>Показы</td><td class="fw-bold"><?= number_format((int)($all_data['impression'] ?? 0)) ?></td></tr>
<tr><td>Клики</td><td class="fw-bold"><?= number_format((int)($all_data['click'] ?? 0)) ?></td></tr>
<tr><td>CTR</td><td class="fw-bold"><?= ctr((int)($all_data['impression']??0),(int)($all_data['click']??0)) ?></td></tr>
</table>
</div>
</div>
</div>
</div>
<!-- Drill-down modal -->
<div class="modal fade" id="mapDrillModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="mapDrillTitle">—</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="mapDrillBody">
<div class="text-center py-4 text-muted">Загрузка...</div>
</div>
</div>
</div>
</div>
<!-- График трафика -->
<div class="card shadow-sm mt-4">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-semibold"><?= h($chart_title) ?></span>
<?php if ($top_exclude): ?>
<small class="text-muted">без CN/US</small>
<?php endif ?>
</div>
<div class="card-body">
<div style="position:relative;height:220px">
<canvas id="trafficChart"></canvas>
</div>
</div>
</div>
<!-- GEO Coverage -->
<div id="geo-coverage" class="card shadow-sm mt-4 mb-4">
<div class="card-header">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<span class="fw-semibold">GEO Coverage<?php if ($top_exclude): ?> <small class="text-muted fw-normal">без CN/US</small><?php endif ?></span>
<select id="geoBrandFilter" class="form-select form-select-sm" style="max-width:180px">
<option value="0">Все бренды</option>
<?php foreach ($geo_brands_list as $bl): ?>
<option value="<?= $bl['id'] ?>" <?= $geo_brand === (int)$bl['id'] ? 'selected' : '' ?>>
<?= h($bl['name']) ?>
</option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="card-body p-0">
<?php if (empty($geo_rows)): ?>
<p class="text-muted text-center py-3 mb-0">Нет данных</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="geoCoverageTable">
<thead class="table-light">
<tr>
<th style="width:40px">#</th>
<th class="geo-sortable" data-col="geo" style="cursor:pointer">GEO <span class="geo-sort-icon text-muted">↕</span></th>
<th class="text-center geo-sortable" data-col="brands" style="cursor:pointer">Брендов <span class="geo-sort-icon text-muted">↕</span></th>
<th class="text-center geo-sortable" data-col="imp" style="cursor:pointer">Показы <span class="geo-sort-icon text-muted">↓</span></th>
<th class="text-center geo-sortable" data-col="clk" style="cursor:pointer">Клики <span class="geo-sort-icon text-muted">↕</span></th>
<th class="text-center geo-sortable" data-col="ctr" style="cursor:pointer">CTR <span class="geo-sort-icon text-muted">↕</span></th>
</tr>
</thead>
<tbody>
<?php foreach ($geo_rows as $i => $row):
$is_problem = $row['imp'] > $avg_imp && $row['brands'] < $avg_brands;
$row_class = $is_problem ? 'table-danger' : '';
$flag_icon = $row['cc'] === 'crypto' ? '💎' : ($row['cc'] === 'en' ? '🌐' : ($row['cc'] === '-' ? '❓' : null));
$landing_url = $row['cc'] !== '-' ? '/?country=' . urlencode($row['cc']) : null;
$ctr_val = $row['imp'] > 0 ? round($row['clk'] / $row['imp'] * 100, 1) : 0;
$country_title = match($row['cc']) {
'en' => 'Global',
'crypto' => 'Crypto',
'-' => 'Unknown / Not set',
default => country_name($row['cc']),
};
?>
<tr class="<?= $row_class ?>"<?= $landing_url ? ' style="cursor:pointer" onclick="window.open(\'' . h($landing_url) . '\',\'_blank\')"' : '' ?>>
<td class="text-muted"><?= $i + 1 ?></td>
<td data-geo="<?= h($row['cc']) ?>" title="<?= h($country_title) ?>">
<div class="d-flex align-items-center gap-2">
<?php if ($flag_icon): ?>
<span><?= $flag_icon ?></span>
<?php else: ?>
<img src="https://flagicons.lipis.dev/flags/4x3/<?= h($row['cc']) ?>.svg"
width="20" height="15" style="border-radius:2px;flex-shrink:0"
alt="<?= h($country_title) ?>" title="<?= h($country_title) ?>">
<?php endif ?>
<span class="fw-semibold"><?= $row['cc'] === '-' ? '—' : h(strtoupper($row['cc'])) ?></span>
<?php if ($is_problem): ?>
<span class="badge bg-danger" title="Популярное GEO, мало брендов">!</span>
<?php endif ?>
</div>
</td>
<td class="text-center" data-brands="<?= $row['brands'] ?>">
<?php if ($row['brands'] === 0): ?>
<span class="text-danger fw-bold">0</span>
<?php else: ?>
<a href="/admin/brands.php" class="text-decoration-none"><?= $row['brands'] ?></a>
<?php endif ?>
</td>
<td class="text-center" data-imp="<?= $row['imp'] ?>"><?= number_format($row['imp']) ?></td>
<td class="text-center" data-clk="<?= $row['clk'] ?>"><?= number_format($row['clk']) ?></td>
<td class="text-center" data-ctr="<?= $ctr_val ?>"><?= $ctr_val ?>%</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<?php endif ?>
</div>
<?php if (!empty($geo_rows)): ?>
<div class="card-footer text-muted small">
<span class="badge bg-danger me-1">!</span> — популярное GEO с малым кол-вом брендов (выше среднего по трафику, ниже среднего по брендам)
</div>
<?php endif ?>
</div>
<!-- Language Coverage -->
<div id="lang-coverage" class="card shadow-sm mb-4">
<div class="card-header">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<span class="fw-semibold">Language Coverage</span>
<select id="langBrandFilter" class="form-select form-select-sm" style="max-width:180px">
<option value="0">Все бренды</option>
<?php foreach ($lang_brands_list as $bl): ?>
<option value="<?= $bl['id'] ?>" <?= $lang_brand === (int)$bl['id'] ? 'selected' : '' ?>>
<?= h($bl['name']) ?>
</option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="card-body p-0">
<?php if (empty($lang_rows)): ?>
<p class="text-muted text-center py-3 mb-0">Нет данных</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="langCoverageTable">
<thead class="table-light">
<tr>
<th style="width:40px">#</th>
<th class="lang-sortable" data-col="lang" style="cursor:pointer">Язык <span class="lang-sort-icon text-muted">↕</span></th>
<th class="text-center lang-sortable" data-col="brands" style="cursor:pointer">Брендов <span class="lang-sort-icon text-muted">↕</span></th>
<th class="text-center lang-sortable" data-col="imp" style="cursor:pointer">Показы <span class="lang-sort-icon text-muted">↓</span></th>
<th class="text-center lang-sortable" data-col="clk" style="cursor:pointer">Клики <span class="lang-sort-icon text-muted">↕</span></th>
<th class="text-center lang-sortable" data-col="ctr" style="cursor:pointer">CTR <span class="lang-sort-icon text-muted">↕</span></th>
</tr>
</thead>
<tbody>
<?php foreach ($lang_rows as $i => $row):
$ctr_val = $row['imp'] > 0 ? round($row['clk'] / $row['imp'] * 100, 1) : 0;
$flag_cc = $lang_flag_map[$row['lc']] ?? $row['lc'];
$lang_name = $lang_names[$row['lc']] ?? strtoupper($row['lc']);
?>
<tr>
<td class="text-muted"><?= $i + 1 ?></td>
<td data-lang="<?= h($row['lc']) ?>" title="<?= h($lang_name) ?>">
<div class="d-flex align-items-center gap-2">
<img src="https://flagicons.lipis.dev/flags/4x3/<?= h($flag_cc) ?>.svg"
width="20" height="15" style="border-radius:2px;flex-shrink:0"
alt="<?= h($lang_name) ?>" title="<?= h($lang_name) ?>">
<span class="fw-semibold"><?= h(strtoupper($row['lc'])) ?></span>
<span class="text-muted small"><?= h($lang_name) ?></span>
</div>
</td>
<td class="text-center" data-brands="<?= $row['brands'] ?>">
<?php if ($row['brands'] === 0): ?>
<span class="text-danger fw-bold">0</span>
<?php else: ?>
<a href="/admin/translations.php" class="text-decoration-none"><?= $row['brands'] ?></a>
<?php endif ?>
</td>
<td class="text-center" data-imp="<?= $row['imp'] ?>"><?= number_format($row['imp']) ?></td>
<td class="text-center" data-clk="<?= $row['clk'] ?>"><?= number_format($row['clk']) ?></td>
<td class="text-center" data-ctr="<?= $ctr_val ?>"><?= $ctr_val ?>%</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<?php endif ?>
</div>
</div>
<!-- Источники трафика -->
<div id="ref-coverage" class="card shadow-sm mb-4">
<div class="card-header">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<span class="fw-semibold">Источники трафика</span>
<select id="refBrandFilter" class="form-select form-select-sm" style="max-width:180px">
<option value="0">Все бренды</option>
<?php foreach ($geo_brands_list as $bl): ?>
<option value="<?= $bl['id'] ?>" <?= $ref_brand === (int)$bl['id'] ? 'selected' : '' ?>>
<?= h($bl['name']) ?>
</option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="card-body p-0">
<?php if (empty($ref_rows_raw)): ?>
<p class="text-muted text-center py-3 mb-0">Нет данных</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="refTable">
<thead class="table-light">
<tr>
<th style="width:40px">#</th>
<th class="ref-sortable" data-col="src" style="cursor:pointer">Источник <span class="ref-sort-icon text-muted">↕</span></th>
<th class="text-end ref-sortable" data-col="visits" style="cursor:pointer">Визитов <span class="ref-sort-icon text-muted">↓</span></th>
<th class="text-end" style="width:120px">Доля</th>
</tr>
</thead>
<tbody>
<?php
$ref_total = array_sum(array_column($ref_rows_raw, 'visits'));
foreach ($ref_rows_raw as $i => $rr):
$is_direct = $rr['src'] === '__direct__';
$share = $ref_total > 0 ? round($rr['visits'] / $ref_total * 100, 1) : 0;
?>
<tr>
<td class="text-muted"><?= $i + 1 ?></td>
<td data-src="<?= $is_direct ? '' : h($rr['src']) ?>">
<?php if ($is_direct): ?>
<span class="text-muted fst-italic">Direct / None</span>
<?php else: ?>
<a href="https://<?= h($rr['src']) ?>" target="_blank" rel="noopener"
class="text-decoration-none">
<img src="https://www.google.com/s2/favicons?domain=<?= urlencode($rr['src']) ?>&sz=16"
width="16" height="16" class="me-1" style="vertical-align:middle;border-radius:2px"
onerror="this.style.display='none'">
<?= h($rr['src']) ?>
</a>
<?php endif ?>
</td>
<td class="text-end" data-visits="<?= $rr['visits'] ?>"><?= number_format($rr['visits']) ?></td>
<td class="text-end">
<div class="d-flex align-items-center justify-content-end gap-2">
<div class="progress flex-grow-1" style="height:6px;max-width:60px">
<div class="progress-bar bg-primary" style="width:<?= $share ?>%"></div>
</div>
<span class="text-muted small" style="min-width:36px"><?= $share ?>%</span>
</div>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<?php endif ?>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script>
(function () {
var labels = <?= json_encode($chart_labels) ?>;
var impData = <?= json_encode($chart_imp) ?>;
var clkData = <?= json_encode($chart_clk) ?>;
var ctx = document.getElementById('trafficChart').getContext('2d');
new Chart(ctx, {
data: {
labels: labels,
datasets: [
{
type: 'bar',
label: 'Показы',
data: impData,
backgroundColor: 'rgba(13, 110, 253, 0.25)',
borderColor: 'rgba(13, 110, 253, 0.6)',
borderWidth: 1,
borderRadius: 3,
yAxisID: 'yImp',
order: 2
},
{
type: 'line',
label: 'Клики',
data: clkData,
borderColor: '#fd7e14',
backgroundColor: 'rgba(253, 126, 20, 0.12)',
borderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
pointBackgroundColor: '#fd7e14',
tension: 0.3,
fill: true,
yAxisID: 'yClk',
order: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top', labels: { boxWidth: 12, padding: 16 } },
tooltip: {
callbacks: {
label: function (ctx) {
return ' ' + ctx.dataset.label + ': ' + ctx.parsed.y.toLocaleString();
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { maxRotation: 45, font: { size: 11 } }
},
yImp: {
type: 'linear', position: 'left', beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: { font: { size: 11 } },
title: { display: true, text: 'Показы', font: { size: 11 } }
},
yClk: {
type: 'linear', position: 'right', beginAtZero: true,
grid: { drawOnChartArea: false },
ticks: { font: { size: 11 } },
title: { display: true, text: 'Клики', font: { size: 11 } }
}
}
}
});
})();
</script>
<script>
(function () {
function getParams() { return new URLSearchParams(window.location.search); }
function navigate(overrides, anchor) {
var p = getParams();
Object.keys(overrides).forEach(function (k) { p.set(k, overrides[k]); });
window.location.href = '?' + p.toString() + (anchor ? '#' + anchor : '');
}
// Глобальный период
document.querySelectorAll('#globalPeriodBtns button').forEach(function (btn) {
btn.addEventListener('click', function () { navigate({ period: btn.dataset.period }); });
});
// Exclude CN/US
document.getElementById('excludeCNUS').addEventListener('change', function () {
navigate({ exclude_cnus: this.checked ? '1' : '0' });
});
// Бренд-фильтры
document.getElementById('geoBrandFilter').addEventListener('change', function () {
navigate({ geo_brand: this.value }, 'geo-coverage');
});
document.getElementById('langBrandFilter')?.addEventListener('change', function () {
navigate({ lang_brand: this.value }, 'lang-coverage');
});
document.getElementById('refBrandFilter')?.addEventListener('change', function () {
navigate({ ref_brand: this.value }, 'ref-coverage');
});
// ---- Сортировка Топ брендов ----
var sortCol = 'clk', sortAsc = false;
function updateIcons() {
document.querySelectorAll('#topBrandsTable th.sortable').forEach(function (th) {
var icon = th.querySelector('.sort-icon');
if (th.dataset.col === sortCol) {
icon.textContent = sortAsc ? '↑' : '↓';
icon.classList.remove('text-muted');
} else {
icon.textContent = '↕';
icon.classList.add('text-muted');
}
});
}
function sortTable() {
var tbody = document.querySelector('#topBrandsTable tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var av = parseFloat(a.querySelector('[data-' + sortCol + ']')?.dataset[sortCol] ?? 0);
var bv = parseFloat(b.querySelector('[data-' + sortCol + ']')?.dataset[sortCol] ?? 0);
return sortAsc ? av - bv : bv - av;
});
rows.forEach(function (r, i) {
var numCell = r.querySelector('td:first-child');
if (numCell) numCell.textContent = i + 1;
tbody.appendChild(r);
});
updateIcons();
}
document.querySelectorAll('#topBrandsTable th.sortable').forEach(function (th) {
th.addEventListener('click', function () {
if (sortCol === th.dataset.col) { sortAsc = !sortAsc; }
else { sortCol = th.dataset.col; sortAsc = false; }
sortTable();
});
});
updateIcons();
// ---- GEO Coverage: сортировка + collapse ----
var GEO_LIMIT = 25, geoExpanded = false, geoSortCol = 'imp', geoSortAsc = false;
function updateGeoIcons() {
document.querySelectorAll('#geoCoverageTable th.geo-sortable').forEach(function (th) {
var icon = th.querySelector('.geo-sort-icon');
if (th.dataset.col === geoSortCol) {
icon.textContent = geoSortAsc ? '↑' : '↓'; icon.classList.remove('text-muted');
} else { icon.textContent = '↕'; icon.classList.add('text-muted'); }
});
}
function applyGeoCollapse() {
var tbody = document.querySelector('#geoCoverageTable tbody');
if (!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr.geo-data-row'));
var old = tbody.querySelector('tr.geo-toggle-row');
if (old) old.remove();
if (rows.length <= GEO_LIMIT) return;
var hidden = rows.length - GEO_LIMIT;
rows.forEach(function (r, i) { r.style.display = (geoExpanded || i < GEO_LIMIT) ? '' : 'none'; });
var tr = document.createElement('tr');
tr.className = 'geo-toggle-row';
tr.innerHTML = '<td colspan="6" class="text-center py-2"><button class="btn btn-sm btn-outline-secondary px-4" id="geoToggleBtn">'
+ (geoExpanded ? '↑ Свернуть' : '+ показать ещё ' + hidden + ' стран') + '</button></td>';
if (geoExpanded) tbody.appendChild(tr);
else rows[GEO_LIMIT - 1].after(tr);
document.getElementById('geoToggleBtn').addEventListener('click', function () {
geoExpanded = !geoExpanded; applyGeoCollapse();
});
}
function sortGeoTable() {
var tbody = document.querySelector('#geoCoverageTable tbody');
if (!tbody) return;
var old = tbody.querySelector('tr.geo-toggle-row'); if (old) old.remove();
var rows = Array.from(tbody.querySelectorAll('tr.geo-data-row'));
rows.sort(function (a, b) {
if (geoSortCol === 'geo') {
var av = (a.querySelector('[data-geo]')?.dataset.geo || '').toLowerCase();
var bv = (b.querySelector('[data-geo]')?.dataset.geo || '').toLowerCase();
return geoSortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
}
var av = parseFloat(a.querySelector('[data-' + geoSortCol + ']')?.dataset[geoSortCol] ?? 0);
var bv = parseFloat(b.querySelector('[data-' + geoSortCol + ']')?.dataset[geoSortCol] ?? 0);
return geoSortAsc ? av - bv : bv - av;
});
rows.forEach(function (r, i) {
var n = r.querySelector('td:first-child'); if (n) n.textContent = i + 1;
tbody.appendChild(r);
});
updateGeoIcons(); applyGeoCollapse();
}
document.querySelectorAll('#geoCoverageTable th.geo-sortable').forEach(function (th) {
th.addEventListener('click', function () {
if (geoSortCol === th.dataset.col) { geoSortAsc = !geoSortAsc; }
else { geoSortCol = th.dataset.col; geoSortAsc = false; }
sortGeoTable();
});
});
document.querySelectorAll('#geoCoverageTable tbody tr').forEach(function (r) { r.classList.add('geo-data-row'); });
updateGeoIcons(); applyGeoCollapse();
// ---- Language Coverage: сортировка + collapse ----
var LANG_LIMIT = 25, langExpanded = false, langSortCol = 'imp', langSortAsc = false;
function updateLangIcons() {
document.querySelectorAll('#langCoverageTable th.lang-sortable').forEach(function (th) {
var icon = th.querySelector('.lang-sort-icon');
if (th.dataset.col === langSortCol) {
icon.textContent = langSortAsc ? '↑' : '↓'; icon.classList.remove('text-muted');
} else { icon.textContent = '↕'; icon.classList.add('text-muted'); }
});
}
function applyLangCollapse() {
var tbody = document.querySelector('#langCoverageTable tbody');
if (!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr.lang-data-row'));
var old = tbody.querySelector('tr.lang-toggle-row'); if (old) old.remove();
if (rows.length <= LANG_LIMIT) return;
var hidden = rows.length - LANG_LIMIT;
rows.forEach(function (r, i) { r.style.display = (langExpanded || i < LANG_LIMIT) ? '' : 'none'; });
var tr = document.createElement('tr');
tr.className = 'lang-toggle-row';
tr.innerHTML = '<td colspan="6" class="text-center py-2"><button class="btn btn-sm btn-outline-secondary px-4" id="langToggleBtn">'
+ (langExpanded ? '↑ Свернуть' : '+ показать ещё ' + hidden + ' языков') + '</button></td>';
if (langExpanded) tbody.appendChild(tr);
else rows[LANG_LIMIT - 1].after(tr);
document.getElementById('langToggleBtn').addEventListener('click', function () {
langExpanded = !langExpanded; applyLangCollapse();
});
}
function sortLangTable() {
var tbody = document.querySelector('#langCoverageTable tbody');
if (!tbody) return;
var old = tbody.querySelector('tr.lang-toggle-row'); if (old) old.remove();
var rows = Array.from(tbody.querySelectorAll('tr.lang-data-row'));
rows.sort(function (a, b) {
if (langSortCol === 'lang') {
var av = (a.querySelector('[data-lang]')?.dataset.lang || '').toLowerCase();
var bv = (b.querySelector('[data-lang]')?.dataset.lang || '').toLowerCase();
return langSortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
}
var av = parseFloat(a.querySelector('[data-' + langSortCol + ']')?.dataset[langSortCol] ?? 0);
var bv = parseFloat(b.querySelector('[data-' + langSortCol + ']')?.dataset[langSortCol] ?? 0);
return langSortAsc ? av - bv : bv - av;
});
rows.forEach(function (r, i) {
var n = r.querySelector('td:first-child'); if (n) n.textContent = i + 1;
tbody.appendChild(r);
});
updateLangIcons(); applyLangCollapse();
}
document.querySelectorAll('#langCoverageTable th.lang-sortable').forEach(function (th) {
th.addEventListener('click', function () {
if (langSortCol === th.dataset.col) { langSortAsc = !langSortAsc; }
else { langSortCol = th.dataset.col; langSortAsc = false; }
sortLangTable();
});
});
document.querySelectorAll('#langCoverageTable tbody tr').forEach(function (r) { r.classList.add('lang-data-row'); });
updateLangIcons(); applyLangCollapse();
// ---- Referrer: сортировка + collapse ----
var REF_LIMIT = 25, refExpanded = false, refSortCol = 'visits', refSortAsc = false;
function updateRefIcons() {
document.querySelectorAll('#refTable th.ref-sortable').forEach(function (th) {
var icon = th.querySelector('.ref-sort-icon');
if (th.dataset.col === refSortCol) {
icon.textContent = refSortAsc ? '↑' : '↓'; icon.classList.remove('text-muted');
} else { icon.textContent = '↕'; icon.classList.add('text-muted'); }
});
}
function applyRefCollapse() {
var tbody = document.querySelector('#refTable tbody');
if (!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr.ref-data-row'));
var old = tbody.querySelector('tr.ref-toggle-row'); if (old) old.remove();
if (rows.length <= REF_LIMIT) return;
var hidden = rows.length - REF_LIMIT;
rows.forEach(function (r, i) { r.style.display = (refExpanded || i < REF_LIMIT) ? '' : 'none'; });
var tr = document.createElement('tr');
tr.className = 'ref-toggle-row';
tr.innerHTML = '<td colspan="4" class="text-center py-2"><button class="btn btn-sm btn-outline-secondary px-4" id="refToggleBtn">'
+ (refExpanded ? '↑ Свернуть' : '+ показать ещё ' + hidden + ' источников') + '</button></td>';
if (refExpanded) tbody.appendChild(tr);
else rows[REF_LIMIT - 1].after(tr);
document.getElementById('refToggleBtn').addEventListener('click', function () {
refExpanded = !refExpanded; applyRefCollapse();
});
}
function sortRefTable() {
var tbody = document.querySelector('#refTable tbody');
if (!tbody) return;
var old = tbody.querySelector('tr.ref-toggle-row'); if (old) old.remove();
var rows = Array.from(tbody.querySelectorAll('tr.ref-data-row'));
rows.sort(function (a, b) {
if (refSortCol === 'src') {
var av = (a.querySelector('[data-src]')?.dataset.src || '').toLowerCase();
var bv = (b.querySelector('[data-src]')?.dataset.src || '').toLowerCase();
return refSortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
}
var av = parseFloat(a.querySelector('[data-visits]')?.dataset.visits ?? 0);
var bv = parseFloat(b.querySelector('[data-visits]')?.dataset.visits ?? 0);
return refSortAsc ? av - bv : bv - av;
});
rows.forEach(function (r, i) {
var n = r.querySelector('td:first-child'); if (n) n.textContent = i + 1;
tbody.appendChild(r);
});
updateRefIcons(); applyRefCollapse();
}
document.querySelectorAll('#refTable th.ref-sortable').forEach(function (th) {
th.addEventListener('click', function () {
if (refSortCol === th.dataset.col) { refSortAsc = !refSortAsc; }
else { refSortCol = th.dataset.col; refSortAsc = false; }
sortRefTable();
});
});
document.querySelectorAll('#refTable tbody tr').forEach(function (r) { r.classList.add('ref-data-row'); });
updateRefIcons(); applyRefCollapse();
})();
</script>
<!-- World Map: D3 + TopoJSON -->
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<script>
(function(){
// ISO numeric → alpha-2
var N2A = {4:'af',8:'al',12:'dz',24:'ao',32:'ar',36:'au',40:'at',31:'az',
50:'bd',112:'by',56:'be',64:'bt',68:'bo',70:'ba',76:'br',100:'bg',116:'kh',
120:'cm',124:'ca',152:'cl',156:'cn',170:'co',188:'cr',191:'hr',192:'cu',
196:'cy',203:'cz',208:'dk',218:'ec',818:'eg',222:'sv',233:'ee',231:'et',
246:'fi',250:'fr',268:'ge',276:'de',288:'gh',300:'gr',320:'gt',332:'ht',
340:'hn',348:'hu',356:'in',360:'id',364:'ir',368:'iq',372:'ie',376:'il',
380:'it',392:'jp',400:'jo',398:'kz',404:'ke',410:'kr',414:'kw',417:'kg',
418:'la',422:'lb',428:'lv',434:'ly',440:'lt',442:'lu',807:'mk',458:'my',
484:'mx',496:'mn',504:'ma',508:'mz',524:'np',528:'nl',554:'nz',558:'ni',
566:'ng',578:'no',512:'om',586:'pk',591:'pa',600:'py',604:'pe',608:'ph',
616:'pl',620:'pt',634:'qa',642:'ro',643:'ru',682:'sa',686:'sn',710:'za',
724:'es',144:'lk',729:'sd',752:'se',756:'ch',760:'sy',762:'tj',834:'tz',
764:'th',792:'tr',795:'tm',800:'ug',804:'ua',784:'ae',826:'gb',840:'us',
858:'uy',860:'uz',862:'ve',704:'vn',887:'ye',894:'zm',716:'zw',688:'rs',
646:'rw',266:'ga',454:'mw',694:'sl',706:'so',630:'pr',388:'jm',214:'do',
496:'mn',598:'pg',84:'bz',516:'na',430:'lr',232:'er',706:'so'};
var mapMode = 'imp';
var mapExclude = <?= $top_exclude ? 'true' : 'false' ?>;
var mapStats = {};
var mapRecent = [];
var mapTimer = null;
var pulseTimer = null;
var svg, path, projection, g;
var miniSvg, miniPath, miniProjection;
var drillModal = null;
var mapExpanded = false;
var fullMapInited = false;
var now = Date.now();
function setMapMode(mode) {
mapMode = mode;
document.getElementById('map-mode-imp').classList.toggle('active', mode === 'imp');
document.getElementById('map-mode-clk').classList.toggle('active', mode === 'clk');
renderColors();
}
window.setMapMode = setMapMode;
function toggleMap(e) {
if (e) e.stopPropagation();
mapExpanded = !mapExpanded;
document.getElementById('world-map-mini').style.display = mapExpanded ? 'none' : 'block';
document.getElementById('world-map-full').style.display = mapExpanded ? 'block' : 'none';
document.getElementById('map-chevron').style.transform = mapExpanded ? 'rotate(180deg)' : '';
if (mapExpanded && !fullMapInited) {
fullMapInited = true;
initFullMap(window._mapWorld);
} else if (mapExpanded) {
renderPulses();
}
}
window.toggleMap = toggleMap;
function initMiniMap(world) {
var el = document.getElementById('world-map-mini');
var W = el.offsetWidth, H = 460;
miniSvg = d3.select('#world-map-mini').append('svg')
.attr('width', W).attr('height', H).style('display','block');
miniProjection = d3.geoNaturalEarth1()
.scale(Math.min(W / 5.71, H / 2.91))
.translate([W / 2, H / 2]);
miniPath = d3.geoPath().projection(miniProjection);
var mg = miniSvg.append('g');
mg.append('rect').attr('width', W).attr('height', H).attr('fill','#c8d8e8');
var countries = topojson.feature(world, world.objects.countries).features;
mg.selectAll('.mc').data(countries).join('path')
.attr('class','mc').attr('d', miniPath)
.attr('data-id', function(d){ return N2A[+d.id]||''; })
.attr('fill','#b8c8d8').attr('stroke','#fff').attr('stroke-width',.3);
}
function renderMiniColors() {
if (!miniSvg) return;
var vals = Object.values(mapStats).map(function(s){ return s[mapMode]||0; });
var maxVal = vals.length ? d3.max(vals) : 1;
miniSvg.selectAll('.mc').attr('fill', function(d){
return countryColor(N2A[+d.id]||'', maxVal);
});
}
function initFullMap(world) {
var el = document.getElementById('world-map');
var W = el.offsetWidth, H = el.offsetHeight;
svg = d3.select('#world-map').append('svg')
.attr('width', W).attr('height', H).style('display','block');
projection = d3.geoNaturalEarth1()
.scale(Math.min(W / 5.71, H / 2.91))
.translate([W / 2, H / 2]);
path = d3.geoPath().projection(projection);
g = svg.append('g');
g.append('rect').attr('width', W).attr('height', H).attr('fill','#c8d8e8').attr('rx',4);
g.append('path').datum(d3.geoGraticule()())
.attr('d', path).attr('fill','none')
.attr('stroke','rgba(255,255,255,0.25)').attr('stroke-width',.3);
var countries = topojson.feature(world, world.objects.countries).features;
g.selectAll('.country').data(countries).join('path')
.attr('class','country').attr('d', path)
.attr('data-id', function(d){ return N2A[+d.id]||''; })
.attr('fill','#b8c8d8').attr('stroke','#fff').attr('stroke-width',.4)
.style('cursor','pointer')
.on('mouseover', onCountryHover)
.on('mousemove', onCountryMove)
.on('mouseout', onCountryOut)
.on('click', onCountryClick);
svg.append('g').attr('id','pulse-layer');
if (!document.getElementById('map-tt')) {
var tt = document.createElement('div');
tt.id = 'map-tt';
tt.style.cssText = 'display:none;position:fixed;z-index:9999;background:rgba(15,20,40,.88);color:#fff;padding:8px 12px;border-radius:8px;font-size:12px;pointer-events:none;line-height:1.6;box-shadow:0 4px 16px rgba(0,0,0,.3)';
document.body.appendChild(tt);
}
svg.call(d3.zoom().scaleExtent([1,8]).on('zoom', function(e){ g.attr('transform', e.transform); }));
renderColors();
renderPulses();
}
function countryColor(cc, maxVal) {
if (!cc || !mapStats[cc]) return '#b8c8d8';
var val = mapStats[cc][mapMode] || 0;
if (!val || !maxVal) return '#b8c8d8';
var t = Math.pow(val / maxVal, 0.4);
return d3.interpolate('#c8dff0', '#1a4a8a')(t);
}
function renderColors() {
var vals = Object.values(mapStats).map(function(s){ return s[mapMode] || 0; });
var maxVal = vals.length ? d3.max(vals) : 1;
if (g) g.selectAll('.country').attr('fill', function(d){
return countryColor(N2A[+d.id] || '', maxVal);
});
renderMiniColors();
}
function renderPulses() {
if (!svg) return;
var pl = d3.select('#pulse-layer');
pl.selectAll('*').remove();
now = Date.now();
var WINDOW = 30 * 60 * 1000; // 30 мин
// Группируем по стране — берём последнее событие
var byCC = {};
mapRecent.forEach(function(r){
var age = now - r.ts * 1000;
if (age > WINDOW) return;
if (!byCC[r.cc] || r.ts > byCC[r.cc].ts) byCC[r.cc] = r;
});
Object.values(byCC).forEach(function(r){
var age = now - r.ts * 1000;
var intensity = 1 - age / WINDOW;
var centroid = getCentroid(r.cc);
if (!centroid) return;
var x = centroid[0], y = centroid[1];
var color = r.type === 'click' ? '#ff6b35' : '#22c55e';
var sz = 6 + intensity * 5;
// Статичный кружок
pl.append('circle')
.attr('cx', x).attr('cy', y).attr('r', sz / 2)
.attr('fill', color).attr('opacity', 0.7 * intensity + 0.2)
.attr('pointer-events','none');
// Пульс
var pulse = pl.append('circle')
.attr('cx', x).attr('cy', y).attr('r', sz / 2)
.attr('fill','none').attr('stroke', color)
.attr('stroke-width', 1.5).attr('opacity', 0.8)
.attr('pointer-events','none');
(function animate(circ){
circ.attr('r', sz / 2).attr('opacity', 0.8)
.transition().duration(1600).ease(d3.easeLinear)
.attr('r', sz / 2 + 14).attr('opacity', 0)
.on('end', function(){ animate(circ); });
})(pulse);
});
}
// Центроиды стран (приближённые, для отображения пульса)
var CENTROIDS = {
af:[66,33],al:[20,41],dz:[3,28],ao:[18,-12],ar:[-64,-34],au:[134,-25],
at:[14,47],az:[47,40],bd:[90,24],by:[28,53],be:[4,51],bo:[-65,-17],
ba:[17,44],br:[-52,-10],bg:[25,43],kh:[105,13],cm:[12,6],ca:[-96,56],
cl:[-71,-30],cn:[105,35],co:[-74,4],cr:[-84,10],hr:[16,45],cu:[-80,22],
cy:[33,35],cz:[16,50],dk:[10,56],ec:[-78,-2],eg:[30,27],sv:[-89,14],
ee:[25,59],et:[40,8],fi:[26,64],fr:[2,46],ge:[44,42],de:[10,51],
gh:[-1,8],gr:[22,39],gt:[-90,15],hn:[-87,15],hu:[19,47],in:[78,20],
id:[120,-5],ir:[53,32],iq:[44,33],ie:[-8,53],il:[35,31],it:[12,43],
jp:[138,36],jo:[36,31],kz:[67,48],ke:[38,0],kr:[128,37],kw:[47,29],
kg:[74,41],la:[103,18],lb:[36,34],lv:[25,57],ly:[17,27],lt:[24,56],
mk:[21,42],my:[110,3],mx:[-102,24],mn:[105,46],ma:[-7,32],mz:[35,-18],
np:[84,28],nl:[5,52],nz:[174,-41],ni:[-85,13],ng:[8,10],no:[10,62],
om:[57,22],pk:[70,30],pa:[-80,9],py:[-58,-23],pe:[-76,-10],ph:[122,13],
pl:[20,52],pt:[-8,39],qa:[51,25],ro:[25,46],ru:[100,60],sa:[45,24],
sn:[-14,14],za:[25,-29],es:[-4,40],lk:[81,8],sd:[30,15],se:[18,62],
ch:[8,47],sy:[38,35],tj:[71,39],tz:[35,-6],th:[101,15],tr:[35,39],
tm:[59,40],ug:[32,1],ua:[32,49],ae:[54,24],gb:[-2,54],us:[-98,38],
uy:[-56,-33],uz:[63,41],ve:[-66,8],vn:[108,14],ye:[48,15],zm:[28,-14],
zw:[30,-20],rs:[21,44],rw:[30,-2],ge:[44,42],sk:[19,49],si:[15,46],
md:[29,47],lt:[24,56],hr:[16,45],ba:[17,44],me:[19,43],al:[20,41],
mk:[21,42],xk:[21,43],by:[28,53],am:[45,40],az:[47,40]
};
function getCentroid(cc) {
if (!cc) return null;
if (CENTROIDS[cc]) {
return projection(CENTROIDS[cc]);
}
// Fallback: find path and compute centroid
var found = null;
g && g.selectAll('.country').each(function(d){
if (N2A[+d.id] === cc) {
found = path.centroid(d);
}
});
return found;
}
var ttEl;
function onCountryHover(e, d) {
var cc = N2A[+d.id] || '';
if (!cc) return;
ttEl = document.getElementById('map-tt');
var s = mapStats[cc] || {imp:0,clk:0};
var ctr = s.imp ? Math.round(s.clk/s.imp*1000)/10 + '%' : '—';
var flag = '<img src="https://flagicons.lipis.dev/flags/4x3/'+cc+'.svg" width="16" height="12" style="border-radius:2px;vertical-align:middle;margin-right:4px">';
ttEl.innerHTML = flag + '<strong><?php echo ""; ?>' + cc.toUpperCase() + '</strong><br>'
+ '👁 Показы: <b>' + (s.imp||0).toLocaleString() + '</b><br>'
+ '🖱 Клики: <b>' + (s.clk||0).toLocaleString() + '</b><br>'
+ '📊 CTR: <b>' + ctr + '</b>';
ttEl.style.display = 'block';
d3.select(this).attr('stroke','#ff6b00').attr('stroke-width',1.5);
}
function onCountryMove(e) {
if (!ttEl) return;
ttEl.style.left = (e.clientX + 14) + 'px';
ttEl.style.top = (e.clientY - 10) + 'px';
}
function onCountryOut() {
if (ttEl) ttEl.style.display = 'none';
d3.select(this).attr('stroke','#fff').attr('stroke-width',.4);
}
function onCountryClick(e, d) {
var cc = N2A[+d.id] || '';
if (!cc) return;
if (ttEl) ttEl.style.display = 'none';
openDrill(cc);
}
function openDrill(cc) {
var s = mapStats[cc] || {imp:0,clk:0};
var ctr = s.imp ? Math.round(s.clk/s.imp*1000)/10 + '%' : '0%';
var flag = '<img src="https://flagicons.lipis.dev/flags/4x3/'+cc+'.svg" width="20" height="15" style="border-radius:2px;vertical-align:middle;margin-right:6px">';
document.getElementById('mapDrillTitle').innerHTML = flag + cc.toUpperCase();
var body = document.getElementById('mapDrillBody');
body.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm text-secondary"></div></div>';
if (!drillModal) drillModal = new bootstrap.Modal(document.getElementById('mapDrillModal'));
drillModal.show();
fetch('/admin/map-data.php?cc=' + cc + (mapExclude ? '&exclude_cnus=1' : ''))
.then(function(r){ return r.json(); })
.then(function(data) {
var drill = data.drill || {};
var brands = drill.brands || [];
var days = drill.days || [];
var html = '<div class="row g-3 mb-3">'
+ '<div class="col-4"><div class="card text-center p-2"><div class="fw-bold fs-5">'+s.imp.toLocaleString()+'</div><div class="text-muted small">Показы</div></div></div>'
+ '<div class="col-4"><div class="card text-center p-2"><div class="fw-bold fs-5">'+s.clk.toLocaleString()+'</div><div class="text-muted small">Клики</div></div></div>'
+ '<div class="col-4"><div class="card text-center p-2"><div class="fw-bold fs-5">'+ctr+'</div><div class="text-muted small">CTR</div></div></div>'
+ '</div>';
if (brands.length) {
html += '<h6 class="text-muted fw-semibold mb-2">Топ брендов</h6><div class="table-responsive"><table class="table table-sm align-middle mb-3"><thead class="table-light"><tr><th>Бренд</th><th class="text-center">Показы</th><th class="text-center">Клики</th><th class="text-center">CTR</th></tr></thead><tbody>';
brands.forEach(function(b){
var bctr = b.imp ? Math.round(b.clk/b.imp*1000)/10+'%' : '—';
var logo = b.logo ? '<img src="'+b.logo+'" style="height:20px;max-width:50px;object-fit:contain;vertical-align:middle;margin-right:6px">' : '';
html += '<tr><td>'+logo+b.name+'</td><td class="text-center">'+b.imp+'</td><td class="text-center">'+b.clk+'</td><td class="text-center">'+bctr+'</td></tr>';
});
html += '</tbody></table></div>';
}
if (days.length > 1) {
var labels = days.map(function(d){ return d.day.slice(5); });
var impD = days.map(function(d){ return +d.imp; });
var clkD = days.map(function(d){ return +d.clk; });
html += '<h6 class="text-muted fw-semibold mb-2">Последние 14 дней</h6><div style="height:160px;position:relative"><canvas id="drillChart"></canvas></div>';
body.innerHTML = html;
new Chart(document.getElementById('drillChart'), {
type:'bar',
data:{ labels:labels, datasets:[
{label:'Показы',data:impD,backgroundColor:'rgba(59,130,246,.5)',borderColor:'#3b82f6',borderWidth:1},
{label:'Клики', data:clkD,backgroundColor:'rgba(239,68,68,.5)', borderColor:'#ef4444',borderWidth:1}
]},
options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{boxWidth:12,font:{size:11}}}},scales:{x:{ticks:{font:{size:10}}},y:{ticks:{font:{size:10}}}}}
});
return;
}
body.innerHTML = html;
})
.catch(function(){ body.innerHTML = '<div class="text-danger small">Ошибка загрузки</div>'; });
}
function fetchMapData() {
fetch('/admin/map-data.php' + (mapExclude ? '?exclude_cnus=1' : ''))
.then(function(r){ return r.json(); })
.then(function(data){
mapStats = data.stats || {};
mapRecent = data.recent || [];
renderColors();
renderPulses();
// Мигание LIVE-бейджа
var badge = document.getElementById('map-live');
badge.style.opacity = '0';
setTimeout(function(){ badge.style.opacity = '1'; }, 200);
});
}
// Загрузка карты
fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
.then(function(r){ return r.json(); })
.then(function(world){
window._mapWorld = world;
initMiniMap(world);
fetchMapData();
setInterval(fetchMapData, 30000);
});
})();
</script>
</body>
</html>