License Server
Analytics
Licenses
Components
Edit Component
Update the component metadata, code, and availability.
View Component
Back
Component Name
Component name cannot be changed after creation.
Display Name
*
Version
*
Active
Description
Component: API Graphics
Controller Code (Optional)
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; class ApiGraphicsController extends Controller { /** * Display the API graphics interface. */ public function index(Request $request) { // Get Matomo configuration from authenticated user $user = auth()->user(); if (!$user || !$user->matomo_configured) { return redirect()->route('onboarding')->with('error', 'Please configure your Matomo instance first.'); } // Use global Matomo configuration from user profile $matomoUrl = $user->matomo_url; $matomoToken = $user->matomo_token ? decrypt($user->matomo_token) : null; $siteId = $user->matomo_site_id ?? 1; // Get dynamic parameters from URL (period, date, segment) $period = $request->get('period', 'day'); $date = $request->get('date', 'today'); $segment = $request->get('segment', ''); // Format is always JSON for using tools directly $format = 'json'; // Fetch Matomo data server-side to avoid Cloudflare blocking $matomoData = $this->fetchMatomoDataWithConfig($matomoUrl, $matomoToken, $siteId, $period, $date, $segment, $format); return view('components.api-graphics.index', compact('matomoData', 'matomoUrl', 'matomoToken', 'siteId', 'period', 'date', 'segment')); } private function fetchMatomoDataWithConfig($matomoUrl, $token, $siteId, $period, $date, $segment = '', $format = 'json') { $baseUrl = rtrim($matomoUrl, '/') . '/index.php'; try { // Initialize data structure $data = [ 'visits' => [], 'pageviews' => [], 'countries' => [], 'countryVisits' => [], 'devices' => [], 'deviceVisits' => [], 'referrers' => [], 'referrerVisits' => [], 'goals' => [], 'goalConversions' => [], 'topPages' => [], 'pageVisits' => [] ]; $context = stream_context_create([ 'http' => [ 'timeout' => 30, 'method' => 'GET', 'header' => 'User-Agent: Laravel Matomo Dashboard' ] ]); // Build base parameters $baseParams = [ 'module' => 'API', 'idSite' => $siteId, 'period' => $period, 'date' => $date, 'format' => $format, 'token_auth' => $token, ]; if ($segment && $segment !== '' && $segment !== 'undefined') { $baseParams['segment'] = $segment; } // Fetch visits summary $visitsSummaryUrl = $baseUrl . '?' . http_build_query(array_merge($baseParams, ['method' => 'VisitsSummary.get'])); $response = file_get_contents($visitsSummaryUrl, false, $context); if ($response !== false) { $summary = json_decode($response, true); if ($summary) { $data['visits_summary'] = $summary; \Illuminate\Support\Facades\Log::info('Matomo visits summary fetched:', $summary); } else { \Illuminate\Support\Facades\Log::warning('Failed to decode visits summary response:', ['response' => $response]); } } else { \Illuminate\Support\Facades\Log::error('Failed to fetch visits summary from Matomo API'); } // Fetch visits over time - use VisitsSummary.get for historical data // For range periods, get last 30 days $visitsDate = $date; if ($period === 'day' && $date === 'today') { // For today, get last 30 days $visitsDate = 'last30'; } elseif ($period === 'day' && $date === 'yesterday') { // For yesterday, get last 30 days ending yesterday $yesterday = date('Y-m-d', strtotime('-1 day')); $visitsDate = date('Y-m-d', strtotime('-30 days')) . ',' . $yesterday; } elseif ($period === 'range') { // For range, use the range $visitsDate = $date; } else { // For other periods, get last 30 days $visitsDate = 'last30'; } // Use VisitsSummary.get which returns data indexed by date $visitsOverTimeParams = array_merge($baseParams, [ 'method' => 'VisitsSummary.get', 'date' => $visitsDate ]); $visitsOverTimeUrl = $baseUrl . '?' . http_build_query($visitsOverTimeParams); $response = @file_get_contents($visitsOverTimeUrl, false, $context); if ($response !== false) { $visitsData = json_decode($response, true); if ($visitsData && (!isset($visitsData['result']) || $visitsData['result'] !== 'error')) { // Handle the response format: dates as keys, each value is an array with nb_visits, nb_actions, etc. if (is_array($visitsData)) { // Extract labels (dates) and visit counts $labels = array_keys($visitsData); $visits = array_map(function($v) { return $v['nb_visits'] ?? 0; }, $visitsData); $pageviews = array_map(function($v) { return $v['nb_actions'] ?? 0; }, $visitsData); $data['visits'] = array_values($visits); $data['pageviews'] = array_values($pageviews); \Illuminate\Support\Facades\Log::info('Visits over time fetched:', [ 'count' => count($data['visits']), 'first_few' => array_slice($data['visits'], 0, 5) ]); } elseif (isset($visitsData['nb_visits'])) { // Single day data - get historical data instead $historicalParams = array_merge($baseParams, [ 'method' => 'VisitsSummary.get', 'date' => 'last30' ]); $historicalUrl = $baseUrl . '?' . http_build_query($historicalParams); $historicalResponse = @file_get_contents($historicalUrl, false, $context); if ($historicalResponse !== false) { $historicalData = json_decode($historicalResponse, true); if (is_array($historicalData)) { $visits = array_map(function($v) { return $v['nb_visits'] ?? 0; }, $historicalData); $pageviews = array_map(function($v) { return $v['nb_actions'] ?? 0; }, $historicalData); $data['visits'] = array_values($visits); $data['pageviews'] = array_values($pageviews); } } } } } // Fetch countries data $countriesUrl = $baseUrl . '?' . http_build_query(array_merge($baseParams, ['method' => 'UserCountry.getCountry'])); $response = file_get_contents($countriesUrl, false, $context); if ($response !== false) { $countriesData = json_decode($response, true); if (is_array($countriesData)) { $countriesData = array_slice($countriesData, 0, 10); foreach ($countriesData as $country) { $data['countries'][] = $country['label'] ?? 'Unknown'; $data['countryVisits'][] = $country['nb_visits'] ?? 0; } } } // Fetch top pages data $topPagesUrl = $baseUrl . '?' . http_build_query(array_merge($baseParams, ['method' => 'Actions.getPageUrls'])); $response = file_get_contents($topPagesUrl, false, $context); if ($response !== false) { $pagesData = json_decode($response, true); if (is_array($pagesData)) { $pagesData = array_slice($pagesData, 0, 10); foreach ($pagesData as $page) { $data['topPages'][] = $page['label'] ?? 'Unknown Page'; $data['pageVisits'][] = $page['nb_visits'] ?? 0; } } } // Fetch devices data $devicesUrl = $baseUrl . '?' . http_build_query(array_merge($baseParams, ['method' => 'DevicesDetection.getType'])); $response = file_get_contents($devicesUrl, false, $context); if ($response !== false) { $devicesData = json_decode($response, true); if (is_array($devicesData)) { foreach ($devicesData as $device) { $data['devices'][] = $device['label'] ?? 'Unknown'; $data['deviceVisits'][] = $device['nb_visits'] ?? 0; } } } // Fetch referrers data $referrersUrl = $baseUrl . '?' . http_build_query(array_merge($baseParams, ['method' => 'Referrers.getReferrerType'])); $response = file_get_contents($referrersUrl, false, $context); if ($response !== false) { $referrersData = json_decode($response, true); if (is_array($referrersData)) { $referrersData = array_slice($referrersData, 0, 10); foreach ($referrersData as $referrer) { $data['referrers'][] = $referrer['label'] ?? 'Unknown'; $data['referrerVisits'][] = $referrer['nb_visits'] ?? 0; } } } // Fetch goals data $goalsUrl = $baseUrl . '?' . http_build_query(array_merge($baseParams, ['method' => 'Goals.get'])); $response = file_get_contents($goalsUrl, false, $context); if ($response !== false) { $goalsData = json_decode($response, true); if (is_array($goalsData)) { foreach ($goalsData as $goal) { $data['goals'][] = $goal['label'] ?? 'Unknown Goal'; $data['goalConversions'][] = $goal['nb_conversions'] ?? 0; } } } // Ensure visits_summary is always set if (!isset($data['visits_summary'])) { $data['visits_summary'] = null; } return $data; } catch (\Exception $e) { \Illuminate\Support\Facades\Log::error('Matomo API Error: ' . $e->getMessage()); return [ 'visits' => [], 'pageviews' => [], 'countries' => [], 'countryVisits' => [], 'devices' => [], 'deviceVisits' => [], 'referrers' => [], 'referrerVisits' => [], 'goals' => [], 'goalConversions' => [], 'topPages' => [], 'pageVisits' => [], 'visits_summary' => null, 'error' => $e->getMessage() ]; } } }
PHP code for the component controller.
Routes Code (Optional)
<?php use Illuminate\Support\Facades\Route; use App\Http\Controllers\ApiGraphicsController; Route::middleware(['auth', 'component.visible'])->group(function () { Route::get('/api-graphics', [ApiGraphicsController::class, 'index'])->name('dashboard.api-graphics'); });
Route definitions for the component.
Views (Optional - JSON)
{ "index.blade.php": "@extends('layouts.app')\n\n@section('title', 'Matomo Dashboard Graphics - Matomo Tools')\n\n@section('styles')\n@include('dashboard._floating_container_styles')\n@endsection\n\n@section('content')\n<div class=\"floating-container\">\n<div class=\"row\">\n <!-- Header Card -->\n <div class=\"col-12 mb-4\">\n <div class=\"card\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h5 class=\"card-title mb-0\">Matomo Dashboard Graphics<\/h5>\n <div class=\"d-flex gap-2\">\n <button class=\"btn btn-outline-primary\" onclick=\"showPeriodModal()\">\n <i class=\"fas fa-calendar me-1\"><\/i> Select Period\n <\/button>\n <button class=\"btn btn-outline-primary\" onclick=\"refreshData()\">\n <i class=\"fas fa-sync me-1\"><\/i> Refresh\n <\/button>\n <button class=\"btn btn-outline-danger\" onclick=\"exportAsPDF()\" title=\"Export as PDF\">\n <i class=\"fas fa-file-pdf me-1\"><\/i> Export PDF\n <\/button>\n <button class=\"btn btn-primary\" onclick=\"toggleFullscreen()\">\n <i class=\"fas fa-expand me-1\"><\/i> Fullscreen\n <\/button>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <!-- \/ Header Card -->\n\n <!-- Transitions Card -->\n <div class=\"col-12 mb-4\">\n <div class=\"card\" id=\"transitionsSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h5 class=\"card-title mb-0\">Transitions<\/h5>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('transitionsSection', 'transitions')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download Image\n <\/button>\n <\/div>\n <!-- Matomo Transitions Report -->\n <div class=\"matomo-transitions-report\">\n <div class=\"transitions-header\">\n <h4 class=\"transitions-title\">Transitions<\/h4>\n <div class=\"transitions-controls\">\n <select class=\"form-select form-select-sm\" id=\"transitionsType\">\n <option value=\"pageUrls\">Page URLs<\/option>\n <option value=\"pageTitles\">Page Titles<\/option>\n <option value=\"entryPages\">Entry Pages<\/option>\n <option value=\"exitPages\">Exit Pages<\/option>\n <\/select>\n <select class=\"form-select form-select-sm\" id=\"transitionsPage\">\n <option value=\"\/\">\/ (Loading...)<\/option>\n <\/select>\n <\/div>\n <\/div>\n \n <!-- Sankey Diagram Container (Matomo Enhanced Style) -->\n <div class=\"transitions-sankey-container\">\n <div id=\"sankeyChart\">\n <svg id=\"viz\" width=\"1280\" height=\"640\"><\/svg>\n <div id=\"tooltip\" class=\"tooltip\"><\/div>\n <\/div>\n <div class=\"center-card\" id=\"centerCard\">\n <div class=\"card-head\">\n <a href=\"#\" id=\"centerPageLink\">Loading...<\/a>\n <\/div>\n <div class=\"card-body\" id=\"centerCardBody\">\n <div>Loading...<\/div>\n <\/div>\n <\/div>\n <\/div>\n \n <!-- Details Modal -->\n <div id=\"trafficDetailsModal\" class=\"traffic-details-modal\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h3 id=\"modalTitle\">Traffic Details<\/h3>\n <button class=\"modal-close\" onclick=\"closeTrafficDetails()\">×<\/button>\n <\/div>\n <div class=\"modal-body\" id=\"modalBody\">\n <p>Loading details...<\/p>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <!-- \/ Transitions Card -->\n\n <!-- Users Flow Card -->\n <div class=\"col-12 mb-4\">\n <div class=\"card\" id=\"usersFlowSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h5 class=\"card-title mb-0\">Users Flow<\/h5>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('usersFlowSection', 'users-flow')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download Image\n <\/button>\n <\/div>\n <!-- Matomo Users Flow Report -->\n <div class=\"matomo-users-flow\">\n <div class=\"flow-header\">\n <h4 class=\"flow-title\">Users Flow<\/h4>\n <p class=\"flow-description\">\n This visualization shows you how your visitors navigate through your website. \n You can add more steps by clicking on the plus icon which is shown in the top right after the last interaction.\n <\/p>\n <div class=\"flow-controls\">\n <select class=\"form-select form-select-sm\" id=\"flowDevice\">\n <option value=\"all\">All<\/option>\n <option value=\"desktop\">Desktop<\/option>\n <option value=\"mobile\">Mobile<\/option>\n <option value=\"tablet\">Tablet<\/option>\n <\/select>\n <select class=\"form-select form-select-sm\" id=\"flowSteps\">\n <option value=\"5\">5<\/option>\n <option value=\"10\">10<\/option>\n <option value=\"20\">20<\/option>\n <option value=\"50\">50<\/option>\n <\/select>\n <select class=\"form-select form-select-sm\" id=\"flowType\">\n <option value=\"pageUrls\">Page URLs<\/option>\n <option value=\"pageTitles\">Page Titles<\/option>\n <option value=\"events\">Events<\/option>\n <\/select>\n <\/div>\n <\/div>\n \n <div class=\"flow-diagram\" id=\"flowDiagram\">\n <div class=\"users-flow-sankey-container\" id=\"usersFlowContainer\">\n <div class=\"flow-scroll-wrapper\">\n <svg id=\"usersFlowSankey\" width=\"100%\" height=\"600\"><\/svg>\n <\/div>\n <div id=\"usersFlowTooltip\" class=\"tooltip\"><\/div>\n <\/div>\n <\/div>\n \n <!-- Interaction Details Modal -->\n <div id=\"interactionDetailsModal\" class=\"traffic-details-modal\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h3 id=\"interactionModalTitle\">Interaction Details<\/h3>\n <button class=\"modal-close\" onclick=\"closeInteractionDetails()\">×<\/button>\n <\/div>\n <div class=\"modal-body\" id=\"interactionModalBody\">\n <div class=\"loading-text\">Loading details...<\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <!-- \/ Users Flow Card -->\n\n <!-- Overview Stats Card -->\n <div class=\"col-12 mb-4\">\n <div class=\"card card-wrapper\">\n <!-- Matomo Overview Stats -->\n <div class=\"row mb-4\">\n <div class=\"col-md-3\">\n <div class=\"stats-card card-transition hover-lift\">\n <div class=\"stats-number text-primary\" id=\"totalVisits\">0<\/div>\n <div class=\"stats-label\">Total Visits<\/div>\n <\/div>\n <\/div>\n <div class=\"col-md-3\">\n <div class=\"stats-card card-transition hover-lift\">\n <div class=\"stats-number text-success\" id=\"totalPageviews\">0<\/div>\n <div class=\"stats-label\">Page Views<\/div>\n <\/div>\n <\/div>\n <div class=\"col-md-3\">\n <div class=\"stats-card card-transition hover-lift\">\n <div class=\"stats-number text-info\" id=\"uniqueVisitors\">0<\/div>\n <div class=\"stats-label\">Unique Visitors<\/div>\n <\/div>\n <\/div>\n <div class=\"col-md-3\">\n <div class=\"stats-card card-transition hover-lift\">\n <div class=\"stats-number text-warning\" id=\"bounceRate\">0%<\/div>\n <div class=\"stats-label\">Bounce Rate<\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <!-- \/ Overview Stats Card -->\n\n <!-- Main Charts Card -->\n <div class=\"col-12 mb-4\">\n <!-- Main Dashboard Charts -->\n <div class=\"row mb-4\">\n <div class=\"col-lg-8\">\n <div class=\"card\" id=\"visitsChartSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h6 class=\"card-title mb-0\">Visits Over Time<\/h6>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('visitsChartSection', 'visits-over-time')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download\n <\/button>\n <\/div>\n <div class=\"card-body\">\n <canvas id=\"visitsChart\" width=\"400\" height=\"200\"><\/canvas>\n <\/div>\n <\/div>\n <\/div>\n <div class=\"col-lg-4\">\n <div class=\"card\" id=\"countriesChartSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h6 class=\"card-title mb-0\">Visitor Countries<\/h6>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('countriesChartSection', 'visitor-countries')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download\n <\/button>\n <\/div>\n <div class=\"card-body\">\n <canvas id=\"countriesChart\" width=\"300\" height=\"200\"><\/canvas>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <!-- \/ Main Charts Card -->\n\n <!-- Additional Charts Card -->\n <div class=\"col-12 mb-4\">\n <!-- Additional Matomo Charts -->\n <div class=\"row mb-4\">\n <div class=\"col-lg-6\">\n <div class=\"card\" id=\"topPagesSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h6 class=\"card-title mb-0\">Top Pages<\/h6>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('topPagesSection', 'top-pages')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download\n <\/button>\n <\/div>\n <div class=\"card-body\">\n <canvas id=\"topPagesChart\" width=\"400\" height=\"300\"><\/canvas>\n <\/div>\n <\/div>\n <\/div>\n <div class=\"col-lg-6\">\n <div class=\"card\" id=\"devicesSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h6 class=\"card-title mb-0\">Device Types<\/h6>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('devicesSection', 'device-types')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download\n <\/button>\n <\/div>\n <div class=\"card-body\">\n <canvas id=\"devicesChart\" width=\"400\" height=\"300\"><\/canvas>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <!-- \/ Additional Charts Card -->\n\n <!-- Referrers and Goals Card -->\n <div class=\"col-12 mb-4\">\n <!-- Referrers and Goals -->\n <div class=\"row mb-4\">\n <div class=\"col-lg-8\">\n <div class=\"card\" id=\"referrersSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h6 class=\"card-title mb-0\">Top Referrers<\/h6>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('referrersSection', 'top-referrers')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download\n <\/button>\n <\/div>\n <div class=\"card-body\">\n <canvas id=\"referrersChart\" width=\"600\" height=\"300\"><\/canvas>\n <\/div>\n <\/div>\n <\/div>\n <div class=\"col-lg-4\">\n <div class=\"card\" id=\"goalsSection\">\n <div class=\"card-header d-flex align-items-center justify-content-between\">\n <h6 class=\"card-title mb-0\">Goal Conversions<\/h6>\n <button class=\"btn btn-sm btn-outline-primary\" onclick=\"downloadSectionImage('goalsSection', 'goal-conversions')\" title=\"Download as Image\">\n <i class=\"fas fa-download me-1\"><\/i>Download\n <\/button>\n <\/div>\n <div class=\"card-body\">\n <canvas id=\"goalsChart\" width=\"300\" height=\"300\"><\/canvas>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <\/div>\n <!-- \/ Referrers and Goals Card -->\n\n\n<!-- Period Configuration Modal -->\n<div class=\"modal fade\" id=\"periodModal\" tabindex=\"-1\" aria-labelledby=\"periodModalLabel\" aria-hidden=\"true\">\n <div class=\"modal-dialog modal-lg\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\" id=\"periodModalLabel\">\n <i class=\"fas fa-calendar me-2\"><\/i>Select Period & Date Range\n <\/h5>\n <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"><\/button>\n <\/div>\n <div class=\"modal-body\">\n <form id=\"periodForm\">\n <div class=\"row\">\n <div class=\"col-md-6\">\n <div class=\"mb-3\">\n <label for=\"period\" class=\"form-label\">Period<\/label>\n <select class=\"form-select\" id=\"period\" required>\n <option value=\"day\" {{ $period === 'day' ? 'selected' : '' }}>Day<\/option>\n <option value=\"week\" {{ $period === 'week' ? 'selected' : '' }}>Week<\/option>\n <option value=\"month\" {{ $period === 'month' ? 'selected' : '' }}>Month<\/option>\n <option value=\"year\" {{ $period === 'year' ? 'selected' : '' }}>Year<\/option>\n <option value=\"range\" {{ $period === 'range' ? 'selected' : '' }}>Date Range<\/option>\n <\/select>\n <\/div>\n <\/div>\n <div class=\"col-md-6\">\n <div class=\"mb-3\">\n <label for=\"date\" class=\"form-label\">Date<\/label>\n <input type=\"text\" class=\"form-control\" id=\"date\" value=\"{{ $date }}\" placeholder=\"today, yesterday, 2024-01-01, etc.\">\n <div class=\"form-text\">Use 'today', 'yesterday', or specific dates like '2024-01-01'<\/div>\n <\/div>\n <\/div>\n <\/div>\n \n <div class=\"row\" id=\"dateRangeRow\" style=\"display: {{ $period === 'range' ? 'block' : 'none' }};\">\n <div class=\"col-md-6\">\n <div class=\"mb-3\">\n <label for=\"dateFrom\" class=\"form-label\">From Date<\/label>\n <input type=\"date\" class=\"form-control\" id=\"dateFrom\">\n <\/div>\n <\/div>\n <div class=\"col-md-6\">\n <div class=\"mb-3\">\n <label for=\"dateTo\" class=\"form-label\">To Date<\/label>\n <input type=\"date\" class=\"form-control\" id=\"dateTo\">\n <\/div>\n <\/div>\n <\/div>\n \n <div class=\"row\">\n <div class=\"col-md-12\">\n <div class=\"mb-3\">\n <label for=\"segment\" class=\"form-label\">Segment (Optional)<\/label>\n <input type=\"text\" class=\"form-control\" id=\"segment\" value=\"{{ $segment }}\" placeholder=\"e.g., country==US;deviceType==desktop\">\n <div class=\"form-text\">Advanced: Matomo segment expression<\/div>\n <\/div>\n <\/div>\n <\/div>\n \n <div class=\"alert alert-info\">\n <i class=\"fas fa-info-circle me-2\"><\/i>\n <strong>Quick Presets:<\/strong>\n <div class=\"mt-2\">\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary me-2\" onclick=\"setPreset('today')\">Today<\/button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary me-2\" onclick=\"setPreset('yesterday')\">Yesterday<\/button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary me-2\" onclick=\"setPreset('last7days')\">Last 7 Days<\/button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary me-2\" onclick=\"setPreset('last30days')\">Last 30 Days<\/button>\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary\" onclick=\"setPreset('thisMonth')\">This Month<\/button>\n <\/div>\n <\/div>\n \n <div class=\"alert alert-secondary\">\n <i class=\"fas fa-link me-2\"><\/i>\n <strong>Matomo Configuration:<\/strong> Using your profile settings ({{ $matomoUrl }}, Site ID: {{ $siteId }})\n <br><small>To update your Matomo API settings, go to <a href=\"{{ route('profile.edit') }}\" class=\"alert-link\">Profile Settings<\/a><\/small>\n <\/div>\n <\/form>\n <\/div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel<\/button>\n <button type=\"button\" class=\"btn btn-primary\" onclick=\"applyPeriodConfig()\">\n <i class=\"fas fa-save me-1\"><\/i>Apply & Load Data\n <\/button>\n <\/div>\n <\/div>\n <\/div>\n<\/div>\n<\/div>\n@endsection\n\n@section('styles')\n<style>\n\/* Modern SaaS Dashboard Styles - API Graphics Page *\/\n.api-graphics-page {\n padding: 2rem 0;\n}\n\n\/* Stats Cards with Modern SaaS Style *\/\n.stats-card {\n background: var(--card-bg);\n border: 1px solid var(--border-color);\n border-radius: 16px;\n padding: 1.5rem;\n text-align: center;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n margin-bottom: 1rem;\n position: relative;\n overflow: hidden;\n}\n\n.stats-card::before {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n height: 3px;\n background: linear-gradient(90deg, #6B7280, #9CA3AF);\n transform: scaleX(0);\n transform-origin: left;\n transition: transform 0.3s ease;\n}\n\n.stats-card:hover {\n transform: translateY(-4px);\n border-color: rgba(156, 163, 175, 0.4);\n box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);\n}\n\n.stats-card:hover::before {\n transform: scaleX(1);\n}\n\n.stats-number {\n font-size: 2.5rem;\n font-weight: 700;\n margin-bottom: 0.5rem;\n}\n\n.stats-label {\n color: var(--text-muted);\n font-size: 0.9rem;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n\/* Modern Card Styles *\/\n.card {\n background: var(--card-bg);\n border: 1px solid var(--border-color);\n border-radius: 16px;\n margin-bottom: 2rem;\n transition: all 0.3s ease;\n overflow: hidden;\n}\n\n.card:hover {\n border-color: rgba(156, 163, 175, 0.4);\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);\n}\n\n\/* Remove background from outer wrapper cards that contain nested cards *\/\n.card-wrapper {\n background: transparent !important;\n border: none !important;\n box-shadow: none !important;\n}\n\n.card-wrapper:hover {\n border-color: transparent !important;\n box-shadow: none !important;\n transform: none;\n}\n\n\/* Keep nested cards with their backgrounds *\/\n.card .card {\n margin-bottom: 1.5rem;\n}\n\n.card .card:last-child {\n margin-bottom: 0;\n}\n\n\/* Adjust padding for wrapper cards that contain nested cards *\/\n.card > .row {\n padding: 0;\n margin: 0;\n}\n\n.card-header {\n background: rgba(156, 163, 175, 0.05);\n border-bottom: 1px solid var(--border-color);\n padding: 1.5rem;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.card-title {\n font-size: 1.125rem;\n font-weight: 600;\n color: var(--text-light);\n margin: 0;\n}\n\n.card-body {\n padding: 1.5rem;\n}\n\n\/* Responsive card adjustments *\/\n@media (max-width: 768px) {\n .card-body {\n padding: 1rem;\n }\n \n .card-header {\n padding: 1rem;\n }\n \n .card-header .d-flex {\n flex-direction: column;\n gap: 0.5rem;\n }\n \n .card-header .btn {\n width: 100%;\n font-size: 0.85rem;\n padding: 0.5rem;\n }\n}\n\n.table {\n color: var(--text-primary);\n}\n\n.table th {\n border-color: var(--border-color);\n color: var(--text-muted);\n font-weight: 600;\n text-transform: uppercase;\n font-size: 0.8rem;\n letter-spacing: 0.5px;\n}\n\n.table td {\n border-color: var(--border-color);\n vertical-align: middle;\n}\n\n.table-hover tbody tr:hover {\n background-color: var(--hover-bg);\n}\n\n.form-control, .form-select {\n background: var(--card-bg);\n border: 1px solid var(--border-color);\n color: var(--text-color);\n padding: 0.75rem 1rem;\n border-radius: 12px;\n font-size: 0.95rem;\n transition: all 0.3s ease;\n}\n\n.form-control:focus, .form-select:focus {\n background: var(--card-bg);\n border-color: rgba(156, 163, 175, 0.5);\n color: var(--text-color);\n box-shadow: 0 0 0 3px rgba(107, 114, 128, 0.1);\n outline: none;\n}\n\n.form-control::placeholder {\n color: var(--text-muted);\n}\n\n\/* Chart containers *\/\ncanvas {\n max-width: 100%;\n height: auto !important;\n}\n\n\/* Responsive chart containers *\/\n@media (max-width: 768px) {\n .col-lg-8 canvas,\n .col-lg-6 canvas,\n .col-lg-4 canvas {\n max-height: 250px !important;\n }\n \n .card-body {\n overflow-x: auto;\n }\n}\n\n\/* Modern Button Styles *\/\n.btn {\n border-radius: 12px;\n font-weight: 500;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n padding: 0.625rem 1.25rem;\n}\n\n.btn:hover {\n transform: translateY(-2px);\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);\n}\n\n.btn-primary {\n background: linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%);\n border: none;\n color: white;\n}\n\n.btn-primary:hover {\n background: linear-gradient(135deg, #9CA3AF 0%, #D1D5DB 100%);\n}\n\n.btn-outline-primary {\n border: 1px solid rgba(156, 163, 175, 0.3);\n color: #D1D5DB;\n background: transparent;\n}\n\n.btn-outline-primary:hover {\n background: rgba(156, 163, 175, 0.1);\n border-color: rgba(156, 163, 175, 0.5);\n color: #F3F4F6;\n}\n\n.btn-outline-success {\n border: 1px solid rgba(16, 185, 129, 0.3);\n color: #10B981;\n background: transparent;\n}\n\n.btn-outline-success:hover {\n background: rgba(16, 185, 129, 0.1);\n border-color: rgba(16, 185, 129, 0.5);\n}\n\n.btn-outline-danger {\n border: 1px solid rgba(239, 68, 68, 0.3);\n color: #EF4444;\n background: transparent;\n}\n\n.btn-outline-danger:hover {\n background: rgba(239, 68, 68, 0.1);\n border-color: rgba(239, 68, 68, 0.5);\n color: #FCA5A5;\n}\n\n\/* Modern Modal Styles *\/\n.modal-content {\n background: var(--card-bg);\n border: 1px solid var(--border-color);\n border-radius: 16px;\n box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);\n}\n\n.modal-header {\n background: rgba(156, 163, 175, 0.05);\n border-bottom: 1px solid var(--border-color);\n color: var(--text-light);\n padding: 1.5rem;\n}\n\n.modal-title {\n color: var(--text-light);\n font-weight: 600;\n font-size: 1.25rem;\n}\n\n.modal-body {\n color: var(--text-color);\n padding: 1.5rem;\n}\n\n.modal-footer {\n border-top: 1px solid var(--border-color);\n padding: 1.5rem;\n background: rgba(156, 163, 175, 0.05);\n}\n\n.btn-close {\n filter: invert(1);\n}\n\n\/* Transitions and Flow styles - Modern SaaS Style *\/\n.matomo-transitions-report,\n.matomo-users-flow {\n padding: 1.5rem;\n color: var(--text-color);\n \/* Background removed - already inside .card which has background *\/\n}\n\n.transitions-header,\n.flow-header {\n margin-bottom: 1.5rem;\n}\n\n.transitions-title,\n.flow-title {\n font-size: 1.25rem;\n font-weight: 600;\n color: var(--text-light);\n margin-bottom: 0.5rem;\n}\n\n.flow-description {\n color: var(--text-muted);\n font-size: 0.9rem;\n line-height: 1.6;\n margin-bottom: 1rem;\n}\n\n.transitions-controls,\n.flow-controls {\n display: flex;\n gap: 0.75rem;\n flex-wrap: wrap;\n}\n\n\/* Matomo-style Transitions - Enhanced CSS Variables *\/\n:root {\n --blue-50: #e6f7fe;\n --blue-100: #cfeefe;\n --blue-200: #a3d3f9;\n --blue-300: #7bbaf3;\n --blue-400: #5fa8ec;\n --blue-500: #058dc7;\n --blue-700: #046ba0;\n --beige-100: #f6f1e3;\n --beige-200: #efe7d2;\n --ink-800: #222;\n --ink-600: #4a4a4a;\n --ink-400: #6b7280;\n}\n\n\/* Matomo-style Transitions Container - Modern Style *\/\n.transitions-sankey-container {\n margin: 24px auto;\n width: 100%;\n max-width: 1280px;\n height: 640px;\n position: relative;\n background: rgba(156, 163, 175, 0.05);\n border: 1px solid var(--border-color);\n border-radius: 16px;\n overflow: hidden;\n}\n\n#sankeyChart {\n position: relative;\n width: 100%;\n height: 640px;\n}\n\n#sankeyChart svg {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n\/* Users Flow Sankey Container *\/\n.users-flow-sankey-container {\n margin: 24px auto;\n width: 100%;\n max-width: 100%;\n height: 600px; \/* Reduced from 900px *\/\n position: relative;\n background: rgba(156, 163, 175, 0.05);\n border: 1px solid var(--border-color);\n border-radius: 16px;\n overflow: hidden;\n user-select: none; \/* Prevent text selection while dragging *\/\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n}\n\n.flow-scroll-wrapper {\n width: 100%;\n height: 100%;\n overflow: hidden; \/* Remove scrollbars, use drag only *\/\n position: relative;\n}\n\n.flow-scroll-wrapper::-webkit-scrollbar {\n height: 8px;\n}\n\n.flow-scroll-wrapper::-webkit-scrollbar-track {\n background: rgba(156, 163, 175, 0.1);\n border-radius: 4px;\n}\n\n.flow-scroll-wrapper::-webkit-scrollbar-thumb {\n background: rgba(156, 163, 175, 0.3);\n border-radius: 4px;\n}\n\n.flow-scroll-wrapper::-webkit-scrollbar-thumb:hover {\n background: rgba(156, 163, 175, 0.5);\n}\n\n#usersFlowSankey {\n display: block;\n min-width: 100%;\n min-height: 600px; \/* Reduced from 900px *\/\n cursor: grab; \/* Show grab cursor to indicate draggable *\/\n}\n\n#usersFlowSankey:active {\n cursor: grabbing; \/* Show grabbing cursor while dragging *\/\n}\n\n.flow-diagram {\n width: 100%;\n overflow: hidden;\n}\n\n\/* Interaction column styling for scrolling *\/\n.interaction-column-group {\n cursor: pointer;\n transition: opacity 0.2s ease;\n}\n\n.interaction-column-group:hover {\n opacity: 0.9;\n}\n\n.interaction-bar {\n cursor: pointer;\n transition: opacity 0.2s ease;\n}\n\n.interaction-bar:hover {\n opacity: 0.8;\n}\n\n\/* Matomo-style Center Card *\/\n.center-card {\n position: absolute;\n left: 50%;\n top: 50%;\n transform: translate(-50%, -50%);\n width: 320px;\n background: var(--card-bg);\n border-radius: 8px;\n box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);\n overflow: hidden;\n z-index: 20;\n pointer-events: none;\n}\n\n.card-head {\n padding: 14px 16px;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.card-head a {\n color: var(--blue-500);\n text-decoration: none;\n font-weight: 600;\n font-size: 14px;\n font-family: \"Open Sans\", -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n}\n\n.card-body {\n padding: 14px 16px;\n font-size: 14px;\n color: var(--text-color);\n font-family: \"Open Sans\", -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n}\n\n.card-section-title {\n font-weight: 600;\n margin: 10px 0 6px;\n color: var(--text-color);\n font-size: 13px;\n}\n\n.card-body .muted {\n color: var(--text-muted);\n}\n\n.card-body .small {\n font-size: 12px;\n line-height: 1.6;\n margin: 2px 0;\n}\n\n\/* Tooltip *\/\n.tooltip {\n position: absolute;\n pointer-events: none;\n background: rgba(0, 0, 0, 0.8);\n color: #fff;\n padding: 6px 10px;\n font-size: 12px;\n border-radius: 4px;\n opacity: 0;\n transition: opacity 0.12s ease;\n z-index: 30;\n font-family: \"Open Sans\", -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n}\n\n\/* Node labels *\/\n.node-label {\n font-size: 13px;\n fill: var(--text-color);\n font-family: \"Open Sans\", -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n pointer-events: none;\n}\n\n\/* Percent badges *\/\n.pct-badge {\n fill: var(--card-bg);\n stroke: rgba(255, 255, 255, 0.2);\n stroke-width: 1;\n rx: 3;\n}\n\n.pct-text {\n font-size: 11px;\n fill: var(--text-muted);\n pointer-events: none;\n font-family: \"Open Sans\", -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n}\n\n.transitions-page-info {\n border: 1px solid rgba(255, 255, 255, 0.08);\n}\n\n\/* Traffic Details Modal *\/\n.traffic-details-modal {\n display: none;\n position: fixed;\n z-index: 10000;\n left: 0;\n top: 0;\n width: 100%;\n height: 100%;\n background-color: rgba(0, 0, 0, 0.7);\n animation: fadeIn 0.2s ease;\n}\n\n.traffic-details-modal.show {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.modal-content {\n background: var(--card-bg);\n border-radius: 12px;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);\n width: 90%;\n max-width: 600px;\n max-height: 80vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n animation: slideUp 0.3s ease;\n}\n\n.modal-header {\n padding: 20px 24px;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.modal-header h3 {\n margin: 0;\n color: var(--text-color);\n font-size: 1.2rem;\n font-weight: 600;\n font-family: \"Open Sans\", sans-serif;\n}\n\n.modal-close {\n background: none;\n border: none;\n color: var(--text-muted);\n font-size: 28px;\n cursor: pointer;\n padding: 0;\n width: 32px;\n height: 32px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 4px;\n transition: all 0.2s ease;\n}\n\n.modal-close:hover {\n background: rgba(255, 255, 255, 0.1);\n color: var(--text-color);\n}\n\n.modal-body {\n padding: 24px;\n overflow-y: auto;\n color: var(--text-color);\n font-family: \"Open Sans\", sans-serif;\n}\n\n.details-list {\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.details-item {\n padding: 12px 16px;\n margin-bottom: 8px;\n background: rgba(255, 255, 255, 0.05);\n border-radius: 8px;\n border-left: 3px solid var(--primary-color);\n display: flex;\n justify-content: space-between;\n align-items: center;\n transition: all 0.2s ease;\n}\n\n.details-item:hover {\n background: rgba(255, 255, 255, 0.08);\n transform: translateX(4px);\n}\n\n.details-item-label {\n flex: 1;\n color: var(--text-color);\n font-size: 14px;\n word-break: break-word;\n}\n\n.details-item-value {\n color: var(--primary-color);\n font-weight: 600;\n font-size: 14px;\n margin-left: 16px;\n}\n\n.details-item-url {\n color: var(--text-muted);\n font-size: 12px;\n margin-top: 4px;\n word-break: break-all;\n}\n\n.empty-details {\n text-align: center;\n padding: 40px 20px;\n color: var(--text-muted);\n}\n\n@keyframes fadeIn {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@keyframes slideUp {\n from {\n transform: translateY(20px);\n opacity: 0;\n }\n to {\n transform: translateY(0);\n opacity: 1;\n }\n}\n\n@media (max-width: 768px) {\n .matomo-transitions-report,\n .matomo-users-flow {\n padding: 1rem;\n font-size: 0.9rem;\n }\n \n .transitions-header,\n .flow-header {\n flex-direction: column;\n gap: 1rem;\n }\n \n .transitions-controls,\n .flow-controls {\n flex-direction: column;\n width: 100%;\n }\n \n .transitions-controls select,\n .flow-controls select {\n width: 100%;\n margin-bottom: 0.5rem;\n }\n \n \/* Download buttons responsive *\/\n .card-header .btn-sm {\n font-size: 0.75rem;\n padding: 0.25rem 0.5rem;\n }\n \n .card-header .btn-sm i {\n font-size: 0.7rem;\n }\n}\n\n.loading {\n opacity: 0.6;\n pointer-events: none;\n}\n\n.loading .card {\n position: relative;\n}\n\n.loading .card::after {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.1);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 10;\n}\n\n.loading .card::before {\n content: 'Loading...';\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n background: var(--card-bg);\n padding: 1rem 2rem;\n border-radius: 12px;\n border: 1px solid var(--border-color);\n z-index: 11;\n font-weight: 600;\n color: var(--text-light);\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);\n}\n\n.pulse {\n animation: pulse 2s infinite;\n}\n\n@keyframes pulse {\n 0% { opacity: 1; }\n 50% { opacity: 0.5; }\n 100% { opacity: 1; }\n}\n<\/style>\n@endsection\n\n@section('scripts')\n<script src=\"https:\/\/cdn.jsdelivr.net\/npm\/chart.js\"><\/script>\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/html2canvas\/1.4.1\/html2canvas.min.js\"><\/script>\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/jspdf\/2.5.1\/jspdf.umd.min.js\"><\/script>\n<script src=\"https:\/\/d3js.org\/d3.v7.min.js\"><\/script>\n<script src=\"https:\/\/cdn.jsdelivr.net\/npm\/d3-sankey@0.12.3\/dist\/d3-sankey.min.js\"><\/script>\n<script>\n\/\/ Global variables\nlet charts = {};\n\/\/ Use global Matomo configuration from user profile\nlet apiConfig = {\n url: '{{ $matomoUrl }}',\n token: '{{ $matomoToken }}',\n siteId: {{ $siteId }},\n period: '{{ $period }}',\n date: '{{ $date }}',\n segment: '{{ $segment }}',\n format: 'json', \/\/ Always JSON for using tools directly\n configured: true\n};\n\n\/\/ Matomo data - fetched server-side to avoid Cloudflare blocking\nlet matomoData = {!! json_encode($matomoData ?? [\n 'visits' => [],\n 'pageviews' => [],\n 'countries' => [],\n 'devices' => [],\n 'referrers' => [],\n 'goals' => [],\n 'topPages' => []\n]) !!};\n\n\/\/ Show notification function\nfunction showNotification(message, type = 'info') {\n \/\/ Create notification element\n const notification = document.createElement('div');\n notification.className = `alert alert-${type} alert-dismissible fade show`;\n notification.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;';\n notification.innerHTML = `\n ${message}\n <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"alert\"><\/button>\n `;\n \n document.body.appendChild(notification);\n \n \/\/ Auto-remove after 5 seconds\n setTimeout(() => {\n if (notification.parentNode) {\n notification.parentNode.removeChild(notification);\n }\n }, 5000);\n}\n\n\/\/ Show period configuration modal\nfunction showPeriodModal() {\n \/\/ Populate form with current values\n document.getElementById('period').value = apiConfig.period;\n document.getElementById('date').value = apiConfig.date;\n document.getElementById('segment').value = apiConfig.segment;\n \n \/\/ Show\/hide date range fields based on current period\n toggleDateRangeFields();\n \n \/\/ Show modal\n const modal = new bootstrap.Modal(document.getElementById('periodModal'));\n modal.show();\n}\n\n\/\/ Toggle date range fields based on period selection\nfunction toggleDateRangeFields() {\n const period = document.getElementById('period').value;\n const dateRangeRow = document.getElementById('dateRangeRow');\n const dateInput = document.getElementById('date');\n \n if (period === 'range') {\n dateRangeRow.style.display = 'block';\n dateInput.style.display = 'none';\n } else {\n dateRangeRow.style.display = 'none';\n dateInput.style.display = 'block';\n }\n}\n\n\/\/ Set preset configurations\nfunction setPreset(preset) {\n const periodSelect = document.getElementById('period');\n const dateInput = document.getElementById('date');\n const dateFromInput = document.getElementById('dateFrom');\n const dateToInput = document.getElementById('dateTo');\n \n switch(preset) {\n case 'today':\n periodSelect.value = 'day';\n dateInput.value = 'today';\n break;\n case 'yesterday':\n periodSelect.value = 'day';\n dateInput.value = 'yesterday';\n break;\n case 'last7days':\n periodSelect.value = 'range';\n const today = new Date();\n const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);\n dateFromInput.value = weekAgo.toISOString().split('T')[0];\n dateToInput.value = today.toISOString().split('T')[0];\n break;\n case 'last30days':\n periodSelect.value = 'range';\n const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);\n dateFromInput.value = thirtyDaysAgo.toISOString().split('T')[0];\n dateToInput.value = today.toISOString().split('T')[0];\n break;\n case 'thisMonth':\n periodSelect.value = 'month';\n dateInput.value = 'today';\n break;\n }\n \n toggleDateRangeFields();\n}\n\n\/\/ Apply period configuration and load data\nfunction applyPeriodConfig() {\n \/\/ Get form values (only period, date, segment - URL\/token\/siteId come from profile)\n const period = document.getElementById('period').value;\n const segment = document.getElementById('segment').value || '';\n \n let date;\n if (period === 'range') {\n const dateFrom = document.getElementById('dateFrom').value;\n const dateTo = document.getElementById('dateTo').value;\n if (!dateFrom || !dateTo) {\n showNotification('Please select both from and to dates for date range', 'error');\n return;\n }\n date = `${dateFrom},${dateTo}`;\n } else {\n date = document.getElementById('date').value;\n if (!date) {\n showNotification('Please enter a date', 'error');\n return;\n }\n }\n \n \/\/ Update global config (keep URL\/token\/siteId from profile)\n apiConfig.period = period;\n apiConfig.date = date;\n apiConfig.segment = segment;\n \/\/ Format is always JSON\n apiConfig.format = 'json';\n \n \/\/ Close modal\n const modal = bootstrap.Modal.getInstance(document.getElementById('periodModal'));\n modal.hide();\n \n \/\/ Load data with new period configuration\n loadMatomoDataWithConfig();\n}\n\n\/\/ Load Matomo data with current configuration\nfunction loadMatomoDataWithConfig() {\n try {\n showNotification('Loading data with new period configuration...', 'info');\n \n \/\/ Build URL parameters for the page reload (only period, date, segment - API config comes from profile)\n const params = new URLSearchParams();\n params.set('period', apiConfig.period);\n params.set('date', apiConfig.date);\n if (apiConfig.segment) {\n params.set('segment', apiConfig.segment);\n }\n \/\/ Format is always JSON, no need to pass it\n \n \/\/ Reload the page with new period parameters\n window.location.href = window.location.pathname + '?' + params.toString();\n \n } catch (error) {\n showNotification('Error loading data: ' + error.message, 'error');\n }\n}\n\n\/\/ Process Matomo data from API response\nfunction processMatomoData(data) {\n \/\/ Process visits summary\n if (data.visits_summary) {\n const summary = data.visits_summary;\n \n \/\/ Calculate totals from historical data if today's data is zero\n let totalVisits = summary.nb_visits || 0;\n let totalPageviews = summary.nb_actions || 0;\n let totalVisitors = summary.nb_visitors || 0;\n let bounceRate = summary.bounce_rate || '0%';\n \n \/\/ If today's data is zero, calculate from historical data\n if (totalVisits === 0 && data.visits && data.visits.length > 0) {\n totalVisits = data.visits.reduce((sum, visits) => sum + visits, 0);\n totalPageviews = data.pageviews.reduce((sum, pageviews) => sum + pageviews, 0);\n totalVisitors = Math.round(totalVisits * 0.8); \/\/ Estimate unique visitors\n bounceRate = 'N\/A'; \/\/ Can't calculate bounce rate from historical data\n }\n \n document.getElementById('totalVisits').textContent = totalVisits;\n document.getElementById('totalPageviews').textContent = totalPageviews;\n document.getElementById('uniqueVisitors').textContent = totalVisitors;\n document.getElementById('bounceRate').textContent = bounceRate;\n }\n}\n\n\/\/ Initialize dashboard\ndocument.addEventListener('DOMContentLoaded', function() {\n \n \n initializeCharts();\n \n \/\/ Load period configuration from URL parameters (API config comes from profile)\n loadPeriodConfigFromURL();\n \n \/\/ Then load data\n loadInitialData();\n initializeAPIGraphics(); \/\/ Initialize new API graphics\n \n \/\/ Add event listener for period dropdown\n const periodSelect = document.getElementById('period');\n if (periodSelect) {\n periodSelect.addEventListener('change', toggleDateRangeFields);\n }\n \n \/\/ Force update charts after a short delay to ensure data is loaded\n setTimeout(() => {\n updateAllCharts();\n }, 1000);\n});\n\n\/\/ Load period configuration from URL parameters (API config comes from profile)\nfunction loadPeriodConfigFromURL() {\n const urlParams = new URLSearchParams(window.location.search);\n \n \/\/ Only load period, date, and segment from URL - API config comes from user profile\n if (urlParams.has('period')) {\n apiConfig.period = urlParams.get('period');\n }\n if (urlParams.has('date')) {\n apiConfig.date = urlParams.get('date');\n }\n if (urlParams.has('segment')) {\n apiConfig.segment = urlParams.get('segment');\n }\n \/\/ Format is always JSON, no need to load from URL\n \n \/\/ Load transitions and user flow data with the loaded configuration\n if (apiConfig.period && apiConfig.date) {\n loadMatomoTransitions();\n loadMatomoUserFlow();\n }\n}\n\n\/\/ Initialize all charts\nfunction initializeCharts() {\n initializeVisitsChart();\n initializeCountriesChart();\n initializeTopPagesChart();\n initializeDevicesChart();\n initializeReferrersChart();\n initializeGoalsChart();\n}\n\n\/\/ Visits Chart\nfunction initializeVisitsChart() {\n const ctx = document.getElementById('visitsChart').getContext('2d');\n charts.visits = new Chart(ctx, {\n type: 'line',\n data: {\n labels: generateTimeLabels(30),\n datasets: [{\n label: 'Visits',\n data: [],\n borderColor: '#007bff',\n backgroundColor: 'rgba(0, 123, 255, 0.1)',\n tension: 0.4,\n fill: true\n }, {\n label: 'Unique Visitors',\n data: [],\n borderColor: '#28a745',\n backgroundColor: 'rgba(40, 167, 69, 0.1)',\n tension: 0.4,\n fill: false\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n scales: {\n y: {\n beginAtZero: true,\n grid: {\n color: 'rgba(255, 255, 255, 0.1)'\n }\n },\n x: {\n grid: {\n color: 'rgba(255, 255, 255, 0.1)'\n }\n }\n },\n plugins: {\n legend: {\n labels: {\n color: '#ffffff'\n }\n }\n }\n }\n });\n}\n\n\/\/ Countries Chart\nfunction initializeCountriesChart() {\n const ctx = document.getElementById('countriesChart').getContext('2d');\n charts.countries = new Chart(ctx, {\n type: 'doughnut',\n data: {\n labels: [],\n datasets: [{\n data: [],\n backgroundColor: [\n '#007bff', '#28a745', '#ffc107', '#dc3545', '#6f42c1',\n '#fd7e14', '#20c997', '#e83e8c', '#6c757d', '#17a2b8'\n ],\n borderWidth: 0\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n plugins: {\n legend: {\n position: 'bottom',\n labels: {\n color: '#ffffff',\n padding: 20\n }\n }\n }\n }\n });\n}\n\n\/\/ Top Pages Chart\nfunction initializeTopPagesChart() {\n const ctx = document.getElementById('topPagesChart').getContext('2d');\n charts.topPages = new Chart(ctx, {\n type: 'bar',\n data: {\n labels: [],\n datasets: [{\n label: 'Page Views',\n data: [],\n backgroundColor: 'rgba(0, 123, 255, 0.8)',\n borderColor: '#007bff',\n borderWidth: 1\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n indexAxis: 'y',\n scales: {\n x: {\n beginAtZero: true,\n grid: {\n color: 'rgba(255, 255, 255, 0.1)'\n }\n },\n y: {\n grid: {\n color: 'rgba(255, 255, 255, 0.1)'\n }\n }\n },\n plugins: {\n legend: {\n labels: {\n color: '#ffffff'\n }\n }\n }\n }\n });\n}\n\n\/\/ Devices Chart\nfunction initializeDevicesChart() {\n const ctx = document.getElementById('devicesChart').getContext('2d');\n charts.devices = new Chart(ctx, {\n type: 'pie',\n data: {\n labels: [],\n datasets: [{\n data: [],\n backgroundColor: [\n '#007bff', '#28a745', '#ffc107', '#dc3545', '#6f42c1'\n ],\n borderWidth: 0\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n plugins: {\n legend: {\n position: 'bottom',\n labels: {\n color: '#ffffff',\n padding: 20\n }\n }\n }\n }\n });\n}\n\n\/\/ Referrers Chart\nfunction initializeReferrersChart() {\n const ctx = document.getElementById('referrersChart').getContext('2d');\n charts.referrers = new Chart(ctx, {\n type: 'bar',\n data: {\n labels: [],\n datasets: [{\n label: 'Visits',\n data: [],\n backgroundColor: 'rgba(40, 167, 69, 0.8)',\n borderColor: '#28a745',\n borderWidth: 1\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n scales: {\n y: {\n beginAtZero: true,\n grid: {\n color: 'rgba(255, 255, 255, 0.1)'\n }\n },\n x: {\n grid: {\n color: 'rgba(255, 255, 255, 0.1)'\n }\n }\n },\n plugins: {\n legend: {\n labels: {\n color: '#ffffff'\n }\n }\n }\n }\n });\n}\n\n\/\/ Goals Chart\nfunction initializeGoalsChart() {\n const ctx = document.getElementById('goalsChart').getContext('2d');\n charts.goals = new Chart(ctx, {\n type: 'doughnut',\n data: {\n labels: [],\n datasets: [{\n data: [],\n backgroundColor: [\n '#28a745', '#ffc107', '#dc3545', '#6f42c1', '#fd7e14'\n ],\n borderWidth: 0\n }]\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n plugins: {\n legend: {\n position: 'bottom',\n labels: {\n color: '#ffffff',\n padding: 20\n }\n }\n }\n }\n });\n}\n\n\/\/ Note: API configuration is now managed globally through user profile\n\/\/ No separate configure function needed\n\n\/\/ Load initial data\nfunction loadInitialData() {\n \/\/ DO NOT load from localStorage - privacy concern\n \/\/ Configuration should come from URL parameters or form input only\n \n \/\/ Load real data from Matomo API\n loadMatomoData();\n}\n\n\/\/ Load data from Matomo API\nfunction loadMatomoData() {\n try {\n \/\/ Ensure matomoData exists and has proper structure\n if (!matomoData) {\n\n matomoData = {\n visits: [],\n pageviews: [],\n countries: [],\n countryVisits: [],\n devices: [],\n deviceVisits: [],\n referrers: [],\n referrerVisits: [],\n goals: [],\n goalConversions: [],\n topPages: [],\n pageVisits: []\n };\n }\n \n \/\/ Ensure all arrays exist\n matomoData.visits = matomoData.visits || [];\n matomoData.pageviews = matomoData.pageviews || [];\n matomoData.countries = matomoData.countries || [];\n matomoData.countryVisits = matomoData.countryVisits || [];\n matomoData.devices = matomoData.devices || [];\n matomoData.deviceVisits = matomoData.deviceVisits || [];\n matomoData.referrers = matomoData.referrers || [];\n matomoData.referrerVisits = matomoData.referrerVisits || [];\n matomoData.goals = matomoData.goals || [];\n matomoData.goalConversions = matomoData.goalConversions || [];\n matomoData.topPages = matomoData.topPages || [];\n matomoData.pageVisits = matomoData.pageVisits || [];\n \n showNotification('Loading data from Matomo...', 'info');\n \n \/\/ Show loading state\n document.body.classList.add('loading');\n \n \/\/ Debug: Log the matomoData to console\n\n \n \/\/ Data is already embedded in the page from server-side\n let totalVisits = 0;\n let totalPageviews = 0;\n let totalVisitors = 0;\n let bounceRate = '0%';\n \n if (matomoData && matomoData.visits_summary) {\n \/\/ Process visits summary\n const summary = matomoData.visits_summary;\n\n \n \/\/ Check if there's an error in the response\n if (summary.result === 'error') {\n\n showNotification('Matomo API Error: ' + summary.message, 'error');\n } else {\n \/\/ Check if summary is an array (historical data) or single object (today's data)\n if (Array.isArray(summary)) {\n \/\/ Historical data - calculate totals from all days\n totalVisits = summary.reduce((sum, day) => sum + (day.nb_visits || 0), 0);\n totalPageviews = summary.reduce((sum, day) => sum + (day.nb_actions || 0), 0);\n totalVisitors = summary.reduce((sum, day) => sum + (day.nb_uniq_visitors || 0), 0);\n \n \/\/ Calculate average bounce rate\n const daysWithData = summary.filter(day => day.nb_visits > 0);\n if (daysWithData.length > 0) {\n const totalBounceRate = daysWithData.reduce((sum, day) => {\n const rate = parseFloat(day.bounce_rate) || 0;\n return sum + rate;\n }, 0);\n bounceRate = Math.round(totalBounceRate \/ daysWithData.length) + '%';\n }\n \n showNotification('Historical Matomo data loaded successfully!', 'success');\n } else if (summary.nb_visits !== undefined) {\n \/\/ Single day data\n totalVisits = summary.nb_visits || 0;\n totalPageviews = summary.nb_actions || 0;\n totalVisitors = summary.nb_visitors || 0;\n bounceRate = summary.bounce_rate || '0%';\n \n if (totalVisits > 0) {\n showNotification('Real Matomo data loaded successfully!', 'success');\n } else {\n showNotification('No data for selected period', 'info');\n }\n }\n }\n }\n \n \/\/ Fallback: calculate from visits array if available\n if (totalVisits === 0 && matomoData.visits && matomoData.visits.length > 0) {\n totalVisits = matomoData.visits.reduce((sum, visits) => sum + (visits || 0), 0);\n totalPageviews = (matomoData.pageviews && matomoData.pageviews.length > 0) \n ? matomoData.pageviews.reduce((sum, pageviews) => sum + (pageviews || 0), 0)\n : 0;\n totalVisitors = Math.round(totalVisits * 0.8); \/\/ Estimate unique visitors\n bounceRate = 'N\/A';\n showNotification('Historical data loaded from visits array', 'info');\n }\n \n \/\/ Update stats displays\n document.getElementById('totalVisits').textContent = totalVisits;\n document.getElementById('totalPageviews').textContent = totalPageviews;\n document.getElementById('uniqueVisitors').textContent = totalVisitors;\n document.getElementById('bounceRate').textContent = bounceRate;\n \n \/\/ Update all charts - always call this even if data is empty\n updateAllCharts();\n \n \/\/ Remove loading state\n document.body.classList.remove('loading');\n \n } catch (error) {\n\n document.body.classList.remove('loading');\n showNotification('Error loading data: ' + error.message, 'error');\n }\n}\n\n\n\/\/ Update all charts\nfunction updateAllCharts() {\n \/\/ Update visits chart\n if (charts.visits) {\n const visitsData = matomoData.visits || [];\n const pageviewsData = matomoData.pageviews || [];\n \n \/\/ Ensure data arrays match label count\n const dataLength = Math.max(visitsData.length, pageviewsData.length, 30);\n const visitsArray = Array.isArray(visitsData) ? visitsData : [];\n const pageviewsArray = Array.isArray(pageviewsData) ? pageviewsData : [];\n \n \/\/ Generate labels if needed\n if (charts.visits.data.labels.length !== dataLength) {\n charts.visits.data.labels = generateTimeLabels(dataLength);\n }\n \n \/\/ Pad arrays if needed\n while (visitsArray.length < dataLength) {\n visitsArray.push(0);\n }\n while (pageviewsArray.length < dataLength) {\n pageviewsArray.push(0);\n }\n \n charts.visits.data.datasets[0].data = visitsArray.slice(0, dataLength);\n charts.visits.data.datasets[1].data = pageviewsArray.slice(0, dataLength);\n \n \/\/ Update chart labels if needed\n if (charts.visits.data.labels.length !== dataLength) {\n charts.visits.data.labels = generateTimeLabels(dataLength);\n }\n \n charts.visits.update();\n \n \/\/ If still no data, show warning\n if (visitsArray.every(v => v === 0) && pageviewsArray.every(p => p === 0)) {\n \/\/ No visits data available\n }\n }\n \n \/\/ Update countries chart\n if (charts.countries) {\n const countries = matomoData.countries || [];\n const countryVisits = matomoData.countryVisits || [];\n charts.countries.data.labels = countries;\n charts.countries.data.datasets[0].data = countryVisits;\n charts.countries.update();\n\n }\n \n \/\/ Update top pages chart\n if (charts.topPages) {\n const topPages = matomoData.topPages || [];\n const pageVisits = matomoData.pageVisits || [];\n charts.topPages.data.labels = topPages;\n charts.topPages.data.datasets[0].data = pageVisits;\n charts.topPages.update();\n\n }\n \n \/\/ Update devices chart\n if (charts.devices) {\n const devices = matomoData.devices || [];\n const deviceVisits = matomoData.deviceVisits || [];\n charts.devices.data.labels = devices;\n charts.devices.data.datasets[0].data = deviceVisits;\n charts.devices.update();\n\n }\n \n \/\/ Update referrers chart\n if (charts.referrers) {\n const referrers = matomoData.referrers || [];\n const referrerVisits = matomoData.referrerVisits || [];\n charts.referrers.data.labels = referrers;\n charts.referrers.data.datasets[0].data = referrerVisits;\n charts.referrers.update();\n\n }\n \n \/\/ Update goals chart\n if (charts.goals) {\n const goals = matomoData.goals || [];\n const goalConversions = matomoData.goalConversions || [];\n charts.goals.data.labels = goals;\n charts.goals.data.datasets[0].data = goalConversions;\n charts.goals.update();\n\n }\n}\n\n\n\/\/ Refresh data\nfunction refreshData() {\n loadMatomoData();\n}\n\n\/\/ Export data\nfunction exportData() {\n const data = {\n timestamp: new Date().toISOString(),\n config: apiConfig,\n data: matomoData\n };\n \n const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application\/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `matomo-dashboard-${new Date().toISOString().split('T')[0]}.json`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n \n showNotification('Data exported successfully!', 'success');\n}\n\n\/\/ Export dashboard as PDF\nasync function exportAsPDF() {\n try {\n showNotification('Generating PDF... This may take a moment.', 'info');\n \n const { jsPDF } = window.jspdf;\n const pdf = new jsPDF('p', 'mm', 'a4');\n const pageWidth = pdf.internal.pageSize.getWidth();\n const pageHeight = pdf.internal.pageSize.getHeight();\n const margin = 15;\n const contentWidth = pageWidth - (margin * 2);\n let yPosition = margin;\n \n \/\/ Helper function to add new page if needed\n const checkPageBreak = (requiredHeight) => {\n if (yPosition + requiredHeight > pageHeight - margin) {\n pdf.addPage();\n yPosition = margin;\n return true;\n }\n return false;\n };\n \n \/\/ Helper function to add text with styling\n const addText = (text, x, y, options = {}) => {\n pdf.setFontSize(options.fontSize || 12);\n const color = options.color || [0, 0, 0];\n if (Array.isArray(color) && color.length === 3) {\n pdf.setTextColor(color[0], color[1], color[2]);\n } else {\n pdf.setTextColor(0, 0, 0);\n }\n pdf.setFont(options.font || 'helvetica', options.style || 'normal');\n pdf.text(text, x, y);\n };\n \n \/\/ Helper function to add a line\n const addLine = (x1, y1, x2, y2, color = [200, 200, 200]) => {\n if (Array.isArray(color) && color.length === 3) {\n pdf.setDrawColor(color[0], color[1], color[2]);\n } else {\n pdf.setDrawColor(200, 200, 200);\n }\n pdf.setLineWidth(0.5);\n pdf.line(x1, y1, x2, y2);\n };\n \n \/\/ Title Page\n pdf.setFillColor(255, 255, 255);\n pdf.rect(0, 0, pageWidth, pageHeight, 'F');\n \n \/\/ Title\n pdf.setFontSize(28);\n pdf.setTextColor(33, 37, 41);\n pdf.setFont('helvetica', 'bold');\n pdf.text('Matomo Dashboard Graphics', pageWidth \/ 2, 40, { align: 'center' });\n \n \/\/ Subtitle\n pdf.setFontSize(14);\n pdf.setTextColor(108, 117, 125);\n pdf.setFont('helvetica', 'normal');\n pdf.text('Analytics Report', pageWidth \/ 2, 50, { align: 'center' });\n \n \/\/ Info box\n const infoY = 65;\n pdf.setFillColor(248, 249, 250);\n pdf.roundedRect(margin, infoY, pageWidth - (margin * 2), 25, 3, 3, 'F');\n \n pdf.setFontSize(11);\n pdf.setTextColor(33, 37, 41);\n const periodText = apiConfig.period ? apiConfig.period.charAt(0).toUpperCase() + apiConfig.period.slice(1) : 'N\/A';\n const dateText = apiConfig.date || 'N\/A';\n const siteIdText = apiConfig.siteId || 'N\/A';\n pdf.text(`Period: ${periodText}`, margin + 5, infoY + 8);\n pdf.text(`Date: ${dateText}`, margin + 5, infoY + 15);\n pdf.text(`Site ID: ${siteIdText}`, pageWidth - margin - 5, infoY + 8, { align: 'right' });\n pdf.text(`Generated: ${new Date().toLocaleString()}`, pageWidth - margin - 5, infoY + 15, { align: 'right' });\n \n yPosition = infoY + 35;\n \n \/\/ Export stats section first\n const statsCards = document.querySelectorAll('.stats-card');\n if (statsCards.length > 0) {\n checkPageBreak(40);\n addText('Overview Statistics', margin, yPosition, { fontSize: 16, style: 'bold' });\n yPosition += 8;\n addLine(margin, yPosition, pageWidth - margin, yPosition);\n yPosition += 10;\n \n const statsData = [];\n statsCards.forEach(card => {\n const number = card.querySelector('.stats-number')?.textContent || '0';\n const label = card.querySelector('.stats-label')?.textContent || '';\n statsData.push({ label, number });\n });\n \n if (statsData.length > 0) {\n const boxHeight = 25;\n const boxWidth = (contentWidth - 15) \/ 4;\n statsData.forEach((stat, index) => {\n if (index % 4 === 0 && index > 0) {\n checkPageBreak(boxHeight + 10);\n yPosition += boxHeight + 10;\n }\n \n const xPos = margin + (index % 4) * (boxWidth + 5);\n \n pdf.setFillColor(248, 249, 250);\n pdf.roundedRect(xPos, yPosition, boxWidth, boxHeight, 3, 3, 'F');\n \n pdf.setDrawColor(220, 220, 220);\n pdf.setLineWidth(0.5);\n pdf.roundedRect(xPos, yPosition, boxWidth, boxHeight, 3, 3);\n \n addText(stat.number, xPos + boxWidth \/ 2, yPosition + 10, { fontSize: 18, style: 'bold', color: [33, 37, 41] });\n addText(stat.label, xPos + boxWidth \/ 2, yPosition + 18, { fontSize: 9, color: [108, 117, 125] });\n });\n yPosition += 35;\n }\n }\n \n \/\/ Get all chart sections\n const sections = [\n { id: 'visitsChartSection', title: 'Visits Over Time', type: 'chart' },\n { id: 'countriesChartSection', title: 'Visitor Countries', type: 'chart' },\n { id: 'topPagesSection', title: 'Top Pages', type: 'chart' },\n { id: 'devicesSection', title: 'Device Types', type: 'chart' },\n { id: 'referrersSection', title: 'Top Referrers', type: 'chart' },\n { id: 'goalsSection', title: 'Goal Conversions', type: 'chart' },\n { id: 'transitionsSection', title: 'Transitions', type: 'section' },\n { id: 'usersFlowSection', title: 'Users Flow', type: 'section' }\n ];\n \n \/\/ Export charts\n for (const section of sections) {\n if (section.type === 'chart') {\n const chartElement = document.getElementById(section.id);\n if (!chartElement) continue;\n \n const canvas = chartElement.querySelector('canvas');\n if (!canvas) continue;\n \n checkPageBreak(60);\n \n \/\/ Section title\n addText(section.title, margin, yPosition, { fontSize: 16, style: 'bold' });\n yPosition += 8;\n addLine(margin, yPosition, pageWidth - margin, yPosition);\n yPosition += 10;\n \n \/\/ Get chart instance - we'll need to update chart colors for PDF\n let chartInstance = null;\n const chartId = canvas.id;\n if (chartId === 'visitsChart') chartInstance = charts.visits;\n else if (chartId === 'countriesChart') chartInstance = charts.countries;\n else if (chartId === 'topPagesChart') chartInstance = charts.topPages;\n else if (chartId === 'devicesChart') chartInstance = charts.devices;\n else if (chartId === 'referrersChart') chartInstance = charts.referrers;\n else if (chartId === 'goalsChart') chartInstance = charts.goals;\n \n \/\/ Store original chart options if we need to restore them\n let originalOptions = null;\n if (chartInstance && chartInstance.options) {\n originalOptions = JSON.parse(JSON.stringify(chartInstance.options));\n \n \/\/ Update chart colors to use black text for PDF\n if (chartInstance.options.plugins && chartInstance.options.plugins.legend) {\n chartInstance.options.plugins.legend.labels.color = '#000000';\n }\n if (chartInstance.options.scales) {\n Object.keys(chartInstance.options.scales).forEach(scaleKey => {\n if (chartInstance.options.scales[scaleKey].ticks) {\n chartInstance.options.scales[scaleKey].ticks.color = '#000000';\n }\n if (chartInstance.options.scales[scaleKey].title) {\n chartInstance.options.scales[scaleKey].title.color = '#000000';\n }\n });\n }\n chartInstance.update('none'); \/\/ Update without animation\n }\n \n \/\/ Wait for chart to update\n await new Promise(resolve => setTimeout(resolve, 200));\n \n \/\/ Create a temporary canvas with white background for the chart\n const tempCanvas = document.createElement('canvas');\n tempCanvas.width = canvas.width;\n tempCanvas.height = canvas.height;\n const tempCtx = tempCanvas.getContext('2d');\n \n \/\/ Fill with white background\n tempCtx.fillStyle = '#ffffff';\n tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);\n \n \/\/ Draw the original chart on top\n tempCtx.drawImage(canvas, 0, 0);\n \n \/\/ Restore original chart options\n if (chartInstance && originalOptions) {\n chartInstance.options = originalOptions;\n chartInstance.update('none');\n }\n \n \/\/ Get chart as image with white background\n const chartImage = tempCanvas.toDataURL('image\/png', 1.0);\n const imgWidth = contentWidth;\n const imgHeight = (canvas.height * imgWidth) \/ canvas.width;\n \n \/\/ Limit chart height to fit on page\n const maxHeight = pageHeight - yPosition - margin;\n const finalHeight = Math.min(imgHeight, maxHeight);\n const finalWidth = (canvas.width * finalHeight) \/ canvas.height;\n \n pdf.addImage(chartImage, 'PNG', margin + (contentWidth - finalWidth) \/ 2, yPosition, finalWidth, finalHeight);\n yPosition += finalHeight + 15;\n } else if (section.type === 'section') {\n const sectionElement = document.getElementById(section.id);\n if (!sectionElement) continue;\n \n checkPageBreak(80);\n \n \/\/ Section title\n addText(section.title, margin, yPosition, { fontSize: 16, style: 'bold' });\n yPosition += 8;\n addLine(margin, yPosition, pageWidth - margin, yPosition);\n yPosition += 10;\n \n \/\/ Capture section as image with transparent background for transitions\/users flow\n const isTransparentSection = section.id === 'transitionsSection' || section.id === 'usersFlowSection';\n \n \/\/ Hide ALL header elements for PDF export using multiple methods\n const cardHeader = sectionElement.querySelector('.card-header');\n let headerStyles = {};\n if (cardHeader) {\n headerStyles.display = cardHeader.style.display;\n headerStyles.visibility = cardHeader.style.visibility;\n headerStyles.opacity = cardHeader.style.opacity;\n headerStyles.height = cardHeader.style.height;\n cardHeader.style.display = 'none';\n cardHeader.style.visibility = 'hidden';\n cardHeader.style.opacity = '0';\n cardHeader.style.height = '0';\n cardHeader.style.overflow = 'hidden';\n }\n \n \/\/ Hide transitions\/flow controls (dropdowns) for PDF export\n const transitionsHeader = sectionElement.querySelector('.transitions-header, .flow-header');\n let headerControlsStyles = {};\n if (transitionsHeader) {\n headerControlsStyles.display = transitionsHeader.style.display;\n headerControlsStyles.visibility = transitionsHeader.style.visibility;\n headerControlsStyles.opacity = transitionsHeader.style.opacity;\n headerControlsStyles.height = transitionsHeader.style.height;\n transitionsHeader.style.display = 'none';\n transitionsHeader.style.visibility = 'hidden';\n transitionsHeader.style.opacity = '0';\n transitionsHeader.style.height = '0';\n transitionsHeader.style.overflow = 'hidden';\n }\n \n \/\/ Also hide transitions-title and flow-title if they exist separately\n const transitionsTitle = sectionElement.querySelector('.transitions-title, .flow-title');\n let titleStyles = {};\n if (transitionsTitle) {\n titleStyles.display = transitionsTitle.style.display;\n titleStyles.visibility = transitionsTitle.style.visibility;\n titleStyles.opacity = transitionsTitle.style.opacity;\n transitionsTitle.style.display = 'none';\n transitionsTitle.style.visibility = 'hidden';\n transitionsTitle.style.opacity = '0';\n }\n \n \/\/ Hide any button elements (like Download Image button)\n const downloadButtons = sectionElement.querySelectorAll('button[onclick*=\"download\"], button[title*=\"Download\"], .btn[title*=\"Download\"], button .fa-download');\n const buttonStyles = [];\n downloadButtons.forEach((btn, index) => {\n buttonStyles[index] = {\n display: btn.style.display,\n visibility: btn.style.visibility,\n opacity: btn.style.opacity\n };\n btn.style.display = 'none';\n btn.style.visibility = 'hidden';\n btn.style.opacity = '0';\n });\n \n \/\/ Temporarily change text colors to black for PDF export (but not SVG paths\/rectangles)\n const styleElement = document.createElement('style');\n styleElement.id = 'pdf-export-styles';\n styleElement.textContent = `\n \/* Change text colors to black, but completely exclude SVG elements *\/\n #${section.id} *:not(svg):not(svg *):not(svg):not(path):not(rect):not(circle):not(polygon):not(line):not(g) {\n color: #000000 !important;\n }\n #${section.id} .text-light:not(svg *),\n #${section.id} .text-white:not(svg *),\n #${section.id} [class*=\"text-light\"]:not(svg *),\n #${section.id} [class*=\"text-white\"]:not(svg *) {\n color: #000000 !important;\n }\n \/* Only change text labels to black, not the diagram paths or shapes *\/\n #${section.id} svg text,\n #${section.id} svg .node-label,\n #${section.id} svg .pct-text,\n #${section.id} svg tspan {\n fill: #000000 !important;\n color: #000000 !important;\n }\n \/* DO NOT override SVG shapes - let them keep their original colors *\/\n #${section.id} svg rect,\n #${section.id} svg path,\n #${section.id} svg circle,\n #${section.id} svg polygon,\n #${section.id} svg line,\n #${section.id} svg g {\n \/* Do not set fill or stroke here - let inline styles\/attributes work *\/\n }\n \/* Keep all SVG shapes (paths, rects, circles, polygons, lines) with their original colors - don't override *\/\n #${section.id} svg path,\n #${section.id} svg rect,\n #${section.id} svg circle,\n #${section.id} svg polygon,\n #${section.id} svg line,\n #${section.id} svg g[class*=\"node\"],\n #${section.id} svg g[class*=\"link\"],\n #${section.id} svg .sankey-node,\n #${section.id} svg .sankey-link {\n fill: unset !important;\n stroke: unset !important;\n }\n \/* Ensure transitions diagram paths and nodes keep their colors *\/\n #${section.id} .transitions-sankey-container svg path,\n #${section.id} .transitions-sankey-container svg rect,\n #${section.id} .transitions-sankey-container svg circle,\n #${section.id} .transitions-sankey-container svg g {\n fill: unset !important;\n stroke: unset !important;\n }\n \/* DO NOT override Users Flow SVG colors - let inline styles from JavaScript handle it *\/\n #${section.id} .users-flow-sankey-container svg rect,\n #${section.id} .users-flow-sankey-container svg path {\n \/* Colors will be set via inline styles before capture *\/\n }\n \/* Keep center card background and text readable *\/\n #${section.id} .center-card {\n background: #ffffff !important;\n border: 1px solid #cccccc !important;\n }\n #${section.id} .center-card * {\n color: #000000 !important;\n }\n \/* Force hide headers and controls *\/\n #${section.id} .card-header,\n #${section.id} .transitions-header,\n #${section.id} .flow-header,\n #${section.id} .transitions-title,\n #${section.id} .flow-title,\n #${section.id} button[onclick*=\"download\"],\n #${section.id} button[title*=\"Download\"] {\n display: none !important;\n visibility: hidden !important;\n opacity: 0 !important;\n height: 0 !important;\n overflow: hidden !important;\n margin: 0 !important;\n padding: 0 !important;\n }\n \/* Hide center card that obscures the diagram *\/\n #${section.id} .center-card,\n #${section.id} #centerCard {\n display: none !important;\n visibility: hidden !important;\n opacity: 0 !important;\n }\n `;\n document.head.appendChild(styleElement);\n \n \/\/ For transparent sections, we need to temporarily modify styles to remove background\n let originalStyles = {};\n if (isTransparentSection) {\n const element = sectionElement;\n originalStyles.backgroundColor = element.style.backgroundColor;\n originalStyles.background = element.style.background;\n element.style.backgroundColor = 'transparent';\n element.style.background = 'none';\n }\n \n \/\/ For transitions and users flow, increase the size MASSIVELY for better visibility in PDF\n let originalHeight = null;\n let originalWidth = null;\n let originalSvgHeight = null;\n let originalSvgWidth = null;\n if (isTransparentSection) {\n const sankeyContainer = sectionElement.querySelector('.transitions-sankey-container, .flow-container');\n if (sankeyContainer) {\n originalHeight = sankeyContainer.style.height;\n originalWidth = sankeyContainer.style.width;\n \/\/ Set MASSIVE height for PDF - much larger than page\n sankeyContainer.style.height = '2000px'; \/\/ MUCH larger height\n sankeyContainer.style.width = '100%';\n sankeyContainer.style.minHeight = '2000px';\n \n const svg = sectionElement.querySelector('svg');\n if (svg) {\n originalSvgHeight = svg.getAttribute('height');\n originalSvgWidth = svg.getAttribute('width');\n \/\/ Set SVG to MASSIVE size\n const svgWidth = Math.max(sectionElement.scrollWidth || 2000, 2000);\n svg.setAttribute('height', '2000');\n svg.setAttribute('width', svgWidth);\n svg.style.width = '100%';\n svg.style.height = '2000px';\n svg.style.minHeight = '2000px';\n \n \/\/ Also update the #sankeyChart container\n const sankeyChart = sectionElement.querySelector('#sankeyChart');\n if (sankeyChart) {\n sankeyChart.style.height = '2000px';\n sankeyChart.style.minHeight = '2000px';\n }\n }\n }\n }\n \n \/\/ Wait longer for styles and SVG to render\n await new Promise(resolve => setTimeout(resolve, 800));\n \n \/\/ For Users Flow, explicitly set colors as inline styles before capture\n if (isTransparentSection && sectionElement.querySelector('.users-flow-sankey-container')) {\n const svg = sectionElement.querySelector('.users-flow-sankey-container svg');\n if (svg) {\n \/\/ Find all rects (bars) and explicitly set green for visit bars, red for exit bars\n const rects = svg.querySelectorAll('rect');\n rects.forEach(rect => {\n const fill = rect.getAttribute('fill');\n const stroke = rect.getAttribute('stroke');\n \n \/\/ Green bars (visit bars) - #4CAF50\n if (fill === '#4CAF50' || fill === '#22c55e' || fill === '#10b981' || fill === 'green') {\n rect.style.setProperty('fill', '#4CAF50', 'important');\n rect.style.setProperty('stroke', '#2E7D32', 'important');\n }\n \/\/ Red bars (exit bars) - #f44336 or #E53935\n else if (fill === '#f44336' || fill === '#E53935' || fill === '#ef4444' || fill === '#dc2626' || fill === 'red') {\n rect.style.setProperty('fill', '#f44336', 'important');\n }\n \/\/ For any other fill, preserve it\n else if (fill && fill !== 'none' && fill !== 'transparent') {\n rect.style.setProperty('fill', fill, 'important');\n }\n \n if (stroke && stroke !== 'none' && stroke !== 'transparent') {\n rect.style.setProperty('stroke', stroke, 'important');\n }\n });\n \n \/\/ Find all paths (flow lines) and explicitly set light blue\n const paths = svg.querySelectorAll('path');\n paths.forEach(path => {\n const stroke = path.getAttribute('stroke');\n \n \/\/ Light blue flow lines - #add8e6\n if (stroke === '#add8e6' || !stroke) {\n path.style.setProperty('stroke', '#add8e6', 'important');\n path.style.setProperty('fill', 'none', 'important');\n path.style.setProperty('opacity', '0.6', 'important');\n }\n else if (stroke && stroke !== 'none' && stroke !== 'transparent') {\n path.style.setProperty('stroke', stroke, 'important');\n path.style.setProperty('fill', 'none', 'important');\n }\n });\n \n \/\/ Force a repaint\n svg.style.display = 'none';\n svg.offsetHeight; \/\/ Trigger reflow\n svg.style.display = '';\n \n \/\/ Wait for browser to apply styles\n await new Promise(resolve => setTimeout(resolve, 200));\n }\n }\n \n \/\/ For Transitions, explicitly set colors as inline styles before capture\n if (isTransparentSection && sectionElement.querySelector('.transitions-sankey-container')) {\n const svg = sectionElement.querySelector('.transitions-sankey-container svg, #sankeyChart svg, #viz');\n if (svg) {\n \/\/ Find all paths (links) and preserve their stroke colors\n const paths = svg.querySelectorAll('path');\n paths.forEach(path => {\n const stroke = path.getAttribute('stroke');\n const fill = path.getAttribute('fill');\n \n \/\/ Preserve stroke color for links\n if (stroke && stroke !== 'none' && stroke !== 'transparent' && stroke !== 'rgba(255, 255, 255, 0.2)') {\n path.style.setProperty('stroke', stroke, 'important');\n }\n \/\/ Set fill to none for paths\n if (fill === 'none' || !fill) {\n path.style.setProperty('fill', 'none', 'important');\n } else if (fill && fill !== 'transparent') {\n path.style.setProperty('fill', fill, 'important');\n }\n });\n \n \/\/ Find all rects (nodes) and preserve their fill colors\n const rects = svg.querySelectorAll('rect');\n rects.forEach(rect => {\n const fill = rect.getAttribute('fill');\n const stroke = rect.getAttribute('stroke');\n \n \/\/ Preserve fill color - don't override if it has a color\n if (fill && fill !== 'none' && fill !== 'transparent' && !fill.includes('url(')) {\n rect.style.setProperty('fill', fill, 'important');\n }\n \n \/\/ Preserve stroke color\n if (stroke && stroke !== 'none' && stroke !== 'transparent') {\n rect.style.setProperty('stroke', stroke, 'important');\n }\n });\n \n \/\/ Find all circles and preserve their colors\n const circles = svg.querySelectorAll('circle');\n circles.forEach(circle => {\n const fill = circle.getAttribute('fill');\n const stroke = circle.getAttribute('stroke');\n \n if (fill && fill !== 'none' && fill !== 'transparent') {\n circle.style.setProperty('fill', fill, 'important');\n }\n if (stroke && stroke !== 'none' && stroke !== 'transparent') {\n circle.style.setProperty('stroke', stroke, 'important');\n }\n });\n \n \/\/ Force a repaint\n svg.style.display = 'none';\n svg.offsetHeight; \/\/ Trigger reflow\n svg.style.display = '';\n \n \/\/ Wait for browser to apply styles\n await new Promise(resolve => setTimeout(resolve, 200));\n }\n }\n \n const canvas = await html2canvas(sectionElement, {\n backgroundColor: isTransparentSection ? null : '#ffffff',\n scale: 2.5, \/\/ Higher scale for better quality\n logging: false,\n useCORS: true,\n allowTaint: false,\n width: sectionElement.scrollWidth,\n height: sectionElement.scrollHeight,\n windowWidth: sectionElement.scrollWidth || 2000,\n windowHeight: sectionElement.scrollHeight || 2000\n });\n \n \/\/ Remove temporary style\n const tempStyle = document.getElementById('pdf-export-styles');\n if (tempStyle) {\n tempStyle.remove();\n }\n \n \/\/ Restore card header\n if (cardHeader) {\n cardHeader.style.display = headerStyles.display || '';\n cardHeader.style.visibility = headerStyles.visibility || '';\n cardHeader.style.opacity = headerStyles.opacity || '';\n cardHeader.style.height = headerStyles.height || '';\n cardHeader.style.overflow = '';\n }\n \n \/\/ Restore transitions\/flow controls\n if (transitionsHeader) {\n transitionsHeader.style.display = headerControlsStyles.display || '';\n transitionsHeader.style.visibility = headerControlsStyles.visibility || '';\n transitionsHeader.style.opacity = headerControlsStyles.opacity || '';\n transitionsHeader.style.height = headerControlsStyles.height || '';\n transitionsHeader.style.overflow = '';\n }\n \n \/\/ Restore title\n if (transitionsTitle) {\n transitionsTitle.style.display = titleStyles.display || '';\n transitionsTitle.style.visibility = titleStyles.visibility || '';\n transitionsTitle.style.opacity = titleStyles.opacity || '';\n }\n \n \/\/ Restore buttons\n downloadButtons.forEach((btn, index) => {\n if (buttonStyles[index]) {\n btn.style.display = buttonStyles[index].display || '';\n btn.style.visibility = buttonStyles[index].visibility || '';\n btn.style.opacity = buttonStyles[index].opacity || '';\n }\n });\n \n \/\/ Restore original dimensions\n if (isTransparentSection && originalHeight !== null) {\n const sankeyContainer = sectionElement.querySelector('.transitions-sankey-container, .flow-container');\n if (sankeyContainer) {\n if (originalHeight) {\n sankeyContainer.style.height = originalHeight;\n } else {\n sankeyContainer.style.height = '';\n }\n if (originalWidth) {\n sankeyContainer.style.width = originalWidth;\n } else {\n sankeyContainer.style.width = '';\n }\n const svg = sectionElement.querySelector('svg');\n if (svg) {\n if (originalSvgHeight) {\n svg.setAttribute('height', originalSvgHeight);\n } else {\n svg.setAttribute('height', '640');\n }\n if (originalSvgWidth) {\n svg.setAttribute('width', originalSvgWidth);\n } else {\n svg.setAttribute('width', '1280');\n }\n svg.style.width = '';\n svg.style.height = '';\n svg.style.minHeight = '';\n }\n \n \/\/ Restore sankeyChart container\n const sankeyChart = sectionElement.querySelector('#sankeyChart');\n if (sankeyChart) {\n sankeyChart.style.height = '';\n sankeyChart.style.minHeight = '';\n }\n }\n }\n \n \/\/ Restore original styles\n if (isTransparentSection) {\n const element = sectionElement;\n if (originalStyles.backgroundColor) {\n element.style.backgroundColor = originalStyles.backgroundColor;\n } else {\n element.style.backgroundColor = '';\n }\n if (originalStyles.background) {\n element.style.background = originalStyles.background;\n } else {\n element.style.background = '';\n }\n }\n \n const imgData = canvas.toDataURL('image\/png');\n const imgWidth = contentWidth;\n const imgHeight = (canvas.height * imgWidth) \/ canvas.width;\n \n \/\/ For transitions and users flow, use full page width and allow it to span multiple pages\n if (isTransparentSection) {\n \/\/ Use full content width for better visibility\n const fullWidth = contentWidth;\n const fullHeight = (canvas.height * fullWidth) \/ canvas.width;\n \n \/\/ Check if it fits on one page\n const availableHeight = pageHeight - yPosition - margin - 20; \/\/ Leave space for footer\n \n if (fullHeight <= availableHeight) {\n \/\/ Fits on one page - use full width\n pdf.addImage(imgData, 'PNG', margin, yPosition, fullWidth, fullHeight);\n yPosition += fullHeight + 15;\n } else {\n \/\/ Split across multiple pages\n let remainingHeight = fullHeight;\n let sourceY = 0;\n let currentY = yPosition;\n \n while (remainingHeight > 0) {\n const pageHeightAvailable = pageHeight - currentY - margin - 20;\n const heightToUse = Math.min(remainingHeight, pageHeightAvailable);\n const sourceHeight = (heightToUse \/ fullHeight) * canvas.height;\n \n \/\/ Create a temporary canvas for this page portion\n const tempCanvas = document.createElement('canvas');\n tempCanvas.width = canvas.width;\n tempCanvas.height = sourceHeight;\n const tempCtx = tempCanvas.getContext('2d');\n tempCtx.drawImage(canvas, 0, sourceY, canvas.width, sourceHeight, 0, 0, canvas.width, sourceHeight);\n const pageImageData = tempCanvas.toDataURL('image\/png');\n \n pdf.addImage(pageImageData, 'PNG', margin, currentY, fullWidth, heightToUse);\n \n sourceY += sourceHeight;\n remainingHeight -= heightToUse;\n \n if (remainingHeight > 0) {\n pdf.addPage();\n currentY = margin;\n } else {\n yPosition = currentY + heightToUse + 15;\n }\n }\n }\n } else {\n \/\/ For other sections, use normal sizing\n const maxHeight = pageHeight - yPosition - margin;\n const finalHeight = Math.min(imgHeight, maxHeight);\n const finalWidth = (canvas.width * finalHeight) \/ canvas.height;\n \n pdf.addImage(imgData, 'PNG', margin + (contentWidth - finalWidth) \/ 2, yPosition, finalWidth, finalHeight);\n yPosition += finalHeight + 15;\n }\n }\n }\n \n \/\/ Add footer to all pages\n const pageCount = pdf.internal.getNumberOfPages();\n for (let i = 1; i <= pageCount; i++) {\n pdf.setPage(i);\n pdf.setFontSize(9);\n pdf.setTextColor(108, 117, 125);\n pdf.setFont('helvetica', 'normal');\n pdf.text(`Page ${i} of ${pageCount}`, pageWidth \/ 2, pageHeight - 5, { align: 'center' });\n pdf.text('Matomo Tools - Dashboard Graphics', margin, pageHeight - 5);\n pdf.text(new Date().toLocaleDateString(), pageWidth - margin, pageHeight - 5, { align: 'right' });\n }\n \n \/\/ Save PDF\n const filename = `matomo-dashboard-${new Date().toISOString().split('T')[0]}.pdf`;\n pdf.save(filename);\n \n showNotification('PDF exported successfully!', 'success');\n \n } catch (error) {\n\n showNotification('Error generating PDF: ' + error.message, 'error');\n }\n}\n\n\/\/ Toggle fullscreen\nfunction toggleFullscreen() {\n const card = document.querySelector('.card');\n card.classList.toggle('fullscreen-mode');\n \n \/\/ Update button text\n const button = event.target;\n const icon = button.querySelector('i');\n const text = button.querySelector('span') || button.childNodes[2];\n \n if (card.classList.contains('fullscreen-mode')) {\n icon.className = 'fas fa-compress me-1';\n text.textContent = 'Exit Fullscreen';\n } else {\n icon.className = 'fas fa-expand me-1';\n text.textContent = 'Fullscreen';\n }\n \n \/\/ Resize charts\n Object.values(charts).forEach(chart => {\n if (chart && chart.resize) {\n chart.resize();\n }\n });\n}\n\n\/\/ Generate time labels\nfunction generateTimeLabels(days) {\n const labels = [];\n const today = new Date();\n \n for (let i = days - 1; i >= 0; i--) {\n const date = new Date(today);\n date.setDate(date.getDate() - i);\n labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));\n }\n \n return labels;\n}\n\n\n\/\/ API Graphics Interactive Functions\nlet flowAnimationInterval;\nlet currentFlowStep = 0;\n\n\/\/ Flow diagram control functions\nfunction playFlow() {\n if (flowAnimationInterval) {\n clearInterval(flowAnimationInterval);\n }\n \n const nodes = document.querySelectorAll('.flow-node');\n const connections = document.querySelectorAll('.flow-connection');\n \n \/\/ Reset all nodes\n nodes.forEach(node => {\n node.classList.remove('active', 'completed');\n });\n \n connections.forEach(conn => {\n conn.classList.remove('active', 'completed');\n });\n \n currentFlowStep = 0;\n \n flowAnimationInterval = setInterval(() => {\n if (currentFlowStep < nodes.length) {\n \/\/ Activate current node\n nodes[currentFlowStep].classList.add('active');\n \n \/\/ Activate previous connection\n if (currentFlowStep > 0) {\n connections[currentFlowStep - 1].classList.add('active');\n }\n \n currentFlowStep++;\n } else {\n \/\/ Complete all nodes and connections\n nodes.forEach(node => {\n node.classList.remove('active');\n node.classList.add('completed');\n });\n \n connections.forEach(conn => {\n conn.classList.remove('active');\n conn.classList.add('completed');\n });\n \n clearInterval(flowAnimationInterval);\n }\n }, 1000);\n}\n\nfunction pauseFlow() {\n if (flowAnimationInterval) {\n clearInterval(flowAnimationInterval);\n }\n}\n\nfunction resetFlow() {\n if (flowAnimationInterval) {\n clearInterval(flowAnimationInterval);\n }\n \n const nodes = document.querySelectorAll('.flow-node');\n const connections = document.querySelectorAll('.flow-connection');\n \n nodes.forEach(node => {\n node.classList.remove('active', 'completed');\n });\n \n connections.forEach(conn => {\n conn.classList.remove('active', 'completed');\n });\n \n currentFlowStep = 0;\n}\n\nfunction selectNode(node) {\n \/\/ Remove active class from all nodes\n document.querySelectorAll('.flow-node').forEach(n => {\n n.classList.remove('active');\n });\n \n \/\/ Add active class to clicked node\n node.classList.add('active');\n}\n\n\/\/ API Status Animation\nfunction animateAPIStatus() {\n const indicators = document.querySelectorAll('.api-status-indicator');\n \n indicators.forEach((indicator, index) => {\n setTimeout(() => {\n indicator.style.animation = 'none';\n setTimeout(() => {\n indicator.style.animation = 'pulse 2s infinite';\n }, 100);\n }, index * 200);\n });\n}\n\n\/\/ Update API metrics with realistic data\nfunction updateAPIMetrics() {\n const metrics = {\n apiResponseTime: Math.floor(Math.random() * 50) + 20 + 'ms',\n apiUptime: (99.5 + Math.random() * 0.4).toFixed(1) + '%',\n dbConnections: Math.floor(Math.random() * 20) + 5,\n dbQueries: (Math.random() * 2 + 0.5).toFixed(1) + 'k',\n cacheHitRate: Math.floor(Math.random() * 20) + 80 + '%',\n cacheSize: (Math.random() * 3 + 1).toFixed(1) + 'GB',\n cdnLatency: Math.floor(Math.random() * 30) + 15 + 'ms',\n cdnBandwidth: Math.floor(Math.random() * 200) + 100 + 'MB'\n };\n \n Object.keys(metrics).forEach(key => {\n const element = document.getElementById(key);\n if (element) {\n element.textContent = metrics[key];\n }\n });\n}\n\n\/\/ User Flow Step Animation\nfunction animateUserFlow() {\n const steps = document.querySelectorAll('.flow-step');\n let currentStep = 0;\n \n const stepInterval = setInterval(() => {\n if (currentStep < steps.length) {\n \/\/ Mark previous steps as completed\n for (let i = 0; i < currentStep; i++) {\n steps[i].classList.remove('active');\n steps[i].classList.add('completed');\n steps[i].querySelector('.step-number').classList.remove('active');\n steps[i].querySelector('.step-number').classList.add('completed');\n }\n \n \/\/ Mark current step as active\n if (currentStep < steps.length) {\n steps[currentStep].classList.add('active');\n steps[currentStep].querySelector('.step-number').classList.add('active');\n }\n \n currentStep++;\n } else {\n clearInterval(stepInterval);\n }\n }, 2000);\n}\n\n\/\/ Initialize API Graphics\nfunction initializeAPIGraphics() {\n \/\/ Start API status animation\n animateAPIStatus();\n \n \/\/ Update metrics every 5 seconds\n updateAPIMetrics();\n setInterval(updateAPIMetrics, 5000);\n \n \/\/ Start user flow animation after 2 seconds\n setTimeout(animateUserFlow, 2000);\n \n \/\/ Add hover effects to cards\n document.querySelectorAll('.api-status-card, .stats-card').forEach(card => {\n card.classList.add('hover-lift');\n });\n \n \/\/ Load Matomo transitions and user flow data (only if config is available)\n if (apiConfig.period && apiConfig.date) {\n loadMatomoTransitions();\n loadMatomoUserFlow();\n }\n \n \/\/ Add event listeners for controls\n setupTransitionsControls();\n setupUserFlowControls();\n}\n\n\/\/ Store available pages with transitions data\nlet availablePagesWithTransitions = [];\n\n\/\/ Load available pages for transitions\nasync function loadTransitionsPages() {\n try {\n const requestData = {\n url: apiConfig.url,\n token: apiConfig.token,\n siteId: parseInt(apiConfig.siteId),\n period: apiConfig.period,\n date: apiConfig.date\n };\n \n \/\/ Fetch top pages to populate dropdown\n const response = await fetch('\/matomo-api\/proxy', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n },\n body: JSON.stringify({\n ...requestData,\n method: 'Actions.getPageUrls',\n expanded: 1,\n flat: 1,\n filter_limit: 50 \/\/ Get top 50 pages\n })\n });\n \n const result = await response.json();\n if (result.success && result.data && Array.isArray(result.data)) {\n const pageSelect = document.getElementById('transitionsPage');\n if (pageSelect) {\n \/\/ Clear existing options\n pageSelect.innerHTML = '<option value=\"\">Loading pages...<\/option>';\n \n \/\/ Store pages for later use\n availablePagesWithTransitions = result.data.map(page => ({\n url: page.label || page.url || '\/',\n title: page.label || page.url || '\/',\n visits: page.nb_visits || 0\n })).filter(page => page.visits > 0); \/\/ Only pages with visits\n \n \/\/ Sort by visits descending\n availablePagesWithTransitions.sort((a, b) => b.visits - a.visits);\n \n \/\/ Clear and add pages from API\n pageSelect.innerHTML = '<option value=\"\/\">\/ (Select a page)<\/option>';\n \n \/\/ Add pages from API\n availablePagesWithTransitions.forEach(page => {\n const option = document.createElement('option');\n option.value = page.url;\n option.textContent = `${page.title} (${page.visits} visits)`;\n pageSelect.appendChild(option);\n });\n \n \/\/ If we have pages, try to load transitions for the first one\n if (availablePagesWithTransitions.length > 0) {\n \/\/ Set the first page as default\n pageSelect.value = availablePagesWithTransitions[0].url;\n }\n }\n }\n } catch (error) {\n\n }\n}\n\n\/\/ Check if transitions are allowed for the period\nasync function checkTransitionsAllowed() {\n try {\n const requestData = {\n url: apiConfig.url,\n token: apiConfig.token,\n siteId: parseInt(apiConfig.siteId),\n period: apiConfig.period,\n date: apiConfig.date\n };\n \n const response = await fetch('\/matomo-api\/proxy', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n },\n body: JSON.stringify({\n ...requestData,\n method: 'Transitions.isPeriodAllowed'\n })\n });\n \n const result = await response.json();\n\n \n if (result.success && result.data !== undefined) {\n return result.data === true || result.data === 1 || result.data === '1';\n }\n return false;\n } catch (error) {\n\n return false;\n }\n}\n\n\/\/ Load Matomo Transitions Data\nasync function loadMatomoTransitions() {\n try {\n showNotification('Loading transitions data...', 'info');\n \n \/\/ Check if transitions are allowed for this period (optional check)\n \/\/ const isAllowed = await checkTransitionsAllowed();\n\n \n \/\/ First, load available pages if dropdown is empty\n const pageSelect = document.getElementById('transitionsPage');\n if (pageSelect && pageSelect.options.length <= 1) {\n await loadTransitionsPages();\n }\n \n \/\/ Get current page from dropdown\n let currentPage = document.getElementById('transitionsPage').value || '\/';\n \n \/\/ Make sure page URL starts with \/\n if (!currentPage.startsWith('\/')) {\n currentPage = '\/' + currentPage;\n }\n \n \/\/ Debug: Log the request parameters\n\n\n \n \/\/ Ensure pageUrl is properly formatted (remove trailing slash if it's not the root)\n let formattedPageUrl = currentPage;\n if (formattedPageUrl !== '\/' && formattedPageUrl.endsWith('\/')) {\n formattedPageUrl = formattedPageUrl.slice(0, -1);\n }\n \n \/\/ Note: The actual API call uses getTransitionsForAction with full URL\n \/\/ This is constructed server-side after fetching the site's main URL\n\n\n\n\n\n \n const requestData = {\n url: apiConfig.url,\n token: apiConfig.token,\n siteId: parseInt(apiConfig.siteId),\n period: apiConfig.period,\n date: apiConfig.date,\n pageUrl: formattedPageUrl,\n segment: apiConfig.segment || '',\n limitBeforeGrouping: '0' \/\/ Matomo API parameter\n };\n\n\n \n \/\/ Use server-side proxy to avoid Cloudflare blocking\n const response = await fetch('\/matomo-api\/transitions', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n },\n body: JSON.stringify(requestData)\n });\n \n \/\/ Debug: Log the response\n\n\n \n \/\/ Check if response is HTML (error page) instead of JSON\n const contentType = response.headers.get('content-type');\n if (!contentType || !contentType.includes('application\/json')) {\n const htmlResponse = await response.text();\n\n throw new Error('Server returned HTML instead of JSON. Check server logs for errors.');\n }\n \n const result = await response.json();\n\n\n\n\n \n if (!result.success) {\n\n \/\/ Handle specific Matomo API errors\n if (result.error && (result.error.includes('NoDataForAction') || result.error.includes('No data'))) {\n\n \n \/\/ Try to find a page with transitions data\n if (availablePagesWithTransitions.length > 0) {\n\n \n \/\/ Try the first few pages from our list\n for (let i = 0; i < Math.min(5, availablePagesWithTransitions.length); i++) {\n const testPage = availablePagesWithTransitions[i];\n if (testPage.url === formattedPageUrl) {\n continue; \/\/ Skip the one we just tried\n }\n \n\n \n \/\/ Try this page\n const testRequestData = {\n ...requestData,\n pageUrl: testPage.url\n };\n \n try {\n const testResponse = await fetch('\/matomo-api\/transitions', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n },\n body: JSON.stringify(testRequestData)\n });\n \n const testResult = await testResponse.json();\n \n if (testResult.success && testResult.data) {\n\n \n \/\/ Update dropdown to show this page\n const pageSelect = document.getElementById('transitionsPage');\n if (pageSelect) {\n pageSelect.value = testPage.url;\n }\n \n \/\/ Transform and display this page's data\n const transformedData = transformMatomoTransitionsData(testResult.data, testPage.url);\n if (transformedData && transformedData.nodes && transformedData.nodes.length > 0) {\n updateTransitionsDisplay(transformedData);\n showNotification(`Transitions data loaded for: ${testPage.title}`, 'success');\n return;\n }\n }\n } catch (testError) {\n\n continue;\n }\n }\n }\n \n \/\/ If we couldn't find any page with data, show sample data\n\n showNotification('No transitions data available for the selected period\/date. Transitions require sufficient traffic data. Showing sample data.', 'info');\n loadSampleTransitionsData();\n return;\n }\n throw new Error(result.error || 'Failed to load transitions data');\n }\n \n \/\/ Check if data exists and is valid\n if (!result.data) {\n\n showNotification('No data received from Matomo API. Showing sample data.', 'warning');\n loadSampleTransitionsData();\n return;\n }\n \n \/\/ Log the raw data structure\n\n\n \n \/\/ Transform Matomo API response to expected format\n const transformedData = transformMatomoTransitionsData(result.data, currentPage);\n\n \n \/\/ Check if transformation was successful - data should have pageviews and at least some traffic data\n if (!transformedData || transformedData.pageviews === undefined || \n (transformedData.pageviews === 0 && \n (!transformedData.fromInternalPages || transformedData.fromInternalPages === 0) &&\n (!transformedData.directEntries || transformedData.directEntries === 0))) {\n\n\n showNotification('Failed to process transitions data or no data available. Showing sample data.', 'warning');\n loadSampleTransitionsData();\n return;\n }\n \n \/\/ Update transitions data\n updateTransitionsDisplay(transformedData);\n \n showNotification('Transitions data loaded successfully!', 'success');\n \n } catch (error) {\n\n showNotification('Error loading transitions: ' + error.message, 'error');\n \n \/\/ Fallback to sample data\n loadSampleTransitionsData();\n }\n}\n\n\/\/ Store detailed traffic data for modal\nlet detailedTrafficData = null;\n\n\/\/ Transform Matomo API transitions data to expected format\nfunction transformMatomoTransitionsData(matomoData, pageUrl) {\n if (!matomoData || typeof matomoData !== 'object') {\n\n return null;\n }\n \n\n \n \/\/ Matomo API returns transitions in a specific format\n \/\/ The response structure can vary - it might be an object with properties or an array\n \/\/ Check if data is an array (Matomo sometimes returns arrays)\n let dataObj = matomoData;\n if (Array.isArray(matomoData) && matomoData.length > 0) {\n dataObj = matomoData[0];\n\n }\n \n \/\/ Try to extract page title and basic info\n const pageTitle = dataObj.pageTitle || dataObj.label || dataObj.name || pageUrl;\n \n \/\/ Extract pageviews - could be in different locations\n \/\/ Check pageMetrics first (new API structure)\n let pageviews = 0;\n if (dataObj.pageMetrics && dataObj.pageMetrics.pageviews) {\n pageviews = dataObj.pageMetrics.pageviews;\n } else {\n pageviews = dataObj.pageviews || dataObj.nb_pageviews || dataObj.pageviews_count || dataObj.nb_visits || dataObj.visits || 0;\n }\n \n const pageReloads = dataObj.pageMetrics?.loops || dataObj.pageReloads || dataObj.reloads || dataObj.nb_reloads || 0;\n \n\n \n \/\/ Extract incoming traffic data - Matomo API structure\n \/\/ The API might return data directly or nested\n let previousPages = [];\n let previousSearches = [];\n let previousSites = [];\n let previousCampaigns = [];\n \n \/\/ Check if data is nested or flat - try multiple possible locations\n if (dataObj.previousPages) {\n previousPages = Array.isArray(dataObj.previousPages) ? dataObj.previousPages : [];\n } else if (dataObj.previousPageUrls) {\n previousPages = Array.isArray(dataObj.previousPageUrls) ? dataObj.previousPageUrls : [];\n }\n \n if (dataObj.previousSearches) {\n previousSearches = Array.isArray(dataObj.previousSearches) ? dataObj.previousSearches : [];\n } else if (dataObj.previousSiteSearches) {\n previousSearches = Array.isArray(dataObj.previousSiteSearches) ? dataObj.previousSiteSearches : [];\n }\n \n if (dataObj.previousSites) {\n previousSites = Array.isArray(dataObj.previousSites) ? dataObj.previousSites : [];\n } else if (dataObj.previousWebsites) {\n previousSites = Array.isArray(dataObj.previousWebsites) ? dataObj.previousWebsites : [];\n }\n \n if (dataObj.previousCampaigns) {\n previousCampaigns = Array.isArray(dataObj.previousCampaigns) ? dataObj.previousCampaigns : [];\n }\n \n\n \n \/\/ Extract outgoing traffic data\n let followingPages = [];\n let followingSites = [];\n \/\/ Extract exits from pageMetrics (new API structure)\n let exits = 0;\n if (dataObj.pageMetrics && dataObj.pageMetrics.exits !== undefined) {\n exits = dataObj.pageMetrics.exits;\n } else {\n exits = dataObj.exits || dataObj.nb_exits || dataObj.exit_count || 0;\n }\n \n if (dataObj.followingPages) {\n followingPages = Array.isArray(dataObj.followingPages) ? dataObj.followingPages : [];\n } else if (dataObj.nextPages) {\n followingPages = Array.isArray(dataObj.nextPages) ? dataObj.nextPages : [];\n }\n \n if (dataObj.followingSites) {\n followingSites = Array.isArray(dataObj.followingSites) ? dataObj.followingSites : [];\n } else if (dataObj.nextSites) {\n followingSites = Array.isArray(dataObj.nextSites) ? dataObj.nextSites : [];\n }\n \n\n \n \/\/ Calculate incoming traffic totals\n let fromInternalPages = 0;\n let fromInternalSearches = 0;\n let fromSearchEngines = 0;\n let fromSocialNetworks = 0;\n let fromAI = 0;\n let fromWebsites = 0;\n let fromCampaigns = 0;\n \n \/\/ Extract direct entries and process referrers array (new API structure)\n \/\/ IMPORTANT: pageMetrics.entries is TOTAL entries, not just direct entries\n \/\/ Direct entries should only come from the referrers array\n let directEntries = 0;\n \n \/\/ Store referrer details for modal display\n let referrerDetails = {\n search: [],\n social: [],\n website: [],\n campaign: [],\n direct: []\n };\n \n \/\/ Process referrers array if available (new API structure)\n if (dataObj.referrers && Array.isArray(dataObj.referrers)) {\n dataObj.referrers.forEach(referrer => {\n const shortName = (referrer.shortName || '').toLowerCase();\n const label = (referrer.label || '').toLowerCase();\n const visits = referrer.visits || referrer.referrals || referrer.value || 0;\n \n \/\/ Extract details array for modal display\n const details = referrer.details || [];\n \n \/\/ Direct entries from referrers array\n if (shortName === 'direct' || label.includes('direct entries')) {\n directEntries += visits;\n \/\/ Store details if available\n if (details.length > 0) {\n referrerDetails.direct = details.map(d => ({\n label: d.label || 'Unknown',\n referrals: parseInt(d.referrals || d.value || 0)\n }));\n }\n } else if (shortName === 'search' || label.includes('search engines')) {\n fromSearchEngines += visits;\n \/\/ Store details for modal\n if (details.length > 0) {\n referrerDetails.search = details.map(d => ({\n label: d.label || 'Unknown',\n referrals: parseInt(d.referrals || d.value || 0)\n }));\n }\n } else if (shortName === 'social' || label.includes('social networks')) {\n fromSocialNetworks += visits;\n \/\/ Store details for modal\n if (details.length > 0) {\n referrerDetails.social = details.map(d => ({\n label: d.label || 'Unknown',\n referrals: parseInt(d.referrals || d.value || 0)\n }));\n }\n } else if (shortName === 'website' || label.includes('websites')) {\n fromWebsites += visits;\n \/\/ Store details for modal\n if (details.length > 0) {\n referrerDetails.website = details.map(d => ({\n label: d.label || 'Unknown',\n referrals: parseInt(d.referrals || d.value || 0)\n }));\n }\n } else if (shortName === 'campaign' || label.includes('campaigns')) {\n fromCampaigns += visits;\n \/\/ Store details for modal\n if (details.length > 0) {\n referrerDetails.campaign = details.map(d => ({\n label: d.label || 'Unknown',\n referrals: parseInt(d.referrals || d.value || 0)\n }));\n }\n }\n });\n }\n \n \/\/ Fallback: if no referrers array, try other sources\n if (directEntries === 0 && !dataObj.referrers) {\n directEntries = matomoData.directEntries || matomoData.nb_directEntries || matomoData.direct || 0;\n }\n \n \/\/ Process previous pages (internal traffic)\n previousPages.forEach(page => {\n const referrals = page.referrals || page.nb_referrals || page.value || page.nb_visits || 0;\n fromInternalPages += referrals;\n });\n \n \/\/ Process previous searches (internal searches)\n previousSearches.forEach(search => {\n const referrals = search.referrals || search.nb_referrals || search.value || search.nb_visits || 0;\n fromInternalSearches += referrals;\n });\n \n \/\/ Process previous sites (external traffic)\n previousSites.forEach(site => {\n const siteName = (site.label || site.name || site.url || '').toLowerCase();\n const referrals = site.referrals || site.nb_referrals || site.value || site.nb_visits || 0;\n \n if (siteName.includes('google') || siteName.includes('bing') || siteName.includes('yahoo') || siteName.includes('duckduckgo')) {\n fromSearchEngines += referrals;\n } else if (siteName.includes('facebook') || siteName.includes('twitter') || siteName.includes('linkedin') || siteName.includes('instagram')) {\n fromSocialNetworks += referrals;\n } else if (siteName.includes('chatgpt') || siteName.includes('claude') || siteName.includes('bard') || siteName.includes('perplexity')) {\n fromAI += referrals;\n } else {\n fromWebsites += referrals;\n }\n });\n \n \/\/ Process previous campaigns\n previousCampaigns.forEach(campaign => {\n const referrals = campaign.referrals || campaign.nb_referrals || campaign.value || campaign.nb_visits || 0;\n fromCampaigns += referrals;\n });\n \n \/\/ Calculate outgoing traffic totals\n let toInternalPages = 0;\n let internalSearches = 0;\n let downloads = 0;\n let outlinks = 0;\n \n \/\/ Process following pages (internal traffic)\n followingPages.forEach(page => {\n const referrals = page.referrals || page.nb_referrals || page.value || page.nb_visits || 0;\n toInternalPages += referrals;\n });\n \n \/\/ Process following sites (outlinks)\n followingSites.forEach(site => {\n const referrals = site.referrals || site.nb_referrals || site.value || site.nb_visits || 0;\n outlinks += referrals;\n });\n \n \/\/ If we don't have pageviews, try to calculate from incoming + outgoing\n if (pageviews === 0 && (fromInternalPages + fromSearchEngines + fromSocialNetworks + fromWebsites + fromCampaigns + directEntries > 0)) {\n pageviews = fromInternalPages + fromSearchEngines + fromSocialNetworks + fromWebsites + fromCampaigns + directEntries;\n }\n \n \/\/ Build the transformed data structure\n const transformed = {\n pageUrl: pageUrl,\n pageTitle: pageTitle,\n pageviews: pageviews,\n pageReloads: pageReloads,\n fromInternalPages: fromInternalPages,\n fromInternalSearches: fromInternalSearches,\n fromSearchEngines: fromSearchEngines,\n fromSocialNetworks: fromSocialNetworks,\n fromAI: fromAI,\n fromWebsites: fromWebsites,\n fromCampaigns: fromCampaigns,\n directEntries: directEntries,\n toInternalPages: toInternalPages,\n internalSearches: internalSearches,\n downloads: downloads,\n outlinks: outlinks,\n exits: exits,\n \/\/ Store detailed data for modal\n previousPages: previousPages,\n \/\/ Use referrer details if available, otherwise fallback to filtered previousSites\n previousSearchEngines: referrerDetails.search.length > 0 ? referrerDetails.search : previousSites.filter(site => {\n const name = (site.label || site.name || site.url || '').toLowerCase();\n return name.includes('google') || name.includes('bing') || name.includes('yahoo') || name.includes('duckduckgo');\n }),\n previousSocials: referrerDetails.social.length > 0 ? referrerDetails.social : previousSites.filter(site => {\n const name = (site.label || site.name || site.url || '').toLowerCase();\n return name.includes('facebook') || name.includes('twitter') || name.includes('linkedin') || name.includes('instagram');\n }),\n previousWebsites: referrerDetails.website.length > 0 ? referrerDetails.website : previousSites.filter(site => {\n const name = (site.label || site.name || site.url || '').toLowerCase();\n return !name.includes('google') && !name.includes('bing') && !name.includes('yahoo') && \n !name.includes('duckduckgo') && !name.includes('facebook') && !name.includes('twitter') && \n !name.includes('linkedin') && !name.includes('instagram') && !name.includes('chatgpt') && \n !name.includes('claude') && !name.includes('bard') && !name.includes('perplexity');\n }),\n previousCampaigns: referrerDetails.campaign.length > 0 ? referrerDetails.campaign : previousCampaigns,\n followingPages: followingPages,\n followingOutlinks: followingSites\n };\n \n\n return transformed;\n}\n\n\/\/ Update transitions display with real data using enhanced Matomo-style Sankey diagram\nfunction updateTransitionsDisplay(data) {\n const pageUrl = data.pageUrl || '\/';\n const pageTitle = data.pageTitle || pageUrl;\n const pageviews = data.pageviews || 0;\n const pageReloads = data.pageReloads || 0;\n \n \/\/ Store detailed data for modal\n detailedTrafficData = data;\n \n \/\/ Update center card with detailed breakdown\n updateCenterCard(pageUrl, pageTitle, pageviews, pageReloads, data);\n \n \/\/ Prepare data for two Sankey graphs\n const incoming = [];\n const outgoing = [];\n \n \/\/ Incoming traffic\n if (data.fromInternalPages && data.fromInternalPages > 0) {\n incoming.push({ label: \"From internal pages\", value: data.fromInternalPages, type: \"internal\" });\n }\n if (data.fromSearchEngines && data.fromSearchEngines > 0) {\n incoming.push({ label: \"From search engines\", value: data.fromSearchEngines, type: \"search\" });\n }\n if (data.fromSocialNetworks && data.fromSocialNetworks > 0) {\n incoming.push({ label: \"From social networks\", value: data.fromSocialNetworks, type: \"social\" });\n }\n if (data.fromAI && data.fromAI > 0) {\n incoming.push({ label: \"From AI assistants\", value: data.fromAI, type: \"ai\" });\n }\n if (data.fromWebsites && data.fromWebsites > 0) {\n incoming.push({ label: \"From websites\", value: data.fromWebsites, type: \"websites\" });\n }\n if (data.fromCampaigns && data.fromCampaigns > 0) {\n incoming.push({ label: \"From campaigns\", value: data.fromCampaigns, type: \"campaigns\" });\n }\n if (data.directEntries && data.directEntries > 0) {\n incoming.push({ label: \"Direct entries\", value: data.directEntries, type: \"direct\" });\n }\n \n \/\/ Outgoing traffic\n if (data.toInternalPages && data.toInternalPages > 0) {\n outgoing.push({ label: \"To internal pages\", value: data.toInternalPages, type: \"internal\" });\n }\n if (data.outlinks && data.outlinks > 0) {\n outgoing.push({ label: \"Outlinks\", value: data.outlinks, type: \"outlinks\" });\n }\n if (data.exits && data.exits > 0) {\n outgoing.push({ label: \"Exits\", value: data.exits, type: \"exits\" });\n }\n \n \/\/ Draw enhanced Sankey diagram\n if (incoming.length > 0 || outgoing.length > 0) {\n drawEnhancedSankeyChart(incoming, outgoing, pageviews, data);\n } else {\n \/\/ Show empty state\n d3.select(\"#viz\").selectAll(\"*\").remove();\n d3.select(\"#viz\")\n .append(\"text\")\n .attr(\"x\", 640)\n .attr(\"y\", 320)\n .attr(\"text-anchor\", \"middle\")\n .attr(\"fill\", \"var(--text-muted)\")\n .style(\"font-family\", \"Open Sans, sans-serif\")\n .style(\"font-size\", \"14px\")\n .text(\"No transitions data available\");\n }\n}\n\n\/\/ Update center card with detailed breakdown\nfunction updateCenterCard(pageUrl, pageTitle, pageviews, pageReloads, data) {\n const centerCard = document.getElementById('centerCard');\n const centerLink = document.getElementById('centerPageLink');\n const cardBody = document.getElementById('centerCardBody');\n \n centerLink.textContent = pageTitle;\n centerLink.href = pageUrl;\n \n let html = `<div><strong>${pageviews}<\/strong> pageviews<\/div>`;\n html += `<div class=\"muted small\">${pageReloads} page reloads<\/div>`;\n \n \/\/ Incoming traffic breakdown\n html += `<div class=\"card-section-title\">Incoming traffic<\/div>`;\n html += `<div class=\"small muted\">${data.fromInternalPages || 0} from internal pages<\/div>`;\n html += `<div class=\"small muted\">${data.fromInternalSearches || 0} from internal searches<\/div>`;\n html += `<div class=\"small muted\">${data.fromSearchEngines || 0} from search engines<\/div>`;\n html += `<div class=\"small muted\">${data.fromSocialNetworks || 0} from social networks<\/div>`;\n html += `<div class=\"small muted\">${data.fromAI || 0} from AI assistants<\/div>`;\n html += `<div class=\"small muted\">${data.fromWebsites || 0} from websites<\/div>`;\n html += `<div class=\"small muted\">${data.fromCampaigns || 0} from campaigns<\/div>`;\n html += `<div class=\"small muted\">${data.directEntries || 0} direct entries<\/div>`;\n \n \/\/ Outgoing traffic breakdown\n html += `<div class=\"card-section-title\">Outgoing traffic<\/div>`;\n html += `<div class=\"small muted\">${data.toInternalPages || 0} to internal pages<\/div>`;\n html += `<div class=\"small muted\">${data.internalSearches || 0} internal searches<\/div>`;\n html += `<div class=\"small muted\">${data.downloads || 0} downloads<\/div>`;\n html += `<div class=\"small muted\">${data.outlinks || 0} outlinks<\/div>`;\n html += `<div class=\"small muted\"><strong>${data.exits || 0}<\/strong> exits<\/div>`;\n \n cardBody.innerHTML = html;\n}\n\n\/\/ Draw enhanced Matomo-style Sankey chart with two graphs\nlet sankeyChartInstance = null;\n\nfunction drawEnhancedSankeyChart(incoming, outgoing, totalPageviews, detailedData = null) {\n try {\n const svg = d3.select(\"#viz\");\n if (!svg.node()) {\n\n return;\n }\n \n \/\/ Check if D3 is loaded\n if (typeof d3 === 'undefined' || typeof d3.sankey === 'undefined') {\n\n return;\n }\n \n const width = 1280;\n const height = 640;\n \n \/\/ Clear previous chart\n svg.selectAll(\"*\").remove();\n \n const g = svg.append(\"g\");\n \n \/\/ Color palette (Matomo style)\n const col = {\n internal: \"#e9e4d6\",\n search: \"#d9eaf9\",\n ai: \"#e6f7fe\",\n direct: \"#cfeefe\",\n social: \"#d9eaf9\",\n websites: \"#d9eaf9\",\n campaigns: \"#d9eaf9\",\n outlinks: \"#d9ecfb\",\n exits: \"#f6f7f8\",\n spine: \"#5fa8ec\"\n };\n \n \/\/ Create gradients for flows\n const defs = svg.append(\"defs\");\n \n function makeGradient(id, colorHex, leftToRight = true) {\n const grad = defs.append(\"linearGradient\")\n .attr(\"id\", id)\n .attr(\"x1\", leftToRight ? \"0%\" : \"100%\")\n .attr(\"y1\", \"0%\")\n .attr(\"x2\", leftToRight ? \"100%\" : \"0%\")\n .attr(\"y2\", \"0%\");\n grad.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", colorHex).attr(\"stop-opacity\", 0.85);\n grad.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", colorHex).attr(\"stop-opacity\", 0.15);\n }\n \n makeGradient(\"gradInternal\", col.internal, true);\n makeGradient(\"gradSearch\", col.search, true);\n makeGradient(\"gradAI\", col.ai, true);\n makeGradient(\"gradDirect\", col.direct, true);\n makeGradient(\"gradSocial\", col.social, true);\n makeGradient(\"gradWebsites\", col.websites, true);\n makeGradient(\"gradCampaigns\", col.campaigns, true);\n makeGradient(\"gradOutlinks\", col.outlinks, false);\n makeGradient(\"gradExits\", \"#c7d5e0\", false);\n \n \/\/ Build two sankey graphs: left (incoming -> center) and right (center -> outgoing)\n const centerWidth = 12;\n const leftExtent = [[20, 20], [width\/2 - 100, height-20]];\n const rightExtent = [[width\/2 + 100, 20], [width-20, height-20]];\n \n \/\/ Create nodes for left sankey\n const leftNodes = incoming.map(d => ({ name: d.label, type: d.type }));\n leftNodes.push({ name: \"\/\", type: \"spine\" }); \/\/ center\n const leftLinks = incoming.map(d => ({\n source: d.label, target: \"\/\", value: d.value, type: d.type\n }));\n \n const leftGraph = {\n nodes: leftNodes.map(d => Object.assign({}, d)),\n links: leftLinks.map(d => Object.assign({}, d))\n };\n \n \/\/ Create nodes for right sankey\n const rightNodes = [{ name: \"\/\", type: \"spine\" }]\n .concat(outgoing.map(d => ({ name: d.label, type: d.type })));\n const rightLinks = outgoing.map(d => ({\n source: \"\/\", target: d.label, value: d.value, type: d.type\n }));\n \n const rightGraph = {\n nodes: rightNodes.map(d => Object.assign({}, d)),\n links: rightLinks.map(d => Object.assign({}, d))\n };\n \n \/\/ Sankey generators\n const sankeyLeft = d3.sankey()\n .nodeAlign(d3.sankeyRight)\n .nodeWidth(16)\n .nodePadding(16)\n .extent(leftExtent);\n \n const sankeyRight = d3.sankey()\n .nodeAlign(d3.sankeyLeft)\n .nodeWidth(16)\n .nodePadding(16)\n .extent(rightExtent);\n \n \/\/ Map node names to indices for sankey\n function indexByName(graph) {\n const nameToIndex = new Map();\n graph.nodes.forEach((n, i) => nameToIndex.set(n.name, i));\n graph.links.forEach(l => {\n if (typeof l.source === \"string\") l.source = nameToIndex.get(l.source);\n if (typeof l.target === \"string\") l.target = nameToIndex.get(l.target);\n });\n }\n \n indexByName(leftGraph);\n indexByName(rightGraph);\n \n const left = sankeyLeft(leftGraph);\n const right = sankeyRight(rightGraph);\n \n \/\/ Align the vertical spine (center node) heights visually\n function spineSpan(nodes) {\n const n = nodes.find(n => n.name === \"\/\");\n return [n.y0, n.y1];\n }\n \n const [ly0, ly1] = spineSpan(left.nodes);\n const [ry0, ry1] = spineSpan(right.nodes);\n const y0 = Math.min(ly0, ry0);\n const y1 = Math.max(ly1, ry1);\n \n \/\/ Get tooltip element\n const tooltip = d3.select(\"#tooltip\");\n \n \/\/ Draw left links\n g.append(\"g\").selectAll(\"path.leftLink\")\n .data(left.links)\n .join(\"path\")\n .attr(\"class\", \"leftLink\")\n .attr(\"d\", d3.sankeyLinkHorizontal())\n .attr(\"fill\", \"none\")\n .attr(\"stroke\", l => {\n switch(l.type) {\n case \"internal\": return \"url(#gradInternal)\";\n case \"search\": return \"url(#gradSearch)\";\n case \"ai\": return \"url(#gradAI)\";\n case \"direct\": return \"url(#gradDirect)\";\n case \"social\": return \"url(#gradSocial)\";\n case \"websites\": return \"url(#gradWebsites)\";\n case \"campaigns\": return \"url(#gradCampaigns)\";\n default: return \"url(#gradSearch)\";\n }\n })\n .attr(\"stroke-width\", d => Math.max(1, d.width))\n .attr(\"opacity\", 0.9)\n .on(\"mousemove\", (event, d) => {\n tooltip.style(\"opacity\", 1)\n .style(\"left\", (event.pageX + 10) + \"px\")\n .style(\"top\", (event.pageY - 20) + \"px\")\n .html(`<strong>${d.source.name}<\/strong> \u2192 <strong>${d.target.name}<\/strong><br>${d.value} visits`);\n })\n .on(\"mouseleave\", () => tooltip.style(\"opacity\", 0));\n \n \/\/ Draw right links\n g.append(\"g\").selectAll(\"path.rightLink\")\n .data(right.links)\n .join(\"path\")\n .attr(\"class\", \"rightLink\")\n .attr(\"d\", d3.sankeyLinkHorizontal())\n .attr(\"fill\", \"none\")\n .attr(\"stroke\", l => {\n switch(l.type) {\n case \"internal\": return \"url(#gradOutlinks)\";\n case \"outlinks\": return \"url(#gradOutlinks)\";\n case \"exits\": return \"url(#gradExits)\";\n default: return \"url(#gradOutlinks)\";\n }\n })\n .attr(\"stroke-width\", d => Math.max(1, d.width))\n .attr(\"opacity\", 0.9)\n .on(\"mousemove\", (event, d) => {\n tooltip.style(\"opacity\", 1)\n .style(\"left\", (event.pageX + 10) + \"px\")\n .style(\"top\", (event.pageY - 20) + \"px\")\n .html(`<strong>${d.source.name}<\/strong> \u2192 <strong>${d.target.name}<\/strong><br>${d.value} visits`);\n })\n .on(\"mouseleave\", () => tooltip.style(\"opacity\", 0));\n \n \/\/ Left nodes (source categories) - make clickable\n g.append(\"g\").selectAll(\"rect.leftNode\")\n .data(left.nodes.filter(n => n.name !== \"\/\"))\n .join(\"rect\")\n .attr(\"class\", \"leftNode\")\n .attr(\"x\", d => d.x0)\n .attr(\"y\", d => d.y0)\n .attr(\"width\", d => d.x1 - d.x0)\n .attr(\"height\", d => d.y1 - d.y0)\n .attr(\"fill\", d => {\n switch(d.type) {\n case \"internal\": return col.internal;\n case \"search\": return col.search;\n case \"ai\": return col.ai;\n case \"direct\": return col.direct;\n case \"social\": return col.social;\n case \"websites\": return col.websites;\n case \"campaigns\": return col.campaigns;\n default: return col.search;\n }\n })\n .attr(\"stroke\", \"rgba(255, 255, 255, 0.2)\")\n .style(\"cursor\", \"pointer\")\n .on(\"click\", function(event, d) {\n showTrafficDetails(d.name, d.type, \"incoming\", detailedData);\n })\n .on(\"mouseover\", function(event, d) {\n d3.select(this).attr(\"stroke\", \"#8B5CF6\").attr(\"stroke-width\", 2);\n })\n .on(\"mouseout\", function() {\n d3.select(this).attr(\"stroke\", \"rgba(255, 255, 255, 0.2)\").attr(\"stroke-width\", 1);\n });\n \n \/\/ Right nodes (destination categories) - make clickable\n g.append(\"g\").selectAll(\"rect.rightNode\")\n .data(right.nodes.filter(n => n.name !== \"\/\"))\n .join(\"rect\")\n .attr(\"class\", \"rightNode\")\n .attr(\"x\", d => d.x0)\n .attr(\"y\", d => d.y0)\n .attr(\"width\", d => d.x1 - d.x0)\n .attr(\"height\", d => d.y1 - d.y0)\n .attr(\"fill\", d => d.type === \"exits\" ? col.exits : col.outlinks)\n .attr(\"stroke\", \"rgba(255, 255, 255, 0.2)\")\n .style(\"cursor\", \"pointer\")\n .on(\"click\", function(event, d) {\n showTrafficDetails(d.name, d.type, \"outgoing\", detailedData);\n })\n .on(\"mouseover\", function(event, d) {\n d3.select(this).attr(\"stroke\", \"#8B5CF6\").attr(\"stroke-width\", 2);\n })\n .on(\"mouseout\", function() {\n d3.select(this).attr(\"stroke\", \"rgba(255, 255, 255, 0.2)\").attr(\"stroke-width\", 1);\n });\n \n \/\/ Labels left - make clickable too\n g.append(\"g\").selectAll(\"text.leftLbl\")\n .data(left.nodes.filter(n => n.name !== \"\/\"))\n .join(\"text\")\n .attr(\"class\", \"node-label\")\n .attr(\"x\", d => d.x0 + 6)\n .attr(\"y\", d => (d.y0 + d.y1)\/2 + 4)\n .style(\"fill\", \"var(--text-color)\")\n .style(\"cursor\", \"pointer\")\n .on(\"click\", function(event, d) {\n showTrafficDetails(d.name, d.type, \"incoming\", detailedData);\n })\n .text(d => d.name);\n \n \/\/ Labels right - make clickable too\n g.append(\"g\").selectAll(\"text.rightLbl\")\n .data(right.nodes.filter(n => n.name !== \"\/\"))\n .join(\"text\")\n .attr(\"class\", \"node-label\")\n .attr(\"x\", d => d.x1 - 6)\n .attr(\"text-anchor\", \"end\")\n .attr(\"y\", d => (d.y0 + d.y1)\/2 + 4)\n .style(\"fill\", \"var(--text-color)\")\n .style(\"cursor\", \"pointer\")\n .on(\"click\", function(event, d) {\n showTrafficDetails(d.name, d.type, \"outgoing\", detailedData);\n })\n .text(d => d.name);\n \n \/\/ Vertical spine bar\n const spineX = width\/2 - centerWidth\/2;\n g.append(\"rect\")\n .attr(\"x\", spineX)\n .attr(\"y\", y0)\n .attr(\"width\", centerWidth)\n .attr(\"height\", y1 - y0)\n .attr(\"fill\", col.spine)\n .attr(\"opacity\", 0.9)\n .attr(\"rx\", 2);\n \n \/\/ Percent badges\n function pct(value) {\n return Math.round((value \/ totalPageviews) * 100) + \"%\";\n }\n \n \/\/ Left main inbound (Direct entries) badge\n const direct = incoming.find(d => d.label === \"Direct entries\");\n if (direct && totalPageviews > 0) {\n const n = left.nodes.find(n => n.name === \"Direct entries\");\n if (n) {\n const bx = n.x0 + 6, by = n.y0 + 6;\n g.append(\"rect\").attr(\"class\", \"pct-badge\")\n .attr(\"x\", bx).attr(\"y\", by).attr(\"width\", 44).attr(\"height\", 18);\n g.append(\"text\").attr(\"class\", \"pct-text\")\n .attr(\"x\", bx + 22).attr(\"y\", by + 12).attr(\"text-anchor\", \"middle\")\n .style(\"fill\", \"var(--text-muted)\")\n .text(pct(direct.value));\n }\n }\n \n \/\/ Right outbound Exits badge\n const exits = outgoing.find(d => d.label === \"Exits\");\n if (exits && totalPageviews > 0) {\n const n = right.nodes.find(n => n.name === \"Exits\");\n if (n) {\n const bx = n.x1 - 50, by = n.y0 + 6;\n g.append(\"rect\").attr(\"class\", \"pct-badge\")\n .attr(\"x\", bx).attr(\"y\", by).attr(\"width\", 44).attr(\"height\", 18);\n g.append(\"text\").attr(\"class\", \"pct-text\")\n .attr(\"x\", bx + 22).attr(\"y\", by + 12).attr(\"text-anchor\", \"middle\")\n .style(\"fill\", \"var(--text-muted)\")\n .text(pct(exits.value));\n }\n }\n \n \/\/ Store instance for resize\n sankeyChartInstance = { incoming, outgoing, totalPageviews, width, height };\n \n \/\/ Setup resize handler\n setupD3SankeyResizeHandler();\n \n } catch (error) {\n\n svg.append(\"text\")\n .attr(\"x\", width\/2)\n .attr(\"y\", height\/2)\n .attr(\"text-anchor\", \"middle\")\n .style(\"fill\", \"var(--text-danger)\")\n .text(\"Error loading chart: \" + error.message);\n }\n}\n\n\/\/ Tooltip functions (Matomo style)\nlet tooltip = null;\n\nfunction showTooltip(event, text) {\n if (!tooltip) {\n tooltip = d3.select(\"body\")\n .append(\"div\")\n .attr(\"class\", \"sankey-tooltip\")\n .style(\"position\", \"absolute\")\n .style(\"padding\", \"8px 12px\")\n .style(\"background\", \"rgba(0, 0, 0, 0.75)\")\n .style(\"color\", \"#fff\")\n .style(\"border-radius\", \"4px\")\n .style(\"pointer-events\", \"none\")\n .style(\"font-size\", \"12px\")\n .style(\"font-family\", \"Open Sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif\")\n .style(\"z-index\", \"10000\")\n .style(\"box-shadow\", \"0 2px 8px rgba(0,0,0,0.3)\");\n }\n \n tooltip\n .html(text)\n .style(\"left\", (event.pageX + 10) + \"px\")\n .style(\"top\", (event.pageY - 10) + \"px\")\n .style(\"opacity\", 1);\n}\n\nfunction hideTooltip() {\n if (tooltip) {\n tooltip.style(\"opacity\", 0);\n }\n}\n\n\/\/ Handle window resize for enhanced Sankey chart\nlet d3ResizeTimeout;\nlet d3ResizeHandler = null;\n\nfunction setupD3SankeyResizeHandler() {\n if (d3ResizeHandler) return; \/\/ Already set up\n \n d3ResizeHandler = () => {\n if (sankeyChartInstance && sankeyChartInstance.incoming && sankeyChartInstance.outgoing) {\n clearTimeout(d3ResizeTimeout);\n d3ResizeTimeout = setTimeout(() => {\n if (sankeyChartInstance) {\n \/\/ Redraw with current data\n drawEnhancedSankeyChart(\n sankeyChartInstance.incoming, \n sankeyChartInstance.outgoing, \n sankeyChartInstance.totalPageviews\n );\n }\n }, 250);\n }\n };\n \n window.addEventListener('resize', d3ResizeHandler);\n}\n\n\/\/ Show traffic details modal\nfunction showTrafficDetails(label, type, direction, detailedData) {\n if (!detailedData) {\n\n return;\n }\n \n const modal = document.getElementById('trafficDetailsModal');\n const modalTitle = document.getElementById('modalTitle');\n const modalBody = document.getElementById('modalBody');\n \n modalTitle.textContent = label;\n \n let items = [];\n let html = '';\n \n \/\/ Get details based on type and direction\n if (direction === 'incoming') {\n switch(type) {\n case 'internal':\n if (detailedData.previousPages && Array.isArray(detailedData.previousPages)) {\n items = detailedData.previousPages.map(p => ({\n label: p.label || p.url || 'Unknown',\n url: p.url || '',\n value: p.referrals || 0\n }));\n }\n break;\n case 'search':\n if (detailedData.previousSearchEngines && Array.isArray(detailedData.previousSearchEngines)) {\n items = detailedData.previousSearchEngines.map(p => ({\n label: p.label || p.name || 'Unknown',\n url: p.url || '',\n value: p.referrals || p.value || 0\n }));\n }\n break;\n case 'social':\n if (detailedData.previousSocials && Array.isArray(detailedData.previousSocials)) {\n items = detailedData.previousSocials.map(p => ({\n label: p.label || p.name || 'Unknown',\n url: p.url || '',\n value: p.referrals || 0\n }));\n }\n break;\n case 'websites':\n if (detailedData.previousWebsites && Array.isArray(detailedData.previousWebsites)) {\n items = detailedData.previousWebsites.map(p => ({\n label: p.label || p.name || 'Unknown',\n url: p.url || '',\n value: p.referrals || p.value || 0\n }));\n }\n break;\n case 'campaigns':\n if (detailedData.previousCampaigns && Array.isArray(detailedData.previousCampaigns)) {\n items = detailedData.previousCampaigns.map(p => ({\n label: p.label || p.name || 'Unknown',\n url: p.url || '',\n value: p.referrals || p.value || 0\n }));\n }\n break;\n case 'direct':\n html = '<div class=\"empty-details\">Direct entries represent visitors who typed the URL directly or used a bookmark.<\/div>';\n break;\n }\n } else if (direction === 'outgoing') {\n switch(type) {\n case 'internal':\n if (detailedData.followingPages && Array.isArray(detailedData.followingPages)) {\n items = detailedData.followingPages.map(p => ({\n label: p.label || p.url || 'Unknown',\n url: p.url || '',\n value: p.referrals || 0\n }));\n }\n break;\n case 'outlinks':\n if (detailedData.followingOutlinks && Array.isArray(detailedData.followingOutlinks)) {\n items = detailedData.followingOutlinks.map(p => ({\n label: p.label || p.url || 'Unknown',\n url: p.url || '',\n value: p.referrals || 0\n }));\n }\n break;\n case 'exits':\n html = '<div class=\"empty-details\">Exits represent visitors who left the website after viewing this page.<\/div>';\n break;\n }\n }\n \n \/\/ Build HTML if we have items\n if (items.length > 0) {\n html = '<ul class=\"details-list\">';\n items.forEach(item => {\n html += `\n <li class=\"details-item\">\n <div>\n <div class=\"details-item-label\">${item.label}<\/div>\n ${item.url ? `<div class=\"details-item-url\">${item.url}<\/div>` : ''}\n <\/div>\n <div class=\"details-item-value\">${item.value}<\/div>\n <\/li>\n `;\n });\n html += '<\/ul>';\n } else if (!html) {\n html = '<div class=\"empty-details\">No detailed data available for this traffic source.<\/div>';\n }\n \n modalBody.innerHTML = html;\n modal.classList.add('show');\n \n \/\/ Close on outside click\n modal.onclick = function(event) {\n if (event.target === modal) {\n closeTrafficDetails();\n }\n };\n \n \/\/ Close on Escape key\n document.addEventListener('keydown', function escapeHandler(event) {\n if (event.key === 'Escape') {\n closeTrafficDetails();\n document.removeEventListener('keydown', escapeHandler);\n }\n });\n}\n\n\/\/ Close traffic details modal\nfunction closeTrafficDetails() {\n const modal = document.getElementById('trafficDetailsModal');\n modal.classList.remove('show');\n}\n\n\/\/ Load sample transitions data (fallback)\nfunction loadSampleTransitionsData() {\n const sampleData = {\n pageUrl: '\/',\n pageviews: 347,\n pageReloads: 54,\n fromInternalPages: 36,\n fromSearchEngines: 6,\n fromSocialNetworks: 2,\n fromWebsites: 4,\n fromCampaigns: 1,\n directEntries: 244,\n toInternalPages: 50,\n exits: 243,\n internalPages: [\n { url: '\/portofoliu\/', visits: 9 },\n { url: '\/contact\/', visits: 8 },\n { url: '\/gazduire-website\/', visits: 7 },\n { url: '\/despre-noi\/', visits: 3 },\n { url: '\/clients\/', visits: 2 }\n ]\n };\n \n updateTransitionsDisplay(sampleData);\n}\n\n\/\/ Load Matomo User Flow Data\nasync function loadMatomoUserFlow() {\n try {\n showNotification('Loading user flow data...', 'info');\n \n \/\/ Get flow parameters\n const device = document.getElementById('flowDevice').value;\n const steps = document.getElementById('flowSteps').value;\n const type = document.getElementById('flowType').value;\n \n \/\/ Debug: Log the request parameters\n\n const requestData = {\n url: apiConfig.url,\n token: apiConfig.token,\n siteId: parseInt(apiConfig.siteId),\n period: apiConfig.period,\n date: apiConfig.date,\n deviceType: device,\n steps: steps,\n type: type\n };\n\n \n \/\/ Use server-side proxy to avoid Cloudflare blocking\n const response = await fetch('\/matomo-api\/user-flow', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n },\n body: JSON.stringify(requestData)\n });\n \n \/\/ Debug: Log the response\n\n\n \n \/\/ Check if response is HTML (error page) instead of JSON\n const contentType = response.headers.get('content-type');\n if (!contentType || !contentType.includes('application\/json')) {\n const htmlResponse = await response.text();\n\n throw new Error('Server returned HTML instead of JSON. Check server logs for errors.');\n }\n \n const result = await response.json();\n\n \n if (!result.success) {\n\n \/\/ Handle specific Matomo API errors\n if (result.error && (result.error.includes('NoDataForAction') || result.error.includes('No data'))) {\n\n showNotification('No user flow data available. Showing sample data.', 'info');\n loadSampleUserFlowData();\n return;\n }\n throw new Error(result.error || 'Failed to load user flow data');\n }\n \n \/\/ Update user flow display - now expects rawResponse structure\n if (result.data && result.data.rawResponse) {\n updateUserFlowDisplay({ rawResponse: result.data.rawResponse });\n } else {\n\n showNotification('Invalid data structure received', 'error');\n loadSampleUserFlowData();\n }\n \n showNotification('User flow data loaded successfully!', 'success');\n \n } catch (error) {\n\n showNotification('Error loading user flow: ' + error.message, 'error');\n \n \/\/ Fallback to sample data\n loadSampleUserFlowData();\n }\n}\n\n\/\/ Store user flow data globally for modal access\nlet userFlowData = null;\n\n\/\/ Helper functions (matching Matomo's logic)\nconst OUT_NODE_NAME = '_out_';\nconst SUMMARY_NODE_NAME = 'Others';\n\nfunction isOutNode(name) {\n return name === OUT_NODE_NAME;\n}\n\nfunction isSummaryNode(name) {\n if (!name) return false;\n const nameLower = name.toLowerCase();\n \/\/ Match \"Others\", \"Others (>X pages)\", translated versions, etc.\n return nameLower.includes('others') || \n nameLower.startsWith('others') ||\n name === SUMMARY_NODE_NAME;\n}\n\n\/\/ Build nodes and links from raw Matomo response (like Matomo's buildNodesAndIndexes)\nfunction buildNodesAndIndexes(rawResponse, numSteps = 4) {\n const links = [];\n const nodes = [];\n const depthNodes = [];\n \n let maxSankeyChartDepth = 0;\n let maxNodeLength = 0;\n \n \/\/ Find max depth\n rawResponse.forEach((row) => {\n const depth = parseInt(row.label, 10);\n if (depth > maxSankeyChartDepth) {\n maxSankeyChartDepth = depth;\n }\n });\n \n if (numSteps > maxSankeyChartDepth) {\n numSteps = maxSankeyChartDepth;\n }\n \n let nodeIndex = 0;\n rawResponse.forEach((depthRow) => {\n const depth = parseInt(depthRow.label, 10);\n \n if (!depthRow.subtable) {\n return;\n }\n \n if (depthRow.subtable.length + 1 > maxNodeLength) {\n maxNodeLength = depthRow.subtable.length + 1; \/\/ +1 for out node\n }\n \n if (depth > numSteps) {\n return;\n }\n \n const depthNode = {\n depth: depth - 1,\n in: 0,\n out: 0,\n totalIn: depthRow.nb_visits || 0,\n totalOut: depthRow.nb_proceeded || 0,\n totalExits: depthRow.nb_exits || 0,\n };\n \n depthRow.subtable.forEach((sourceRow) => {\n const sourceLabel = sourceRow.label;\n \n if (!isSummaryNode(sourceLabel)) {\n depthNode.in += sourceRow.nb_visits || 0;\n depthNode.out += sourceRow.nb_proceeded || 0;\n }\n \n nodes.push({\n depth: depth - 1,\n name: sourceLabel,\n node: nodeIndex,\n totalIn: sourceRow.nb_visits || 0,\n totalOut: sourceRow.nb_proceeded || 0,\n totalExits: sourceRow.nb_exits || 0,\n pagesInGroup: sourceRow.nb_pages_in_group || 0,\n isSummaryNode: isSummaryNode(sourceLabel),\n idSubtable: sourceRow.idsubdatatable || null,\n });\n \n nodeIndex += 1;\n \n if (depth >= numSteps) {\n return;\n }\n \n if (!sourceRow.subtable) {\n return;\n }\n \n (sourceRow.subtable || []).forEach((targetRow) => {\n links.push({\n depth,\n source: nodeIndex - 1,\n target: targetRow.label,\n value: targetRow.nb_visits || 0,\n });\n });\n \n if (sourceRow.nb_exits) {\n links.push({\n depth,\n source: nodeIndex - 1,\n target: OUT_NODE_NAME,\n value: sourceRow.nb_exits,\n });\n }\n });\n \n depthNodes.push(depthNode);\n \n if (depth > 1) {\n nodes.push({\n depth: depth - 1,\n name: OUT_NODE_NAME,\n node: nodeIndex,\n value: 0,\n totalIn: 0,\n });\n nodeIndex += 1;\n }\n });\n \n \/\/ Replace target labels with node indices\n links.forEach((link) => {\n nodes.some((element) => {\n if (link.target === element.name && link.depth === element.depth) {\n link.target = element.node;\n return true;\n }\n return false;\n });\n });\n \n return { nodes, links, depthNodes, maxSankeyChartDepth, maxNodeLength };\n}\n\n\/\/ Update user flow display with real data - Matomo-style Sankey diagram\nasync function updateUserFlowDisplay(data) {\n const flowDiagram = document.getElementById('flowDiagram');\n const svg = d3.select('#usersFlowSankey');\n \n \/\/ Store data globally\n userFlowData = data;\n \n \/\/ Check for rawResponse structure (new format) - transform to column-based layout\n if (data.rawResponse) {\n if (!data.rawResponse || data.rawResponse.length === 0) {\n flowDiagram.innerHTML = '<div class=\"text-center p-4\"><p>No user flow data available<\/p><\/div>';\n return;\n }\n \n \/\/ Transform raw Matomo response to column-based format\n const transformedData = await transformRawResponseToColumnFormat(data.rawResponse);\n \n if (!transformedData.interactions || transformedData.interactions.length === 0) {\n flowDiagram.innerHTML = '<div class=\"text-center p-4\"><p>No user flow data available<\/p><\/div>';\n return;\n }\n \n \/\/ Store both formats for popup access\n userFlowData = {\n interactions: transformedData.interactions,\n rawResponse: transformedData.rawResponse\n };\n \n \/\/ Re-render after fetching Others nodes to ensure they're expanded\n \/\/ Use column-based layout (original design)\n renderOldColumnLayout(transformedData);\n return;\n }\n \n \/\/ Fallback to old format for backward compatibility\n if (!data.interactions || data.interactions.length === 0) {\n flowDiagram.innerHTML = '<div class=\"text-center p-4\"><p>No user flow data available<\/p><\/div>';\n return;\n }\n \n \/\/ Use column-based layout (original design)\n renderOldColumnLayout(data);\n}\n\n\/\/ Transform raw Matomo response to column-based format\nasync function transformRawResponseToColumnFormat(rawResponse) {\n const interactions = [];\n const othersNodesToFetch = []; \/\/ Store \"Others\" nodes that need fetching\n \n rawResponse.forEach((depthRow) => {\n const depth = parseInt(depthRow.label, 10);\n const nbVisits = depthRow.nb_visits || 0;\n const nbProceeded = depthRow.nb_proceeded || 0;\n const nbExits = depthRow.nb_exits || 0;\n const idSubtable = depthRow.idsubdatatable || null;\n \n const pages = [];\n \n if (depthRow.subtable && Array.isArray(depthRow.subtable)) {\n depthRow.subtable.forEach((pageRow) => {\n const pageLabel = pageRow.label || '';\n const pageVisits = pageRow.nb_visits || 0;\n const pageExits = pageRow.nb_exits || pageRow.exit_nb_visits || 0;\n const pageProceeded = pageRow.nb_proceeded || 0;\n const pageIdSubtable = pageRow.idsubdatatable || null;\n const isSummary = isSummaryNode(pageLabel);\n \n \/\/ Store flow data (subtable) for connecting lines - Matomo's logic\n const flowTargets = [];\n if (pageRow.subtable && Array.isArray(pageRow.subtable)) {\n pageRow.subtable.forEach((targetRow) => {\n flowTargets.push({\n label: targetRow.label || '',\n visits: targetRow.nb_visits || 0\n });\n });\n }\n \n \/\/ For \"Others\" nodes, try to expand if we have subtable data or idSubtable\n if (isSummary) {\n\n \n \/\/ First try to expand from subtable if available\n if (pageRow.subtable && Array.isArray(pageRow.subtable) && pageRow.subtable.length > 0) {\n\n \/\/ Expand \"Others\" to show actual URLs from subtable\n pageRow.subtable.forEach((targetRow) => {\n const targetLabel = targetRow.label || '';\n const targetVisits = targetRow.nb_visits || 0;\n const targetExits = targetRow.nb_exits || 0;\n const targetProceeded = targetRow.nb_proceeded || 0;\n \n \/\/ Skip if this is also an \"Others\" node\n if (!isSummaryNode(targetLabel) && (targetVisits > 0 || targetLabel)) {\n pages.push({\n url: targetLabel,\n visits: targetVisits,\n exits: targetExits,\n proceeded: targetProceeded,\n idSubtable: targetRow.idsubdatatable || null,\n flowTargets: targetRow.subtable ? targetRow.subtable.map(t => ({\n label: t.label || '',\n visits: t.nb_visits || 0\n })) : []\n });\n }\n });\n } else if (pageIdSubtable) {\n \/\/ If no subtable but we have idSubtable, store for fetching\n\n othersNodesToFetch.push({\n depth: depth,\n idSubtable: pageIdSubtable,\n pageVisits: pageVisits,\n pageExits: pageExits,\n pageProceeded: pageProceeded\n });\n } else {\n\n }\n \/\/ Don't add \"Others\" itself to the pages list\n } else if ((pageVisits > 0 || pageLabel) && !isSummaryNode(pageLabel)) {\n \/\/ Regular page (not \"Others\") - double check it's not a summary node\n pages.push({\n url: pageLabel,\n visits: pageVisits,\n exits: pageExits,\n proceeded: pageProceeded,\n idSubtable: pageIdSubtable,\n flowTargets: flowTargets \/\/ Store which pages this flows to\n });\n } else if (isSummaryNode(pageLabel)) {\n \/\/ Safety check: if somehow we got here with an \"Others\" node, skip it\n\n }\n });\n }\n \n if (pages.length > 0 || nbVisits > 0) {\n interactions.push({\n totalVisits: nbVisits,\n proceeded: nbProceeded,\n exits: nbExits,\n pages: pages,\n idSubtable: idSubtable,\n depth: depth \/\/ Store depth for flow line matching\n });\n }\n });\n \n \/\/ Fetch \"Others\" nodes that don't have subtable data\n let fetchedOthersCount = 0;\n if (othersNodesToFetch.length > 0 && apiConfig) {\n\n for (const othersNode of othersNodesToFetch) {\n try {\n const requestData = {\n url: apiConfig.url,\n token: apiConfig.token,\n siteId: parseInt(apiConfig.siteId),\n period: apiConfig.period,\n date: apiConfig.date,\n interactionPosition: othersNode.depth.toString(),\n idSubtable: othersNode.idSubtable,\n dataSource: document.getElementById('flowType')?.value || 'pageUrls',\n segment: apiConfig.segment || ''\n };\n \n\n \n const response = await fetch('\/matomo-api\/interaction-details', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n },\n body: JSON.stringify(requestData)\n });\n \n const result = await response.json();\n \n if (result.success && result.data && result.data.length > 0) {\n\n \/\/ Add the fetched pages to the corresponding interaction\n const interactionIndex = othersNode.depth - 1;\n if (interactions[interactionIndex]) {\n \/\/ Add fetched pages to the interaction\n result.data.forEach((pageData) => {\n \/\/ Skip if this is also an \"Others\" node\n if (!isSummaryNode(pageData.url)) {\n interactions[interactionIndex].pages.push({\n url: pageData.url || '',\n visits: pageData.visits || 0,\n exits: pageData.exits || 0,\n proceeded: pageData.proceeded || 0,\n idSubtable: null,\n flowTargets: []\n });\n fetchedOthersCount++;\n }\n });\n }\n } else {\n\n }\n } catch (error) {\n\n }\n }\n \n if (fetchedOthersCount > 0) {\n\n }\n }\n \n \/\/ Final safety check: remove any \"Others\" nodes that might have slipped through\n interactions.forEach((interaction, index) => {\n if (interaction.pages) {\n interaction.pages = interaction.pages.filter(page => !isSummaryNode(page.url));\n }\n });\n \n return { interactions: interactions, rawResponse: rawResponse };\n}\n\n\/\/ Render Sankey-style diagram (Matomo style with vertical nodes and curved links)\nfunction renderSankeyDiagram(svgElement, nodes, links, depthNodes, maxNodeLength, numSteps) {\n \/\/ Clear previous visualization\n const svg = d3.select(svgElement.node ? svgElement.node() : '#usersFlowSankey');\n svg.selectAll(\"*\").remove();\n \n const NODE_WIDTH = 200;\n const NODE_PADDING = 40;\n const DEPTH_WIDTH = 350;\n \n const margin = { top: 70, right: 20, bottom: 20, left: 5 };\n const width = 550 + (numSteps - 2) * DEPTH_WIDTH + 150;\n const sankeyWidth = width - 150;\n const height = maxNodeLength * 100 + margin.top;\n \n \/\/ Set SVG dimensions\n svg.attr('width', width + margin.left + margin.right)\n .attr('height', height + margin.top + margin.bottom);\n \n const g = svg.append('g')\n .attr('transform', `translate(${margin.left},${margin.top})`);\n \n \/\/ Create gradients (Matomo style)\n const normalGradient = g.append('defs')\n .append('linearGradient')\n .attr('id', 'normalGradient')\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '0%')\n .attr('y2', '100%');\n \n normalGradient.append('stop')\n .attr('offset', '0%')\n .attr('stop-color', '#F2FFE9')\n .attr('stop-opacity', 1);\n \n normalGradient.append('stop')\n .attr('offset', '100%')\n .attr('stop-color', '#84D04D')\n .attr('stop-opacity', 1);\n \n const pageOutGradient = g.append('defs')\n .append('linearGradient')\n .attr('id', 'pageOutGradient')\n .attr('x1', '0%')\n .attr('y1', '0%')\n .attr('x2', '0%')\n .attr('y2', '100%');\n \n pageOutGradient.append('stop')\n .attr('offset', '0%')\n .attr('stop-color', '#FCE8E8')\n .attr('stop-opacity', 1);\n \n pageOutGradient.append('stop')\n .attr('offset', '100%')\n .attr('stop-color', '#FA5858')\n .attr('stop-opacity', 1);\n \n \/\/ Simple Sankey layout: position nodes vertically by depth\n const nodePositions = {};\n const depthGroups = {};\n \n \/\/ Group nodes by depth\n nodes.forEach(node => {\n if (!depthGroups[node.depth]) {\n depthGroups[node.depth] = [];\n }\n depthGroups[node.depth].push(node);\n });\n \n \/\/ Position nodes vertically within each depth\n Object.keys(depthGroups).forEach(depth => {\n const depthNodes = depthGroups[depth];\n let currentY = 0;\n \n depthNodes.forEach(node => {\n const nodeHeight = Math.max(20, (node.totalIn \/ 100) * 50); \/\/ Scale by visits\n nodePositions[node.node] = {\n x: depth * DEPTH_WIDTH,\n y: currentY,\n height: nodeHeight,\n width: NODE_WIDTH\n };\n currentY += nodeHeight + NODE_PADDING;\n });\n });\n \n \/\/ Draw depth headers\n depthNodes.forEach((depthNode, idx) => {\n const depthInfo = g.append('g')\n .attr('class', `depthInfo depth${depthNode.depth + 1}`);\n \n const depthText = depthInfo.append('text')\n .attr('x', depthNode.depth * DEPTH_WIDTH)\n .attr('y', -60)\n .attr('fill', 'black')\n .style('font-weight', 'bold')\n .style('font-size', '14px')\n .text(`Interaction ${depthNode.depth + 1}`);\n \n depthInfo.append('text')\n .attr('x', depthNode.depth * DEPTH_WIDTH)\n .attr('y', -40)\n .attr('fill', 'black')\n .style('font-size', '13px')\n .text(`${depthNode.totalIn} visits, ${depthNode.totalOut} proceeded, ${depthNode.totalExits} exits`);\n });\n \n \/\/ Draw links (curved paths)\n const linkGroup = g.append('g').attr('class', 'links');\n \n links.forEach(link => {\n const sourceNode = nodes.find(n => n.node === link.source);\n const targetNode = nodes.find(n => n.node === link.target);\n \n if (!sourceNode || !targetNode) return;\n \n const sourcePos = nodePositions[link.source];\n const targetPos = nodePositions[link.target];\n \n if (!sourcePos || !targetPos) return;\n \n const sourceX = sourcePos.x + sourcePos.width;\n const sourceY = sourcePos.y + sourcePos.height \/ 2;\n const targetX = targetPos.x;\n const targetY = targetPos.y + targetPos.height \/ 2;\n \n \/\/ Create curved path (Matomo style)\n const path = d3.path();\n const midX = (sourceX + targetX) \/ 2;\n path.moveTo(sourceX, sourceY);\n path.bezierCurveTo(midX, sourceY, midX, targetY, targetX, targetY);\n \n const linkColor = isOutNode(targetNode.name) ? '#ec5540' : '#A9E2F3';\n const linkWidth = Math.max(1, Math.min(15, link.value \/ 10));\n \n linkGroup.append('path')\n .attr('d', path.toString())\n .attr('stroke', linkColor)\n .attr('stroke-width', linkWidth)\n .attr('fill', 'none')\n .attr('opacity', 0.8)\n .attr('class', isOutNode(targetNode.name) ? 'outNodeLink' : 'link');\n });\n \n \/\/ Draw nodes (vertical bars)\n const nodeGroup = g.append('g').attr('class', 'nodes');\n \n nodes.forEach(node => {\n if (isOutNode(node.name)) return; \/\/ Skip out nodes for now\n \n const pos = nodePositions[node.node];\n if (!pos) return;\n \n const nodeG = nodeGroup.append('g')\n .attr('class', `node nodeDepth${node.depth + 1}`)\n .attr('transform', `translate(${pos.x},${pos.y})`)\n .style('cursor', 'pointer')\n .on('click', () => {\n if (node.idSubtable) {\n showInteractionDetails(node.depth + 1, node.idSubtable);\n }\n });\n \n \/\/ Draw node rectangle with gradient\n nodeG.append('rect')\n .attr('width', pos.width)\n .attr('height', pos.height)\n .attr('fill', 'url(#normalGradient)')\n .attr('stroke', '#333')\n .attr('stroke-width', 1);\n \n \/\/ Node label\n let displayName = node.name;\n if (displayName.length > 33) {\n displayName = displayName.substr(0, 15) + '...' + displayName.substr(-15);\n }\n \n nodeG.append('text')\n .attr('x', 4)\n .attr('y', -5)\n .attr('fill', 'black')\n .style('font-size', '12px')\n .style('font-family', 'Arial')\n .text(displayName);\n \n \/\/ Visit count\n nodeG.append('text')\n .attr('x', 4)\n .attr('y', 18)\n .attr('fill', 'black')\n .style('font-size', '12px')\n .style('font-family', 'Arial')\n .text(node.totalIn);\n });\n \n \/\/ Enable horizontal scrolling\n const scrollWrapper = document.querySelector('.flow-scroll-wrapper');\n if (scrollWrapper) {\n scrollWrapper.scrollLeft = 0;\n }\n}\n\n\/\/ Render old column-based layout (fallback)\nfunction renderOldColumnLayout(data) {\n const flowDiagram = document.getElementById('flowDiagram');\n const svg = d3.select('#usersFlowSankey');\n \n \/\/ Check if D3 is loaded\n if (typeof d3 === 'undefined') {\n\n flowDiagram.innerHTML = '<div class=\"text-center p-4\"><p>Visualization library not loaded<\/p><\/div>';\n return;\n }\n \n try {\n \/\/ Clear previous visualization\n svg.selectAll(\"*\").remove();\n \n \/\/ Set up dimensions - show 2-3 columns at a time, scroll for rest\n const visibleColumns = 2.5; \/\/ Show 2.5 columns at a time\n const containerViewportWidth = flowDiagram.clientWidth || 1200;\n \/\/ Ensure minimum column width to accommodate wider bars (250px min bar + margins)\n const minColumnWidth = 300; \/\/ Minimum column width for proper bar display\n const columnWidth = Math.max(minColumnWidth, Math.floor(containerViewportWidth \/ visibleColumns));\n const totalWidth = data.interactions.length * columnWidth;\n const containerHeight = 600; \/\/ Reduced from 900px\n const margin = { top: 50, right: 20, bottom: 20, left: 20 };\n const barMaxWidth = columnWidth - 50; \/\/ More margin for wider bars\n \n svg.attr('width', totalWidth).attr('height', containerHeight);\n \n const g = svg.append('g')\n .attr('transform', `translate(${margin.left}, ${margin.top})`);\n \n \/\/ Create flow lines group FIRST so it renders behind bars\n const flowLinesGroup = g.append(\"g\").attr(\"class\", \"flow-lines\");\n \n \/\/ Add drag-to-pan functionality\n let isDragging = false;\n let dragStart = { x: 0, y: 0 };\n let currentTransform = { x: 0, y: 0 };\n \n const dragBehavior = d3.drag()\n .on(\"start\", function(event) {\n isDragging = true;\n dragStart.x = event.x - currentTransform.x;\n dragStart.y = event.y - currentTransform.y;\n \/\/ Change cursor to indicate dragging\n svg.style(\"cursor\", \"grabbing\");\n \/\/ Prevent default scrolling while dragging\n event.sourceEvent.preventDefault();\n event.sourceEvent.stopPropagation();\n })\n .on(\"drag\", function(event) {\n currentTransform.x = event.x - dragStart.x;\n currentTransform.y = event.y - dragStart.y;\n \n \/\/ Calculate drag limits - asymmetric for better navigation\n \/\/ When dragging right, we move content left (negative X transform)\n const viewportWidth = flowDiagram.clientWidth || containerViewportWidth || 1200;\n const contentWidth = totalWidth; \/\/ Full width of all interactions\n const maxDragXLeft = (viewportWidth * 0.1); \/\/ 10% left limit to prevent empty space\n \/\/ Right limit: allow dragging left (negative) to show all content\n \/\/ Content starts at margin.left, so we can drag left by (contentWidth - viewportWidth + margin.left)\n const maxDragXRight = -(contentWidth - viewportWidth + margin.left);\n const maxDragY = (containerHeight * 0.1); \/\/ 10% of height for vertical panning\n \n \/\/ Constrain drag to limits (asymmetric for X)\n \/\/ maxDragXLeft is positive (right drag), maxDragXRight is negative (left drag)\n currentTransform.x = Math.max(maxDragXRight, Math.min(maxDragXLeft, currentTransform.x));\n currentTransform.y = Math.max(-maxDragY, Math.min(maxDragY, currentTransform.y));\n \n \/\/ Apply transform to the main group\n g.attr(\"transform\", `translate(${margin.left + currentTransform.x}, ${margin.top + currentTransform.y})`);\n \n \/\/ Prevent default scrolling\n event.sourceEvent.preventDefault();\n })\n .on(\"end\", function() {\n isDragging = false;\n \/\/ Reset cursor\n svg.style(\"cursor\", \"grab\");\n });\n \n \/\/ Apply drag to the SVG\n svg.call(dragBehavior);\n svg.style(\"cursor\", \"grab\");\n \n \/\/ Also allow dragging on the container background\n const flowContainer = document.getElementById('usersFlowContainer');\n if (flowContainer) {\n \/\/ Make container draggable via mouse events as fallback\n let containerIsDragging = false;\n let containerDragStart = { x: 0, y: 0 };\n \n flowContainer.addEventListener('mousedown', function(e) {\n \/\/ Only start drag if clicking on background (not on bars or text)\n if (e.target === flowContainer || e.target === flowContainer.querySelector('.flow-scroll-wrapper') || e.target === svg.node()) {\n containerIsDragging = true;\n containerDragStart.x = e.clientX - currentTransform.x;\n containerDragStart.y = e.clientY - currentTransform.y;\n flowContainer.style.cursor = 'grabbing';\n }\n });\n \n flowContainer.addEventListener('mousemove', function(e) {\n if (containerIsDragging) {\n currentTransform.x = e.clientX - containerDragStart.x;\n currentTransform.y = e.clientY - containerDragStart.y;\n \n \/\/ Calculate drag limits - asymmetric for better navigation\n \/\/ When dragging right, we move content left (negative X transform)\n const viewportWidth = flowDiagram.clientWidth || containerViewportWidth || 1200;\n const contentWidth = totalWidth; \/\/ Full width of all interactions\n const maxDragXLeft = (viewportWidth * 0.1); \/\/ 10% left limit to prevent empty space\n \/\/ Right limit: allow dragging left (negative) to show all content\n \/\/ Content starts at margin.left, so we can drag left by (contentWidth - viewportWidth + margin.left)\n const maxDragXRight = -(contentWidth - viewportWidth + margin.left);\n const maxDragY = (containerHeight * 0.1); \/\/ 10% of height for vertical panning\n \n \/\/ Constrain drag to limits (asymmetric for X)\n \/\/ maxDragXLeft is positive (right drag), maxDragXRight is negative (left drag)\n currentTransform.x = Math.max(maxDragXRight, Math.min(maxDragXLeft, currentTransform.x));\n currentTransform.y = Math.max(-maxDragY, Math.min(maxDragY, currentTransform.y));\n \n g.attr(\"transform\", `translate(${margin.left + currentTransform.x}, ${margin.top + currentTransform.y})`);\n }\n });\n \n flowContainer.addEventListener('mouseup', function() {\n containerIsDragging = false;\n flowContainer.style.cursor = 'grab';\n });\n \n flowContainer.addEventListener('mouseleave', function() {\n containerIsDragging = false;\n flowContainer.style.cursor = 'grab';\n });\n \n flowContainer.style.cursor = 'grab';\n }\n \n \/\/ Calculate max visits for scaling\n const maxVisits = Math.max(...data.interactions.flatMap(i => i.pages.map(p => p.visits)), 1);\n \n \/\/ Store page positions for drawing flow lines and interaction metadata\n const pagePositions = [];\n const interactionMetadata = [];\n \n \/\/ First pass: Draw all bars and collect positions\n data.interactions.forEach((interaction, stepIndex) => {\n const stepX = stepIndex * columnWidth;\n const isLast = stepIndex === data.interactions.length - 1;\n let currentY = 0;\n \n \/\/ Store interaction metadata\n interactionMetadata.push({\n stepIndex: stepIndex,\n stepNumber: stepIndex + 1,\n totalVisits: interaction.totalVisits,\n proceeded: interaction.proceeded,\n exits: interaction.exits,\n idSubtable: interaction.idSubtable || null\n });\n \n \/\/ Create column group for click handling (but not when dragging)\n const columnGroup = g.append(\"g\")\n .attr(\"class\", \"interaction-column-group\")\n .attr(\"data-interaction\", stepIndex + 1)\n .style(\"cursor\", \"pointer\")\n .on(\"click\", function(e) {\n \/\/ Only trigger click if we're not dragging\n if (!isDragging) {\n showInteractionDetails(stepIndex + 1, interaction.idSubtable || null);\n }\n });\n \n \/\/ Modern column header with background\n const headerBg = columnGroup.append(\"rect\")\n .attr(\"x\", stepX + 5)\n .attr(\"y\", currentY - 15)\n .attr(\"width\", columnWidth - 10)\n .attr(\"height\", 50)\n .attr(\"fill\", \"rgba(156, 163, 175, 0.1)\")\n .attr(\"rx\", 8)\n .attr(\"stroke\", \"rgba(156, 163, 175, 0.2)\")\n .attr(\"stroke-width\", 1);\n \n \/\/ Column header text - larger and bolder\n columnGroup.append(\"text\")\n .attr(\"x\", stepX + (columnWidth \/ 2))\n .attr(\"y\", currentY + 5)\n .attr(\"text-anchor\", \"middle\")\n .attr(\"fill\", \"var(--text-color)\")\n .style(\"font-size\", \"16px\") \/\/ Increased from 14px\n .style(\"font-weight\", \"700\")\n .style(\"font-family\", \"system-ui, -apple-system, sans-serif\")\n .text(`Interaction ${stepIndex + 1}${isLast ? '\u25b2' : ''}`);\n \n currentY += 25;\n \n \/\/ Summary text - larger and more readable\n const summaryText = `${interaction.totalVisits} visits${interaction.proceeded ? `, ${interaction.proceeded} proceeded` : ''}${interaction.exits ? `, ${interaction.exits} exits` : ''}`;\n columnGroup.append(\"text\")\n .attr(\"x\", stepX + (columnWidth \/ 2))\n .attr(\"y\", currentY)\n .attr(\"text-anchor\", \"middle\")\n .attr(\"fill\", \"var(--text-muted)\")\n .style(\"font-size\", \"13px\") \/\/ Increased from 11px\n .style(\"font-weight\", \"500\")\n .style(\"font-family\", \"system-ui, -apple-system, sans-serif\")\n .text(summaryText);\n \n currentY += 35; \/\/ More spacing before bars\n \n \/\/ Store positions for this step\n const stepPositions = [];\n \n \/\/ Draw pages as horizontal bars with modern design\n interaction.pages.forEach((page, pageIndex) => {\n const barWidth = (page.visits \/ maxVisits) * barMaxWidth;\n const barHeight = 36; \/\/ Increased height for better visibility\n const barSpacing = 8; \/\/ Spacing between bars\n const barX = stepX + 15;\n const barY = currentY;\n \n \/\/ Visit bar (green) - make clickable (but not when dragging)\n const barGroup = columnGroup.append(\"g\")\n .attr(\"class\", \"interaction-bar\")\n .style(\"cursor\", \"pointer\")\n .on(\"click\", function(e) {\n \/\/ Only trigger click if we're not dragging\n if (!isDragging) {\n e.stopPropagation(); \/\/ Prevent column click\n showInteractionDetails(stepIndex + 1, interaction.idSubtable || null);\n }\n });\n \n \/\/ Calculate minimum bar width to fit both text and number\n const textPadding = 18; \/\/ Generous padding from left edge\n const rightPadding = 18; \/\/ Generous padding from right edge\n const numberWidth = 55; \/\/ Space for visit count (including padding)\n const minBarWidth = 280; \/\/ Increased minimum bar width\n \n \/\/ Create temporary text element to measure actual width\n const tempText = g.append(\"text\")\n .attr(\"x\", -1000) \/\/ Off-screen\n .attr(\"y\", -1000)\n .style(\"font-size\", \"13px\")\n .style(\"font-weight\", \"600\")\n .style(\"font-family\", \"system-ui, -apple-system, sans-serif\")\n .style(\"visibility\", \"hidden\")\n .text(page.url);\n \n \/\/ Measure actual text width\n const textBBox = tempText.node().getBBox();\n const actualTextWidth = textBBox.width;\n \n \/\/ Remove temporary text\n tempText.remove();\n \n \/\/ Calculate required width based on actual text measurement\n const minGap = 15; \/\/ Minimum gap between text and number\n const requiredWidth = actualTextWidth + textPadding + numberWidth + rightPadding + minGap;\n const calculatedBarWidth = Math.max(barWidth, Math.max(minBarWidth, requiredWidth));\n \n \/\/ Ensure bar is wide enough\n const visitBarWidth = calculatedBarWidth;\n \n \/\/ Modern rounded bar with shadow effect\n const barRect = barGroup.append(\"rect\")\n .attr(\"x\", barX)\n .attr(\"y\", barY)\n .attr(\"width\", visitBarWidth)\n .attr(\"height\", barHeight)\n .attr(\"fill\", \"#4CAF50\")\n .attr(\"stroke\", \"#2E7D32\")\n .attr(\"stroke-width\", 1.5)\n .attr(\"rx\", 6)\n .attr(\"opacity\", 0.95);\n \n \/\/ Show full text without truncation - positioned with proper padding\n const pageLabel = page.url;\n \n \/\/ Page label - full text, positioned with proper padding\n const labelText = barGroup.append(\"text\")\n .attr(\"x\", barX + textPadding)\n .attr(\"y\", barY + 22)\n .attr(\"fill\", \"#fff\")\n .style(\"font-size\", \"13px\")\n .style(\"font-weight\", \"600\")\n .style(\"pointer-events\", \"none\")\n .style(\"font-family\", \"system-ui, -apple-system, sans-serif\")\n .text(pageLabel);\n \n \/\/ Visit count - positioned on the right with proper padding\n const visitCountText = barGroup.append(\"text\")\n .attr(\"x\", barX + visitBarWidth - rightPadding)\n .attr(\"y\", barY + 22)\n .attr(\"fill\", \"#fff\")\n .style(\"font-size\", \"13px\")\n .style(\"font-weight\", \"700\")\n .style(\"text-anchor\", \"end\")\n .style(\"pointer-events\", \"none\")\n .style(\"font-family\", \"system-ui, -apple-system, sans-serif\")\n .text(page.visits);\n \n \/\/ Exit bar (red) - below the visit bar with better spacing\n if (page.exits && page.exits > 0) {\n const exitBarWidth = (page.exits \/ maxVisits) * barMaxWidth;\n const exitBarHeight = 8; \/\/ Slightly taller for visibility\n const exitBarY = barY + barHeight + 4; \/\/ More spacing from main bar\n \n \/\/ Exit bar\n barGroup.append(\"rect\")\n .attr(\"x\", barX)\n .attr(\"y\", exitBarY)\n .attr(\"width\", Math.max(exitBarWidth, 40))\n .attr(\"height\", exitBarHeight)\n .attr(\"fill\", \"#f44336\")\n .attr(\"rx\", 4)\n .attr(\"opacity\", 0.9);\n \n \/\/ Exit label - positioned outside the bar, larger font\n const exitBarActualWidth = Math.max(exitBarWidth, 40);\n barGroup.append(\"text\")\n .attr(\"x\", barX + exitBarActualWidth + 8) \/\/ More space from bar\n .attr(\"y\", exitBarY + 6)\n .attr(\"fill\", \"#f44336\")\n .style(\"font-size\", \"12px\") \/\/ Increased from 9px\n .style(\"font-weight\", \"600\")\n .style(\"pointer-events\", \"none\")\n .style(\"font-family\", \"system-ui, -apple-system, sans-serif\")\n .text(`${page.exits} exits`);\n }\n \n \/\/ Store position for flow lines (will be updated if bar is extended)\n stepPositions.push({\n x: barX + visitBarWidth, \/\/ Right edge of bar (will update if extended)\n y: barY + (barHeight \/ 2), \/\/ Center of bar vertically\n visits: page.visits,\n page: page,\n pageIndex: pageIndex,\n barY: barY, \/\/ Store full bar position for multiple flow lines\n barHeight: barHeight,\n barTop: barY, \/\/ Top edge of bar\n barBottom: barY + barHeight \/\/ Bottom edge of bar\n });\n \n \/\/ Increased spacing between bars\n currentY += barHeight + (page.exits ? 20 : 12) + barSpacing;\n });\n \n pagePositions.push(stepPositions);\n });\n \n \/\/ Wait a bit for bars to be sized, then draw flow lines using Matomo's logic\n setTimeout(() => {\n \/\/ Second pass: Draw flow lines (Matomo-style light blue curved lines)\n \/\/ Use Matomo's subtable structure to determine which pages connect to which\n \/\/ flowLinesGroup was already created above, so bars will render on top\n \n data.interactions.forEach((interaction, stepIndex) => {\n const isLast = stepIndex === data.interactions.length - 1;\n if (isLast) return; \/\/ No flow lines from the last step\n \n const stepPositions = pagePositions[stepIndex] || [];\n const nextStepPositions = pagePositions[stepIndex + 1] || [];\n const nextStepX = (stepIndex + 1) * columnWidth;\n \n if (stepPositions.length === 0 || nextStepPositions.length === 0) return;\n \n \/\/ Use Matomo's flow logic: each source page has flowTargets array\n \/\/ Also connect all pages to all pages in next step (fallback for missing flowTargets)\n stepPositions.forEach((sourcePos, sourceIndex) => {\n const sourcePage = sourcePos.page;\n \n \/\/ Track which targets we've connected to\n const connectedTargets = new Set();\n \n \/\/ First, use flowTargets if available (exact Matomo connections)\n if (sourcePage.flowTargets && sourcePage.flowTargets.length > 0) {\n sourcePage.flowTargets.forEach((flowTarget, flowIndex) => {\n \/\/ Find matching target page in next step by label - more flexible matching\n let targetPos = nextStepPositions.find(pos => \n pos.page.url === flowTarget.label || \n pos.page.url === flowTarget.label.replace(\/^https?:\\\/\\\/\/, '') ||\n flowTarget.label === pos.page.url ||\n flowTarget.label.replace(\/^https?:\\\/\\\/\/, '') === pos.page.url\n );\n \n \/\/ If exact match not found, try partial matching\n if (!targetPos) {\n targetPos = nextStepPositions.find(pos => {\n const sourceUrl = (sourcePage.url || '').toLowerCase();\n const targetUrl = (pos.page.url || '').toLowerCase();\n const flowLabel = (flowTarget.label || '').toLowerCase();\n \n return targetUrl.includes(flowLabel) || \n flowLabel.includes(targetUrl) ||\n (targetUrl && flowLabel && (\n targetUrl.split('\/').pop() === flowLabel.split('\/').pop() ||\n targetUrl.split('\/').slice(-2).join('\/') === flowLabel.split('\/').slice(-2).join('\/')\n ));\n });\n }\n \n if (targetPos && flowTarget.visits > 0) {\n connectedTargets.add(targetPos.pageIndex);\n drawFlowLine(sourcePos, targetPos, sourceIndex, flowIndex, flowTarget.visits, nextStepX, flowLinesGroup, sourcePage.flowTargets.length);\n }\n });\n }\n \n \/\/ Fallback: connect to all pages in next step if no flowTargets or to show all possible flows\n \/\/ Limit to top pages to avoid too many lines\n if (sourcePage.flowTargets.length === 0 || nextStepPositions.length <= 10) {\n const maxTargets = Math.min(5, nextStepPositions.length);\n const sortedTargets = [...nextStepPositions]\n .sort((a, b) => b.visits - a.visits)\n .slice(0, maxTargets);\n \n sortedTargets.forEach((targetPos, targetIndex) => {\n \/\/ Only connect if not already connected via flowTargets\n if (!connectedTargets.has(targetPos.pageIndex)) {\n const flowValue = targetPos.visits;\n drawFlowLine(sourcePos, targetPos, sourceIndex, targetIndex + sourcePage.flowTargets.length, flowValue, nextStepX, flowLinesGroup, sortedTargets.length);\n }\n });\n }\n });\n \n \/\/ Helper function to draw a flow line with color gradient\n function drawFlowLine(sourcePos, targetPos, sourceIndex, flowIndex, flowValue, nextStepX, flowLinesGroup, maxFlows) {\n \/\/ Start from right edge of source box (end of the bar)\n const startX = sourcePos.x;\n \n \/\/ Distribute flow lines vertically along the source bar height\n \/\/ Ensure lines stay within the bar boundaries (barY to barY + barHeight)\n const lineSpacing = sourcePos.barHeight \/ (maxFlows + 1);\n const startY = Math.max(\n sourcePos.barY + 2, \/\/ Minimum: 2px from top of bar\n Math.min(\n sourcePos.barY + sourcePos.barHeight - 2, \/\/ Maximum: 2px from bottom of bar\n sourcePos.barY + (lineSpacing * (flowIndex + 1))\n )\n );\n \n \/\/ End at left edge of target box\n const endX = nextStepX + 15; \/\/ Left edge of next column\n \/\/ Ensure end point is within target bar boundaries\n const targetBarTop = targetPos.barTop || targetPos.barY || (targetPos.y - (targetPos.barHeight || 18));\n const targetBarBottom = targetPos.barBottom || (targetBarTop + (targetPos.barHeight || 36));\n const endY = Math.max(\n targetBarTop + 2, \/\/ Minimum: 2px from top of bar\n Math.min(\n targetBarBottom - 2, \/\/ Maximum: 2px from bottom of bar\n targetPos.y \/\/ Center of target bar\n )\n );\n \n \/\/ Calculate line width based on actual flow value\n const maxFlow = Math.max(...data.interactions.flatMap(i => \n i.pages.flatMap(p => {\n if (p.flowTargets && p.flowTargets.length > 0) {\n return p.flowTargets.map(ft => ft.visits);\n }\n return [p.visits];\n })\n ), 1);\n const lineWidth = Math.max(2, Math.min(18, (flowValue \/ maxFlow) * 16));\n \n \/\/ Adjust startY and endY to account for line width so thick lines don't extend beyond bars\n \/\/ Line center should be at least (lineWidth\/2) pixels from bar edges\n const halfLineWidth = lineWidth \/ 2;\n const adjustedStartY = Math.max(\n sourcePos.barY + halfLineWidth + 2,\n Math.min(\n sourcePos.barY + sourcePos.barHeight - halfLineWidth - 2,\n startY\n )\n );\n const adjustedEndY = Math.max(\n targetBarTop + halfLineWidth + 2,\n Math.min(\n targetBarBottom - halfLineWidth - 2,\n endY\n )\n );\n \n \/\/ Create smooth curved path (bezier curve) - Matomo style\n const midX = (startX + endX) \/ 2;\n const path = d3.path();\n path.moveTo(startX, adjustedStartY);\n path.bezierCurveTo(\n midX, adjustedStartY, \/\/ Control point 1: horizontal from start\n midX, adjustedEndY, \/\/ Control point 2: horizontal to end\n endX, adjustedEndY \/\/ End point\n );\n \n \/\/ Draw curved line with Matomo-style light blue color\n flowLinesGroup.append(\"path\")\n .attr(\"d\", path.toString())\n .attr(\"stroke\", \"#add8e6\") \/\/ Light blue like Matomo\n .attr(\"stroke-width\", lineWidth)\n .attr(\"fill\", \"none\")\n .attr(\"opacity\", 0.6)\n .style(\"pointer-events\", \"none\")\n .style(\"stroke-linecap\", \"round\")\n .style(\"stroke-linejoin\", \"round\");\n }\n });\n }, 100); \/\/ Small delay to ensure bars are sized\n \n \/\/ Scroll to beginning initially\n const scrollWrapper = document.querySelector('.flow-scroll-wrapper');\n if (scrollWrapper) {\n scrollWrapper.scrollLeft = 0;\n }\n \n } catch (error) {\n\n flowDiagram.innerHTML = '<div class=\"text-center p-4\"><p>Error rendering user flow visualization<\/p><\/div>';\n }\n}\n\n\/\/ Show interaction details modal\nasync function showInteractionDetails(interactionNumber, idSubtable) {\n const modal = document.getElementById('interactionDetailsModal');\n const modalTitle = document.getElementById('interactionModalTitle');\n const modalBody = document.getElementById('interactionModalBody');\n \n modalTitle.textContent = `Users Flow - Interaction ${interactionNumber}`;\n modalBody.innerHTML = '<div class=\"loading-text\">Loading details...<\/div>';\n modal.classList.add('show');\n \n try {\n \/\/ Get interaction data from stored userFlowData\n \/\/ Check both new format (interactions) and fallback to rawResponse\n let interaction = null;\n \n if (userFlowData && userFlowData.interactions && userFlowData.interactions[interactionNumber - 1]) {\n interaction = userFlowData.interactions[interactionNumber - 1];\n } else if (userFlowData && userFlowData.rawResponse && userFlowData.rawResponse[interactionNumber - 1]) {\n \/\/ Fallback: transform raw response on the fly\n const depthRow = userFlowData.rawResponse[interactionNumber - 1];\n const pages = [];\n \n if (depthRow.subtable && Array.isArray(depthRow.subtable)) {\n depthRow.subtable.forEach((pageRow) => {\n const pageLabel = pageRow.label || '';\n const pageVisits = pageRow.nb_visits || 0;\n const pageExits = pageRow.nb_exits || pageRow.exit_nb_visits || 0;\n const pageProceeded = pageRow.nb_proceeded || 0;\n \n if (pageVisits > 0 || pageLabel) {\n pages.push({\n url: pageLabel,\n visits: pageVisits,\n exits: pageExits,\n proceeded: pageProceeded\n });\n }\n });\n }\n \n interaction = {\n pages: pages,\n totalVisits: depthRow.nb_visits || 0,\n proceeded: depthRow.nb_proceeded || 0,\n exits: depthRow.nb_exits || 0\n };\n }\n \n if (!interaction || !interaction.pages || interaction.pages.length === 0) {\n \/\/ If we have idSubtable, try to fetch from API\n if (idSubtable && apiConfig) {\n try {\n const requestData = {\n url: apiConfig.url,\n token: apiConfig.token,\n siteId: parseInt(apiConfig.siteId),\n period: apiConfig.period,\n date: apiConfig.date,\n interactionPosition: interactionNumber.toString(),\n idSubtable: idSubtable,\n dataSource: document.getElementById('flowType')?.value || 'pageUrls',\n segment: apiConfig.segment || ''\n };\n \n const response = await fetch('\/matomo-api\/interaction-details', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application\/json',\n 'X-CSRF-TOKEN': document.querySelector('meta[name=\"csrf-token\"]').getAttribute('content')\n },\n body: JSON.stringify(requestData)\n });\n \n const result = await response.json();\n \n if (result.success && result.data && result.data.length > 0) {\n interaction = { pages: result.data };\n }\n } catch (apiError) {\n\n }\n }\n \n if (!interaction || !interaction.pages || interaction.pages.length === 0) {\n modalBody.innerHTML = '<div class=\"empty-details\">No data available for this interaction.<\/div>';\n return;\n }\n }\n \n \/\/ Build table HTML\n let html = `\n <div class=\"table-responsive\">\n <table class=\"table table-hover\">\n <thead>\n <tr>\n <th>PAGE<\/th>\n <th>VISITS<\/th>\n <th>PROCEEDED<\/th>\n <th>EXITS<\/th>\n <\/tr>\n <\/thead>\n <tbody>\n `;\n \n interaction.pages.forEach(page => {\n html += `\n <tr>\n <td>${page.url || 'N\/A'}<\/td>\n <td>${page.visits || 0}<\/td>\n <td>${page.proceeded || '-'}<\/td>\n <td>${page.exits || 0}<\/td>\n <\/tr>\n `;\n });\n \n html += `\n <\/tbody>\n <\/table>\n <\/div>\n <div class=\"mt-3 text-muted\" style=\"font-size: 12px;\">\n Click on a row to see to which pages your visitors proceeded.\n <\/div>\n `;\n \n modalBody.innerHTML = html;\n \n } catch (error) {\n\n modalBody.innerHTML = '<div class=\"empty-details\">Error loading interaction details.<\/div>';\n }\n \n \/\/ Close on outside click\n modal.onclick = function(event) {\n if (event.target === modal) {\n closeInteractionDetails();\n }\n };\n \n \/\/ Close on Escape key\n document.addEventListener('keydown', function escapeHandler(event) {\n if (event.key === 'Escape') {\n closeInteractionDetails();\n document.removeEventListener('keydown', escapeHandler);\n }\n });\n}\n\n\/\/ Close interaction details modal\nfunction closeInteractionDetails() {\n const modal = document.getElementById('interactionDetailsModal');\n modal.classList.remove('show');\n}\n\n\/\/ Load sample user flow data (fallback)\nfunction loadSampleUserFlowData() {\n const sampleData = {\n interactions: [\n {\n totalVisits: 299,\n proceeded: 75,\n exits: 224,\n pages: [\n { url: 'be-online.ro', visits: 259, exits: 224 },\n { url: '\/gazduire-website', visits: 9 },\n { url: '\/contact', visits: 8 },\n { url: '\/portofoliu', visits: 7 },\n { url: '\/clients', visits: 5 },\n { url: 'Others (>6 pages)', visits: 11 }\n ]\n },\n {\n totalVisits: 75,\n proceeded: 35,\n exits: 40,\n pages: [\n { url: 'be-online.ro', visits: 39, exits: 40 },\n { url: '\/contact', visits: 10 },\n { url: '\/despre-noi', visits: 7 },\n { url: '\/portofoliu', visits: 4 },\n { url: '\/servicii', visits: 4 },\n { url: 'Others (>8 pages)', visits: 11 }\n ]\n },\n {\n totalVisits: 35,\n pages: [\n { url: 'be-online', visits: 11 },\n { url: '\/portofoli', visits: 4 },\n { url: '\/clients', visits: 3 },\n { url: '\/despre-i', visits: 3 },\n { url: '\/gazduire', visits: 3 },\n { url: 'Others', visits: 11 }\n ]\n }\n ]\n };\n \n updateUserFlowDisplay(sampleData);\n}\n\n\/\/ Setup transitions controls\nfunction setupTransitionsControls() {\n const transitionsType = document.getElementById('transitionsType');\n const transitionsPage = document.getElementById('transitionsPage');\n \n if (transitionsType) {\n transitionsType.addEventListener('change', () => {\n loadMatomoTransitions();\n });\n }\n \n if (transitionsPage) {\n transitionsPage.addEventListener('change', () => {\n loadMatomoTransitions();\n });\n }\n}\n\n\/\/ Setup user flow controls\nfunction setupUserFlowControls() {\n const flowDevice = document.getElementById('flowDevice');\n const flowSteps = document.getElementById('flowSteps');\n const flowType = document.getElementById('flowType');\n \n if (flowDevice) {\n flowDevice.addEventListener('change', () => {\n loadMatomoUserFlow();\n });\n }\n \n if (flowSteps) {\n flowSteps.addEventListener('change', () => {\n loadMatomoUserFlow();\n });\n }\n \n if (flowType) {\n flowType.addEventListener('change', () => {\n loadMatomoUserFlow();\n });\n }\n}\n\n\/\/ Cleanup on page unload\nwindow.addEventListener('beforeunload', function() {\n if (flowAnimationInterval) {\n clearInterval(flowAnimationInterval);\n }\n});\n\n\/\/ Download section as image\nasync function downloadSectionImage(sectionId, filename) {\n try {\n const section = document.getElementById(sectionId);\n if (!section) {\n showNotification('Section not found', 'error');\n return;\n }\n \n showNotification('Generating image...', 'info');\n \n \/\/ Find the content area - exclude header with buttons\n let contentArea = section.querySelector('.card-body');\n \n \/\/ For transitions and users flow, get the report div\n if (!contentArea || sectionId === 'transitionsSection' || sectionId === 'usersFlowSection') {\n contentArea = section.querySelector('.matomo-transitions-report') || \n section.querySelector('.matomo-users-flow');\n }\n \n \/\/ If it's a chart section, capture the chart properly\n const chartCanvas = section.querySelector('canvas');\n if (chartCanvas) {\n \/\/ Get chart instance\n const chartId = chartCanvas.id;\n let chart = null;\n if (chartId === 'visitsChart') chart = charts.visits;\n else if (chartId === 'countriesChart') chart = charts.countries;\n else if (chartId === 'topPagesChart') chart = charts.topPages;\n else if (chartId === 'devicesChart') chart = charts.devices;\n else if (chartId === 'referrersChart') chart = charts.referrers;\n else if (chartId === 'goalsChart') chart = charts.goals;\n \n \/\/ Create formatted container with title and chart\n const chartTitle = section.querySelector('.card-header .card-title')?.textContent || '';\n contentArea = document.createElement('div');\n contentArea.style.cssText = `\n background: #1A1A1A;\n padding: 2rem;\n border-radius: 12px;\n min-width: 600px;\n `;\n \n if (chartTitle) {\n const titleEl = document.createElement('h5');\n titleEl.textContent = chartTitle;\n titleEl.style.cssText = 'color: #FFFFFF; margin-bottom: 1.5rem; font-weight: 600; font-size: 1.1rem;';\n contentArea.appendChild(titleEl);\n }\n \n \/\/ Use Chart.js to get the chart as base64 image\n if (chart) {\n try {\n \/\/ Get chart as base64 image - this is the best quality\n const chartImage = chart.toBase64Image('image\/png', 1);\n const img = document.createElement('img');\n \n \/\/ Wait for image to load\n await new Promise((resolve, reject) => {\n img.onload = resolve;\n img.onerror = reject;\n img.src = chartImage;\n img.style.cssText = 'max-width: 100%; height: auto; display: block;';\n contentArea.appendChild(img);\n });\n } catch (e) {\n\n \/\/ Fallback: clone the canvas\n const canvasClone = chartCanvas.cloneNode(true);\n contentArea.appendChild(canvasClone);\n }\n } else {\n \/\/ Fallback: clone the canvas\n const canvasClone = chartCanvas.cloneNode(true);\n contentArea.appendChild(canvasClone);\n }\n }\n \n if (!contentArea) {\n contentArea = section.cloneNode(true);\n \/\/ Remove download button and card header from cloned content\n const downloadBtn = contentArea.querySelector('.btn-outline-primary');\n if (downloadBtn) downloadBtn.remove();\n const cardHeader = contentArea.querySelector('.card-header');\n if (cardHeader && !cardHeader.querySelector('.card-title')) {\n cardHeader.remove();\n }\n }\n \n \/\/ Create a temporary container with proper formatting\n const tempContainer = document.createElement('div');\n tempContainer.style.cssText = `\n position: absolute;\n left: -9999px;\n top: 0;\n width: ${contentArea.offsetWidth || 800}px;\n background: #0F0F0F;\n padding: 2rem;\n border-radius: 12px;\n `;\n \n \/\/ Clone the content\n const clonedContent = contentArea.cloneNode(true);\n tempContainer.appendChild(clonedContent);\n document.body.appendChild(tempContainer);\n \n \/\/ Wait for any images\/charts to render\n await new Promise(resolve => setTimeout(resolve, 1000));\n \n \/\/ Use html2canvas to capture the formatted content\n const canvas = await html2canvas(tempContainer, {\n backgroundColor: '#0F0F0F',\n scale: 2,\n logging: false,\n useCORS: true,\n allowTaint: false,\n width: tempContainer.scrollWidth,\n height: tempContainer.scrollHeight\n });\n \n \/\/ Remove temporary container\n document.body.removeChild(tempContainer);\n \n \/\/ Convert canvas to blob and download\n canvas.toBlob(function(blob) {\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `matomo-${filename}-${new Date().toISOString().split('T')[0]}.png`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n \n showNotification('Image downloaded successfully!', 'success');\n }, 'image\/png');\n \n } catch (error) {\n\n showNotification('Error generating image: ' + error.message, 'error');\n }\n}\n<\/script>\n@endsection\n" }
JSON object with view file paths or inline content.
Assets (Optional - JSON)
[]
JSON array of asset file paths.
Update Component
Delete Component