- Installed p7h/nas-file-manager package via private VCS repo - Published config/nas-file-manager.php with super_admin middleware restriction - Added NAS env vars to .env.example - Created admin/nas-storage page with connection info panel and file browser widget - Added NAS Storage link to admin sidebar (super_admin only) - Added SuperAdminController@nasStorage method and admin.nas-storage route - Includes all accumulated branch changes: profile wall, 2FA, audit logs, settings panel, country/phone/timezone components, posts, slideshow, playlist shares, video downloads/shares, comment likes, notifications, social links, and more Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
591 lines
24 KiB
PHP
591 lines
24 KiB
PHP
@extends('admin.layout')
|
||
|
||
@section('title', 'Analytics — ' . $video->title)
|
||
@section('page_title', 'Video Analytics')
|
||
|
||
@php
|
||
function flagEmoji(string $code): string {
|
||
$chars = '';
|
||
foreach (str_split(strtoupper($code)) as $c) {
|
||
$chars .= mb_chr(0x1F1E6 + ord($c) - ord('A'));
|
||
}
|
||
return $chars;
|
||
}
|
||
@endphp
|
||
|
||
@section('content')
|
||
<style>
|
||
.analytics-back {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--text-secondary);
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
margin-bottom: 18px;
|
||
transition: color .15s;
|
||
}
|
||
.analytics-back:hover { color: var(--text-primary); }
|
||
|
||
/* Video info header */
|
||
.video-info-card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px;
|
||
padding: 20px 24px;
|
||
display: flex;
|
||
gap: 20px;
|
||
align-items: flex-start;
|
||
margin-bottom: 24px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.video-info-thumb {
|
||
width: 140px;
|
||
height: 88px;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
background: #1a1a1a;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-secondary);
|
||
font-size: 28px;
|
||
}
|
||
.video-info-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; }
|
||
.video-info-body { flex: 1; min-width: 200px; }
|
||
.video-info-title { font-size: 18px; font-weight: 700; margin-bottom: 6px; line-height: 1.3; }
|
||
.video-info-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
|
||
.video-info-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 12px;
|
||
padding: 3px 10px;
|
||
border-radius: 20px;
|
||
background: #2a2a2a;
|
||
border: 1px solid var(--border-color);
|
||
color: var(--text-secondary);
|
||
}
|
||
.video-info-badge.red { color: var(--brand-red); border-color: var(--brand-red); }
|
||
.video-info-actions { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-shrink: 0; }
|
||
|
||
/* KPI cards */
|
||
.kpi-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.kpi-card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px;
|
||
padding: 18px 16px;
|
||
text-align: center;
|
||
}
|
||
.kpi-card .kpi-icon {
|
||
font-size: 22px;
|
||
margin-bottom: 8px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.kpi-card .kpi-value {
|
||
font-size: 26px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
margin-bottom: 4px;
|
||
}
|
||
.kpi-card .kpi-label {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.kpi-card.red .kpi-value { color: var(--brand-red); }
|
||
.kpi-card.green .kpi-value { color: #4caf50; }
|
||
.kpi-card.blue .kpi-value { color: #2196f3; }
|
||
.kpi-card.orange .kpi-value { color: #ff9800; }
|
||
.kpi-card.purple .kpi-value { color: #9c27b0; }
|
||
.kpi-card.teal .kpi-value { color: #009688; }
|
||
|
||
/* Panel */
|
||
.an-panel {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 12px;
|
||
padding: 20px 22px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.an-panel-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: .6px;
|
||
margin-bottom: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
}
|
||
.chart-wrap { position: relative; height: 220px; }
|
||
.chart-wrap-tall { position: relative; height: 280px; }
|
||
|
||
/* Country list */
|
||
.country-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 0;
|
||
border-bottom: 1px solid rgba(255,255,255,.04);
|
||
}
|
||
.country-row:last-child { border-bottom: none; }
|
||
.country-rank { font-size: 12px; color: var(--text-secondary); width: 18px; text-align: right; flex-shrink: 0; }
|
||
.country-flag { font-size: 18px; line-height: 1; flex-shrink: 0; }
|
||
.country-name { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.country-bar-wrap { width: 90px; flex-shrink: 0; }
|
||
.country-bar { height: 5px; border-radius: 3px; background: var(--brand-red); min-width: 3px; }
|
||
.country-count { font-size: 13px; font-weight: 600; flex-shrink: 0; width: 36px; text-align: right; }
|
||
|
||
/* Age & gender grid */
|
||
.analytics-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||
@media (max-width: 768px) { .analytics-2col { grid-template-columns: 1fr; } }
|
||
|
||
/* Gender placeholder */
|
||
.gender-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 160px;
|
||
color: var(--text-secondary);
|
||
text-align: center;
|
||
gap: 10px;
|
||
}
|
||
.gender-placeholder i { font-size: 32px; opacity: .4; }
|
||
.gender-placeholder p { font-size: 13px; line-height: 1.5; max-width: 260px; margin: 0; }
|
||
|
||
/* Recent viewers table */
|
||
.viewer-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.viewer-table th {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px;
|
||
color: var(--text-secondary);
|
||
padding: 0 12px 10px;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border-color);
|
||
font-weight: 500;
|
||
}
|
||
.viewer-table td { padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
|
||
.viewer-table tr:last-child td { border-bottom: none; }
|
||
.viewer-avatar {
|
||
width: 30px; height: 30px; border-radius: 50%; object-fit: cover;
|
||
background: #2a2a2a; display: inline-flex; align-items: center;
|
||
justify-content: center; font-size: 13px; color: var(--text-secondary);
|
||
}
|
||
.viewer-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
|
||
.guest-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: #252525; color: #888; border: 1px solid #333; }
|
||
.auth-badge { font-size: 10px; padding: 2px 7px; border-radius: 10px; background: rgba(33,150,243,.12); color: #64b5f6; border: 1px solid rgba(33,150,243,.25); }
|
||
</style>
|
||
|
||
<!-- Back -->
|
||
<a href="{{ route('admin.videos') }}" class="analytics-back">
|
||
<i class="bi bi-arrow-left"></i> Back to Videos
|
||
</a>
|
||
|
||
<!-- Video info header -->
|
||
<div class="video-info-card">
|
||
<div class="video-info-thumb">
|
||
@if($video->thumbnail)
|
||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
|
||
@else
|
||
<i class="bi bi-play-circle"></i>
|
||
@endif
|
||
</div>
|
||
<div class="video-info-body">
|
||
<div class="video-info-title">{{ $video->title }}</div>
|
||
<div style="font-size:13px; color:var(--text-secondary); margin-bottom:6px;">
|
||
by <a href="{{ route('channel', $video->user->channel) }}" target="_blank" style="color:var(--text-secondary);">{{ $video->user->name }}</a>
|
||
</div>
|
||
<div class="video-info-meta">
|
||
<span class="video-info-badge">
|
||
<i class="bi bi-calendar3"></i>
|
||
{{ $video->created_at->format('M d, Y') }}
|
||
</span>
|
||
<span class="video-info-badge">
|
||
<i class="bi bi-clock"></i>
|
||
{{ $video->formatted_duration ?? '—' }}
|
||
</span>
|
||
<span class="video-info-badge">
|
||
<i class="bi bi-tag"></i>
|
||
{{ ucfirst($video->type) }}
|
||
</span>
|
||
@php
|
||
$statusColors = ['ready'=>'#4caf50','processing'=>'#ff9800','pending'=>'#888','failed'=>'#f44336'];
|
||
$sc = $statusColors[$video->status] ?? '#888';
|
||
@endphp
|
||
<span class="video-info-badge" style="color:{{ $sc }}; border-color:{{ $sc }};">
|
||
<i class="bi bi-circle-fill" style="font-size:7px;"></i>
|
||
{{ ucfirst($video->status) }}
|
||
</span>
|
||
<span class="video-info-badge">
|
||
<i class="bi bi-eye"></i>
|
||
{{ ucfirst($video->visibility) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="video-info-actions">
|
||
<a href="{{ route('videos.show', $video) }}" target="_blank" class="btn btn-sm btn-outline-light">
|
||
<i class="bi bi-play-circle"></i> Watch
|
||
</a>
|
||
<a href="{{ route('admin.videos.edit', $video) }}" class="btn btn-sm btn-outline-light">
|
||
<i class="bi bi-pencil"></i> Edit
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- KPIs -->
|
||
<div class="kpi-grid">
|
||
<div class="kpi-card red">
|
||
<div class="kpi-icon"><i class="bi bi-eye"></i></div>
|
||
<div class="kpi-value">{{ number_format($totalViews) }}</div>
|
||
<div class="kpi-label">Total Views</div>
|
||
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">all watch events</div>
|
||
</div>
|
||
<div class="kpi-card blue">
|
||
<div class="kpi-icon"><i class="bi bi-people"></i></div>
|
||
<div class="kpi-value">{{ number_format($totalUniqueViewers) }}</div>
|
||
<div class="kpi-label">Unique Viewers</div>
|
||
<div style="font-size:10px; color:var(--text-secondary); margin-top:3px;">each person once</div>
|
||
</div>
|
||
<div class="kpi-card green">
|
||
<div class="kpi-icon"><i class="bi bi-heart"></i></div>
|
||
<div class="kpi-value">{{ number_format($totalLikes) }}</div>
|
||
<div class="kpi-label">Likes</div>
|
||
</div>
|
||
<div class="kpi-card orange">
|
||
<div class="kpi-icon"><i class="bi bi-chat"></i></div>
|
||
<div class="kpi-value">{{ number_format($totalComments) }}</div>
|
||
<div class="kpi-label">Comments</div>
|
||
</div>
|
||
<div class="kpi-card purple">
|
||
<div class="kpi-icon"><i class="bi bi-person-check"></i></div>
|
||
<div class="kpi-value">{{ number_format($authViewers) }}</div>
|
||
<div class="kpi-label">Logged-in</div>
|
||
</div>
|
||
<div class="kpi-card teal">
|
||
<div class="kpi-icon"><i class="bi bi-incognito"></i></div>
|
||
<div class="kpi-value">{{ number_format($guestViewers) }}</div>
|
||
<div class="kpi-label">Guests</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Views over time -->
|
||
<div class="an-panel">
|
||
<div class="an-panel-title"><i class="bi bi-graph-up"></i> View Events – Last 30 Days <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(total plays, not unique)</span></div>
|
||
<div class="chart-wrap">
|
||
<canvas id="dailyChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Countries -->
|
||
<div class="analytics-2col">
|
||
<!-- Left: list -->
|
||
<div class="an-panel" style="margin-bottom:0;">
|
||
<div class="an-panel-title"><i class="bi bi-globe2"></i> Viewers by Country <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
|
||
@php $maxCountry = $viewsByCountry->first()->total ?? 1; @endphp
|
||
@forelse($viewsByCountry as $i => $row)
|
||
<div class="country-row">
|
||
<span class="country-rank">{{ $i + 1 }}</span>
|
||
<span class="country-flag">{{ flagEmoji($row->country) }}</span>
|
||
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
|
||
<div class="country-bar-wrap">
|
||
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
||
</div>
|
||
<span class="country-count">{{ number_format($row->total) }}</span>
|
||
</div>
|
||
@empty
|
||
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
|
||
No country data yet — views are still being collected.
|
||
</div>
|
||
@endforelse
|
||
</div>
|
||
|
||
<!-- Right: chart -->
|
||
<div class="an-panel" style="margin-bottom:0;">
|
||
<div class="an-panel-title"><i class="bi bi-bar-chart-horizontal"></i> Country Distribution</div>
|
||
@if($viewsByCountry->isNotEmpty())
|
||
<div class="chart-wrap-tall">
|
||
<canvas id="countryChart"></canvas>
|
||
</div>
|
||
@else
|
||
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
|
||
Chart will appear once geo data is collected.
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom:24px;"></div>
|
||
|
||
<!-- Age groups + Gender -->
|
||
<div class="analytics-2col">
|
||
<!-- Age -->
|
||
<div class="an-panel" style="margin-bottom:0;">
|
||
<div class="an-panel-title"><i class="bi bi-person-badge"></i> Age Groups <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
|
||
@php $totalAge = array_sum($ageGroups); @endphp
|
||
@if($totalAge > 0)
|
||
<div class="chart-wrap">
|
||
<canvas id="ageChart"></canvas>
|
||
</div>
|
||
<div style="margin-top:14px; display:grid; grid-template-columns: repeat(auto-fill, minmax(100px,1fr)); gap:6px;">
|
||
@foreach($ageGroups as $label => $count)
|
||
@if($count > 0)
|
||
<div style="background:#1a1a1a; border-radius:8px; padding:8px 10px; text-align:center;">
|
||
<div style="font-size:15px; font-weight:700;">{{ number_format($count) }}</div>
|
||
<div style="font-size:11px; color:var(--text-secondary);">{{ $label }}</div>
|
||
</div>
|
||
@endif
|
||
@endforeach
|
||
</div>
|
||
@else
|
||
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
|
||
Age data requires users to set their birthday in profile settings.
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<!-- Gender -->
|
||
<div class="an-panel" style="margin-bottom:0;">
|
||
<div class="an-panel-title"><i class="bi bi-gender-ambiguous"></i> Gender Split <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(unique viewers)</span></div>
|
||
@php $genderTotal = array_sum($genderCounts); @endphp
|
||
@if($genderTotal > 0)
|
||
<div style="display:flex; align-items:center; gap:24px; flex-wrap:wrap;">
|
||
<div style="position:relative; width:160px; height:160px; flex-shrink:0; margin: 0 auto;">
|
||
<canvas id="genderChart"></canvas>
|
||
</div>
|
||
<div style="flex:1; min-width:120px;">
|
||
@php
|
||
$genderIcons = ['Male' => 'bi-gender-male', 'Female' => 'bi-gender-female', 'Prefer not to say' => 'bi-dash-circle'];
|
||
$genderColors = ['Male' => '#2196f3', 'Female' => '#e91e63', 'Prefer not to say' => '#888'];
|
||
@endphp
|
||
@foreach($genderCounts as $label => $count)
|
||
@if($count > 0)
|
||
<div style="display:flex; align-items:center; gap:9px; margin-bottom:10px;">
|
||
<span style="width:10px; height:10px; border-radius:50%; background:{{ $genderColors[$label] }}; flex-shrink:0;"></span>
|
||
<i class="bi {{ $genderIcons[$label] }}" style="color:{{ $genderColors[$label] }};"></i>
|
||
<span style="flex:1; font-size:13px;">{{ $label }}</span>
|
||
<strong style="font-size:14px;">{{ number_format($count) }}</strong>
|
||
<span style="font-size:12px; color:var(--text-secondary);">{{ round(($count / $genderTotal) * 100) }}%</span>
|
||
</div>
|
||
@endif
|
||
@endforeach
|
||
<div style="font-size:11px; color:var(--text-secondary); margin-top:8px;">Based on {{ number_format($genderTotal) }} logged-in viewer{{ $genderTotal !== 1 ? 's' : '' }} who set their gender.</div>
|
||
</div>
|
||
</div>
|
||
@else
|
||
<div class="gender-placeholder">
|
||
<i class="bi bi-gender-ambiguous"></i>
|
||
<p>No gender data yet. Viewers can set their gender in profile settings.</p>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom:24px;"></div>
|
||
|
||
<!-- Recent viewers -->
|
||
<div class="an-panel">
|
||
<div class="an-panel-title"><i class="bi bi-clock-history"></i> Recent View Events <span style="font-weight:400; font-size:11px; text-transform:none; letter-spacing:0; color:var(--text-secondary);">(last 20 individual plays)</span></div>
|
||
@if($recentViews->isNotEmpty())
|
||
<div style="overflow-x:auto;">
|
||
<table class="viewer-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Viewer</th>
|
||
<th>Country</th>
|
||
<th>IP Address</th>
|
||
<th>Type</th>
|
||
<th>Watched At</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach($recentViews as $view)
|
||
<tr>
|
||
<td>
|
||
<div style="display:flex; align-items:center; gap:10px;">
|
||
<div class="viewer-avatar">
|
||
@if($view->viewer_avatar)
|
||
<img src="{{ asset('storage/avatars/' . $view->viewer_avatar) }}" alt="">
|
||
@else
|
||
<i class="bi bi-person"></i>
|
||
@endif
|
||
</div>
|
||
<span>{{ $view->viewer_name ?? 'Guest' }}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
@if($view->country)
|
||
<span style="font-size:16px;">{{ flagEmoji($view->country) }}</span>
|
||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
|
||
@else
|
||
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
||
@endif
|
||
</td>
|
||
<td style="font-family:monospace; font-size:12px; color:var(--text-secondary);">
|
||
{{ $view->ip_address ?? '—' }}
|
||
</td>
|
||
<td>
|
||
@if($view->user_id)
|
||
<span class="auth-badge"><i class="bi bi-person-check"></i> Logged in</span>
|
||
@else
|
||
<span class="guest-badge"><i class="bi bi-incognito"></i> Guest</span>
|
||
@endif
|
||
</td>
|
||
<td style="color:var(--text-secondary); font-size:12px; white-space:nowrap;">
|
||
{{ \Carbon\Carbon::parse($view->watched_at)->diffForHumans() }}
|
||
<br><span style="font-size:11px; opacity:.6;">{{ \Carbon\Carbon::parse($view->watched_at)->format('M d, Y H:i') }}</span>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
@else
|
||
<div style="text-align:center; padding:40px 0; color:var(--text-secondary); font-size:13px;">
|
||
No views recorded yet.
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
@endsection
|
||
|
||
@section('scripts')
|
||
<script>
|
||
// ── Shared colours ──────────────────────────────────────────────────────────
|
||
const RED = 'rgba(230,30,30,1)';
|
||
const RED_BG = 'rgba(230,30,30,0.15)';
|
||
const GRID = 'rgba(255,255,255,0.06)';
|
||
const TICK = '#888';
|
||
|
||
Chart.defaults.color = TICK;
|
||
Chart.defaults.borderColor = GRID;
|
||
|
||
// ── Views per day ───────────────────────────────────────────────────────────
|
||
new Chart(document.getElementById('dailyChart'), {
|
||
type: 'line',
|
||
data: {
|
||
labels: {!! json_encode($dailyLabels) !!},
|
||
datasets: [{
|
||
label: 'Views',
|
||
data: {!! json_encode($dailyViews) !!},
|
||
borderColor: RED,
|
||
backgroundColor: RED_BG,
|
||
borderWidth: 2,
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5,
|
||
pointBackgroundColor: RED,
|
||
fill: true,
|
||
tension: 0.3,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
x: { grid: { color: GRID }, ticks: { color: TICK, maxTicksLimit: 10 } },
|
||
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
|
||
}
|
||
}
|
||
});
|
||
|
||
// ── Country chart ───────────────────────────────────────────────────────────
|
||
@if($viewsByCountry->isNotEmpty())
|
||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
|
||
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
|
||
const countryMax = Math.max(...countryData);
|
||
|
||
new Chart(document.getElementById('countryChart'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: countryLabels,
|
||
datasets: [{
|
||
data: countryData,
|
||
backgroundColor: countryData.map((v, i) => `rgba(230,30,30,${0.9 - (i / countryData.length) * 0.55})`),
|
||
borderRadius: 4,
|
||
}]
|
||
},
|
||
options: {
|
||
indexAxis: 'y',
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
x: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true },
|
||
y: { grid: { color: 'transparent' }, ticks: { color: TICK } }
|
||
}
|
||
}
|
||
});
|
||
@endif
|
||
|
||
// ── Age groups ──────────────────────────────────────────────────────────────
|
||
@php
|
||
$ageLabels = array_keys($ageGroups);
|
||
$ageValues = array_values($ageGroups);
|
||
$ageColors = ['rgba(33,150,243,0.85)', 'rgba(76,175,80,0.85)', 'rgba(255,152,0,0.85)',
|
||
'rgba(233,30,99,0.85)', 'rgba(156,39,176,0.85)', 'rgba(0,188,212,0.85)', 'rgba(120,120,120,0.6)'];
|
||
@endphp
|
||
@if(array_sum($ageGroups) > 0)
|
||
new Chart(document.getElementById('ageChart'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: {!! json_encode($ageLabels) !!},
|
||
datasets: [{
|
||
data: {!! json_encode($ageValues) !!},
|
||
backgroundColor: {!! json_encode($ageColors) !!},
|
||
borderRadius: 5,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
x: { grid: { color: 'transparent' }, ticks: { color: TICK } },
|
||
y: { grid: { color: GRID }, ticks: { color: TICK, precision: 0 }, beginAtZero: true }
|
||
}
|
||
}
|
||
});
|
||
@endif
|
||
|
||
// ── Gender donut ─────────────────────────────────────────────────────────────
|
||
@php $genderTotal = array_sum($genderCounts); @endphp
|
||
@if($genderTotal > 0)
|
||
new Chart(document.getElementById('genderChart'), {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: {!! json_encode(array_keys($genderCounts)) !!},
|
||
datasets: [{
|
||
data: {!! json_encode(array_values($genderCounts)) !!},
|
||
backgroundColor: ['#2196f3', '#e91e63', '#555'],
|
||
borderWidth: 0,
|
||
hoverOffset: 6,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
cutout: '65%',
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: ctx => ` ${ctx.label}: ${ctx.parsed} (${Math.round(ctx.parsed / {{ $genderTotal }} * 100)}%)`
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
@endif
|
||
</script>
|
||
@endsection
|