diff --git a/plugins/AIAgents/Categories/AIAgentsOverviewSubcategory.php b/plugins/AIAgents/Categories/AIAgentsOverviewSubcategory.php new file mode 100644 index 0000000000000000000000000000000000000000..63a8d7431bb5472300bbf1fe4598fe8cb8496e8f --- /dev/null +++ b/plugins/AIAgents/Categories/AIAgentsOverviewSubcategory.php @@ -0,0 +1,30 @@ +%1$s

', + Piwik::translate('AIAgents_AIAgentsOverviewSubcategoryDescription') + ); + } +} diff --git a/plugins/AIAgents/Reports/Get.php b/plugins/AIAgents/Reports/Get.php new file mode 100644 index 0000000000000000000000000000000000000000..145de78685e5a4602f93b4470da19a965699f64f --- /dev/null +++ b/plugins/AIAgents/Reports/Get.php @@ -0,0 +1,127 @@ +name = Piwik::translate('AIAgents_AIAgentVisits'); + $this->categoryId = 'General_AIAssistants'; + $this->subcategoryId = 'AIAgents_AIAgentsOverview'; + $this->order = 10; + + $this->processedMetrics = [ + new AIAgentMetric(new AverageTimeOnSite(), API::AI_AGENT_COLUMN_SUFFIX), + new AIAgentMetric(new ActionsPerVisit(), API::AI_AGENT_COLUMN_SUFFIX), + new AIAgentMetric(new BounceRate(), API::AI_AGENT_COLUMN_SUFFIX), + new AIAgentMetric(new AverageTimeOnSite(), API::HUMAN_COLUMN_SUFFIX), + new AIAgentMetric(new ActionsPerVisit(), API::HUMAN_COLUMN_SUFFIX), + new AIAgentMetric(new BounceRate(), API::HUMAN_COLUMN_SUFFIX), + ]; + + $this->metrics = [ + 'nb_visits_ai_agent', + 'nb_actions_ai_agent', + 'nb_uniq_visitors_ai_agent', + 'nb_users_ai_agent', + 'max_actions_ai_agent', + + 'nb_visits_human', + 'nb_actions_human', + 'nb_uniq_visitors_human', + 'nb_users_human', + 'max_actions_human', + ]; + } + + public function configureWidgets(WidgetsList $widgetsList, ReportWidgetFactory $factory) + { + $widgetsList->addWidgetConfig( + $factory->createWidget() + ->setName('AIAgents_WidgetGraphAIAgents') + ->forceViewDataTable(Evolution::ID) + ->setAction('getEvolutionGraph') + ->setOrder(1) + ); + + $widgetsList->addWidgetConfig( + $factory->createWidget() + ->forceViewDataTable(Sparklines::ID) + ->setName('AIAgents_WidgetOverviewAIAgents') + ->setOrder(2) + ); + } + + public function configureView(ViewDataTable $view) + { + if ($view->isViewDataTableId(Sparklines::ID)) { + /** @var Sparklines $view */ + $view->requestConfig->apiMethodToRequestDataTable = 'AIAgents.get'; + $this->addSparklineColumns($view); + $view->config->addTranslations($this->getSparklineTranslations()); + } + } + + /** + * @return array + */ + private function getSparklineTranslations(): array + { + return [ + 'avg_time_on_site_ai_agent' => Piwik::translate('AIAgents_SparklineAIAgentAverageVisitDuration'), + 'avg_time_on_site_human' => Piwik::translate('AIAgents_SparklineHumanAverageVisitDuration'), + 'bounce_rate_ai_agent' => Piwik::translate('AIAgents_SparklineAIAgentBounceRate'), + 'bounce_rate_human' => Piwik::translate('AIAgents_SparklineHumanBounceRate'), + 'nb_actions_ai_agent' => Piwik::translate('AIAgents_SparklineAIAgentActions'), + 'nb_actions_human' => Piwik::translate('AIAgents_SparklineHumanActions'), + 'nb_actions_per_visit_ai_agent' => Piwik::translate('AIAgents_SparklineAIAgentAvgActionsPerVisit'), + 'nb_actions_per_visit_human' => Piwik::translate('AIAgents_SparklineHumanAvgActionsPerVisit'), + 'nb_visits_ai_agent' => Piwik::translate('AIAgents_SparklineAIAgentVisits'), + 'nb_visits_human' => Piwik::translate('AIAgents_SparklineHumanVisits'), + ]; + } + + private function addSparklineColumns(Sparklines $view): void + { + $metrics = [ + 'nb_visits', + 'avg_time_on_site', + 'nb_actions_per_visit', + 'bounce_rate', + 'nb_actions', + ]; + + $i = 1; + + foreach ($metrics as $metric) { + foreach (['_ai_agent', '_human'] as $suffix) { + $view->config->addSparklineMetric([$metric . $suffix], $i++); + } + } + } +} diff --git a/plugins/AIAgents/lang/am.json b/plugins/AIAgents/lang/am.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/am.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ar.json b/plugins/AIAgents/lang/ar.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/ar.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/az.json b/plugins/AIAgents/lang/az.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/az.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/bg.json b/plugins/AIAgents/lang/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/bg.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/bs.json b/plugins/AIAgents/lang/bs.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/bs.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ca.json b/plugins/AIAgents/lang/ca.json new file mode 100644 index 0000000000000000000000000000000000000000..f158e70ba8724c55ca5d52c7e7596d2b7c9ef933 --- /dev/null +++ b/plugins/AIAgents/lang/ca.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "Nom de l'agent d'IA", + "AIAgentVisits": "Visites de l'agent d'IA", + "AIAgentVisitsDocumentation": "Aquesta és una descripció general de les visites dels agents d'IA. El trànsit dels agents d'IA només inclou les visites en què una IA obre el vostre lloc web en un navegador real i es comporta com un visitant humà. No inclou les sol·licituds en segon pla de ChatGPT ni d'altres eines d'IA que recuperen les vostres pàgines sense utilitzar un navegador.", + "ColumnAIAgentActions": "Accions de les visites d'agents d'IA", + "ColumnAIAgentAverageVisitDuration": "Durada mitjana d'una visita d'un agent d'IA (en segons)", + "ColumnAIAgentAvgActionsPerVisit": "Mitjana d'accions per visita d'agent d'IA", + "ColumnAIAgentBounceRate": "Taxa de rebot per a visites d'agents d'IA", + "ColumnAIAgentMaxActions": "Accions màximes en una visita d'agent d'IA", + "ColumnAIAgentUniqueVisitors": "Visitants únics d'agents d'IA", + "ColumnAIAgentUsers": "Usuaris d'agents d'IA", + "ColumnAIAgentVisits": "Visites d'agents d'IA", + "ColumnHumanActions": "Accions de visites humanes", + "ColumnHumanAverageVisitDuration": "Durada mitjana d'una visita humana (en segons)", + "ColumnHumanAvgActionsPerVisit": "Mitjana d'accions per visita humana", + "ColumnHumanBounceRate": "Taxa de rebot per a visites humanes", + "ColumnHumanMaxActions": "Accions màximes en una visita humana", + "ColumnHumanUniqueVisitors": "Visitants humans únics", + "ColumnHumanUsers": "Usuaris humans", + "ColumnHumanVisits": "Visites humanes", + "PluginDescription": "Informes sobre l'activitat dels agents d'IA.", + "SparklineAIAgentActions": "accions de visites d'agents d'IA", + "SparklineAIAgentAverageVisitDuration": "durada mitjana de la visita per a visitants d'agents d'IA", + "SparklineAIAgentAvgActionsPerVisit": "accions per visita d'agent d'IA", + "SparklineAIAgentBounceRate": "Les visites d'agents d'IA han rebotat (han abandonat el lloc web després d'una pàgina)", + "SparklineAIAgentVisits": "Visites d'agents d'IA", + "SparklineHumanActions": "accions de visites humanes", + "SparklineHumanAverageVisitDuration": "durada mitjana de la visita per a visitants humans", + "SparklineHumanAvgActionsPerVisit": "accions per visita humana", + "SparklineHumanBounceRate": "les visites humanes han rebotat (han abandonat el lloc web després d'una pàgina)", + "SparklineHumanVisits": "visites humanes", + "WidgetGraphAIAgents": "Agents d'IA en el temps", + "WidgetOverviewAIAgents": "Visió general de l'agent d'IA", + "AIAgentsOverview": "Visió general dels agents d'IA", + "AIAgentsOverviewSubcategoryDescription": "Revisa d'un cop d'ull com els agents d'IA i els visitants humans interactuen amb el teu lloc web. Aquesta visió general mostra mètriques i tendències combinades perquè puguis detectar ràpidament els canvis abans d'explorar informes detallats." + } +} diff --git a/plugins/AIAgents/lang/cy.json b/plugins/AIAgents/lang/cy.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/cy.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/da.json b/plugins/AIAgents/lang/da.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/da.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/de.json b/plugins/AIAgents/lang/de.json new file mode 100644 index 0000000000000000000000000000000000000000..f7b604464f247f9ac9965b76775aca413cc95811 --- /dev/null +++ b/plugins/AIAgents/lang/de.json @@ -0,0 +1,36 @@ +{ + "AIAgents": { + "AIAgentName": "KI-Agentenname", + "AIAgentVisits": "KI-Agentenbesuche", + "AIAgentVisitsDocumentation": "Dies ist eine Übersicht über KI-Agenten-Besuche. Der Datenverkehr von KI-Agenten beinhaltet nur Besuche, bei denen ein KI Ihre Seite in einem echten Browser öffnet und sich wie ein menschlicher Besucher verhält. Es beinhaltet nicht Hintergrundanfragen von ChatGPT oder anderen KI-Tools, die Ihre Seiten abrufen, ohne einen Browser zu verwenden.", + "ColumnAIAgentActions": "Aktionen von KI-Agentenbesuchen", + "ColumnAIAgentAverageVisitDuration": "Durchschnittliche Dauer eines KI-Agenten Besuchs (in Sekunden)", + "ColumnAIAgentAvgActionsPerVisit": "Durchschnittliche Aktionen pro KI-Agenten-Besuch", + "ColumnAIAgentBounceRate": "Absprungquote für KI-Agentenbesuche", + "ColumnAIAgentMaxActions": "Maximale Aktionen beim Besuch eines KI-Agenten", + "ColumnAIAgentUniqueVisitors": "Einzigartige KI-Agenten Besucher", + "ColumnAIAgentVisits": "KI-Agentenbesuche", + "ColumnHumanActions": "Aktionen durch menschliche Besuche", + "ColumnHumanAverageVisitDuration": "Durchschnittliche Dauer eines menschlichen Besuchs (in Sek.)", + "ColumnHumanAvgActionsPerVisit": "Durchschnittliche Aktionen pro menschlichem Besuch", + "ColumnHumanBounceRate": "Absprungquote für menschliche Besuche", + "ColumnHumanMaxActions": "Maximale Aktionen in einem einzigen menschlichen Besuch", + "ColumnHumanUniqueVisitors": "Einzigartige menschliche Besucher", + "ColumnHumanUsers": "Menschliche Benutzer", + "ColumnHumanVisits": "Menschliche Besuche", + "PluginDescription": "Berichte über die Aktivität von KI-Agenten.", + "SparklineAIAgentActions": "Aktionen von KI-Agentenbesuchen", + "SparklineAIAgentAverageVisitDuration": "durchschnittliche Besuchsdauer für KI-Agent Besucher", + "SparklineAIAgentAvgActionsPerVisit": "Aktionen pro KI-Agentenbesuch", + "SparklineAIAgentVisits": "KI-Agentenbesuche", + "SparklineHumanActions": "Aktionen durch menschliche Besuche", + "SparklineHumanAverageVisitDuration": "durchschnittliche Besuchsdauer für menschliche Besucher", + "SparklineHumanAvgActionsPerVisit": "Aktionen pro menschlichem Besuch", + "SparklineHumanBounceRate": "Menschliche Besucher sind abgesprungen (haben die Webseite nach einer Seite verlassen)", + "SparklineHumanVisits": "Menschliche Besuche", + "WidgetGraphAIAgents": "KI-Agenten im Laufe der Zeit", + "WidgetOverviewAIAgents": "Übersicht über KI-Agenten", + "ColumnAIAgentUsers": "KI-Agent Benutzer", + "AIAgentsOverview": "KI-Agenten-Übersicht" + } +} diff --git a/plugins/AIAgents/lang/el.json b/plugins/AIAgents/lang/el.json new file mode 100644 index 0000000000000000000000000000000000000000..197081d4487a412f130bdb43743da9ed4f836b20 --- /dev/null +++ b/plugins/AIAgents/lang/el.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "Όνομα πράκτορα AI", + "AIAgentVisits": "Επισκέψεις πράκτορα AI", + "AIAgentVisitsDocumentation": "Αυτή είναι μια σύνοψη των επισκέψεων του πράκτορα AI. Η κίνηση πρακτόρων AI περιλαμβάνει μόνο επισκέψεις όπου ένα AI ανοίγει τον ιστοτόπο σας σε ένα πραγματικό φυλλομετρητή και συμπεριλαμβάνεται ως επισκέπτης άνθρωπος. Δεν περιλαμβάνει αιτήσεις στο υπόβαθρο από το ChatGPT ή άλλα εργαλεία AI που φορτώνουν τις σελίδες σας χωρίς χρήση φυλλομετρητή.", + "ColumnAIAgentActions": "Ενέργειες από τις επισκέψεις του πράκτορα AI", + "ColumnAIAgentAverageVisitDuration": "Μέση διάρκεια μιας επίσκεψης ενός πράκτορα AI (σε δευτ.)", + "ColumnAIAgentAvgActionsPerVisit": "Μέσος αριθμός ενεργειών ανά επίσκεψη ενός πράκτορα AI", + "ColumnAIAgentBounceRate": "Ρυθμός αναπήδησης επισκέψεων ενός πράκτορα AI", + "ColumnAIAgentMaxActions": "Μέγιστος αριθμός ενεργειών επίσκεψης ενός πράκτορα AI", + "ColumnAIAgentUniqueVisitors": "Μοναδικοί επισκέπτες πρακτόρων AI", + "ColumnAIAgentUsers": "Χρήστες πρακτόρων AI", + "ColumnAIAgentVisits": "Επισκέψεις πρακτόρων AI", + "ColumnHumanActions": "Ενέργειες από επισκέψεις ανθρώπων", + "ColumnHumanAverageVisitDuration": "Μέση διάρκεια μιας επίσκεψης ενός ανθρώπου (σε δευτ.)", + "ColumnHumanAvgActionsPerVisit": "Μέσος αριθμός ενεργειών ανά επίσκεψη ενός ανθρώπου", + "ColumnHumanBounceRate": "Ρυθμός αναπήδησης επισκέψεων ενός ανθρώπου", + "ColumnHumanMaxActions": "Μέγιστος αριθμός ενεργειών επίσκεψης ενός ανθρώπου", + "ColumnHumanUniqueVisitors": "Μοναδικοί επισκέπτες άνθρωποι", + "ColumnHumanUsers": "Ανθρώπινοι χρήστες", + "ColumnHumanVisits": "Επισκέψεις ανθρώπων", + "PluginDescription": "Αναφορές σχετικά με την δραστηριότητα ενός πράκτορα AI.", + "SparklineAIAgentActions": "ενέργειες επισκέψεων από ένα πράκτορα AI", + "SparklineAIAgentAverageVisitDuration": "μέση διάρκεια επίσκεψης για πράκτορες AI", + "SparklineAIAgentAvgActionsPerVisit": "ενέργειες ανά επίσκεψη ενός πράκτορα AI", + "SparklineAIAgentBounceRate": "Επισκέψεις πρακτόρων AI με αναπήδηση (άφησαν τον ιστοτόπο μετά από μια σελίδα)", + "SparklineAIAgentVisits": "Επισκέψεις πρακτόρων AI", + "SparklineHumanActions": "ενέργειες από επισκέψεις ανθρώπων", + "SparklineHumanAverageVisitDuration": "μέση διάρκεια επισκέψεων ανθρώπων", + "SparklineHumanAvgActionsPerVisit": "ενέργειες ανά επίσκεψη ανθρώπου", + "SparklineHumanBounceRate": "ανθρώπινες επισκέψεις με αναπήδηση (άφησαν τον ιστοτόπου μετά από μια σελίδα)", + "SparklineHumanVisits": "επισκέψεις ανθρώπων", + "WidgetGraphAIAgents": "Πράκτορες AI μέσα στο χρόνο", + "WidgetOverviewAIAgents": "Σύνοψη Πρακτόρων ΤΝ", + "AIAgentsOverview": "Σύνοψη Πρακτόρων ΤΝ", + "AIAgentsOverviewSubcategoryDescription": "Σύνοψη του πως οι βοηθοί ΤΝ και οι επισκέπτες άνθρωποι αλληλεπιδρούν με τον ιστοτόπο σας με μια ματιά. Αυτή η σύνοψη φέρει στην επιφάνεια συνδυασμένες μετρικές και τάσεις ώστε να μπορείτε γρήγορα να εντοπίζετε αλλαγές προτού εξερευνήσετε λεπτομερείς αναφορές." + } +} diff --git a/plugins/AIAgents/lang/en.json b/plugins/AIAgents/lang/en.json new file mode 100644 index 0000000000000000000000000000000000000000..57ca27a7d72fbfab059557f8295b874850cad8c2 --- /dev/null +++ b/plugins/AIAgents/lang/en.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "AI Agent Name", + "AIAgentsOverview": "AI Agents Overview", + "AIAgentVisits": "AI Agent Visits", + "AIAgentVisitsDocumentation": "This is an overview of AI Agent visits. AI agent traffic only includes visits where an AI opens your site in a real browser and behaves like a human visitor. It does not include background requests from ChatGPT or other AI tools that fetch your pages without using a browser.", + "AIAgentsOverviewSubcategoryDescription": "Review how AI agents and human visitors engage with your site at a glance. This overview surfaces combined metrics and trends so you can quickly spot changes before exploring detailed reports.", + "ColumnAIAgentActions": "Actions by AI Agent Visits", + "ColumnAIAgentAverageVisitDuration": "Avg. Duration of an AI Agent Visit (in sec)", + "ColumnAIAgentAvgActionsPerVisit": "Avg. Actions per AI Agent Visit", + "ColumnAIAgentBounceRate": "Bounce Rate for AI Agent Visits", + "ColumnAIAgentMaxActions": "Maximum Actions in AI Agent Visit", + "ColumnAIAgentUniqueVisitors": "Unique AI Agent Visitors", + "ColumnAIAgentUsers": "AI Agent Users", + "ColumnAIAgentVisits": "AI Agent Visits", + "ColumnHumanActions": "Actions by Human Visits", + "ColumnHumanAverageVisitDuration": "Avg. Duration of a Human Visit (in sec)", + "ColumnHumanAvgActionsPerVisit": "Avg. Actions per Human Visit", + "ColumnHumanBounceRate": "Bounce Rate for Human Visits", + "ColumnHumanMaxActions": "Maximum Actions in one Human Visit", + "ColumnHumanUniqueVisitors": "Unique Human Visitors", + "ColumnHumanUsers": "Human Users", + "ColumnHumanVisits": "Human Visits", + "PluginDescription": "Reports about AI agent activity.", + "SparklineAIAgentActions": "actions by AI agent visits", + "SparklineAIAgentAverageVisitDuration": "average visit duration for AI agent visitors", + "SparklineAIAgentAvgActionsPerVisit": "actions per AI agent visit", + "SparklineAIAgentBounceRate": "AI agent visits have bounced (left the website after one page)", + "SparklineAIAgentVisits": "AI agent visits", + "SparklineHumanActions": "actions by human visits", + "SparklineHumanAverageVisitDuration": "average visit duration for human visitors", + "SparklineHumanAvgActionsPerVisit": "actions per human visit", + "SparklineHumanBounceRate": "human visits have bounced (left the website after one page)", + "SparklineHumanVisits": "human visits", + "WidgetGraphAIAgents": "AI Agents Over Time", + "WidgetOverviewAIAgents": "AI Agents Overview" + } +} diff --git a/plugins/AIAgents/lang/es-ar.json b/plugins/AIAgents/lang/es-ar.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/es-ar.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/es.json b/plugins/AIAgents/lang/es.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/es.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/et.json b/plugins/AIAgents/lang/et.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/et.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/eu.json b/plugins/AIAgents/lang/eu.json new file mode 100644 index 0000000000000000000000000000000000000000..51aafd4e91a9510781c2a2ce9b403f76b0085c85 --- /dev/null +++ b/plugins/AIAgents/lang/eu.json @@ -0,0 +1,5 @@ +{ + "AIAgents": { + "AIAgentName": "AA agentearen izena" + } +} diff --git a/plugins/AIAgents/lang/fr.json b/plugins/AIAgents/lang/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..e013e88e59ec1e905d61b315b920e34d332969d8 --- /dev/null +++ b/plugins/AIAgents/lang/fr.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "Nom de l'agent IA", + "AIAgentVisits": "Visites de l'agent IA", + "AIAgentVisitsDocumentation": "Voici une vue d'ensemble des visites d'agents IA. Le trafic des agents IA n'inclut que les visites où une IA ouvre votre site dans un navigateur réel et se comporte comme un visiteur humain. Il n'inclut pas les requêtes en arrière-plan de ChatGPT ou d'autres outils d'IA qui récupèrent vos pages sans utiliser un navigateur.", + "ColumnAIAgentActions": "Actions des visites des agents IA", + "ColumnAIAgentAverageVisitDuration": "Durée moyenne d'une visite d'agent IA (en secondes)", + "ColumnAIAgentAvgActionsPerVisit": "Nombre moyen d'actions par visite d'agent IA", + "ColumnAIAgentBounceRate": "Taux de rebond pour les visites d'agents IA", + "ColumnAIAgentMaxActions": "Actions maximales lors de la visite de l'agent IA", + "ColumnAIAgentUniqueVisitors": "Visiteurs uniques d'agents IA", + "ColumnAIAgentUsers": "Utilisateurs d'agents IA", + "ColumnAIAgentVisits": "Visites d'agents IA", + "ColumnHumanActions": "Actions liées aux visites humaines", + "ColumnHumanAverageVisitDuration": "Durée moyenne d'une visite humaine (en secondes)", + "ColumnHumanAvgActionsPerVisit": "Nombre moyen d'actions par visite humaine", + "ColumnHumanBounceRate": "Taux de rebond pour les visites humaines", + "ColumnHumanMaxActions": "Nombre maximal d'actions en une visite humaine", + "ColumnHumanUniqueVisitors": "Humain unique", + "ColumnHumanUsers": "Humain", + "ColumnHumanVisits": "Visites d'humains", + "PluginDescription": "à propos de l'activité des agents IA.", + "SparklineAIAgentActions": "actions des visites d'agents IA", + "SparklineAIAgentAverageVisitDuration": "Durée moyenne de visite des visiteurs d'agents IA", + "SparklineAIAgentAvgActionsPerVisit": "Actions par agent IA", + "SparklineAIAgentBounceRate": "Visites avec rebond des agents IA (quitté le site après une page vue)", + "SparklineAIAgentVisits": "Visites d'agents IA", + "SparklineHumanActions": "Actions par visites d'humains", + "SparklineHumanAverageVisitDuration": "Durée moyenne de visite des visiteurs humains", + "SparklineHumanAvgActionsPerVisit": "actions par visite humaine", + "SparklineHumanBounceRate": "Les visites humaines ont rebondi (les visiteurs ont quitté le site après avoir consulté une seule page)", + "SparklineHumanVisits": "visites humaines", + "WidgetGraphAIAgents": "Agents IA dans le temps", + "WidgetOverviewAIAgents": "Vue d'ensemble des agents IA", + "AIAgentsOverview": "Récapitulatif des agents IA", + "AIAgentsOverviewSubcategoryDescription": "Visualisez en un coup d'œil comment les agents IA et les visiteurs humains interagissent avec votre site. Cette vue d'ensemble présente des métriques et tendances combinées afin que vous puissiez rapidement repérer les changements avant d'explorer les rapports détaillés." + } +} diff --git a/plugins/AIAgents/lang/ga.json b/plugins/AIAgents/lang/ga.json new file mode 100644 index 0000000000000000000000000000000000000000..3924ea3dfbcc6b33f49a2b5d6bdacf218f81b358 --- /dev/null +++ b/plugins/AIAgents/lang/ga.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "Ainm an Ghníomhaire AI", + "AIAgentVisits": "Cuairteanna Gníomhaire AI", + "AIAgentVisitsDocumentation": "Seo forbhreathnú ar chuairteanna Gníomhaire AI. Ní chuimsíonn trácht gníomhaire AI ach cuairteanna ina n-osclaíonn AI do shuíomh i mbrabhsálaí fíor agus ina n-iompraíonn sé mar chuairteoir daonna. Ní chuimsíonn sé iarratais chúlra ó ChatGPT ná uirlisí AI eile a bhailíonn do leathanaigh gan brabhsálaí a úsáid.", + "ColumnAIAgentActions": "Gníomhartha de réir Cuairteanna Gníomhaire AI", + "ColumnAIAgentAverageVisitDuration": "Meánfhad Cuairte Gníomhaire AI (i soicindí)", + "ColumnAIAgentAvgActionsPerVisit": "Meánghníomhartha in aghaidh Cuairte an Ghníomhaire AI", + "ColumnAIAgentBounceRate": "Ráta Preabála do Chuairteanna Gníomhaire AI", + "ColumnAIAgentMaxActions": "Uasmhéid Gníomhartha i gCuairt Ghníomhaire AI", + "ColumnAIAgentUniqueVisitors": "Cuairteoirí Uathúla Gníomhaire AI", + "ColumnAIAgentUsers": "Úsáideoirí Gníomhaire AI", + "ColumnAIAgentVisits": "Cuairteanna Gníomhaire AI", + "ColumnHumanActions": "Gníomhartha de réir Cuairteanna Daonna", + "ColumnHumanAverageVisitDuration": "Meánfhad Cuairte Daonna (i soicindí)", + "ColumnHumanAvgActionsPerVisit": "Meánghníomhartha in aghaidh na Cuairte Daonna", + "ColumnHumanBounceRate": "Ráta Preabála do Chuairteanna Daonna", + "ColumnHumanMaxActions": "Uasmhéid Gníomhartha in aon Chuairt Dhaonna amháin", + "ColumnHumanUniqueVisitors": "Cuairteoirí Daonna Uathúla", + "ColumnHumanUsers": "Úsáideoirí Daonna", + "ColumnHumanVisits": "Cuairteanna Daonna", + "PluginDescription": "Tuairiscí faoi ghníomhaíocht gníomhairí AI.", + "SparklineAIAgentActions": "gníomhartha ag cuairteanna gníomhairí AI", + "SparklineAIAgentAverageVisitDuration": "meánfhad cuairte do chuairteoirí gníomhairí AI", + "SparklineAIAgentAvgActionsPerVisit": "gníomhartha in aghaidh cuairte an ghníomhaire AI", + "SparklineAIAgentBounceRate": "Tá cuairteanna gníomhairí AI tar éis preabadh (d’fhág siad an suíomh Gréasáin tar éis leathanach amháin)", + "SparklineAIAgentVisits": "Cuairteanna gníomhaire AI", + "SparklineHumanActions": "gníomhartha de bharr cuairteanna daonna", + "SparklineHumanAverageVisitDuration": "meánfhad cuairte do chuairteoirí daonna", + "SparklineHumanAvgActionsPerVisit": "gníomhartha in aghaidh cuairte daonna", + "SparklineHumanBounceRate": "tá cuairteanna daonna tar éis preabadh (d’fhág siad an suíomh Gréasáin tar éis leathanach amháin)", + "SparklineHumanVisits": "cuairteanna daonna", + "WidgetGraphAIAgents": "Gníomhairí AI le himeacht ama", + "WidgetOverviewAIAgents": "Forbhreathnú ar Ghníomhairí AI", + "AIAgentsOverview": "Forbhreathnú ar Ghníomhairí AI", + "AIAgentsOverviewSubcategoryDescription": "Athbhreithnigh go hachomair an chaoi a mbíonn gníomhairí AI agus cuairteoirí daonna ag idirghníomhú le do shuíomh. Tugann an forbhreathnú seo léargas ar mhéadrachtaí agus treochtaí comhcheangailte ionas gur féidir leat athruithe a fheiceáil go tapa sula ndéanann tú iniúchadh ar thuairiscí mionsonraithe." + } +} diff --git a/plugins/AIAgents/lang/gl.json b/plugins/AIAgents/lang/gl.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/gl.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/gu.json b/plugins/AIAgents/lang/gu.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/gu.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/hi.json b/plugins/AIAgents/lang/hi.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/hi.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/hr.json b/plugins/AIAgents/lang/hr.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/hr.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/hy.json b/plugins/AIAgents/lang/hy.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/hy.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/id.json b/plugins/AIAgents/lang/id.json new file mode 100644 index 0000000000000000000000000000000000000000..f0ac900ba36776fdfad43f1c5b771d6e415e2273 --- /dev/null +++ b/plugins/AIAgents/lang/id.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "Nama Agen AI", + "AIAgentsOverview": "Ikhtisar Agen AI", + "AIAgentVisits": "Kunjungan Agen AI", + "AIAgentVisitsDocumentation": "Ini adalah ikhtisar kunjungan Agen AI. Trafik agen AI hanya mencakup kunjungan di mana AI membuka situs Anda di browser nyata dan berperilaku seperti pengunjung manusia. Ini tidak termasuk permintaan latar belakang dari ChatGPT atau alat AI lain yang mengambil halaman Anda tanpa menggunakan browser.", + "AIAgentsOverviewSubcategoryDescription": "Tinjau bagaimana agen AI dan pengunjung manusia berinteraksi dengan situs Anda secara sekilas. Ikhtisar ini menampilkan metrik dan tren gabungan sehingga Anda dapat dengan cepat melihat perubahan sebelum menjelajahi laporan terperinci.", + "ColumnAIAgentActions": "Tindakan oleh Kunjungan Agen AI", + "ColumnAIAgentAverageVisitDuration": "Rata-rata Durasi Kunjungan Agen AI (dalam detik)", + "ColumnAIAgentAvgActionsPerVisit": "Rata-rata Tindakan per Kunjungan Agen AI", + "ColumnAIAgentBounceRate": "Tingkat Pentalan untuk Kunjungan Agen AI", + "ColumnAIAgentMaxActions": "Tindakan Maksimal dalam Kunjungan Agen AI", + "ColumnAIAgentUniqueVisitors": "Pengunjung Agen AI Unik", + "ColumnAIAgentUsers": "Pengguna Agen AI", + "ColumnAIAgentVisits": "Kunjungan Agen AI", + "ColumnHumanActions": "Tindakan oleh Kunjungan Manusia", + "ColumnHumanAverageVisitDuration": "Rata-rata Durasi Kunjungan Manusia (dalam detik)", + "ColumnHumanAvgActionsPerVisit": "Rata-rata Tindakan per Kunjungan Manusia", + "ColumnHumanBounceRate": "Tingkat Pentalan untuk Kunjungan Manusia", + "ColumnHumanMaxActions": "Tindakan Maksimal dalam Satu Kunjungan Manusia", + "ColumnHumanUniqueVisitors": "Pengunjung Manusia Unik", + "ColumnHumanUsers": "Pengguna Manusia", + "ColumnHumanVisits": "Kunjungan Manusia", + "PluginDescription": "Laporan tentang aktivitas agen AI.", + "SparklineAIAgentActions": "tindakan oleh kunjungan agen AI", + "SparklineAIAgentAverageVisitDuration": "durasi kunjungan rata-rata untuk pengunjung agen AI", + "SparklineAIAgentAvgActionsPerVisit": "tindakan per kunjungan agen AI", + "SparklineAIAgentBounceRate": "kunjungan agen AI telah pental (keluar dari website setelah satu halaman)", + "SparklineAIAgentVisits": "kunjungan agen AI", + "SparklineHumanActions": "tindakan oleh kunjungan manusia", + "SparklineHumanAverageVisitDuration": "durasi kunjungan rata-rata untuk pengunjung manusia", + "SparklineHumanAvgActionsPerVisit": "tindakan per kunjungan manusia", + "SparklineHumanBounceRate": "kunjungan manusia telah pental (keluar dari website setelah satu halaman)", + "SparklineHumanVisits": "kunjungan manusia", + "WidgetGraphAIAgents": "Agen AI dari Waktu ke Waktu", + "WidgetOverviewAIAgents": "Ikhtisar Agen AI" + } +} diff --git a/plugins/AIAgents/lang/is.json b/plugins/AIAgents/lang/is.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/is.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/it.json b/plugins/AIAgents/lang/it.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/it.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ja.json b/plugins/AIAgents/lang/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/ja.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ka.json b/plugins/AIAgents/lang/ka.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/ka.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ko.json b/plugins/AIAgents/lang/ko.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/ko.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ku.json b/plugins/AIAgents/lang/ku.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/ku.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/lb.json b/plugins/AIAgents/lang/lb.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/lb.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/lt.json b/plugins/AIAgents/lang/lt.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/lt.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ms.json b/plugins/AIAgents/lang/ms.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/ms.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/nn.json b/plugins/AIAgents/lang/nn.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/nn.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/pt-br.json b/plugins/AIAgents/lang/pt-br.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/pt-br.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/pt.json b/plugins/AIAgents/lang/pt.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/pt.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/ru.json b/plugins/AIAgents/lang/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/ru.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/si.json b/plugins/AIAgents/lang/si.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/si.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/sl.json b/plugins/AIAgents/lang/sl.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/sl.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/sr.json b/plugins/AIAgents/lang/sr.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/sr.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/sv.json b/plugins/AIAgents/lang/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..58d6cffb9511c87a645d005542c538fe98058620 --- /dev/null +++ b/plugins/AIAgents/lang/sv.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "AI-agentnamn", + "AIAgentsOverview": "AI-agenter översikt", + "AIAgentVisits": "AI-agent besök", + "AIAgentVisitsDocumentation": "Detta är en översikt över AI-agentbesök. AI-agenttrafik inkluderar bara besök där en AI öppnar din webbplats i en riktig webbläsare och beter sig som en mänsklig besökare. Det inkluderar inte bakgrundsförfrågningar från ChatGPT eller andra AI-verktyg som hämtar dina sidor utan att använda en webbläsare.", + "AIAgentsOverviewSubcategoryDescription": "Se snabbt hur AI-agenter och mänskliga besökare interagerar med din webbplats. Den här översikten visar kombinerade mätvärden och trender så att du snabbt kan upptäcka förändringar innan du utforskar detaljerade rapporter.", + "ColumnAIAgentActions": "Åtgärder från AI-agentbesök", + "ColumnAIAgentAverageVisitDuration": "Genomsnittlig längd på ett AI-agentbesök (i sek)", + "ColumnAIAgentAvgActionsPerVisit": "Genomsnittliga åtgärder per AI-agentbesök", + "ColumnAIAgentBounceRate": "Avvisningsfrekvens för AI-agentbesök", + "ColumnAIAgentMaxActions": "Maximalt antal åtgärder i ett AI-agentbesök", + "ColumnAIAgentUniqueVisitors": "Unika AI-agentbesökare", + "ColumnAIAgentUsers": "AI-agentanvändare", + "ColumnAIAgentVisits": "AI-agentbesök", + "ColumnHumanActions": "Åtgärder från mänskliga besök", + "ColumnHumanAverageVisitDuration": "Genomsnittlig längd på ett mänskligt besök (i sek)", + "ColumnHumanAvgActionsPerVisit": "Genomsnittliga åtgärder per mänskligt besök", + "ColumnHumanBounceRate": "Avvisningsfrekvens för mänskliga besök", + "ColumnHumanMaxActions": "Maximalt antal åtgärder i ett mänskligt besök", + "ColumnHumanUniqueVisitors": "Unika mänskliga besökare", + "ColumnHumanUsers": "Mänskliga besökare", + "ColumnHumanVisits": "Mänskliga besök", + "PluginDescription": "Rapporter om AI-agentaktivitet.", + "SparklineAIAgentActions": "åtgärder från AI-agentbesök", + "SparklineAIAgentAverageVisitDuration": "genomsnittlig besökslängd för AI-agentbesökare", + "SparklineAIAgentAvgActionsPerVisit": "åtgärder per AI-agentbesök", + "SparklineAIAgentBounceRate": "AI-agentbesök har studsat (lämnade webbplatsen efter en sida)", + "SparklineAIAgentVisits": "AI-agentbesök", + "SparklineHumanActions": "åtgärder från mänskliga besök", + "SparklineHumanAverageVisitDuration": "genomsnittlig besökslängd för mänskliga besökare", + "SparklineHumanAvgActionsPerVisit": "åtgärder per mänskligt besök", + "SparklineHumanBounceRate": "mänskliga besök har studsat (lämnade webbplatsen efter en sida)", + "SparklineHumanVisits": "mänskliga besök", + "WidgetGraphAIAgents": "AI-agenter över tid", + "WidgetOverviewAIAgents": "Översikt över AI-agenter" + } +} diff --git a/plugins/AIAgents/lang/ta.json b/plugins/AIAgents/lang/ta.json new file mode 100644 index 0000000000000000000000000000000000000000..f55f03ba4df02485db8d8c8f8fc22afe2db88788 --- /dev/null +++ b/plugins/AIAgents/lang/ta.json @@ -0,0 +1,38 @@ +{ + "AIAgents": { + "AIAgentName": "AI முகவர் பெயர்", + "AIAgentsOverview": "AI முகவர்கள் கண்ணோட்டம்", + "AIAgentVisits": "AI முகவர் வருகை", + "AIAgentVisitsDocumentation": "இது AI முகவர் வருகைகளின் மேலோட்டம். AI முகவர் ட்ராஃபிக்கில், AI உங்கள் தளத்தை உண்மையான உலாவியில் திறந்து, மனித பார்வையாளரைப் போல் நடந்து கொள்ளும் வருகைகள் மட்டுமே அடங்கும். உலாவியைப் பயன்படுத்தாமல் உங்கள் பக்கங்களைப் பெறும் ChatGPT அல்லது பிற AI கருவிகளின் பின்னணி கோரிக்கைகள் இதில் இல்லை.", + "AIAgentsOverviewSubcategoryDescription": "AI முகவர்களும் மனித பார்வையாளர்களும் உங்கள் தளத்தில் எவ்வாறு ஈடுபடுகிறார்கள் என்பதை ஒரே பார்வையில் மதிப்பாய்வு செய்யவும். இந்த மேலோட்டம் அளவீடுகள் மற்றும் போக்குகளை ஒன்றிணைக்கிறது, எனவே விரிவான அறிக்கைகளை ஆராய்வதற்கு முன் மாற்றங்களை விரைவாகக் கண்டறியலாம்.", + "ColumnAIAgentActions": "AI முகவர் வருகையின் செயல்கள்", + "ColumnAIAgentAverageVisitDuration": "சராசரி AI முகவர் வருகையின் காலம் (வினாடி)", + "ColumnAIAgentAvgActionsPerVisit": "சராசரி AI முகவர் வருகைக்கான செயல்கள்", + "ColumnAIAgentBounceRate": "AI முகவர் வருகைகளுக்கான பவுன்ச் வீதம்", + "ColumnAIAgentMaxActions": "AI முகவர் வருகையின் அதிகபட்ச செயல்கள்", + "ColumnAIAgentUniqueVisitors": "தனித்துவமான AI முகவர் பார்வையாளர்கள்", + "ColumnAIAgentUsers": "AI முகவர் பயனர்கள்", + "ColumnAIAgentVisits": "AI முகவர் வருகை", + "ColumnHumanActions": "மனித வருகைகள் மூலம் நடவடிக்கைகள்", + "ColumnHumanAverageVisitDuration": "சராசரி ஒரு மனித வருகையின் காலம் (வினாடிகளில்)", + "ColumnHumanAvgActionsPerVisit": "சராசரி மனித வருகைக்கான செயல்கள்", + "ColumnHumanBounceRate": "மனித வருகைகளுக்கான பவுன்ச் விகிதம்", + "ColumnHumanMaxActions": "ஒரு மனித வருகையில் அதிகபட்ச செயல்கள்", + "ColumnHumanUniqueVisitors": "தனித்துவமான மனித பார்வையாளர்கள்", + "ColumnHumanUsers": "மனித பயனர்கள்", + "ColumnHumanVisits": "மனித வருகைகள்", + "PluginDescription": "AI முகவர் செயல்பாடு பற்றிய அறிக்கைகள்.", + "SparklineAIAgentActions": "AI முகவர் வருகைகள் மூலம் நடவடிக்கைகள்", + "SparklineAIAgentAverageVisitDuration": "AI முகவர் பார்வையாளர்களுக்கான சராசரி வருகை காலம்", + "SparklineAIAgentAvgActionsPerVisit": "AI முகவர் வருகைக்கான நடவடிக்கைகள்", + "SparklineAIAgentBounceRate": "AI முகவர் வருகைகள் துள்ளப்பட்டன (ஒரு பக்கத்திற்குப் பிறகு இணையதளத்தை விட்டு வெளியேறியது)", + "SparklineAIAgentVisits": "AI முகவர் வருகை", + "SparklineHumanActions": "மனித வருகைகள் மூலம் நடவடிக்கைகள்", + "SparklineHumanAverageVisitDuration": "மனித பார்வையாளர்களின் சராசரி வருகை காலம்", + "SparklineHumanAvgActionsPerVisit": "மனித வருகைக்கான நடவடிக்கைகள்", + "SparklineHumanBounceRate": "மனித வருகைகள் துள்ளப்பட்டன (ஒரு பக்கத்திற்குப் பிறகு இணையதளத்தை விட்டு வெளியேறியது)", + "SparklineHumanVisits": "மனித வருகைகள்", + "WidgetGraphAIAgents": "காலப்போக்கில் AI முகவர்கள்", + "WidgetOverviewAIAgents": "AI முகவர்கள் கண்ணோட்டம்" + } +} diff --git a/plugins/AIAgents/lang/te.json b/plugins/AIAgents/lang/te.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/te.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/th.json b/plugins/AIAgents/lang/th.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/th.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/tl.json b/plugins/AIAgents/lang/tl.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/tl.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/uk.json b/plugins/AIAgents/lang/uk.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/uk.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/zh-cn.json b/plugins/AIAgents/lang/zh-cn.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/zh-cn.json @@ -0,0 +1 @@ +{} diff --git a/plugins/AIAgents/lang/zh-tw.json b/plugins/AIAgents/lang/zh-tw.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/plugins/AIAgents/lang/zh-tw.json @@ -0,0 +1 @@ +{} diff --git a/plugins/CoreVisualizations/CoreVisualizations.php b/plugins/CoreVisualizations/CoreVisualizations.php new file mode 100644 index 0000000000000000000000000000000000000000..6a5f44113b523673285db5325fb1bf2440f494ae --- /dev/null +++ b/plugins/CoreVisualizations/CoreVisualizations.php @@ -0,0 +1,92 @@ + 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + 'UsersManager.deleteUser' => 'deleteUser', + 'Template.bodyClass' => 'addBodyClass', + ); + } + + public function deleteUser($userLogin) + { + ViewDataTableManager::clearUserViewDataTableParameters($userLogin); + } + + public function addBodyClass(&$out, $type) + { + $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); + + // The sparklines redesign refreshes sparkline styling app-wide (gated by the flag), + // so sparklines also appear on other page types (e.g. admin). + if ($featureFlagManager->isFeatureActive(SparklinesRedesign::class)) { + $out .= ' sparklines-redesign-enabled'; + } + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.less"; + $stylesheets[] = "plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less"; + + $stylesheets[] = "plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less"; + $stylesheets[] = "plugins/CoreVisualizations/stylesheets/jqplot.less"; + } + + public function getJsFiles(&$jsFiles) + { + + $jsFiles[] = "plugins/CoreVisualizations/javascripts/seriesPicker.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplot.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplotBarGraph.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplotPieGraph.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js"; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'General_MetricsToPlot'; + $translationKeys[] = 'General_MetricToPlot'; + $translationKeys[] = 'General_ChooseMetrics'; + $translationKeys[] = 'General_RecordsToPlot'; + $translationKeys[] = 'General_SaveImageOnYourComputer'; + $translationKeys[] = 'General_ExportAsImage'; + $translationKeys[] = 'General_NoDataForGraph'; + $translationKeys[] = 'General_EvolutionSummaryGeneric'; + $translationKeys[] = 'General_IncompletePeriod'; + $translationKeys[] = 'General_InvalidatedPeriod'; + } +} diff --git a/plugins/CoreVisualizations/FeatureFlags/SparklinesRedesign.php b/plugins/CoreVisualizations/FeatureFlags/SparklinesRedesign.php new file mode 100644 index 0000000000000000000000000000000000000000..950b536e898a6014e408363a73204f5b36cc5022 --- /dev/null +++ b/plugins/CoreVisualizations/FeatureFlags/SparklinesRedesign.php @@ -0,0 +1,22 @@ +properties = $properties; + $this->graphType = $graphType; + $this->isComparing = $graph->isComparing(); + $this->graph = $graph; + } + + /** + * Generates JSON graph data and returns it. + * + * @param DataTable|DataTable\Map $dataTable + * @return array + */ + public function generate($dataTable) + { + $visualization = new Chart(); + + if ($dataTable->getRowsCount() > 0) { + $dataTable->applyQueuedFilters(); + $this->initChartObjectData($dataTable, $visualization); + } + + return $visualization->render(); + } + + /** + * @param DataTable|DataTable\Map $dataTable + * @param Chart $visualization + */ + protected function initChartObjectData($dataTable, $visualization) + { + $xLabels = $dataTable->getColumn('label'); + + $columnsToDisplay = array_values($this->properties['columns_to_display']); + if (($labelColumnIndex = array_search('label', $columnsToDisplay)) !== false) { + unset($columnsToDisplay[$labelColumnIndex]); + } + + $seriesMetadata = null; + if ($this->isComparing) { + [$yLabels, $serieses, $seriesMetadata] = $this->getComparisonTableSerieses($dataTable, $columnsToDisplay); + } else { + [$yLabels, $serieses] = $this->getMainTableSerieses($dataTable, $columnsToDisplay); + } + + $visualization->properties = $this->properties; + + $visualization->setAxisXLabels($xLabels); + $visualization->setAxisYValues($serieses, $seriesMetadata); + $visualization->setAxisYLabels($yLabels); + + $units = $this->getUnitsForSerieses($yLabels); + $visualization->setAxisYUnits($units); + } + + private function getMainTableSerieses(DataTable $dataTable, $columnNames) + { + $columnNameToTranslation = []; + + foreach ($columnNames as $columnName) { + $columnNameToTranslation[$columnName] = @$this->properties['translations'][$columnName]; + } + + $columnNameToValue = array(); + foreach ($columnNames as $columnName) { + $columnNameToValue[$columnName] = $dataTable->getColumn($columnName); + } + + return [$columnNameToTranslation, $columnNameToValue]; + } + + private function getComparisonTableSerieses(DataTable $dataTable, $columnsToDisplay) + { + $seriesLabels = []; + $serieses = []; + $seriesMetadata = []; + + $seriesIndices = []; + + foreach ($dataTable->getRows() as $row) { + /** @var DataTable $comparisonTable */ + $comparisonTable = $row->getComparisons(); + if (empty($comparisonTable)) { + continue; + } + + foreach ($comparisonTable->getRows() as $index => $compareRow) { + foreach ($columnsToDisplay as $columnIndex => $columnName) { + $seriesId = $columnName . '|' . $index; + + if (!isset($seriesIndices[$seriesId])) { + $seriesIndices[$seriesId] = count($seriesIndices); + } + + $seriesLabel = $this->getComparisonSeriesLabel($compareRow, $columnName); + $seriesLabels[$seriesId] = $seriesLabel; + $serieses[$seriesId][] = $compareRow->getColumn($columnName); + + $seriesMetadata[$seriesId] = [ + 'seriesIndex' => $seriesIndices[$seriesId], + 'metricIndex' => $columnIndex, + ]; + } + } + } + + return [$seriesLabels, $serieses, $seriesMetadata]; + } + + protected function getComparisonSeriesLabel(Row $compareRow, $columnName, $rowLabel = false) + { + return $this->getComparisonSeriesLabelFromCompareSeries($compareRow->getMetadata('compareSeriesPretty'), $columnName, $rowLabel); + } + + protected function getComparisonSeriesLabelFromCompareSeries($compareSeriesPretty, $columnName, $rowLabel = false) + { + $columnTranslation = @$this->properties['translations'][$columnName]; + + if (empty($rowLabel)) { + $label = $columnTranslation; + } else { + $label = "$rowLabel ($columnTranslation)"; + } + + $label .= ' ' . $compareSeriesPretty; + return $label; + } + + protected function getUnitsForSerieses($yLabels) + { + // derive units from column names + $units = $this->deriveUnitsFromRequestedColumnNames($yLabels); + if (!empty($this->properties['y_axis_unit'])) { + $units = array_fill(0, count($units), $this->properties['y_axis_unit']); + } + return $units; + } + + private function deriveUnitsFromRequestedColumnNames($yLabels) + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + + $units = array(); + foreach ($yLabels as $seriesId => $ignore) { + $parts = explode('|', $seriesId, 2); + $columnName = $parts[0]; + + $derivedUnit = Metrics::getUnit($columnName, $idSite); + $units[$seriesId] = empty($derivedUnit) ? false : $derivedUnit; + } + return $units; + } +} diff --git a/plugins/CoreVisualizations/JqplotDataGenerator/Chart.php b/plugins/CoreVisualizations/JqplotDataGenerator/Chart.php new file mode 100644 index 0000000000000000000000000000000000000000..3e4654e90ba29c2bc0159e1237562e35b875c616 --- /dev/null +++ b/plugins/CoreVisualizations/JqplotDataGenerator/Chart.php @@ -0,0 +1,240 @@ + + */ + protected $dataStates = []; + + /** + * @var LoggerInterface + */ + protected $logger; + + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? StaticContainer::get(LoggerInterface::class); + } + + public function setAxisXLabels($xLabels, $xTicks = null, $index = 0) + { + $axisName = $this->getXAxis($index); + + $xSteps = $this->properties['x_axis_step_size']; + $showAllTicks = $this->properties['show_all_ticks']; + + $this->axes[$axisName]['labels'] = array_values($xLabels); + + $ticks = array_values($xTicks ?: $xLabels); + + if (!$showAllTicks) { + // unset labels so there are $xSteps number of blank ticks between labels + foreach ($ticks as $i => &$label) { + if ($i % $xSteps != 0) { + $label = ' '; + } + } + } + $this->axes[$axisName]['ticks'] = $ticks; + } + + public function setAxisXOnClick(&$onClick) + { + $this->axes['xaxis']['onclick'] = & $onClick; + } + + /** + * Set the series values + * + * @param $values + * @param array|null $seriesMetadata + * @param array|null $seriesUnits If the series units array is passed then the values will be formatted + */ + public function setAxisYValues(&$values, $seriesMetadata = null, ?array $seriesUnits = null) + { + foreach ($values as $label => &$data) { + $seriesInfo = array( + 'label' => $label, + 'internalLabel' => $label, + ); + + if (isset($seriesMetadata[$label])) { + $seriesInfo = array_merge($seriesInfo, $seriesMetadata[$label]); + } + + $this->series[] = $seriesInfo; + $unit = (isset($seriesUnits[$label]) ? $seriesUnits[$label] : null); + + array_walk($data, function (&$v) use ($unit) { + $v = (float) Common::forceDotAsSeparatorForDecimalPoint($v); + if ($unit === '%') { + $v = $v * 100; + } + }); + $this->data[] = & $data; + } + } + + public function setAxisYUnits($yUnits) + { + $yUnits = array_values(array_map('strval', $yUnits)); + + // generate axis IDs for each unique y unit + $axesIds = array(); + foreach ($yUnits as $idx => $unit) { + if (!isset($axesIds[$unit])) { + // handle axes ids: first y[]axis, then y[2]axis, y[3]axis... + $nextAxisId = empty($axesIds) ? '' : count($axesIds) + 1; + + $axesIds[$unit] = 'y' . $nextAxisId . 'axis'; + } + } + + // generate jqplot axes config + foreach ($axesIds as $unit => $axisId) { + if ($unit === '$' || $unit === '£') { + $this->axes[$axisId]['tickOptions']['formatString'] = $unit . '%s'; + } else { + $this->axes[$axisId]['tickOptions']['formatString'] = '%s' . $unit; + } + } + + $currencies = StaticContainer::get('Piwik\Intl\Data\Provider\CurrencyDataProvider')->getCurrencyList(); + $currencies = array_column($currencies, 0); + + // generate jqplot axes config + foreach ($axesIds as $unit => $axisId) { + if ($unit === '%') { + $this->axes[$axisId]['tickOptions']['formatString'] = str_replace('0', '%s', NumberFormatter::getInstance()->formatPercent(0, 0, 0)); + } elseif (in_array($unit, $currencies)) { + $this->axes[$axisId]['tickOptions']['formatString'] = str_replace('0', '%s', NumberFormatter::getInstance()->formatCurrency(0, $unit, 0)); + } else { + $this->axes[$axisId]['tickOptions']['formatString'] = '%s' . $unit; + } + } + + // map each series to appropriate yaxis + foreach ($yUnits as $idx => $unit) { + $this->series[$idx]['yaxis'] = $axesIds[$unit]; + } + } + + public function setAxisYLabels($labels) + { + foreach ($this->series as &$series) { + $label = $series['internalLabel']; + if (isset($labels[$label])) { + $series['label'] = $labels[$label]; + } + } + } + + public function render() + { + ProxyHttp::overrideCacheControlHeaders(); + + $this->checkDataStateAvailableForAllTicks(); + + // See https://www.jqplot.com/docs/files/jqPlotOptions-txt.html + $data = [ + 'params' => [ + 'axes' => &$this->axes, + 'series' => &$this->series, + ], + 'data' => &$this->data, + 'dataStates' => &$this->dataStates, + ]; + + return $data; + } + + public function setAxisXLabelsMultiple($xLabels, $seriesToXAxis, $ticks = null) + { + foreach ($xLabels as $index => $labels) { + $this->setAxisXLabels($labels, $ticks === null ? null : $ticks[$index], $index); + } + + foreach ($seriesToXAxis as $seriesIndex => $xAxisIndex) { + $axisName = $this->getXAxis($xAxisIndex); + + // don't actually set xaxis otherwise jqplot will show too many axes. however, we need the xaxis labels, so we add them + // to the jqplot config + $this->series[$seriesIndex]['_xaxis'] = $axisName; + } + } + + /** + * Set state information ("complete", "incomplete", ...) for the data points. + * + * Each entry is associated with all values of all series having the same index (stored in $data). + * + * @param array $dataStates + */ + public function setDataStates(array $dataStates): void + { + $this->dataStates = $dataStates; + } + + private function getXAxis($index) + { + $axisName = 'xaxis'; + if ($index != 0) { + $axisName = 'x' . ($index + 1) . 'axis'; + } + return $axisName; + } + + private function checkDataStateAvailableForAllTicks(): void + { + if ([] === $this->dataStates) { + return; + } + + $maxTickCount = 0; + $stateCount = count($this->dataStates); + + if ([] !== $this->data) { + $dataCounts = array_map('count', $this->data); + $uniqueCounts = array_unique($dataCounts); + $maxTickCount = max($uniqueCounts); + } + + if ($stateCount === $maxTickCount) { + return; + } + + $ex = new Exception(sprintf( + 'Data state information does not span graph length (%u ticks, %u states)', + $maxTickCount, + $stateCount + )); + + $this->logger->info('{exception}', ['exception' => $ex]); + } +} diff --git a/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php b/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php new file mode 100644 index 0000000000000000000000000000000000000000..4b56bfe8c4274c87f40947fb9df361a2c5b43aff --- /dev/null +++ b/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php @@ -0,0 +1,374 @@ +properties['columns_to_display'] as $columnName) { + $derivedUnit = Metrics::getUnit($columnName, $idSite); + $units[$columnName] = empty($derivedUnit) ? false : $derivedUnit; + } + return $units; + } + + /** + * @param DataTable|DataTable\Map $dataTable + * @param Chart $visualization + */ + protected function initChartObjectData($dataTable, $visualization) + { + // if the loaded datatable is a simple DataTable, it is most likely a plugin plotting some custom data + // we don't expect plugin developers to return a well defined Set + + if ($dataTable instanceof DataTable) { + parent::initChartObjectData($dataTable, $visualization); + return; + } + + $dataTables = $dataTable->getDataTables(); + + // determine x labels based on both the displayed date range and the compared periods + /** @var Period[][] $xLabels */ + $xLabels = [ + [], // placeholder for first series + ]; + + $this->addComparisonXLabels($xLabels, reset($dataTables)); + $this->addSelectedSeriesXLabels($xLabels, $dataTables); + + $units = $this->getUnitsForColumnsToDisplay(); + + // if rows to display are not specified, default to all rows (TODO: perhaps this should be done elsewhere?) + $rowsToDisplay = $this->properties['rows_to_display'] + ? : array_unique($dataTable->getColumn('label')) + ? : [false] // make sure that a series is plotted even if there is no data + ; + + $columnsToDisplay = array_values($this->properties['columns_to_display']); + + [$seriesMetadata, $seriesUnits, $seriesLabels, $seriesToXAxis] = + $this->getSeriesMetadata($rowsToDisplay, $columnsToDisplay, $units, $dataTables); + + // collect series data to show. each row-to-display/column-to-display permutation creates a series. + $allSeriesData = []; + foreach ($rowsToDisplay as $rowIdentifier) { + $rowLabel = $rowIdentifier; + + if (!empty($this->properties['selectable_rows'])) { + foreach ($this->properties['selectable_rows'] as $row) { + if ($rowIdentifier === $row['matcher']) { + $rowLabel = $row['label']; + } + } + } + + foreach ($columnsToDisplay as $columnName) { + if (!$this->isComparing) { + $this->setNonComparisonSeriesData($allSeriesData, $rowLabel, $columnName, $dataTable); + } else { + $this->setComparisonSeriesData($allSeriesData, $seriesLabels, $rowLabel, $columnName, $dataTable); + } + } + } + + $visualization->properties = $this->properties; + + $units = null; + if ($visualization->properties['request_parameters_to_modify']['format_metrics'] === 0) { + $units = $seriesUnits; + } + $visualization->setAxisYValues($allSeriesData, $seriesMetadata, $units); + $visualization->setAxisYUnits($seriesUnits); + + $xLabelStrs = []; + $xAxisTicks = []; + foreach ($xLabels as $index => $seriesXLabels) { + $xLabelStrs[$index] = array_map(function (Period $p) { + return $p->getLocalizedLongString(); + }, $seriesXLabels); + $xAxisTicks[$index] = array_map(function (Period $p) { + return $p->getLocalizedShortString(); + }, $seriesXLabels); + } + + $visualization->setAxisXLabelsMultiple($xLabelStrs, $seriesToXAxis, $xAxisTicks); + + if ($this->isLinkEnabled()) { + $idSite = Common::getRequestVar('idSite', null, 'int'); + $periodLabel = reset($dataTables)->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getLabel(); + + $axisXOnClick = array(); + foreach ($dataTable->getDataTables() as $metadataDataTable) { + $dateInUrl = $metadataDataTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getDateStart(); + $parameters = array( + 'idSite' => $idSite, + 'period' => $periodLabel, + 'date' => $dateInUrl->toString(), + 'segment' => \Piwik\API\Request::getRawSegmentFromRequest(), + ); + $link = Url::getQueryStringFromParameters($parameters); + $axisXOnClick[] = $link; + } + $visualization->setAxisXOnClick($axisXOnClick); + } + + $this->setDataStates($visualization, $dataTables); + } + + private function getSeriesData($rowLabel, $columnName, DataTable\Map $dataTable) + { + $seriesData = array(); + foreach ($dataTable->getDataTables() as $childTable) { + // get the row for this label (use the first if $rowLabel is false) + if ($rowLabel === false) { + $row = $childTable->getFirstRow(); + } else { + $row = $childTable->getRowFromLabel($rowLabel); + } + + // get series data point. defaults to 0 if no row or no column value. + if ($row === false) { + $seriesData[] = 0; + } else { + $seriesData[] = $row->getColumn($columnName) ? : 0; + } + } + return $seriesData; + } + + /** + * Derive the series label from the row label and the column name. + * If the row label is set, both the label and the column name are displayed. + * @param string $rowLabel + * @param string $columnName + * @return string + */ + private function getSeriesLabel($rowLabel, $columnName) + { + $metricLabel = @$this->properties['translations'][$columnName]; + + if ($rowLabel !== false) { + // eg. "Yahoo! (Visits)" + $label = "$rowLabel ($metricLabel)"; + } else { + // eg. "Visits" + $label = $metricLabel; + } + + return $label; + } + + private function isLinkEnabled() + { + static $linkEnabled; + if (!isset($linkEnabled)) { + // 1) Custom Date Range always have link disabled, otherwise + // the graph data set is way too big and fails to display + // 2) disableLink parameter is set in the Widgetize "embed" code + $linkEnabled = !Common::getRequestVar('disableLink', 0, 'int') + && Common::getRequestVar('period', 'day') != 'range'; + } + return $linkEnabled; + } + + /** + * Each period comparison shows data over different data points than the main series (eg, 2014-02-03,1014-02-06 compared w/ 2015-03-04,2015-03-15). + * Though we only display the selected period's x labels, we need to both have the labels for all these data points for tooltips and to stretch + * out the selected period x axis, in case it is shorter than one of the compared periods (as in the example above). + */ + private function addComparisonXLabels(array &$xLabels, DataTable $table) + { + $comparePeriods = $table->getMetadata('comparePeriods') ?: []; + $compareDates = $table->getMetadata('compareDates') ?: []; + + // get rid of selected period + array_shift($comparePeriods); + array_shift($compareDates); + + foreach (array_values($comparePeriods) as $index => $period) { + $date = $compareDates[$index]; + + $range = Factory::build($period, $date); + foreach ($range->getSubperiods() as $subperiod) { + $xLabels[$index + 1][] = $subperiod; + } + } + } + + /** + * @param array $xLabels + * @param DataTable[] $dataTables + * @throws \Exception + */ + protected function addSelectedSeriesXLabels(array &$xLabels, array $dataTables) + { + $xTicksCount = count($dataTables); + foreach ($xLabels as $labelSeries) { + $xTicksCount = max(count($labelSeries), $xTicksCount); + } + + /** @var Date $startDate */ + $startDate = reset($dataTables)->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getDateStart(); + $periodType = reset($dataTables)->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getLabel(); + + for ($i = 0; $i < $xTicksCount; ++$i) { + $period = Factory::build($periodType, $startDate->addPeriod($i, $periodType)); + $xLabels[0][] = $period; + } + } + + private function setNonComparisonSeriesData(array &$allSeriesData, $rowLabel, $columnName, DataTable\Map $dataTable) + { + $seriesLabel = $this->getSeriesLabel($rowLabel, $columnName); + + $seriesData = $this->getSeriesData($rowLabel, $columnName, $dataTable); + $allSeriesData[$seriesLabel] = $seriesData; + } + + private function setComparisonSeriesData(array &$allSeriesData, array $seriesLabels, $rowLabel, $columnName, DataTable\Map $dataTable) + { + foreach ($dataTable->getDataTables() as $label => $childTable) { + // get the row for this label (use the first if $rowLabel is false) + if ($rowLabel === false) { + $row = $childTable->getFirstRow(); + } else { + $row = $childTable->getRowFromLabel($rowLabel); + } + + if ( + empty($row) + || empty($row->getComparisons()) + ) { + foreach ($seriesLabels as $seriesIndex => $seriesLabelPrefix) { + $wholeSeriesLabel = $this->getComparisonSeriesLabelFromCompareSeries($seriesLabelPrefix, $columnName, $rowLabel); + $allSeriesData[$wholeSeriesLabel][] = 0; + } + + continue; + } + + /** @var DataTable $comparisonTable */ + $comparisonTable = $row->getComparisons(); + foreach ($comparisonTable->getRows() as $compareRow) { + $seriesLabel = $this->getComparisonSeriesLabel($compareRow, $columnName, $rowLabel); + $allSeriesData[$seriesLabel][] = $compareRow->getColumn($columnName); + } + + $totalsRow = $comparisonTable->getTotalsRow(); + if ($totalsRow) { + $seriesLabel = $this->getComparisonSeriesLabel($totalsRow, $columnName, $rowLabel); + $allSeriesData[$seriesLabel][] = $totalsRow->getColumn($columnName); + } + } + } + + private function getSeriesMetadata(array $rowsToDisplay, array $columnsToDisplay, array $units, array $dataTables) + { + $seriesMetadata = null; // maps series labels to any metadata of the series + $seriesUnits = array(); // maps series labels to unit labels + $seriesToXAxis = []; // maps series index to x-axis index (groups of metrics for a single comparison will use the same x-axis) + + $table = reset($dataTables); + $seriesLabels = $table->getMetadata('comparisonSeries') ?: []; + foreach ($rowsToDisplay as $rowIndex => $rowLabel) { + foreach ($columnsToDisplay as $columnIndex => $columnName) { + if ($this->isComparing) { + foreach ($seriesLabels as $seriesIndex => $seriesLabel) { + $wholeSeriesLabel = $this->getComparisonSeriesLabelFromCompareSeries($seriesLabel, $columnName, $rowLabel); + + $allSeriesData[$wholeSeriesLabel] = []; + + $metricIndex = $rowIndex * count($columnsToDisplay) + $columnIndex; + $seriesMetadata[$wholeSeriesLabel] = [ + 'metricIndex' => $metricIndex, + 'seriesIndex' => $seriesIndex, + ]; + + $seriesUnits[$wholeSeriesLabel] = $units[$columnName]; + + [$periodIndex, $segmentIndex] = DataComparisonFilter::getIndividualComparisonRowIndices($table, $seriesIndex); + $seriesToXAxis[] = $periodIndex; + } + } else { + $seriesLabel = $this->getSeriesLabel($rowLabel, $columnName); + $seriesUnits[$seriesLabel] = $units[$columnName]; + } + } + } + + return [$seriesMetadata, $seriesUnits, $seriesLabels, $seriesToXAxis]; + } + + /** + * @param array $dataTables + */ + private function setDataStates(Chart $visualization, array $dataTables): void + { + if (0 === count($dataTables)) { + return; + } + + $dataTableDates = array_keys($dataTables); + $mostRecentDate = end($dataTableDates); + + /** @var Site $site */ + $site = $dataTables[$mostRecentDate]->getMetadata(DataTableFactory::TABLE_METADATA_SITE_INDEX); + + $dataStates = []; + $siteToday = Date::factoryInTimezone('today', $site->getTimezone())->getTimestamp(); + $previousState = ArchiveState::COMPLETE; + + foreach ($dataTableDates as $dataTableDate) { + /** @var Period $period */ + $period = $dataTables[$dataTableDate]->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); + $state = $dataTables[$dataTableDate]->getMetadata(DataTable::ARCHIVE_STATE_METADATA_NAME); + + if (false === $state) { + // Missing archive state information should only occur if no + // usable archive was found in the database. Treat a missing archive + // (for example if there are legitimately zero visits to a site) + // as complete unless it follows an incomplete archive. + $state = ArchiveState::INCOMPLETE === $previousState + ? ArchiveState::INCOMPLETE + : ArchiveState::COMPLETE; + } + + if ($siteToday <= $period->getDateEnd()->getTimestamp()) { + $state = ArchiveState::INCOMPLETE; + } + + $dataStates[$dataTableDate] = $state; + $previousState = $state; + } + + $visualization->setDataStates(array_values($dataStates)); + } +} diff --git a/plugins/CoreVisualizations/Metrics/Formatter/Numeric.php b/plugins/CoreVisualizations/Metrics/Formatter/Numeric.php new file mode 100644 index 0000000000000000000000000000000000000000..dcfddca797b432202d9b02bbe7f07b9dd9b8f6ab --- /dev/null +++ b/plugins/CoreVisualizations/Metrics/Formatter/Numeric.php @@ -0,0 +1,55 @@ +getPrettySizeFromBytesWithUnit($size, self::$byteSizeUnit, $precision); + return $size; + } + + public function getPrettyMoney($value, $idSite) + { + return $value; + } + + public function getPrettyPercentFromQuotient($value) + { + return $value * 100; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/Cloud.php b/plugins/CoreVisualizations/Visualizations/Cloud.php new file mode 100644 index 0000000000000000000000000000000000000000..0a6eb90838f1bbd359472f433c396ab1861e3994 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/Cloud.php @@ -0,0 +1,224 @@ +requestConfig->request_parameters_to_modify['format_metrics'] = 0; + $this->checkRequestIsNotForMultiplePeriods(); + } + + /** + * First pass: metric formatting filters have not been applied, store the raw values for each word + */ + public function afterAllFiltersAreApplied() + { + if ($this->dataTable->getRowsCount() == 0) { + return; + } + + $columnToDisplay = isset($this->config->columns_to_display[1]) ? $this->config->columns_to_display[1] : 'nb_visits'; + foreach ($this->dataTable->getRows() as $row) { + $label = $row->getColumn('label'); + $this->rawValues[$label] = $row->getColumn($columnToDisplay); + } + } + + public function beforeRender() + { + $this->config->show_exclude_low_population = false; + $this->config->show_offset_information = false; + $this->config->show_limit_control = false; + + // manually apply metric formatting + $this->applyMetricsFormatting(true); + $this->generateCloudData(); + } + + private function generateCloudData() + { + if ($this->dataTable->getRowsCount() == 0) { + return; + } + + $columnToDisplay = isset($this->config->columns_to_display[1]) ? $this->config->columns_to_display[1] : 'nb_visits'; + $labelMetadata = []; + + foreach ($this->dataTable->getRows() as $row) { + $logo = false; + if ($this->config->display_logo_instead_of_label) { + $logo = $row->getMetadata('logo'); + } + + $label = $row->getColumn('label'); + + $labelMetadata[$label] = [ + 'logo' => $logo, + 'url' => $row->getMetadata('url'), + ]; + + $this->addWord($label, $this->rawValues[$label]); + + $this->formattedValues[$label] = $row->getColumn($columnToDisplay); + } + + $cloudValues = $this->getCloudValues(); + foreach ($cloudValues as &$value) { + $value['logoWidth'] = round(max(16, $value['percent'])); + } + + $this->assignTemplateVar('labelMetadata', $labelMetadata); + $this->assignTemplateVar('cloudColumn', $columnToDisplay); + $this->assignTemplateVar('cloudValues', $cloudValues); + } + + /** + * Assign word to array + * @param string $word + * @param int $value + */ + public function addWord($word, $value = 1) + { + if (isset($this->wordsArray[$word])) { + $this->wordsArray[$word] += $value; + } else { + $this->wordsArray[$word] = $value; + } + } + + private function getCloudValues() + { + $this->shuffleCloud(); + + if (empty($this->wordsArray)) { + return []; + } + + $maxValue = max($this->wordsArray); + + $return = []; + foreach ($this->wordsArray as $word => $popularity) { + $wordTruncated = $this->truncateWordIfNeeded($word); + $percent = $this->getPercentage($popularity, $maxValue); + $sizeRange = $this->getClassFromPercent($percent); + + $return[$word] = [ + 'word' => $word, + 'wordTruncated' => $wordTruncated, + 'size' => $sizeRange, + 'value' => $this->formattedValues[$word], + 'percent' => $percent, + ]; + } + + return $return; + } + + /** + * Shuffle associated names in array + */ + protected function shuffleCloud() + { + if (self::$debugDisableShuffle) { + return; + } + + $keys = array_keys($this->wordsArray); + + shuffle($keys); + + if (count($keys) && is_array($keys)) { + $tmpArray = $this->wordsArray; + + $this->wordsArray = []; + foreach ($keys as $value) { + $this->wordsArray[$value] = $tmpArray[$value]; + } + } + } + + /** + * Get the class range using a percentage + * + * @param $percent + * + * @return int class + */ + protected function getClassFromPercent($percent) + { + $mapping = [95, 70, 50, 30, 15, 5, 0]; + foreach ($mapping as $key => $value) { + if ($percent >= $value) { + return $key; + } + } + return 0; + } + + /** + * @param $word + * @return string + */ + private function truncateWordIfNeeded($word) + { + $word = Common::unsanitizeInputValue($word); + + if (mb_strlen($word) > $this->truncatingLimit) { + return mb_substr($word, 0, $this->truncatingLimit - 3) . '...'; + } + + return $word; + } + + private function getPercentage($popularity, $maxValue) + { + // case hideFutureHoursWhenToday=1 shows hours with no visits + if ($maxValue == 0) { + return 0; + } + + $percent = ($popularity / $maxValue) * 100; + + return $percent; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/Cloud/Config.php b/plugins/CoreVisualizations/Visualizations/Cloud/Config.php new file mode 100644 index 0000000000000000000000000000000000000000..d39ba635c8b46e599ccd87d468cc7a84ff33e592 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/Cloud/Config.php @@ -0,0 +1,33 @@ +addPropertiesThatCanBeOverwrittenByQueryParams(array('display_logo_instead_of_label')); + } +} diff --git a/plugins/CoreVisualizations/Visualizations/EvolutionPeriodSelector.php b/plugins/CoreVisualizations/Visualizations/EvolutionPeriodSelector.php new file mode 100644 index 0000000000000000000000000000000000000000..0d7f5c52bf2a425b1712290eae19c8f1c86f4643 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/EvolutionPeriodSelector.php @@ -0,0 +1,120 @@ +config = $config; + } + + public function getNumDaysDifference(Date $date1, Date $date2): int + { + $days = abs($date1->getTimestamp() - $date2->getTimestamp()) / 60 / 60 / 24; + return (int) round($days); + } + + public function setDatePeriods($params, Period $originalPeriod, $comparisonPeriods, $isComparing): array + { + $highestPeriodInCommon = $this->getHighestPeriodInCommon($originalPeriod, $comparisonPeriods); + + if ($isComparing) { + // when not comparing we usually show the last30 data points from the end date. However, when comparing dates, + // or periods then we don't want to do this and rather only draw the evolution of the selected range. This way + // you can better compare the two specific date ranges and how they change over time. + // Otherwise, if you were to compare for example today vs yesterday, then you would see the trends for today + // over last 30 days vs yesterday over the last 30 days which isn't very helpful to compare. + // that means when you compare month of Dec 2022 with Dec 2021, then the shown sparkline should be comparing + // Dec 1st 2022,Dec 31st 2022 with Dec 1st 2021,Dec31st 2021. And not show eg the last 30 months. + + // we want to use the higest possible period we can for best performance and for best detecting trends + $params['period'] = $highestPeriodInCommon; + $params['date'] = $originalPeriod->getRangeString(); + + $params['compareDates'] = []; + $params['comparePeriods'] = []; + + foreach ($comparisonPeriods as $period) { + $params['compareDates'][] = $period->getRangeString(); + $params['comparePeriods'][] = $params['period']; // need to ensure to use same period for both the base and the comparison period + } + } else { + // when not comparing we select the last 30 days/weeks/months/years. + // For period range it will select the selected range with a day metric + $params = $this->config->getGraphParamsModified($params); + if ($originalPeriod->getLabel() === 'range') { + // when a longer range is selected, then we select a higher period for improved performance and also to see trends better + // eg when selecting a range of 2 years then we rather show 24 months instead of 730 day points. + $params['period'] = $highestPeriodInCommon; + $params['date'] = $originalPeriod->getRangeString(); + } + } + + return $params; + } + + public function getComparisonPeriodObjects($comparePeriods, $compareDates): array + { + $periods = []; + if (!empty($comparePeriods)) { + foreach ($comparePeriods as $periodIndex => $period) { + $date = $compareDates[$periodIndex]; + $periods[] = Factory::build($period, $date); + } + } + return $periods; + } + + /** + * Given all the periods, determine what is the lowest period we can use. For example if someone compares + * a range of 2 years with a range of 2 days, then we still need to use "day" periods in the sparkline as otherwise + * you would not be able to compare the 2 different sparklines when they use different periods. + * + * For best performance and for better detecting trends we try to select the highest period if all the periods given + * allow it. + * + * @param $comparisonPeriods + */ + public function getHighestPeriodInCommon(Period $originalPeriod, $comparisonPeriods): string + { + $lowestNumDaysInRange = $this->getNumDaysDifference($originalPeriod->getDateStart(), $originalPeriod->getDateEnd()); + + if (!empty($comparisonPeriods)) { + foreach ($comparisonPeriods as $period) { + $numDaysInRange = $this->getNumDaysDifference($period->getDateStart(), $period->getDateEnd()); + if ($numDaysInRange < $lowestNumDaysInRange) { + $lowestNumDaysInRange = $numDaysInRange; + } + } + } + + $periodToUse = 'day'; + if ($lowestNumDaysInRange >= 7 * 365) { + $periodToUse = 'year'; + } elseif ($lowestNumDaysInRange >= 2 * 365) { + $periodToUse = 'month'; + } elseif ($lowestNumDaysInRange >= 180) { + $periodToUse = 'week'; + } + + return $periodToUse; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/Graph.php b/plugins/CoreVisualizations/Visualizations/Graph.php new file mode 100644 index 0000000000000000000000000000000000000000..5127f307245b3ddd843e252ef4517f157c14edf1 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/Graph.php @@ -0,0 +1,285 @@ +addPropertiesThatShouldBeAvailableClientSide(array('columns')); + + return $config; + } + + public function beforeRender() + { + if ($this->config->show_goals) { + $this->config->translations['nb_conversions'] = Piwik::translate('Goals_ColumnConversions'); + $this->config->translations['revenue'] = Piwik::translate('General_TotalRevenue'); + } + } + + public function beforeLoadDataTable() + { + // TODO: this should not be required here. filter_limit should not be a view property, instead HtmlTable should use 'limit' or something, + // and manually set request_parameters_to_modify['filter_limit'] based on that. (same for filter_offset). + $this->requestConfig->request_parameters_to_modify['filter_limit'] = false; + + if ($this->config->max_graph_elements) { + $this->requestConfig->request_parameters_to_modify['filter_truncate'] = $this->config->max_graph_elements - 1; + } + + // Only default to formatting metrics if the request hasn't already been set to not format metrics + if (!isset($this->requestConfig->request_parameters_to_modify['format_metrics'])) { + $this->requestConfig->request_parameters_to_modify['format_metrics'] = 1; + } + + // if addTotalRow was called in GenerateGraphHTML, add a row containing totals of + // different metrics + if ($this->config->add_total_row) { + $this->requestConfig->request_parameters_to_modify['totals'] = 1; + $this->requestConfig->request_parameters_to_modify['keep_totals_row'] = 1; + $this->requestConfig->request_parameters_to_modify['keep_totals_row_label'] = Piwik::translate('General_Total'); + } + + if (!empty($this->config->columns_to_display)) { + $metrics = $this->removeUnavailableMetrics($this->config->columns_to_display); + if (empty($metrics)) { + if (!empty($this->config->selectable_columns)) { + $this->config->columns_to_display = array(reset($this->config->selectable_columns)); + } else { + $this->config->columns_to_display = array('nb_visit'); + } + $this->requestConfig->request_parameters_to_modify['columns'] = 'nb_visits'; + $this->requestConfig->request_parameters_to_modify['columns_to_display'] = 'nb_visits'; + } + } + + $this->metricsFormatter = new Numeric(); + } + + /** + * Determines what rows are selectable and stores them in the selectable_rows property in + * a format the SeriesPicker JavaScript class can use. + */ + public function determineWhichRowsAreSelectable(): void + { + if ($this->config->row_picker_match_rows_by === false) { + return; + } + + // collect all selectable rows + $self = $this; + + $this->dataTable->filter(function (DataTable $dataTable) use ($self) { + + $identifier = $self->config->row_picker_match_rows_by; + + foreach ($dataTable->getRows() as $row) { + $rowLabel = $row->getColumn('label'); + $rowIdentifier = $row->hasColumn($identifier) ? $row->getColumn($identifier) : $row->getMetadata($identifier); + + if (false === $rowLabel || false === $rowIdentifier) { + continue; + } + + $rowIdentifier = (string) $rowIdentifier; // ensure we always have the same type + + // build config + if (!isset($self->selectableRows[$rowIdentifier])) { + $self->selectableRows[$rowIdentifier] = [ + 'label' => $rowLabel, + 'matcher' => $rowIdentifier, + 'displayed' => $self->isRowVisible($rowLabel, $rowIdentifier), + ]; + } + } + }); + } + + public function isRowVisible($rowLabel, $rowIdentifier): bool + { + if (false !== $this->config->row_picker_match_rows_by) { + return is_array($this->config->rows_to_display) && + (in_array($rowLabel, $this->config->rows_to_display) || in_array($rowIdentifier, $this->config->rows_to_display)); + } + + return true; + } + + /** + * Defaults the selectable_columns property if it has not been set and then transforms + * it into something the SeriesPicker JavaScript class can use. + */ + public function afterAllFiltersAreApplied() + { + $this->determineWhichRowsAreSelectable(); + + // set default selectable columns, if none specified + $selectableColumns = $this->config->selectable_columns; + if (false === $selectableColumns) { + $this->generateSelectableColumns(); + } + + $this->ensureValidColumnsToDisplay(); + + $this->addTranslations(); + + $this->config->selectable_rows = array_values($this->selectableRows); + } + + protected function addTranslations(): void + { + if ($this->config->add_total_row) { + $totalTranslation = Piwik::translate('General_Total'); + $this->selectableRows['total'] = [ + 'label' => $totalTranslation, + 'matcher' => 'total', + 'displayed' => $this->isRowVisible($totalTranslation, 'total'), + ]; + } + + if ($this->config->show_goals) { + $this->config->addTranslations([ + 'nb_conversions' => Piwik::translate('Goals_ColumnConversions'), + 'revenue' => Piwik::translate('General_TotalRevenue'), + ]); + } + + $transformed = []; + foreach ($this->config->selectable_columns as $column) { + $transformed[] = [ + 'column' => $column, + 'translation' => @$this->config->translations[$column], + 'displayed' => in_array($column, $this->config->columns_to_display), + ]; + } + $this->config->selectable_columns = $transformed; + } + + protected function generateSelectableColumns() + { + $defaultColumns = $this->getDefaultColumnsToDisplay(); + if ($this->config->show_goals) { + $goalMetrics = array('nb_conversions', 'revenue'); + $defaultColumns = array_merge($defaultColumns, $goalMetrics); + } + + // Use the subset of default columns that are actually present in the datatable + $allColumns = $this->getDataTable()->getColumns(); + $selectableColumns = array_intersect($defaultColumns, $allColumns); + + // If there are no default columns, just strip out the 'label' column and use all the others + if (empty($selectableColumns)) { + $selectableColumns = $this->removeLabelFromArray($allColumns); + } + + $this->config->selectable_columns = $selectableColumns; + } + + private function removeLabelFromArray($theArray) + { + if (!empty($theArray) && is_array($theArray)) { + $key = array_search('label', $theArray); + if ($key !== false) { + unset($theArray[$key]); + $theArray = array_values($theArray); + } + } + + return $theArray; + } + + protected function ensureValidColumnsToDisplay() + { + $columnsToDisplay = $this->config->columns_to_display; + + // Remove 'label' from columns to display if present + $columnsToDisplay = $this->removeLabelFromArray($columnsToDisplay); + + // Strip out any columns_to_display that are not in the dataset + $allColumns = []; + if ($this->report) { + $allColumns = $this->report->getAllMetrics(); + } + $allColumns = array_merge($allColumns, $this->getDataTable()->getColumns()); + + $dataTable = $this->getDataTable(); + if ($dataTable instanceof DataTable\Map) { + $dataTable = $dataTable->getFirstRow(); + } + + /** @var ProcessedMetric[] $extraProcessedMetrics */ + $extraProcessedMetrics = $dataTable->getMetadata(DataTable::EXTRA_PROCESSED_METRICS_METADATA_NAME); + if (!empty($extraProcessedMetrics)) { + $extraProcessedMetricNames = array_map(function (Metric $m) { + return $m->getName(); + }, $extraProcessedMetrics); + $allColumns = array_merge($allColumns, $extraProcessedMetricNames); + } + + $allColumns = array_unique($allColumns); + + // If the datatable has no data, use the default columns (there must be data for evolution graphs or else nothing displays) + if (empty($allColumns)) { + $allColumns = $this->getDefaultColumnsToDisplay(); + } + + $this->config->columns_to_display = $this->removeUnavailableMetrics(array_intersect($columnsToDisplay, $allColumns)); + } + + private function getDefaultColumnsToDisplay() + { + return array( + 'nb_visits', + 'nb_actions', + 'nb_uniq_visitors', + 'nb_users', + ); + } + + private function removeUnavailableMetrics($metrics) + { + $currentPeriod = Common::getRequestVar('period', false); + + if (!SettingsPiwik::isUniqueVisitorsEnabled($currentPeriod)) { + $metrics = array_diff($metrics, ['nb_uniq_visitors', 'nb_users']); + } + + return $metrics; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/Graph/Config.php b/plugins/CoreVisualizations/Visualizations/Graph/Config.php new file mode 100644 index 0000000000000000000000000000000000000000..68a8263c131951305dc1853e9b7db640aeca548b --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/Graph/Config.php @@ -0,0 +1,121 @@ +show_limit_control = false; + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'show_series_picker', + 'allow_multi_select_series_picker', + 'selectable_columns', + 'selectable_rows', + 'display_percentage_in_tooltip', + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array( + 'show_all_ticks', + 'show_series_picker', + )); + } +} diff --git a/plugins/CoreVisualizations/Visualizations/HtmlTable.php b/plugins/CoreVisualizations/Visualizations/HtmlTable.php new file mode 100644 index 0000000000000000000000000000000000000000..76d7b3775699ace3bee722825c038305c709665e --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/HtmlTable.php @@ -0,0 +1,311 @@ +checkRequestIsNotForMultiplePeriods(); + + if ($this->isComparing()) { + $request = $this->getRequestArray(); + if ( + !empty($request['comparePeriods']) + && count($request['comparePeriods']) == 1 + ) { + $this->requestConfig->request_parameters_to_modify['invert_compare_change_compute'] = 1; + } + + // forward the comparisonIdSubtables var if present so it will be used when next/prev links are clicked + $comparisonIdSubtables = Common::getRequestVar('comparisonIdSubtables', false, 'string'); + if (!empty($comparisonIdSubtables)) { + $comparisonIdSubtables = Common::unsanitizeInputValue($comparisonIdSubtables); + $this->config->custom_parameters['comparisonIdSubtables'] = $comparisonIdSubtables; + } + } + } + + public function beforeRender() + { + if ( + $this->requestConfig->idSubtable + && $this->config->show_embedded_subtable + ) { + $this->config->show_visualization_only = true; + } + + if ($this->requestConfig->idSubtable) { + $this->config->show_totals_row = false; + } + + foreach (Metrics::getMetricIdsToProcessReportTotal() as $metricId) { + $this->config->report_ratio_columns[] = Metrics::getReadableColumnName($metricId); + } + if (!empty($this->report)) { + foreach ($this->report->getMetricNamesToProcessReportTotals() as $metricName) { + $this->config->report_ratio_columns[] = $metricName; + } + } + + if ($this->dataTable->getRowsCount()) { + $siteTotalRow = $this->getSiteSummary() ? $this->getSiteSummary()->getFirstRow() : null; + $this->assignTemplateVar('siteTotalRow', $siteTotalRow); + } + + if ($this->isPivoted()) { + $this->config->columns_to_display = $this->dataTable->getColumns(); + } + + if ( + $this->isComparing() + && !empty($this->dataTable) + ) { + $this->assignTemplateVar('comparisonTotals', $this->dataTable->getMetadata('comparisonTotals')); + } + + // Note: This needs to be done right before rendering, as otherwise some plugins might change the columns to display again + if ($this->isFlattened()) { + $dimensions = $this->dataTable->getMetadata('dimensions'); + + $hasMultipleDimensions = is_array($dimensions) && count($dimensions) > 1; + $this->assignTemplateVar('hasMultipleDimensions', $hasMultipleDimensions); + + if ($hasMultipleDimensions) { + if ($this->shouldShowDimensions()) { + // ensure first metric translation is used as label if other dimensions are in separate columns + $this->config->addTranslation('label', $this->config->translations[reset($dimensions)]); + } else { + // concatenate dimensions if table is shown flattened + foreach ($dimensions as $dimension) { + $labels[] = $this->config->translations[$dimension]; + } + $this->config->addTranslation('label', implode(' - ', $labels)); + } + } + + if ($this->shouldShowDimensions() && $hasMultipleDimensions) { + $properties = $this->config; + array_shift($dimensions); // shift away first dimension, as that will be shown as label + + $this->dataTable->filter(function (DataTable $dataTable) use ($properties, $dimensions) { + if (empty($properties->columns_to_display)) { + $columns = $dataTable->getColumns(); + $hasNbVisits = in_array('nb_visits', $columns); + $hasNbUniqVisitors = in_array('nb_uniq_visitors', $columns); + + $properties->setDefaultColumnsToDisplay($columns, $hasNbVisits, $hasNbUniqVisitors); + } + + $label = array_search('label', $properties->columns_to_display); + if ($label !== false) { + unset($properties->columns_to_display[$label]); + } + + foreach (array_reverse($dimensions) as $dimension) { + array_unshift($properties->columns_to_display, $dimension); + } + + array_unshift($properties->columns_to_display, 'label'); + }); + } + } + + $this->assignTemplateVar('segmentTitlePretty', $this->dataTable->getMetadata('segmentPretty')); + + $period = $this->dataTable->getMetadata('period'); + $this->assignTemplateVar('periodTitlePretty', $period ? $period->getLocalizedShortString() : ''); + } + + public function beforeGenericFiltersAreAppliedToLoadedDataTable() + { + if ($this->isPivoted()) { + $this->config->columns_to_display = $this->dataTable->getColumns(); + + $this->dataTable->applyQueuedFilters(); + } + + parent::beforeGenericFiltersAreAppliedToLoadedDataTable(); + + // Note: This needs to be done right before generic filter are applied, to make sorting such columns possible + if ($this->isFlattened()) { + $dimensions = $this->dataTable->getMetadata('dimensions'); + + $hasMultipleDimensions = is_array($dimensions) && count($dimensions) > 1; + + if ($hasMultipleDimensions) { + foreach (Dimension::getAllDimensions() as $dimension) { + $dimensionId = str_replace('.', '_', $dimension->getId()); + $dimensionName = $dimension->getName(); + + if (!empty($dimensionId) && !empty($dimensionName) && in_array($dimensionId, $dimensions)) { + $this->config->translations[$dimensionId] = $dimensionName; + } + } + } + + if ($this->shouldShowDimensions() && $hasMultipleDimensions) { + # replace original label column with first dimension + $firstDimension = array_shift($dimensions); + $this->dataTable->filter('ColumnCallbackAddMetadata', array( + 'label', + 'combinedLabel', + function ($label) { + return $label; + }, + )); + $this->dataTable->filter('ColumnDelete', array('label')); + $this->dataTable->filter('ReplaceColumnNames', array(array($firstDimension => 'label'))); + } + } + } + + public function afterGenericFiltersAreAppliedToLoadedDataTable() + { + parent::afterGenericFiltersAreAppliedToLoadedDataTable(); + + $this->calculateTotalPercentages(); // this must be done before metrics are formatted + } + + private function calculateTotalPercentages() + { + if (empty($this->report)) { + return; + } + + $columnNamesToIndices = Metrics::getMappingFromNameToId(); + $formatter = NumberFormatter::getInstance(); + + $totals = $this->dataTable->getMetadata('totalsUnformatted'); + + $siteSummary = $this->getSiteSummary(); + $siteTotalRow = $siteSummary ? $siteSummary->getFirstRow() : null; + + foreach ($this->dataTable->getRows() as $row) { + foreach ($this->report->getMetrics() as $column => $translation) { + // Try to check the column by it's index (not possible for all metrics, like custom columns) + $indexColumn = !empty($columnNamesToIndices[$column]) ? $columnNamesToIndices[$column] : null; + + $value = (($indexColumn && $row->getColumn($indexColumn)) ? $row->getColumn($indexColumn) : $row->getColumn($column)) ?: 0; + if ($column == 'label') { + continue; + } + + $reportTotal = isset($totals[$column]) ? $totals[$column] : 0; + + if (is_numeric($value)) { + $percentageColumnName = $column . '_row_percentage'; + $rowPercentage = $formatter->formatPercent(Piwik::getPercentageSafe($value, $reportTotal, $precision = 1), $precision); + $row->setMetadata($percentageColumnName, $rowPercentage); + } + + if ($siteTotalRow) { + $siteTotal = $siteTotalRow->getColumn($column) ?: 0; + + $siteTotalPercentage = $column . '_site_total_percentage'; + if ($siteTotal && $siteTotal > $reportTotal) { + $rowPercentage = $formatter->formatPercent(Piwik::getPercentageSafe($value, $siteTotal, $precision = 1), $precision); + $row->setMetadata($siteTotalPercentage, $rowPercentage); + } + } + } + } + } + + protected function isPivoted() + { + return $this->requestConfig->pivotBy || Common::getRequestVar('pivotBy', ''); + } + + /** + * Override to compute a custom cell HTML attributes (such as style). + * + * @param $column + * @return array|null Array of name => value pairs, or null if no custom attributes. + */ + public function getCellHtmlAttributes(Row $row, $column) + { + return null; + } + + public function supportsComparison() + { + return true; + } + + protected function isFlattened() + { + return $this->requestConfig->flat || Common::getRequestVar('flat', ''); + } + + protected function shouldShowDimensions() + { + return $this->requestConfig->show_dimensions || Common::getRequestVar('show_dimensions', ''); + } + + private function getSiteSummary() + { + if (empty($this->siteSummary)) { + // we do not want to get a datatable\map + $period = Common::getRequestVar('period', 'day', 'string'); + if (Period\Range::parseDateRange($period)) { + $period = 'range'; + } + + $this->siteSummary = ApiRequest::processRequest('API.get', [ + 'module' => 'API', + 'action' => 'get', + 'filter_limit' => '-1', + 'disable_generic_filters' => 1, + 'filter_offset' => 0, + 'date' => Common::getRequestVar('date', null, 'string'), + 'idSite' => Common::getRequestVar('idSite', null, 'int'), + 'period' => $period, + 'showColumns' => implode(',', $this->config->columns_to_display), + 'columns' => implode(',', $this->config->columns_to_display), + ], $default = []); + } + + return $this->siteSummary; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/HtmlTable/AllColumns.php b/plugins/CoreVisualizations/Visualizations/HtmlTable/AllColumns.php new file mode 100644 index 0000000000000000000000000000000000000000..b05617b0f69b913d1e7a4f4063b571c88d16e3e3 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/HtmlTable/AllColumns.php @@ -0,0 +1,81 @@ +config->show_extra_columns = true; + + parent::beforeRender(); + } + + public function beforeGenericFiltersAreAppliedToLoadedDataTable() + { + $this->config->datatable_css_class = 'dataTableVizAllColumns'; + + $this->dataTable->filter('AddColumnsProcessedMetrics'); + + $properties = $this->config; + + $this->dataTable->filter(function (DataTable $dataTable) use ($properties) { + $columnsToDisplay = array('label', 'nb_visits'); + + $columns = $dataTable->getColumns(); + + if (in_array('nb_uniq_visitors', $columns)) { + $columnsToDisplay[] = 'nb_uniq_visitors'; + } + + if (in_array('nb_users', $columns)) { + $columnsToDisplay[] = 'nb_users'; + } + + $columnsToDisplay = array_merge( + $columnsToDisplay, + array('nb_actions', 'nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate') + ); + + // only display conversion rate for the plugins that do not provide "per goal" metrics + // otherwise, conversion rate is meaningless as a whole (since we don't process 'cross goals' conversions) + if (!$properties->show_goals) { + $columnsToDisplay[] = 'conversion_rate'; + } + + $properties->columns_to_display = $columnsToDisplay; + }); + + parent::beforeGenericFiltersAreAppliedToLoadedDataTable(); + } + + public function beforeLoadDataTable() + { + parent::beforeLoadDataTable(); + + unset($this->requestConfig->request_parameters_to_modify['pivotBy']); + unset($this->requestConfig->request_parameters_to_modify['pivotByColumn']); + } + + protected function isPivoted() + { + return false; // Pivot not supported + } +} diff --git a/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php b/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php new file mode 100644 index 0000000000000000000000000000000000000000..e08d5f1ab655b384fe2ebbac9f24475f16fa9887 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php @@ -0,0 +1,156 @@ +enable_sort = true; + $this->datatable_js_type = 'DataTable'; + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'show_extra_columns', + 'show_goals_columns', + 'disable_row_evolution', + 'disable_row_actions', + 'enable_sort', + 'keep_summary_row', + 'subtable_controller_action', + 'row_identifier', + 'min_label_width', + 'max_label_width', + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array( + 'show_expanded', + 'disable_row_actions', + 'disable_row_evolution', + 'show_extra_columns', + 'show_goals_columns', + 'disable_subtable_when_show_goals', + 'keep_summary_row', + 'highlight_summary_row', + )); + } +} diff --git a/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php b/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php new file mode 100644 index 0000000000000000000000000000000000000000..97fed7336b13fd8dba06f9803c7f066b4d340bb6 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php @@ -0,0 +1,70 @@ +totals = true; + $this->filter_limit = PiwikConfig::getInstance()->General['datatable_default_limit']; + + if (Common::getRequestVar('enable_filter_excludelowpop', false) == '1') { + if (Common::getRequestVar('flat', 0, 'int') === 1) { + $this->filter_excludelowpop = 'nb_visits'; + } else { + $this->filter_excludelowpop = Metrics::INDEX_NB_VISITS; + } + + $this->filter_excludelowpop_value = false; + } + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'search_recursive', + 'filter_limit', + 'filter_offset', + 'filter_sort_column', + 'filter_sort_order', + 'keep_summary_row', + 'keep_totals_row', + 'show_dimensions', + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array( + 'keep_summary_row', + 'keep_totals_row', + 'show_dimensions', + )); + } +} diff --git a/plugins/CoreVisualizations/Visualizations/JqplotGraph.php b/plugins/CoreVisualizations/Visualizations/JqplotGraph.php new file mode 100644 index 0000000000000000000000000000000000000000..48d43587590e6be211c9b3fd85363b60fea12bb9 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/JqplotGraph.php @@ -0,0 +1,46 @@ +makeDataGenerator($properties); + + return $dataGenerator->generate($dataTable); + } + + /** + * @param $properties + * @return JqplotDataGenerator + */ + abstract protected function makeDataGenerator($properties); +} + +require_once PIWIK_INCLUDE_PATH . '/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php'; +require_once PIWIK_INCLUDE_PATH . '/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php'; +require_once PIWIK_INCLUDE_PATH . '/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php'; diff --git a/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php new file mode 100644 index 0000000000000000000000000000000000000000..6eaf2f7a26df9fda2875371c979121b61bfd9e6e --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php @@ -0,0 +1,60 @@ +checkRequestIsNotForMultiplePeriods(); + + $this->config->datatable_js_type = 'JqplotBarGraphDataTable'; + } + + public static function getDefaultConfig() + { + $config = new Config(); + $config->max_graph_elements = 6; + + return $config; + } + + protected function ensureValidColumnsToDisplay() + { + parent::ensureValidColumnsToDisplay(); + + $columnsToDisplay = $this->config->columns_to_display; + + // Use a sensible default if the columns_to_display is empty + $this->config->columns_to_display = $columnsToDisplay ? : array('nb_visits'); + } + + protected function makeDataGenerator($properties) + { + return JqplotDataGenerator::factory('bar', $properties, $this); + } + + public function supportsComparison() + { + return true; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/JqplotGraph/Config.php b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Config.php new file mode 100644 index 0000000000000000000000000000000000000000..1619a620f75b06ed469fabdc5713795690b86f2a --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Config.php @@ -0,0 +1,64 @@ +show_exclude_low_population = false; + $this->show_offset_information = false; + $this->show_pagination_control = false; + $this->show_exclude_low_population = false; + $this->show_search = false; + $this->show_export_as_image_icon = true; + $this->y_axis_unit = ''; + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'external_series_toggle', + 'external_series_toggle_show_all', + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('x_axis_step_size')); + } +} diff --git a/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php new file mode 100644 index 0000000000000000000000000000000000000000..a378b75a305b78d20d5de242c361a6a0c16cdc8a --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php @@ -0,0 +1,267 @@ +checkRequestIsOnlyForMultiplePeriods(); + + $this->config->show_flatten_table = false; + $this->config->datatable_js_type = 'JqplotEvolutionGraphDataTable'; + } + + public function beforeLoadDataTable() + { + $isComparingDatesOrPeriods = $this->isComparingDatesOrPeriods(); + + if (!$this->isComparing() || !$isComparingDatesOrPeriods) { + $this->calculateEvolutionDateRange(); + } + + parent::beforeLoadDataTable(); + + // fetch archive states for incomplete data point visualization + $this->requestConfig->request_parameters_to_modify['fetch_archive_state'] = true; + + // period will be overridden when 'range' is requested in the UI. + // The graph will display the range in the most suitable period and + // it won't show historical data before the range. + $period = Common::getRequestVar('period', false); + $selector = StaticContainer::get(EvolutionPeriodSelector::class); + + if ($period === 'range') { + $date = Common::getRequestVar('date', false); + $requestingPeriod = Factory::build($period, $date); + + // if a larger date range is selected, then for better performance and for seeing trends better we want to use + // a suitable period (rather than always using for example the day range) + $this->requestConfig->request_parameters_to_modify['period'] = $selector->getHighestPeriodInCommon($requestingPeriod, []); + $this->requestConfig->request_parameters_to_modify['date'] = $requestingPeriod->getRangeString(); + } + + $this->config->custom_parameters['columns'] = $this->config->columns_to_display; + + if ($this->isComparing() && $isComparingDatesOrPeriods) { + $this->config->show_limit_control = false; // since we always show the evolution over the period, there's no point in changing the limit + $this->config->show_periods = false; // the periods can't be changed as they are always fixed when comparing + + $requestArray = $this->request->getRequestArray(); + $requestArray = ApiRequest::getRequestArrayFromString($requestArray); + + $requestingPeriod = Factory::build($requestArray['period'], $requestArray['date']); + + $comparisonPeriods = []; + if (!empty($requestArray['comparePeriods'])) { + $comparisonPeriods = $selector->getComparisonPeriodObjects($requestArray['comparePeriods'], $requestArray['compareDates']); + } + + $this->requestConfig->request_parameters_to_modify = $selector->setDatePeriods( + $this->requestConfig->request_parameters_to_modify, + $requestingPeriod, + $comparisonPeriods, + true + ); + } + } + + private function isComparingDatesOrPeriods(): bool + { + $comparePeriods = Common::getRequestVar('comparePeriods', [], 'array'); + $compareDates = Common::getRequestVar('compareDates', [], 'array'); + + return !empty($comparePeriods) || !empty($compareDates); + } + + public function afterAllFiltersAreApplied() + { + parent::afterAllFiltersAreApplied(); + + if (false === $this->config->x_axis_step_size) { + $rowCount = $this->dataTable->getRowsCount(); + + $this->config->x_axis_step_size = $this->getDefaultXAxisStepSize($rowCount); + } + } + + protected function makeDataGenerator($properties) + { + return JqplotDataGenerator::factory('evolution', $properties, $this); + } + + /** + * Based on the period, date and evolution_{$period}_last_n query parameters, + * calculates the date range this evolution chart will display data for. + */ + private function calculateEvolutionDateRange() + { + $period = Common::getRequestVar('period'); + $idSite = Common::getRequestVar('idSite'); + $timezone = Site::getTimezoneFor($idSite); + + $lastNParamName = self::getLastNParamName($period); + $defaultLastN = $this->config->custom_parameters[$lastNParamName] ?? self::getDefaultLastN($period); + $originalDate = Common::getRequestVar('date', 'last' . $defaultLastN, 'string'); + + if ('range' != $period) { // show evolution limit if the period is not a range + // set the evolution_{$period}_last_n query param + if (Range::parseDateRange($originalDate)) { + // if a multiple period + + // overwrite last_n param using the date range + $oPeriod = new Range($period, $originalDate, $timezone); + $lastN = count($oPeriod->getSubperiods()); + } else { + // if not a multiple period + list($newDate, $lastN) = self::getDateRangeAndLastN($period, $originalDate, $defaultLastN); + $this->requestConfig->request_parameters_to_modify['date'] = $newDate; + $this->config->custom_parameters['dateUsedInGraph'] = $newDate; + } + + $this->config->custom_parameters[$lastNParamName] = $lastN; + } + } + + /** + * Returns the entire date range and lastN value for the current request, based on + * a period type and end date. + * + * @param string $period The period type, 'day', 'week', 'month' or 'year' + * @param string $endDate The end date. + * @param int|null $defaultLastN The default lastN to use. If null, the result of + * getDefaultLastN is used. + * @param int|null $idSite, the id of the site which provides the timezone + * @return array An array w/ two elements. The first is a whole date range and the second + * is the lastN number used, ie, array('2010-01-01,2012-01-02', 2). + */ + public static function getDateRangeAndLastN($period, $endDate, $defaultLastN = null, ?int $idSite = null) + { + if ($defaultLastN === null) { + $defaultLastN = self::getDefaultLastN($period); + } + + $lastNParamName = self::getLastNParamName($period); + $lastN = Common::getRequestVar($lastNParamName, $defaultLastN, 'int'); + + $idSite = $idSite ?? Common::getRequestVar('idSite'); + $site = new Site($idSite); + + $dateRange = Range::getRelativeToEndDate($period, 'last' . $lastN, $endDate, $site); + + return array($dateRange, $lastN); + } + + /** + * Returns the default last N number of dates to display for a given period. + * + * @param string $period 'day', 'week', 'month' or 'year' + * @return int + */ + public static function getDefaultLastN($period) + { + switch ($period) { + case 'week': + return 26; + case 'month': + return 24; + case 'year': + return 5; + case 'day': + default: + return 30; + } + } + + /** + * Returns the query parameter that stores the lastN number of periods to get for + * the evolution graph. + * + * @param string $period The period type, 'day', 'week', 'month' or 'year'. + * @return string + */ + public static function getLastNParamName($period) + { + return "evolution_{$period}_last_n"; + } + + public function getDefaultXAxisStepSize($countGraphElements) + { + // when the number of elements plotted can be small, make sure the X legend is useful + if ($countGraphElements <= 7) { + return 1; + } + + $periodLabel = Common::getRequestVar('period'); + + switch ($periodLabel) { + case 'day': + case 'range': + $steps = 5; + break; + case 'week': + $steps = 4; + break; + case 'month': + $steps = 5; + break; + case 'year': + $steps = 5; + break; + default: + $steps = 5; + break; + } + + $paddedCount = $countGraphElements + 2; // pad count so last label won't be cut off + + return ceil($paddedCount / $steps); + } + + public function supportsComparison() + { + return true; + } + + protected function ensureValidColumnsToDisplay() + { + parent::ensureValidColumnsToDisplay(); + + $columnsToDisplay = $this->config->columns_to_display; + + // Use a sensible default if the columns_to_display is empty + $this->config->columns_to_display = $columnsToDisplay ? : array('nb_visits'); + } +} diff --git a/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php new file mode 100644 index 0000000000000000000000000000000000000000..27565c2293b597c085967a9666ae4646a0c0cd01 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php @@ -0,0 +1,47 @@ +show_all_views_icons = false; + $this->show_table = false; + $this->show_table_all_columns = false; + $this->hide_annotations_view = false; + $this->x_axis_step_size = false; + $this->show_line_graph = true; + + $this->addPropertiesThatShouldBeAvailableClientSide(array('show_line_graph')); + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('show_line_graph')); + + $period = Common::getRequestVar('period'); + if ($period !== 'range') { + $this->show_limit_control = true; + $this->show_periods = true; + } + } +} diff --git a/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php new file mode 100644 index 0000000000000000000000000000000000000000..5219318efa2e16a94ac4994601d7d88966e8d20d --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php @@ -0,0 +1,60 @@ +max_graph_elements = 6; + $config->allow_multi_select_series_picker = false; + + return $config; + } + + public function beforeRender() + { + parent::beforeRender(); + + $this->checkRequestIsNotForMultiplePeriods(); + + $this->config->show_all_ticks = true; + $this->config->datatable_js_type = 'JqplotPieGraphDataTable'; + } + + protected function ensureValidColumnsToDisplay() + { + parent::ensureValidColumnsToDisplay(); + + $columnsToDisplay = $this->config->columns_to_display; + + // Ensure only one column_to_display - it is a pie graph after all! + $metricColumn = reset($columnsToDisplay); + + // Set to a sensible default if no suitable value was found + $this->config->columns_to_display = array($metricColumn ? : 'nb_visits'); + } + + protected function makeDataGenerator($properties) + { + return JqplotDataGenerator::factory('pie', $properties, $this); + } +} diff --git a/plugins/CoreVisualizations/Visualizations/Sparkline.php b/plugins/CoreVisualizations/Visualizations/Sparkline.php new file mode 100644 index 0000000000000000000000000000000000000000..b5cf32713b16ba4d08ecb2a23b1569e0ed23d0c6 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/Sparkline.php @@ -0,0 +1,213 @@ +getRangeString(); + } + + if ($this->isComparing()) { + $this->transformSingleComparisonPeriods(); + } + + $this->loadDataTableFromAPI(); + + // then revert the hack for potentially subsequent getRequestVar + $_GET['period'] = $period; + $_GET['date'] = $date; + + $columnToPlot = $this->getColumnToPlot(); + + $graph = new \Piwik\Visualization\Sparkline(); + + if ($this->isComparing()) { + $otherSeries = []; + + $comparisonSeries = $this->getComparisonSeries($this->dataTable); + foreach ($comparisonSeries as $seriesName) { + $otherSeries[$seriesName] = []; + } + + $this->dataTable->filter(function (DataTable $table) use (&$otherSeries, $columnToPlot) { + foreach ($table->getRows() as $row) { + $comparisons = $row->getComparisons(); + if (empty($comparisons)) { + continue; + } + + foreach ($comparisons->getRows() as $comparisonRow) { + $compareSeriesPretty = $comparisonRow->getMetadata('compareSeriesPretty'); + $otherSeries[$compareSeriesPretty][] = $comparisonRow->getColumn($columnToPlot); + } + } + }); + + foreach ($otherSeries as $seriesValues) { + $seriesValues = $this->ensureValuesEvenIfEmpty($seriesValues); + $graph->addSeries($seriesValues); + } + } else { + $values = $this->getValuesFromDataTable($this->dataTable, $columnToPlot); + $values = $this->ensureValuesEvenIfEmpty($values); + $graph->addSeries($values); + } + + $height = Common::getRequestVar('height', 0, 'int'); + if (!empty($height)) { + $graph->setHeight($height); + } + + $width = Common::getRequestVar('width', 0, 'int'); + if (!empty($width)) { + $graph->setWidth($width); + } + + $graph->main(); + + return $graph->render(); + } + + /** + * @param DataTable\Map $dataTableMap + * @param string $columnToPlot + * + * @return array + * @throws \Exception + */ + protected function getValuesFromDataTableMap($dataTableMap, $columnToPlot) + { + $dataTableMap->applyQueuedFilters(); + + $values = array(); + + foreach ($dataTableMap->getDataTables() as $table) { + if ($table->getRowsCount() > 1) { + throw new Exception("Expecting only one row per DataTable"); + } + + $value = 0; + $onlyRow = $table->getFirstRow(); + + if (false !== $onlyRow) { + if (!empty($columnToPlot)) { + $value = $onlyRow->getColumn($columnToPlot); + } else { + // if not specified, we load by default the first column found + // eg. case of getLastDistinctCountriesGraph + $columns = $onlyRow->getColumns(); + $value = current($columns); + } + } + + $values[] = $value; + } + + return $values; + } + + private function getColumnToPlot() + { + $columns = $this->config->columns_to_display; + + $columnToPlot = false; + + if (!empty($columns)) { + $columnToPlot = reset($columns); + if ($columnToPlot == 'label') { + $columnToPlot = next($columns); + } + } + + return $columnToPlot; + } + + protected function getValuesFromDataTable($dataTable, $columnToPlot) + { + // a Set is returned when using the normal code path to request data from Archives, in all core plugins + // however plugins can also return simple datatable, hence why the sparkline can accept both data types + if ($this->dataTable instanceof DataTable\Map) { + $values = $this->getValuesFromDataTableMap($dataTable, $columnToPlot); + } elseif ($this->dataTable instanceof DataTable) { + $values = $this->dataTable->getColumn($columnToPlot); + } else { + $values = []; + } + + return $values; + } + + private function ensureValuesEvenIfEmpty(array $values) + { + if (empty($values)) { + return array_fill(0, 30, 0); + } + return $values; + } + + private function getComparisonSeries(DataTable\DataTableInterface $dataTable) + { + if ($dataTable instanceof DataTable\Map) { + $tables = $dataTable->getDataTables(); + return reset($tables)->getMetadata('comparisonSeries') ?: []; + } else { + return $dataTable->getMetadata('comparisonSeries') ?: []; + } + } + + private function transformSingleComparisonPeriods() + { + $comparePeriods = Common::getRequestVar('comparePeriods', $default = [], $type = 'array'); + $compareDates = Common::getRequestVar('compareDates', $default = [], $type = 'array'); + + foreach ($comparePeriods as $index => $comparePeriod) { + $compareDate = $compareDates[$index]; + if (Period::isMultiplePeriod($compareDate, $comparePeriod)) { + continue; + } + + $periodObj = Period\Factory::build($comparePeriod, $compareDate); + $comparePeriods[$index] = 'day'; + $compareDates[$index] = $periodObj->getRangeString(); + } + + $this->requestConfig->request_parameters_to_modify['comparePeriods'] = $comparePeriods; + $this->requestConfig->request_parameters_to_modify['compareDates'] = $compareDates; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/Sparklines.php b/plugins/CoreVisualizations/Visualizations/Sparklines.php new file mode 100644 index 0000000000000000000000000000000000000000..2817f379f425c2a283b223be0d4cf4d3bf07577d --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/Sparklines.php @@ -0,0 +1,472 @@ +config->addSparklineMetric('nb_visits'); // if an array of metrics given, they will be displayed comma separated + * $view->config->addTranslation('nb_visits', 'Visits'); + * Results in: [sparkline image] X visits + * Data is fetched from the configured $view->requestConfig->apiMethodToRequestDataTable. + * + * In case you want to add any custom sparklines from any other API method you can call + * {@link Sparklines\Config::addSparkline()}. + * + * Example: + * $sparklineUrlParams = array('columns' => array('nb_visits)); + * $evolution = array('currentValue' => 5, 'pastValue' => 10, 'tooltip' => 'Foo bar'); + * $view->config->addSparkline($sparklineUrlParams, $value = 5, $description = 'Visits', $evolution); + * + * @property Sparklines\Config $config + */ +class Sparklines extends ViewDataTable +{ + public const ID = 'sparklines'; + + public static function getDefaultConfig() + { + return new Sparklines\Config(); + } + + public function supportsComparison() + { + return true; + } + + /** + * @return mixed + */ + public function render() + { + $view = new View('@CoreVisualizations/_dataTableViz_sparklines.twig'); + + $columnsList = []; + if ($this->config->hasSparklineMetrics()) { + foreach ($this->config->getSparklineMetrics() as $cols) { + $columns = $cols['columns']; + if (!is_array($columns)) { + $columns = [$columns]; + } + + $columnsList = array_merge($columns, $columnsList); + } + } + + $view->allMetricsDocumentation = array_merge(Metrics::getDefaultMetricsDocumentation(), $this->config->metrics_documentation); + + $this->requestConfig->request_parameters_to_modify['columns'] = $columnsList; + $this->requestConfig->request_parameters_to_modify['format_metrics'] = '1'; + + /** + * This special request parameter is used to include trend indication columns for all evolution columns + * this is done to be able to determine safely in the view if an evolution is positive or negative, as this + * can't be done with formatted evolution values due to language specific signs being used. + * + * @see DataComparisonFilter::compareChangePercents + */ + $this->requestConfig->request_parameters_to_modify['include_trends'] = '1'; + + $request = $this->getRequestArray(); + if ( + $this->isComparing() + && !empty($request['comparePeriods']) + && count($request['comparePeriods']) == 1 + ) { + $this->requestConfig->request_parameters_to_modify['invert_compare_change_compute'] = 1; + } + + if (!empty($this->requestConfig->apiMethodToRequestDataTable)) { + $this->fetchConfiguredSparklines(); + } + + $view->sparklines = $this->config->getSortedSparklines(); + $view->isWidget = Common::getRequestVar('widget', 0, 'int'); + $view->titleAttributes = $this->config->title_attributes; + $view->footerMessage = $this->config->show_footer_message; + $view->areSparklinesLinkable = $this->config->areSparklinesLinkable(); + $view->isComparing = $this->isComparing(); + + // The redesigned Vue card grid (gated by the SparklinesRedesign feature flag) currently only + // covers the no-comparison layout, so fall back to the legacy Twig layout while comparing. + $featureFlagManager = StaticContainer::get(FeatureFlagManager::class); + $view->useNewSparklinesGrid = $featureFlagManager->isFeatureActive(SparklinesRedesign::class) + && !$this->isComparing(); + + $view->title = ''; + if ($this->config->show_title) { + $view->title = $this->config->title; + } + + return $view->render(); + } + + /** + * Load the datatable from the API using the pre-configured request object + * + * @param array $forcedParams + * + * @return DataTable + */ + protected function loadDataTableFromAPI(array $forcedParams = []) + { + if (!is_null($this->dataTable)) { + // data table is already there + // this happens when setDataTable has been used + return $this->dataTable; + } + + if ($this->isComparing()) { + $forcedParams['compare'] = '1'; + } + + $this->dataTable = $this->request->loadDataTableFromAPI($forcedParams); + + return $this->dataTable; + } + + private function fetchConfiguredSparklines() + { + $data = $this->loadDataTableFromAPI(['format_metrics' => '0']); + + $this->applyFilters($data); + + if (!$this->config->hasSparklineMetrics()) { + foreach ($data->getColumns() as $column) { + $this->config->addSparklineMetric($column); + } + } + + $report = ReportsProvider::factory($this->requestConfig->getApiModuleToRequest(), $this->requestConfig->getApiMethodToRequest()); + $processedMetrics = Report::getProcessedMetricsForTable($data, $report); + $metricFormatter = new MetricFormatter(); + $idSite = $this->getRequestArray()['idSite'] ?? false; + $metricTranslations = Metrics::getDefaultMetricTranslations(); + + $firstRow = $data->getFirstRow(); + if ($firstRow) { + $comparisons = $firstRow->getComparisons(); + } else { + $comparisons = null; + } + + $originalDate = Common::getRequestVar('date'); + $originalPeriod = Common::getRequestVar('period'); + + $comparisonRows = []; + $isComparing = $this->isComparing() && !empty($comparisons); + if ($isComparing) { + foreach ($comparisons->getRows() as $comparisonRow) { + $segment = $comparisonRow->getMetadata('compareSegment'); + if ($segment === false) { + $segment = Request::getRawSegmentFromRequest() ?: ''; + } + + $date = $comparisonRow->getMetadata('compareDate'); + $period = $comparisonRow->getMetadata('comparePeriod'); + + $comparisonRows[$segment][$period][$date] = $comparisonRow; + } + } + + foreach ($this->config->getSparklineMetrics() as $sparklineMetricIndex => $sparklineMetric) { + $column = $sparklineMetric['columns']; + $order = $sparklineMetric['order']; + $graphParams = $sparklineMetric['graphParams']; + + if (!isset($order)) { + $order = 1000; + } + + if ($column === 'label') { + continue; + } + + if (empty($column)) { + $this->config->addPlaceholder($order); + continue; + } + + if (!is_array($column)) { + $column = [$column]; + } + + $columnMetrics = []; + foreach ($column as $col) { + foreach ($processedMetrics as $metricObj) { + if ($metricObj->getName() === $col) { + $columnMetrics[$col] = $metricObj; + break; + } + } + } + + $sparklineUrlParams = array_merge($this->config->custom_parameters, [ + 'columns' => $column, + 'module' => $this->requestConfig->getApiModuleToRequest(), + 'action' => $this->requestConfig->getApiMethodToRequest(), + ]); + + $periodObj = Factory::build($originalPeriod, $originalDate); + $comparePeriods = $data->getMetadata('comparePeriods'); + $compareDates = $data->getMetadata('compareDates'); + + // the first entry includes the original period and we need to remove it + $compareDatesWithoutOriginalDate = $compareDates ? array_slice($compareDates, 1) : []; + $comparePeriodsWithoutOriginalPeriod = $comparePeriods ? array_slice($comparePeriods, 1) : []; + + $periodSelector = new EvolutionPeriodSelector($this->config); + $comparisonPeriods = $periodSelector->getComparisonPeriodObjects($comparePeriodsWithoutOriginalPeriod, $compareDatesWithoutOriginalDate); + $sparklineUrlParams = $periodSelector->setDatePeriods($sparklineUrlParams, $periodObj, $comparisonPeriods, $isComparing); + + if ($isComparing) { + $sparklineUrlParams['compareSegments'] = []; + + $compareSegments = $data->getMetadata('compareSegments'); + foreach ($compareSegments as $segmentIndex => $segment) { + $metrics = []; + $seriesIndices = []; + + foreach ($comparePeriods as $periodIndex => $period) { + $date = $compareDates[$periodIndex]; + + $compareRow = $this->findComparisonRow($comparisonRows, $segment, $period, $date); + if (!$compareRow) { + continue; + } + $segmentPretty = $compareRow->getMetadata('compareSegmentPretty'); + $periodPretty = $compareRow->getMetadata('comparePeriodPretty'); + + $columnToUse = $this->removeUniqueVisitorsIfNotEnabledForPeriod($column, $period); + + [$compareValues, $compareDescriptions, $evolutions] = $this->getValuesAndDescriptions($compareRow, $columnToUse, '_change', '_trend'); + + foreach ($compareValues as $i => $value) { + if (!isset($column[$i])) { + continue; + } + $formattedValue = $this->formatSparklineMetricValue( + $value, + $columnToUse[$i], + $columnMetrics, + $metricFormatter, + $idSite + ); + + $metricInfo = [ + 'value' => $formattedValue, + 'description' => $compareDescriptions[$i], + 'title' => $metricTranslations[$columnToUse[$i]] ?? $compareDescriptions[$i], + 'group' => $periodPretty, + ]; + + if (isset($evolutions[$i])) { + $comparisonIndex = $periodIndex === 0 ? 1 : 0; + $comparisonRow = $comparePeriods[$comparisonIndex] ?? false; + $comparisonDate = $compareDates[$comparisonIndex] ?? false; + $originalCompareRow = ($comparisonRow && $comparisonDate) + ? $this->findComparisonRow($comparisonRows, $segment, $comparisonRow, $comparisonDate) + : false; + $originalValue = $originalCompareRow ? $originalCompareRow->getColumn($columnToUse[$i]) : 0; + if ($originalValue === false) { + $originalValue = 0; + } + $originalPeriodPretty = $originalCompareRow + ? $originalCompareRow->getMetadata('comparePeriodPretty') + : ''; + $formattedOriginalValue = $this->formatSparklineMetricValue( + $originalValue, + $columnToUse[$i], + $columnMetrics, + $metricFormatter, + $idSite + ); + $evolutions[$i]['tooltip'] = Piwik::translate('General_EvolutionSummaryGeneric', [ + $formattedValue . ' ' . $compareDescriptions[$i], + $periodPretty, + $formattedOriginalValue . ' ' . $compareDescriptions[$i], + $originalPeriodPretty, + ltrim((string) $evolutions[$i]['percent'], '+'), + ]); + $metricInfo['evolution'] = $evolutions[$i]; + } + + $metrics[] = $metricInfo; + } + + $seriesIndices[] = DataComparisonFilter::getComparisonSeriesIndex($data, $periodIndex, $segmentIndex); + } + + // only set the title (which is the segment) if comparing more than one segment + $title = count($compareSegments) > 1 ? $segmentPretty : null; + + $params = array_merge($sparklineUrlParams, [ + 'segment' => $segment, + ]); + + $this->config->addSparkline($params, $metrics, $desc = null, null, ($order * 100) + $segmentIndex, $title, $sparklineMetricIndex, $seriesIndices, $graphParams); + } + } else { + [$values, $descriptions] = $this->getValuesAndDescriptions($firstRow, $column); + + $metrics = []; + foreach ($values as $i => $value) { + if (!isset($column[$i])) { + continue; + } + $newMetric = [ + 'value' => $this->formatSparklineMetricValue( + $value, + $column[$i], + $columnMetrics, + $metricFormatter, + $idSite + ), + 'description' => $descriptions[$i], + 'title' => $metricTranslations[$column[$i]] ?? $descriptions[$i], + ]; + + $metrics[] = $newMetric; + } + + $evolution = null; + + $computeEvolution = $this->config->compute_evolution; + if ($computeEvolution) { + $evolution = $computeEvolution(array_combine((is_array($column) ? $column : [$column]), $values), $processedMetrics); + $newMetric['evolution'] = $evolution; + } + + $this->config->addSparkline($sparklineUrlParams, $metrics, $desc = null, $evolution, $order, $title = null, $group = $sparklineMetricIndex, $seriesIndices = null, $graphParams); + } + } + } + + private function applyFilters(DataTable\DataTableInterface $table) + { + foreach ($this->config->getPriorityFilters() as $filter) { + $table->filter($filter[0], $filter[1]); + } + + // queue other filters so they can be applied later if queued filters are disabled + foreach ($this->config->getPresentationFilters() as $filter) { + $table->queueFilter($filter[0], $filter[1]); + } + + $table->applyQueuedFilters(); + } + + private function getValuesAndDescriptions($firstRow, $columns, $evolutionColumnNameSuffix = null, $trendColumnNameSuffix = null) + { + if (!is_array($columns)) { + $columns = [$columns]; + } + + $translations = $this->config->translations; + + $values = []; + $descriptions = []; + $evolutions = []; + + foreach ($columns as $col) { + $value = 0; + if ($firstRow) { + $value = $firstRow->getColumn($col); + } + + if ($value === false) { + $value = 0; + } + + if ($evolutionColumnNameSuffix !== null) { + $evolution = $firstRow->getColumn($col . $evolutionColumnNameSuffix); + $trend = $firstRow->getColumn($col . $trendColumnNameSuffix); + if ($evolution !== false) { + $evolutions[] = [ + 'percent' => ltrim($evolution, '+'), + 'trend' => $trend, + 'tooltip' => '', + 'isLowerValueBetter' => Metrics::isLowerValueBetter($col), + ]; + } + } + + $values[] = $value; + $descriptions[] = isset($translations[$col]) ? $translations[$col] : $col; + } + + return [$values, $descriptions, $evolutions]; + } + + private function removeUniqueVisitorsIfNotEnabledForPeriod($columns, $period) + { + if (SettingsPiwik::isUniqueVisitorsEnabled($period)) { + return $columns; + } + + if (!is_array($columns)) { + $columns = [$columns]; + } + + return array_diff($columns, ['nb_users', 'nb_uniq_visitors']); + } + + private function formatSparklineMetricValue($value, string $columnName, array $columnMetrics, MetricFormatter $metricFormatter, int $idSite) + { + if (strpos($columnName, 'revenue') !== false && $idSite > 0) { + return $metricFormatter->getPrettyMoney($value, $idSite); + } + + if (isset($columnMetrics[$columnName]) && $columnMetrics[$columnName]) { + return $columnMetrics[$columnName]->format($value, $metricFormatter); + } + + return $value; + } + + private function findComparisonRow(array $comparisonRows, string $segment, string $period, string $date): ?DataTable\Row + { + if (isset($comparisonRows[$segment][$period][$date])) { + return $comparisonRows[$segment][$period][$date]; + } + + if (strpos($date, ',') === false) { + $rangeDate = Factory::build($period, $date)->getRangeString(); + if (isset($comparisonRows[$segment][$period][$rangeDate])) { + return $comparisonRows[$segment][$period][$rangeDate]; + } + } + + return null; + } +} diff --git a/plugins/CoreVisualizations/Visualizations/Sparklines/Config.php b/plugins/CoreVisualizations/Visualizations/Sparklines/Config.php new file mode 100644 index 0000000000000000000000000000000000000000..de51b2a991b0605adf230fbce5dc5f65ad01e026 --- /dev/null +++ b/plugins/CoreVisualizations/Visualizations/Sparklines/Config.php @@ -0,0 +1,491 @@ + 123] + * - an array of \Piwik\Plugin\Metrics objects available for the report - useful for formatting values + * + * compute_evolution(array, array) + * + * @var callable + */ + public $compute_evolution = null; + + public function __construct() + { + parent::__construct(); + + $this->translations = Metrics::getDefaultMetricTranslations(); + } + + /** + * @ignore + * @return array + */ + public function getSparklineMetrics() + { + return $this->sparkline_metrics; + } + + /** + * @ignore + * @return bool + */ + public function hasSparklineMetrics() + { + return !empty($this->sparkline_metrics); + } + + /** + * Removes an existing sparkline entry. Especially useful in dataTable filters in case sparklines should be not + * displayed depending on the fetched data. + * + * Example: + * $config->addSparklineMetric('nb_users'); + * $config->filters[] = function ($dataTable) use ($config) { + * if ($dataTable->getFirstRow()->getColumn('nb_users') == 0) { + * // do not show a sparkline if there are no recorded users + * $config->removeSparklineMetric('nb_users'); + * } + * } + * + * @param array|string $metricNames The name of the metrics in the same format they were used when added via + * {@link addSparklineMetric} + */ + public function removeSparklineMetric($metricNames) + { + foreach ($this->sparkline_metrics as $index => $metric) { + if ($metric['columns'] === $metricNames) { + array_splice($this->sparkline_metrics, $index, 1); + + break; + } + } + } + + /** + * Replaces an existing sparkline entry with different columns. Especially useful in dataTable filters in case + * sparklines should be not displayed depending on the fetched data. + * + * Example: + * $config->addSparklineMetric('nb_users'); + * $config->filters[] = function ($dataTable) use ($config) { + * if ($dataTable->getFirstRow()->getColumn('nb_users') == 0) { + * // instead of showing the sparklines for users, show a placeholder if there are no recorded users + * $config->replaceSparklineMetric(array('nb_users'), ''); + * } + * } + * + * @param array|string $metricNames The name of the metrics in the same format they were used when added via + * {@link addSparklineMetric} + * @param array|string $replacementColumns The removed columns will be replaced with these columns + */ + public function replaceSparklineMetric($metricNames, $replacementColumns) + { + foreach ($this->sparkline_metrics as $index => $metric) { + if ($metric['columns'] === $metricNames) { + $this->sparkline_metrics[$index]['columns'] = $replacementColumns; + } + } + } + + /** + * Adds a new sparkline. + * + * It will show a sparkline image, the value of the resolved metric name and a descrption. Optionally, multiple + * values can be shown after a sparkline image by passing multiple metric names + * (eg array('nb_visits', 'nb_actions')). The data will be requested from the configured api method see + * {@link \Piwik\ViewDataTable\RequestConfig::$apiMethodToRequestDataTable}. + * + * Example: + * $config->addSparklineMetric('nb_visits'); + * $config->addTranslation('nb_visits', 'Visits'); + * Results in: [sparkline image] X visits + * + * Example: + * $config->addSparklineMetric(array('nb_visits', 'nb_actions')); + * $config->addTranslations(array('nb_visits' => 'Visits', 'nb_actions' => 'Actions')); + * Results in: [sparkline image] X visits, Y actions + * + * @param string|array $metricName Either one metric name (eg 'nb_visits') or an array of metric names + * @param int|null $order Defines the order. The lower the order the earlier the sparkline will be displayed. + * By default the sparkline will be appended to the end. + * @param array $graphParams The params to use when changing an associated evolution graph. By default this is determined + * from the sparkline URL, but sometimes the sparkline API method may not match the evolution graph API method. + */ + public function addSparklineMetric($metricName, $order = null, $graphParams = null) + { + $this->sparkline_metrics[] = array( + 'columns' => $metricName, + 'order' => $order, + 'graphParams' => $graphParams, + ); + } + + /** + * Adds a placeholder. In this case nothing will be shown, neither a sparkline nor any description. This can be + * useful if you want to have some kind of separator. Eg if you want to have a sparkline on the left side but + * not sparkline on the right side. + * + * @param int|null $order Defines the order. The lower the order the earlier the sparkline will be displayed. + * By default the sparkline will be appended to the end. + */ + public function addPlaceholder($order = null) + { + $this->sparklines[] = array( + 'url' => '', + 'metrics' => array(), + 'order' => $this->getSparklineOrder($order), + + // adding this group ensures the sparkline will be placed between individual sparklines, and not in their own group together + 'group' => 'placeholder' . count($this->sparklines), + ); + } + + /** + * Add a new sparkline to be displayed to the view. + * + * Each sparkline can consist of one or multiple metrics. One metric consists of a value and a description. By + * default the value is shown first, then the description. The description can optionally contain a '%s' in case + * the value shall be displayed within the description. If multiple metrics are given, they will be separated by + * a comma. + * + * @param array $requestParamsForSparkline You need to at least set a module / action eg + * array('columns' => array('nb_visit'), 'module' => '', 'action' => '') + * @param int|float|string|array $metricInfos Either the metric value or an array of values. + * @param string|array $description Either one description or an array of descriptions. If an array, both + * $value and $description need the same amount of array entries. + * $description[0] should be the description for $value[0]. + * $description should be already translated. If $value should appear + * somewhere within the text a `%s` can be used in the translation. + * @param array|null $evolution Optional array containing at least the array keys 'currentValue' and + * 'pastValue' which are needed to calculate the correct percentage. + * An optional 'tooltip' can be set as well. Eg + * array('currentValue' => 10, 'pastValue' => 20, + * 'tooltip' => '10 visits in 2015-07-26 compared to 20 visits in 2015-07-25') + * @param int $order Defines the order. The lower the order the earlier the sparkline will be + * displayed. By default the sparkline will be appended to the end. + * @param string $title The title of this specific sparkline. It is displayed on the left above the sparkline image. + * @param string $group The ID of the group for this sparkline. + * @param int $seriesIndices The indexes of each series displayed in the sparkline. This determines what color is used for each series. Mainly used for comparison. + * @param array $graphParams The params to use when changing an associated evolution graph. By default this is determined + * from the sparkline URL, but sometimes the sparkline API method may not match the evolution graph API method. + * @throws \Exception In case an evolution parameter is set but has wrong data structure + */ + public function addSparkline($requestParamsForSparkline, $metricInfos, $description, $evolution = null, $order = null, $title = null, $group = '', $seriesIndices = null, $graphParams = null) + { + $metrics = array(); + + if ($description === null && is_array($metricInfos)) { + $metrics = $metricInfos; + } else { + $value = $metricInfos; + + if (is_array($value)) { + $values = $value; + } else { + $values = array($value); + } + + if (!is_array($description)) { + $description = array($description); + } + + if (count($values) === count($description)) { + foreach ($values as $index => $value) { + $metrics[] = array( + 'value' => $value, + 'description' => $description[$index], + ); + } + } else { + $msg = 'The number of values and descriptions need to be the same to add a sparkline. '; + $msg .= 'Values: ' . implode(', ', $values) . ' Descriptions: ' . implode(', ', $description); + throw new \Exception($msg); + } + } + + if ( + !empty($requestParamsForSparkline['columns']) + && is_array($requestParamsForSparkline['columns']) + && count($requestParamsForSparkline['columns']) === count($metrics) + ) { + $columns = array_values($requestParamsForSparkline['columns']); + } elseif ( + !empty($requestParamsForSparkline['columns']) + && is_string($requestParamsForSparkline['columns']) + && count($metrics) === 1 + ) { + $columns = array($requestParamsForSparkline['columns']); + } else { + $columns = array(); + } + + foreach ($metrics as $index => $metricInfo) { + $metrics[$index]['column'] = isset($columns[$index]) ? $columns[$index] : ''; + } + + if (empty($metrics)) { + return; + } + + $groupedMetrics = []; + foreach ($metrics as $metricInfo) { + $metricGroup = isset($metricInfo['group']) ? $metricInfo['group'] : ''; + $groupedMetrics[$metricGroup][] = $metricInfo; + } + + $tooltip = $this->generateSparklineTooltip($requestParamsForSparkline); + + $sparkline = array( + 'url' => $this->getUrlSparkline($requestParamsForSparkline), + 'tooltip' => $tooltip, + 'metrics' => $groupedMetrics, + 'order' => $this->getSparklineOrder($order), + 'title' => $title, + 'group' => $group, + 'seriesIndices' => $seriesIndices, + 'graphParams' => $graphParams, + ); + + if (!empty($evolution)) { + if ( + !is_array($evolution) || + !array_key_exists('currentValue', $evolution) || + !array_key_exists('pastValue', $evolution) + ) { + throw new \Exception('In order to show an evolution in the sparklines view a currentValue and pastValue array key needs to be present'); + } + + $evolutionPercent = CalculateEvolutionFilter::calculate($evolution['currentValue'], $evolution['pastValue'], $precision = 1); + + $sparkline['evolution'] = array( + 'percent' => $evolutionPercent, + 'isLowerValueBetter' => !empty($evolution['isLowerValueBetter']) ? $evolution['isLowerValueBetter'] : false, + 'tooltip' => !empty($evolution['tooltip']) ? $evolution['tooltip'] : null, + 'trend' => $evolution['currentValue'] - $evolution['pastValue'], + ); + } + + $this->sparklines[] = $sparkline; + } + + public function generateSparklineTooltip($params) + { + $tooltip = ''; + if (!empty($params['period'])) { + $periodTranslated = Piwik::translate('Intl_Period' . ucfirst($params['period'])); + $tooltip = Piwik::translate('General_SparklineTooltipUsedPeriod', $periodTranslated); + if (!empty($params['date'])) { + $period = Period\Factory::build('day', $params['date']); + $tooltip .= ' ' . Piwik::translate('General_Period') . ': ' . $period->getLocalizedShortString() . '.'; + + if (!empty($params['compareDates'])) { + foreach ($params['compareDates'] as $index => $comparisonDate) { + $comparePeriod = Period\Factory::build('day', $comparisonDate); + $tooltip .= ' ' . Piwik::translate('General_Period') . ' ' . ($index + 2) . ': ' . $comparePeriod->getLocalizedShortString() . '.'; + } + } + } + } + return $tooltip; + } + + /** + * If there are sparklines and evolution graphs on one page, we try to connect them so that when you click on a + * sparkline, the evolution graph will update and show the evolution for that sparkline metric. In some cases + * we might falsely connect sparklines with an evolution graph that don't belong together. In this case you can + * mark all sparklines as "not linkable". This will prevent the sparklines being linked with an evolution graph. + */ + public function setNotLinkableWithAnyEvolutionGraph() + { + $this->evolutionGraphLinkable = false; + } + + /** + * Detect whether sparklines are linkable with an evolution graph. {@link setNotLinkableWithAnyEvolutionGraph()} + */ + public function areSparklinesLinkable() + { + return $this->evolutionGraphLinkable; + } + + /** + * @return array + * @ignore + */ + public function getSortedSparklines() + { + usort($this->sparklines, function ($a, $b) { + if ($a['order'] == $b['order']) { + return 0; + } + return ($a['order'] < $b['order']) ? -1 : 1; + }); + + $sparklines = []; + foreach ($this->sparklines as $sparkline) { + $group = $sparkline['group']; + $sparklines[$group][] = $sparkline; + } + return $sparklines; + } + + private function getSparklineOrder($order) + { + if (!isset($order)) { + // make sure to append to the end if nothing set (in the order they are added) + $order = 999 + count($this->sparklines); + } + + return (int) $order; + } + + /** + * Returns a URL to a sparkline image for a report served by the current plugin. + * + * The result of this URL should be used with the [sparkline()](/api-reference/Piwik/View#twig) twig function. + * + * The current site ID and period will be used. + * + * @param array $customParameters The array of query parameter name/value pairs that + * should be set in result URL. + * @return string The generated URL. + */ + private function getUrlSparkline($customParameters = array()) + { + $customParameters['viewDataTable'] = 'sparkline'; + + $params = $this->getGraphParamsModified($customParameters); + + foreach ($params as $key => $value) { + if (is_array($value) && in_array($key, ['compareDates', 'comparePeriods'])) { + foreach ($value as $index => $inner) { + $value[$index] = rawurlencode($inner); + } + $params[$key] = $value; + } elseif (is_array($value)) { + // convert array values to comma separated + $params[$key] = rawurlencode(implode(',', $value)); + } + } + $url = Url::getCurrentQueryStringWithParametersModified($params); + return $url; + } + + /** + * Returns the array of new processed parameters once the parameters are applied. + * For example: if you set range=last30 and date=2008-03-10, + * the date element of the returned array will be "2008-02-10,2008-03-10" + * + * Parameters you can set: + * - range: last30, previous10, etc. + * - date: YYYY-MM-DD, today, yesterday + * - period: day, week, month, year + * + * @param array $paramsToSet array( 'date' => 'last50', 'viewDataTable' =>'sparkline' ) + * @throws \Piwik\NoAccessException + * @return array + */ + public function getGraphParamsModified($paramsToSet = array()) + { + if (!isset($paramsToSet['period'])) { + $period = Common::getRequestVar('period'); + } else { + $period = $paramsToSet['period']; + } + + if ($period == 'range') { + return $paramsToSet; + } + + if (!isset($paramsToSet['range'])) { + $range = 'last30'; + } else { + $range = $paramsToSet['range']; + } + + if (!isset($paramsToSet['idSite'])) { + $idSite = Common::getRequestVar('idSite'); + } else { + $idSite = $paramsToSet['idSite']; + } + + if (!isset($paramsToSet['date'])) { + $endDate = Common::getRequestVar('date', 'yesterday', 'string'); + } else { + $endDate = $paramsToSet['date']; + } + + $site = new Site($idSite); + + if (is_null($site)) { + throw new NoAccessException("Website not initialized, check that you are logged in and/or using the correct token_auth."); + } + + if ( + !isset($paramsToSet['date']) + || !Range::isMultiplePeriod($paramsToSet['date'], $period) + ) { + $paramsToSet['date'] = Range::getRelativeToEndDate($period, $range, $endDate, $site); + $paramsToSet['period'] = $period; + } + + return $paramsToSet; + } +} diff --git a/plugins/CoreVisualizations/Widgets/SingleMetricView.php b/plugins/CoreVisualizations/Widgets/SingleMetricView.php new file mode 100644 index 0000000000000000000000000000000000000000..5f003a840050c5860e00f85fa4c74f8eee181648 --- /dev/null +++ b/plugins/CoreVisualizations/Widgets/SingleMetricView.php @@ -0,0 +1,93 @@ +addParameters(['column' => $column]); + $config->setCategoryId('General_KpiMetric'); + $config->setName('General_KpiMetric'); + $config->setIsWidgetizable(); + } + + public function render() + { + $column = Common::getRequestVar('column', 'nb_visits', 'string'); + + $goalMetrics = []; + $goals = []; + + $idSite = Common::getRequestVar('idSite'); + $idGoal = Common::getRequestVar('idGoal', false); + + $reportMetadata = Request::processRequest('API.getMetadata', [ + 'idSites' => $idSite, + 'apiModule' => 'API', + 'apiAction' => 'get', + ]); + $reportMetadata = reset($reportMetadata); + + $metricTranslations = array_merge($reportMetadata['metrics'], $reportMetadata['processedMetrics']); + $metricDocumentations = $reportMetadata['metricsDocumentation']; + + if (PluginManager::getInstance()->isPluginActivated('Goals')) { + $reportMetadata = Request::processRequest('API.getMetadata', [ + 'idSites' => $idSite, + 'apiModule' => 'Goals', + 'apiAction' => 'get', + ]); + $reportMetadata = reset($reportMetadata); + + $goalMetrics = array_merge( + array_keys($reportMetadata['metrics']), + array_keys($reportMetadata['processedMetrics']) + ); + $metricDocumentations = array_merge($metricDocumentations, $reportMetadata['metricsDocumentation']); + + $goals = Request::processRequest('Goals.getGoals', ['idSite' => $idSite, 'filter_limit' => '-1'], []); + } + + $lowerIsBetterMetrics = []; + foreach (array_merge(array_keys($metricTranslations), $goalMetrics) as $metricName) { + if (Metrics::isLowerValueBetter($metricName)) { + $lowerIsBetterMetrics[] = $metricName; + } + } + + return '
'; + } + + private function getVueEntryValue($value) + { + $result = json_encode($value); + $result = Common::sanitizeInputValue($result); + return $result; + } +} diff --git a/plugins/CoreVisualizations/config/config.php b/plugins/CoreVisualizations/config/config.php new file mode 100644 index 0000000000000000000000000000000000000000..d5be5de26f92a935f318ed4c0a04fe01434bfe66 --- /dev/null +++ b/plugins/CoreVisualizations/config/config.php @@ -0,0 +1,3 @@ +
'); + var $dataTableFeatures = $dataTable.find('.dataTableFeatures'); + var $footerNavigation = $dataTableFeatures.find('.dataTableFooterNavigation'); + + if ($footerNavigation.length) { + $legendFooter.insertBefore($footerNavigation); + } else if ($dataTableFeatures.length) { + $dataTableFeatures.append($legendFooter); + } else { + $dataTable.append($legendFooter); + } + + return $legendFooter; +} + +// The legend footer stacks the legend above the "Choose metrics" picker once the report is +// narrower than this. We can't use a CSS container query here — Matomo's LESS compiler +// (less.php) can't parse @container — so we measure the footer width and toggle the +// `is-narrow` class the stylesheet targets. This tracks the report's own width, so it also +// kicks in when a report is shown as a narrow dashboard widget rather than full screen. +var FOOTER_STACK_MAX_WIDTH = 321; + +function updateLegendFooterStacking($legendFooter) +{ + if (!$legendFooter || !$legendFooter.length) { + return; + } + + // 0 when the footer isn't laid out yet (e.g. display:none); skip so we don't flip-flop + var width = $legendFooter[0].clientWidth; + if (width > 0) { + $legendFooter.toggleClass('is-narrow', width < FOOTER_STACK_MAX_WIDTH); + } +} + +var MAX_FOOTER_LEGEND_ROWS = 2; +var FOOTER_LEGEND_ROW_TOLERANCE = 1; +var FOOTER_LEGEND_EXPORT_GRAPH_GAP = 12; + +function resetLegendItems($legendItems) +{ + $legendItems + .removeClass('jqplot-legend-item-hidden jqplot-legend-item-overflow') + .each(function () { + var $label = $(this).find('.jqplot-legend-label'); + var originalLabel = $label.attr('data-original-label'); + + if (typeof originalLabel !== 'undefined') { + $label.text(originalLabel); + } + }); +} + +function getLegendRows($legendItems) +{ + var rowTops = []; + var rows = []; + + $legendItems.each(function () { + var $item = $(this); + var itemTop; + var rowIndex = -1; + + if ($item.hasClass('jqplot-legend-item-hidden')) { + return; + } + + itemTop = Math.round($item.position().top); + + for (var i = 0; i < rowTops.length; i++) { + if (Math.abs(rowTops[i] - itemTop) <= FOOTER_LEGEND_ROW_TOLERANCE) { + rowIndex = i; + break; + } + } + + if (rowIndex === -1) { + rowTops.push(itemTop); + rows.push([]); + rowIndex = rows.length - 1; + } + + rows[rowIndex].push(this); + }); + + return rows; +} + +function limitLegendRows($legendContainer, maxRows) +{ + var $legendItems = $legendContainer.children('.jqplot-legend-item'); + var rows; + var overflowSource; + var hideItems = false; + + resetLegendItems($legendItems); + + if (!maxRows || maxRows < 1) { + return; + } + + rows = getLegendRows($legendItems); + + // Left-align the legend (via CSS) once it wraps past a single row; a single row stays centered. + $legendContainer.closest('.jqplot-legend-footer') + .toggleClass('is-multi-row', rows.length >= 2); + + if (rows.length <= maxRows) { + return; + } + + // Reserve the last visible legend slot for the overflow marker so the + // ellipsis stays within the allowed rows instead of landing on a hidden row. + overflowSource = rows[maxRows - 1] && rows[maxRows - 1][rows[maxRows - 1].length - 1]; + + if (!overflowSource) { + return; + } + + $(overflowSource) + .addClass('jqplot-legend-item-overflow') + .find('.jqplot-legend-label') + .text('…'); + + $legendItems.each(function () { + if (this === overflowSource) { + hideItems = true; + return; + } + + if (hideItems) { + $(this).addClass('jqplot-legend-item-hidden'); + } + }); +} + +function getLegendLabelTextForExport(ctx, labelElement, originalLabel, maxWidth) +{ + var labelText = labelElement.textContent || ''; + + if (!originalLabel || maxWidth <= 0 || labelElement.scrollWidth <= labelElement.clientWidth) { + return labelText; + } + + var ellipsis = '…'; + var ellipsisWidth = ctx.measureText(ellipsis).width; + if (ellipsisWidth >= maxWidth) { + return ellipsis; + } + + var low = 0; + var high = originalLabel.length; + var bestFit = ''; + + while (low <= high) { + var middle = Math.floor((low + high) / 2); + var candidate = originalLabel.slice(0, middle) + ellipsis; + + if (ctx.measureText(candidate).width <= maxWidth) { + bestFit = candidate; + low = middle + 1; + } else { + high = middle - 1; + } + } + + return bestFit || ellipsis; +} + +// Re-apply the footer legend row limit for a given dataTable. The "Choose metrics" +// button shares the footer row with the legend items, so the legend's available +// width is only final once the picker has been placed and its Vue button mounted. +// This is called again after the picker is rendered (and on replot/resize) so the +// items wrap against the real width and stay within MAX_FOOTER_LEGEND_ROWS. +function applyFooterLegendRowLimit($dataTable) +{ + var $legendContainer = $dataTable.find('.jqplot-legend-footer.has-legend .jqplot-legend-items'); + if ($legendContainer.length) { + limitLegendRows($legendContainer, MAX_FOOTER_LEGEND_ROWS); + } +} + +(function ($, require) { + var exports = require('piwik/UI'), + DataTable = exports.DataTable, + dataTablePrototype = DataTable.prototype, + getLabelFontFamily = function () { + if (!window.piwik.jqplotLabelFont) { + window.piwik.jqplotLabelFont = $('

').hide().appendTo('body').css('font-family'); + } + + return window.piwik.jqplotLabelFont || 'Arial'; + } + ; + + exports.getLabelFontFamily = getLabelFontFamily; + + function getPlotLinesSeriesColorNames() { + var seriesColorNames = []; + + for (var seriesIndex = 0; seriesIndex < 8; seriesIndex++) { + seriesColorNames.push('series' + seriesIndex); + } + + for (var shade = 1; shade <= 3; shade++) { + for (var shadedSeriesIndex = 0; shadedSeriesIndex < 8; shadedSeriesIndex++) { + seriesColorNames.push('series' + shadedSeriesIndex + '-shade' + shade); + } + } + + return seriesColorNames; + } + + /** + * DataTable UI class for jqPlot graph datatable visualizations. + * + * @constructor + */ + exports.JqplotGraphDataTable = function (element) { + DataTable.call(this, element); + }; + + $.extend(exports.JqplotGraphDataTable.prototype, dataTablePrototype, { + + /** + * Initializes this class. + */ + init: function () { + dataTablePrototype.init.call(this); + + var graphElement = $('.piwik-graph', this.$element); + if (!graphElement.length) { + return; + } + + this._lang = { + noData: _pk_translate('General_NoDataForGraph'), + exportTitle: _pk_translate('General_ExportAsImage'), + exportText: _pk_translate('General_SaveImageOnYourComputer'), + metricsToPlot: _pk_translate('General_MetricsToPlot'), + metricToPlot: _pk_translate('General_MetricToPlot'), + recordsToPlot: _pk_translate('General_RecordsToPlot'), + incompletePeriod: _pk_translate('General_IncompletePeriod'), + invalidatedPeriod: _pk_translate('General_InvalidatedPeriod') + }; + + // set a unique ID for the graph element (required by jqPlot) + this.targetDivId = this.workingDivId + 'Chart'; + graphElement.attr('id', this.targetDivId); + + try { + var graphData = JSON.parse(graphElement.attr('data-data')); + } catch (e) { + console.error('JSON.parse Error: "' + e + "\" in:\n" + graphElement.attr('data-data')); + return; + } + + this.data = graphData.data; + this._setJqplotParameters(graphData.params); + this._setDataStates(graphData.dataStates); + + if (this.props.display_percentage_in_tooltip) { + this._setTooltipPercentages(); + } + + this._bindEvents(); + + // add external series toggle if it should be added + if (this.props.external_series_toggle) { + this.addExternalSeriesToggle( + window[this.props.external_series_toggle], // get the function w/ string name + this.props.external_series_toggle_show_all == 1 + ); + } + + // render the graph (setTimeout is required, otherwise the graph will not + // render initially) + var self = this; + setTimeout(function () { self.render(); }, 1); + }, + + _setDataStates: function (dataStates) { + this.jqplotParams.dataStates = []; + + if (Array.isArray(dataStates)) { + this.jqplotParams.dataStates = dataStates; + } + }, + + _setJqplotParameters: function (params) { + defaultParams = { + grid: { + borderWidth: 0, + shadow: false + }, + title: { + show: false + }, + axesDefaults: { + pad: 1.0, + tickRenderer: $.jqplot.CanvasAxisTickRenderer, + tickOptions: { + showMark: false, + fontSize: '11px', + fontFamily: getLabelFontFamily() + }, + rendererOptions: { + drawBaseline: false + } + }, + axes: { + yaxis: { + tickOptions: { + formatString: '%s', + formatter: $.jqplot.NumberFormatter + } + }, + } + }; + + this.jqplotParams = $.extend(true, {}, defaultParams, params); + + for (var i = 2; typeof this.jqplotParams.axes['y' + i + 'axis'] != 'undefined'; i++) { + this.jqplotParams.axes['y' + i + 'axis'].tickOptions = $.extend(true, {}, { + formatString: '%s', + formatter: $.jqplot.NumberFormatter + }, this.jqplotParams.axes['y' + i + 'axis'].tickOptions); + } + + this._setColors(); + }, + + _setTooltipPercentages: function () { + this.tooltip = {percentages: []}; + + for (var seriesIdx = 0; seriesIdx != this.data.length; ++seriesIdx) { + var series = this.data[seriesIdx]; + var sum = 0; + + $.each(series, function(index, value) { + if ($.isArray(value) && value[1]) { + sum = sum + value[1]; + } else if (!$.isArray(value)) { + sum = sum + value; + } + }); + + var percentages = this.tooltip.percentages[seriesIdx] = []; + for (var valueIdx = 0; valueIdx != series.length; ++valueIdx) { + var value = series[valueIdx]; + if ($.isArray(value) && value[1]) { + value = value[1]; + } + + percentages[valueIdx] = sum > 0 ? Math.round(100 * value / sum) : 0; + } + } + }, + + _bindEvents: function () { + var self = this; + var target = $('#' + this.targetDivId); + + // tooltip show/hide + target.on('jqplotDataHighlight', function (e, seriesIndex, valueIndex) { + self._showDataPointTooltip(this, seriesIndex, valueIndex); + }) + .on('jqplotDataUnhighlight', function () { + self._destroyDataPointTooltip($(this)); + }); + + // handle window resize + this._plotWidth = target.innerWidth(); + target.on('resizeGraph', function () { // TODO: shouldn't be a triggerable event. + self._resizeGraph(); + }); + + // export as image + target.on('piwikExportAsImage', function () { + self.exportAsImage(target, self._lang); + }); + + // manage resources + target.on('piwikDestroyPlot', function () { + if (self._resizeListener) { + $(window).off('resize', self._resizeListener); + } + self._plot.destroy(); + for (var i = 0; i < $.jqplot.visiblePlots.length; i++) { + if ($.jqplot.visiblePlots[i] === self) { + $.jqplot.visiblePlots[i] = null; + } + } + }); + + this.$element.closest('.widgetContent').on('widget:resize', function () { + self._resizeGraph(); + }); + + this._themeModeChangeListener = function () { + self.refreshTheme(); + }; + window.addEventListener('themeModeChange', this._themeModeChangeListener); + }, + + _resizeGraph: function () { + var width = $('#' + this.targetDivId).innerWidth(); + if (width > 0 && Math.abs(this._plotWidth - width) >= 5) { + this._plotWidth = width; + this.render(); + } + }, + + _setWindowResizeListener: function () { + var self = this; + + var timeout = false; + this._resizeListener = function () { + if (timeout) { + window.clearTimeout(timeout); + } + + timeout = window.setTimeout(function () { $('#' + self.targetDivId).trigger('resizeGraph'); }, 300); + }; + $(window).on('resize', this._resizeListener); + }, + + _destroyDataPointTooltip: function ($element) { + if ($element.is( ":data('ui-tooltip')" )) { + $element.tooltip('destroy'); + } + }, + + _showDataPointTooltip: function (element, seriesIndex, valueIndex) { + // empty + }, + + changeSeries: function (columns, rows) { + this.showLoading(); + + columns = columns || []; + if (typeof columns == 'string') { + columns = columns.split(','); + } + + rows = rows || []; + if (typeof rows == 'string') { + rows = rows.split(','); + } + + var dataTable = $('#' + this.workingDivId).data('uiControlObject'); + dataTable.param.columns = columns.join(','); + dataTable.param.rows = rows.join(','); + delete dataTable.param.filter_limit; + delete dataTable.param.totalRows; + if (dataTable.param.filter_sort_column != 'label') { + dataTable.param.filter_sort_column = columns[0]; + } + dataTable.param.disable_generic_filters = '0'; + dataTable.reloadAjaxDataTable(false); + }, + + destroyPlot: function () { + var target = $('#' + this.targetDivId); + var dataTable = target.closest('.dataTable'); + var legendFooter = dataTable.find('.jqplot-legend-footer'); + + // tear down the outgoing picker before its DOM is removed, so it can't leak + if (this._plot && this._plot.plugins && this._plot.plugins.seriesPicker) { + this._plot.plugins.seriesPicker.destroy(); + } + + target.trigger('piwikDestroyPlot'); + if (legendFooter.length) { + legendFooter + .removeClass('has-legend') + .find('.jqplot-legend-items') + .empty(); + } + if (target.data('oldHeight') > 0) { + // handle replot after empty report + target.height(target.data('oldHeight')); + target.data('oldHeight', 0); + target.innerHTML = ''; + } + }, + + _destroy: function () { + if (this._themeModeChangeListener) { + window.removeEventListener('themeModeChange', this._themeModeChangeListener); + } + + if (this._plot) { + this.destroyPlot(); + } + + dataTablePrototype._destroy.call(this); + }, + + showLoading: function () { + var target = $('#' + this.targetDivId); + + var loading = $(document.createElement('div')).addClass('jqplot-loading'); + loading.append(''); + loading.css({ + width: target.innerWidth() + 'px', + height: target.innerHeight() + 'px', + backgroundColor: (this.jqplotParams.grid && this.jqplotParams.grid.background) || '', + opacity: 0 + }); + target.prepend(loading); + loading.css({opacity: .7}); + }, + + /** + * This method sums up total width of all tick according to currently + * set font-family, font-size and font-weight. It is achieved by + * creating span elements with ticks and adding their width. + * Rendered ticks have to be visible to get their real width. But it + * is too fast for user to notice it. If total ticks width is bigger + * than container width then half of ticks is being cut out and their + * width is tested again. Until their total width is smaller than chart + * div. There is a failsafe so check will be performed no more than 20 + * times, which is I think more than enough. Each tick have its own + * gutter, by default width of 5 px from each side so they are more + * readable. + * + * @param $targetDiv + * @private + */ + _checkTicksWidth: function($targetDiv){ + if(typeof this.jqplotParams.axes.xaxis.ticksOriginal === 'undefined' || this.jqplotParams.axes.xaxis.ticksOriginal === {}){ + this.jqplotParams.axes.xaxis.ticksOriginal = this.jqplotParams.axes.xaxis.ticks.slice(); + } + + var ticks = this.jqplotParams.axes.xaxis.ticks = this.jqplotParams.axes.xaxis.ticksOriginal.slice(); + + var divWidth = $targetDiv.width(); + var tickOptions = $.extend(true, {}, this.jqplotParams.axesDefaults.tickOptions, this.jqplotParams.axes.xaxis.tickOptions); + var gutter = tickOptions.gutter || 5; + var sumWidthOfTicks = Number.MAX_VALUE; + var $labelTestChamber = {}; + var tick = ""; + var $body = $("body"); + var maxRunsFailsafe = 20; + var ticksCount = 0; + var key = 0; + + while(sumWidthOfTicks > divWidth && maxRunsFailsafe > 0) { + sumWidthOfTicks = 0; + for (key = 0; key < ticks.length; key++) { + tick = ticks[key]; + if (tick !== " " && tick !== "") { + $labelTestChamber = $("", { + style: 'font-size: ' + (tickOptions.fontSize || '11px') + '; font-family: ' + (tickOptions.fontFamily || 'Arial, Helvetica, sans-serif') + ';' + (tickOptions.fontWeight || 'normal') + ';' + 'clear: both; float: none;', + text: tick + }).appendTo($body); + sumWidthOfTicks += ($labelTestChamber.width() + gutter*2); + $labelTestChamber.remove(); + } + } + + ticksCount = 0; + if (sumWidthOfTicks > divWidth) { + for (key = 0; key < ticks.length; key++) { + tick = ticks[key]; + if (tick !== " " && tick !== "") { + if (ticksCount % 2 == 1) { + ticks[key] = " "; + } + ticksCount++; + } + } + } + maxRunsFailsafe--; + } + }, + + /** Generic render function */ + render: function () { + if (this.data.length == 0) { // sanity check + return; + } + + var targetDivId = this.workingDivId + 'Chart'; + var lang = this._lang; + var dataTableDiv = $('#' + this.workingDivId); + + // if the plot has already been rendered, get rid of the existing plot + var target = $('#' + targetDivId); + if (target.find('canvas').length > 0) { + this.destroyPlot(); + } + + // handle replot + // this has be bound before the check for an empty graph. + // otherwise clicking on sparklines won't work anymore after an empty + // report has been displayed. + var self = this; + + // before drawing a jqplot chart, check if all labels ticks will fit + // into it + if( this.param.viewDataTable === "graphBar" + || this.param.viewDataTable === "graphVerticalBar" + || this.param.viewDataTable === "graphEvolution" ) { + self._checkTicksWidth(target); + } + + // create jqplot chart + try { + var plot = self._plot = $.jqplot(targetDivId, this.data, this.jqplotParams); + } catch (e) { + // this is thrown when refreshing piwik in the browser + if (e != "No plot target specified") { + throw e; + } + } + + self._setWindowResizeListener(); + + var self = this; + + // TODO: this code destroys plots when a page is switched. there must be a better way of managing memory. + if (typeof $.jqplot.visiblePlots == 'undefined') { + $.jqplot.visiblePlots = []; + window.CoreHome.Matomo.on('matomoPageChange', function () { + for (var i = 0; i < $.jqplot.visiblePlots.length; i++) { + if ($.jqplot.visiblePlots[i] == null) { + continue; + } + $.jqplot.visiblePlots[i].destroyPlot(); + } + $.jqplot.visiblePlots = []; + }); + } + + if (typeof plot != 'undefined') { + $.jqplot.visiblePlots.push(self); + } + }, + + /** Export the chart as an image */ + exportAsImage: function (container, lang) { + var pixelRatio = window.devicePixelRatio || 1; + var dataTable = container.closest('.dataTable'); + var legendFooter = dataTable.find('.jqplot-legend-footer.has-legend'); + var hasFooterLegend = legendFooter.length > 0; + var legendHeight = hasFooterLegend ? legendFooter[0].getBoundingClientRect().height : 0; + var legendGraphGap = hasFooterLegend ? FOOTER_LEGEND_EXPORT_GRAPH_GAP : 0; + var exportCanvas = document.createElement('canvas'); + var exportWidth = container.outerWidth(); + var dataTableWidth = dataTable.innerWidth(); + if (dataTableWidth) { + exportWidth = Math.max(exportWidth, dataTableWidth); + } + exportCanvas.width = Math.round(exportWidth * pixelRatio); + exportCanvas.height = Math.round((container.height() + legendGraphGap + legendHeight) * pixelRatio); + + if (!exportCanvas.getContext) { + alert("Sorry, not supported in your browser. Please upgrade your browser :)"); + return; + } + var exportCtx = exportCanvas.getContext('2d'); + exportCtx.fillStyle = (this.jqplotParams.grid && this.jqplotParams.grid.background) || '#ffffff'; + exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + + var canvases = container.find('canvas'); + + for (var i = 0; i < canvases.length; i++) { + var canvas = canvases.eq(i); + var position = canvas.position(); + var parent = canvas.parent(); + if (parent.hasClass('jqplot-axis')) { + var addPosition = parent.position(); + position.left += addPosition.left; + position.top += addPosition.top + parseInt(parent.css('marginTop'), 10); + } + exportCtx.drawImage(canvas[0], Math.round(position.left * pixelRatio), Math.round(position.top * pixelRatio)); + } + + if (hasFooterLegend) { + this.drawLegendForExport( + exportCtx, + legendFooter, + exportWidth, + container.height() + legendGraphGap, + pixelRatio + ); + } + + var exported = exportCanvas.toDataURL("image/png"); + + var img = document.createElement('img'); + img.src = exported; + + img = $(img).css({ + width: Math.round(exportCanvas.width / pixelRatio) + 'px', + height: Math.round(exportCanvas.height / pixelRatio) + 'px' + }); + + var popover = $(document.createElement('div')); + + popover.append('

' + + lang.exportText + '
').append($(img)); + + popover.dialog({ + title: lang.exportTitle, + modal: true, + width: 'auto', + resizable: false, + autoOpen: true, + open: function (event, ui) { + $('.ui-widget-overlay').on('click.popover', function () { + popover.dialog('close'); + }); + }, + close: function (event, ui) { + $(this).dialog("destroy").remove(); + } + }); + }, + + // Draw the footer legend onto the export canvas by replaying its rendered DOM + // geometry. This preserves browser-resolved item positioning, wrapping, + // centering and hidden overflow state without maintaining a second legend + // layout implementation for export. Text is redrawn onto canvas, so the + // export follows the rendered label bounds but does not reproduce CSS text + // overflow behaviour pixel-for-pixel. + drawLegendForExport: function (ctx, legendFooter, exportWidth, topOffset, pixelRatio) { + var footerRect = legendFooter[0].getBoundingClientRect(); + var $items = legendFooter.find('.jqplot-legend-item').filter(function () { + var $item = $(this); + return !$item.hasClass('jqplot-legend-item-hidden') && $item.css('display') !== 'none'; + }); + + if (!$items.length) { + return; + } + + // Center the legend on the rendered bounds of its items, not the whole + // footer: the "Choose metrics" button shares the footer row but is an + // interactive control that is not drawn into the exported image, so it + // must not pull the legend off-centre. + var contentLeft = Infinity; + var contentRight = -Infinity; + $items.each(function () { + var rect = this.getBoundingClientRect(); + contentLeft = Math.min(contentLeft, rect.left); + contentRight = Math.max(contentRight, rect.right); + }); + var offsetX = Math.max(0, (exportWidth - (contentRight - contentLeft)) / 2); + + $items.each(function () { + var $item = $(this); + + var $swatch = $item.find('.jqplot-legend-swatch'); + var $label = $item.find('.jqplot-legend-label'); + var labelElement = $label[0]; + var originalLabel = $label.attr('data-original-label') || $label.text(); + var labelText = $label.text(); + if (!$swatch.length || !$label.length || !labelText) { + return; + } + + // swatch: a circle, positioned and coloured from its rendered box + var swatchRect = $swatch[0].getBoundingClientRect(); + ctx.save(); + ctx.fillStyle = $swatch.css('background-color'); + ctx.beginPath(); + ctx.arc( + (offsetX + (swatchRect.left - contentLeft) + swatchRect.width / 2) * pixelRatio, + (topOffset + (swatchRect.top - footerRect.top) + swatchRect.height / 2) * pixelRatio, + (Math.min(swatchRect.width, swatchRect.height) / 2) * pixelRatio, + 0, + Math.PI * 2 + ); + ctx.fill(); + ctx.restore(); + + // label: drawn at its rendered box and clipped to it, so any CSS + // truncation of a long label is preserved in the exported image + var labelRect = $label[0].getBoundingClientRect(); + var labelLeft = offsetX + (labelRect.left - contentLeft); + var labelTop = topOffset + (labelRect.top - footerRect.top); + var fontSize = parseFloat($label.css('font-size')) || 12; + + ctx.save(); + ctx.beginPath(); + ctx.rect( + labelLeft * pixelRatio, + labelTop * pixelRatio, + labelRect.width * pixelRatio, + labelRect.height * pixelRatio + ); + ctx.clip(); + ctx.font = ($label.css('font-weight') || '400') + ' ' + + Math.round(fontSize * pixelRatio) + 'px ' + + ($label.css('font-family') || require('piwik/UI').getLabelFontFamily()); + ctx.fillStyle = $label.css('color') || '#666666'; + ctx.textBaseline = 'middle'; + labelText = getLegendLabelTextForExport( + ctx, + labelElement, + originalLabel, + labelRect.width * pixelRatio + ); + ctx.fillText( + labelText, + labelLeft * pixelRatio, + (labelTop + labelRect.height / 2) * pixelRatio + ); + ctx.restore(); + }); + }, + + // ------------------------------------------------------------ + // HELPER METHODS + // ------------------------------------------------------------ + + /** Generate ticks in y direction */ + setYTicks: function () { + // default axis + this.setYTicksForAxis('yaxis', this.jqplotParams.axes.yaxis); + + // other axes: y2axis, y3axis... + for (var i = 2; typeof this.jqplotParams.axes['y' + i + 'axis'] != 'undefined'; i++) { + this.setYTicksForAxis('y' + i + 'axis', this.jqplotParams.axes['y' + i + 'axis']); + } + }, + + setYTicksForAxis: function (axisName, axis) { + // calculate maximum x value of all data sets + var maxCrossDataSets = 0; + for (var i = 0; i < this.data.length; i++) { + if (this.jqplotParams.series[i].yaxis == axisName) { + var maxValue = Math.max.apply(Math, this.data[i]); + if (maxValue > maxCrossDataSets) { + maxCrossDataSets = maxValue; + } + maxCrossDataSets = parseFloat(maxCrossDataSets); + } + } + + // add little padding on top + maxCrossDataSets += Math.max(1, Math.round(maxCrossDataSets * .03)); + + // round to the nearest multiple of ten + if (maxCrossDataSets > 15) { + maxCrossDataSets = maxCrossDataSets + 10 - maxCrossDataSets % 10; + } + + if (maxCrossDataSets == 0) { + maxCrossDataSets = 1; + } + + // make sure percent axes don't go above 100% + if ( + axis.tickOptions + && axis.tickOptions.formatString + && axis.tickOptions.formatString.endsWith('%') + && maxCrossDataSets > 100 + ) { + maxCrossDataSets = 100; + } + + // calculate y-values for ticks + var ticks = []; + var numberOfTicks = 2; + var tickDistance = Math.ceil(maxCrossDataSets / numberOfTicks); + for (var i = 0; i <= numberOfTicks; i++) { + ticks.push(i * tickDistance); + } + axis.ticks = ticks; + }, + + /** Get a formatted y values (with unit) */ + formatY: function (value, seriesIndex) { + var floatVal = parseFloat(value); + var intVal = parseInt(value, 10); + if (Math.abs(floatVal - intVal) >= 0.005) { + value = Math.round(floatVal * 100) / 100; + } else if (parseFloat(intVal) == floatVal) { + value = intVal; + } else { + value = floatVal; + } + + var axisId = this.jqplotParams.series[seriesIndex].yaxis; + var formatString = this.jqplotParams.axes[axisId].tickOptions.formatString; + + return $.jqplot.NumberFormatter(formatString, value); + }, + + /** + * Add an external series toggle. + * As opposed to addSeriesPicker, the external series toggle can only show/hide + * series that are already loaded. + * + * @param seriesPickerClass a subclass of JQPlotExternalSeriesToggle + * @param initiallyShowAll + */ + addExternalSeriesToggle: function (seriesPickerClass, initiallyShowAll) { + new seriesPickerClass(this.targetDivId, this, initiallyShowAll); + + if (!initiallyShowAll) { + + var initialMetrics = 0; + var $rowEvolution = $('#'+this.targetDivId).closest('.rowevolution'); + + var newData = []; + var newSeries = []; + if ($rowEvolution.data('initialMetrics')) { + initialMetrics = $rowEvolution.data('initialMetrics'); + + if (Array.isArray(initialMetrics)) { + for (var j = 0; j < initialMetrics.length; j++) { + // find index of series and data + for (var k = 0; k < this.jqplotParams.series.length; k++) { + if (this.jqplotParams.series[k] + && this.jqplotParams.series[k].label + && this.jqplotParams.series[k].label === initialMetrics[j]) { + + newData.push(this.data[k]); + newSeries.push(this.jqplotParams.series[k]); + break; + } + } + } + } + } + + if (newData.length) { + // restore original selection + this.data = newData; + this.jqplotParams.series = newSeries; + } else { + // initially, show only the first series + this.data = [this.data[0]]; + this.jqplotParams.series = [this.jqplotParams.series[0]]; + } + + this.setYTicks(); + } + }, + + /** + * Sets the colors used to render this graph. + */ + _setColors: function () { + var colorManager = piwik.ColorManager; + + var viewDataTable = $('#' + this.workingDivId).data('uiControlObject').param['viewDataTable']; + + var graphType = ''; + if (viewDataTable == 'graphEvolution' || viewDataTable == 'graphStackedBarEvolution') { + graphType = 'evolution'; + } else if (viewDataTable == 'graphPie') { + graphType = 'pie'; + } else if (viewDataTable == 'graphVerticalBar') { + graphType = 'bar'; + } + + var namespace = graphType + '-graph-colors'; + + this._setSeriesColors(namespace); + + this.jqplotParams.grid.background = colorManager.getColor(namespace, 'grid-background'); + this.jqplotParams.grid.borderColor = colorManager.getColor(namespace, 'grid-border'); + this.tickColor = colorManager.getColor(namespace, 'ticks'); + + // Evolution and bar gridlines use a lighter tick color. + if (graphType === 'evolution' || graphType === 'bar') { + var TICK_OPACITY = 0.5; + var tickRgb = colorManager.getRgb(this.tickColor); + this.tickColor = 'rgba(' + tickRgb[0] + ', ' + tickRgb[1] + ', ' + + tickRgb[2] + ', ' + TICK_OPACITY + ')'; + + // Keep gridlines and tick marks the same color. + this.jqplotParams.grid.gridLineColor = this.tickColor; + } + + this.singleMetricColor = colorManager.getColor(namespace, 'single-metric-label'); + + if (this.jqplotParams.pieLegend) { + this.jqplotParams.pieLegend.labelColor = this.singleMetricColor; + } + + if (this.jqplotParams.canvasLegend + && this.jqplotParams.canvasLegend.singleMetric + ) { + this.jqplotParams.canvasLegend.singleMetricColor = this.singleMetricColor; + } + }, + + _setSeriesColors: function (namespace) { + var colorManager = piwik.ColorManager, + seriesColorNames; + + var comparisonService = window.CoreHome.ComparisonsStoreInstance; + if (comparisonService.isComparing() && typeof this.jqplotParams.series[0].seriesIndex !== 'undefined') { + // Use the shared comparison palette when comparing rows. + namespace = 'comparison-series-color'; + + seriesColorNames = []; + this.jqplotParams.series.forEach(function (s) { + var seriesColorName = comparisonService.getSeriesColorName(s.seriesIndex, s.metricIndex); + seriesColorNames.push(seriesColorName); + }); + } else if (namespace === 'evolution-graph-colors' + || namespace === 'bar-graph-colors' + || namespace === 'pie-graph-colors') { + seriesColorNames = getPlotLinesSeriesColorNames(); + } else { + seriesColorNames = ['series0', 'series1', 'series2', 'series3', 'series4', 'series5', + 'series6', 'series7', 'series8', 'series9', 'series10']; + } + + this.jqplotParams.seriesColors = colorManager.getColors(namespace, seriesColorNames, true); + }, + + refreshTheme: function () { + if (!this.data || !this.data.length || !this.$element + || !$.contains(document.documentElement, this.$element[0])) { + return; + } + this._setColors(); + this.render(); + } + }); + + DataTable.registerFooterIconHandler('graphPie', DataTable.switchToGraph); + DataTable.registerFooterIconHandler('graphVerticalBar', DataTable.switchToGraph); + DataTable.registerFooterIconHandler('graphEvolution', DataTable.switchToGraph); + +})(jQuery, require); + +// ---------------------------------------------------------------- +// EXTERNAL SERIES TOGGLE +// Use external dom elements and their events to show/hide series +// ---------------------------------------------------------------- + +function JQPlotExternalSeriesToggle(targetDivId, jqplotObject, initiallyShowAll) { + this.init(targetDivId, originalConfig, initiallyShowAll); +} + +JQPlotExternalSeriesToggle.prototype = { + + init: function (targetDivId, jqplotObject, initiallyShowAll) { + this.targetDivId = targetDivId; + this.jqplotObject = jqplotObject; + this.originalData = jqplotObject.data; + this.originalSeries = jqplotObject.jqplotParams.series; + this.originalAxes = jqplotObject.jqplotParams.axes; + this.originalParams = jqplotObject.jqplotParams; + this.originalSeriesColors = jqplotObject.jqplotParams.seriesColors; + this.initiallyShowAll = initiallyShowAll; + + this.activated = []; + this.target = $('#' + targetDivId); + + this.attachEvents(); + }, + + // can be overridden + attachEvents: function () {}, + + // show a single series + showSeries: function (i) { + this.activated = [i]; + this.replot(); + }, + + // toggle a series (make plotting multiple series possible) + toggleSeries: function (i) { + if (this.activated.indexOf(i) > -1) { + // need to remove the metric + if (this.activated.length > 1) { + // prevent removing the only visible metric + this.activated.splice(this.activated.indexOf(i), 1); + } + } else { + this.activated.push(i); + } + this.replot(); + }, + + replot: function () { + this.beforeReplot(); + + // build new config and replot + var usedAxes = []; + var config = {data: this.originalData, params: this.originalParams}; + config.data = []; + config.params.series = []; + config.params.axes = {xaxis: this.originalAxes.xaxis}; + config.params.seriesColors = []; + + for (var j = 0; j < this.activated.length; j++) { + // find index of series and data + for (var k = 0; k < this.originalSeries.length; k++) { + if (this.originalSeries[k] + && this.originalSeries[k].label + && ( + this.originalSeries[k].label === this.activated[j] + || piwikHelper.htmlDecode(this.originalSeries[k].label) === this.activated[j] + ) + ) { + config.data.push(this.originalData[k]); + config.params.seriesColors.push(this.originalSeriesColors[k]); + config.params.series.push($.extend(true, {}, this.originalSeries[k])); + // build array of used axes + var axis = this.originalSeries[k].yaxis; + if ($.inArray(axis, usedAxes) == -1) { + usedAxes.push(axis); + } + break; + } + } + } + + // build new axes config + var replaceAxes = {}; + for (j = 0; j < usedAxes.length; j++) { + var originalAxisName = usedAxes[j]; + var newAxisName = (j == 0 ? 'yaxis' : 'y' + (j + 1) + 'axis'); + replaceAxes[originalAxisName] = newAxisName; + config.params.axes[newAxisName] = this.originalAxes[originalAxisName]; + } + + // replace axis names in series config + for (j = 0; j < config.params.series.length; j++) { + var series = config.params.series[j]; + series.yaxis = replaceAxes[series.yaxis]; + } + + this.jqplotObject.data = config.data; + this.jqplotObject.jqplotParams = config.params; + this.jqplotObject.setYTicks(); + this.jqplotObject.render(); + }, + + // can be overridden + beforeReplot: function () {} + +}; + +// ROW EVOLUTION SERIES TOGGLE + +function RowEvolutionSeriesToggle(targetDivId, jqplotData, initiallyShowAll) { + this.init(targetDivId, jqplotData, initiallyShowAll); +} + +RowEvolutionSeriesToggle.prototype = JQPlotExternalSeriesToggle.prototype; + +RowEvolutionSeriesToggle.prototype.attachEvents = function () { + var self = this; + + var $rowEvolution = this.target.closest('.rowevolution'); + this.seriesPickers = $rowEvolution.find('table.metrics tr'); + + var initialMetrics = []; + + if ($rowEvolution.data('initialMetrics')) { + initialMetrics = []; + var savedMetrics = $rowEvolution.data('initialMetrics'); + var existingMetricsInSeries = []; + var m = 0; + for (m = 0; m < this.originalSeries.length; m++) { + existingMetricsInSeries.push(this.originalSeries[m].label); + } + for (m = 0; m < savedMetrics.length; m++) { + if (existingMetricsInSeries.indexOf(savedMetrics[m]) > -1) { + // only if it exists... for example unique visitors etc might not be available for some metrics, + // then we need to make sure to highlight the default first metric for example + initialMetrics.push(savedMetrics[m]); + } + } + } + + this.seriesPickers.each(function (i) { + var el = $(this); + + el.off('click').on('click', function (e) { + var metricName = rowEvolutionGetMetricNameFromRow(this); + // we are storing this info on the element as the series picker and the jqplot object gets recreated whenever + // we change a period so we cannot persist the selection there. + if (e.shiftKey) { + self.toggleSeries(metricName); + document.getSelection().removeAllRanges(); // make sure chrome doesn't select text + } else { + self.showSeries(metricName); + } + $rowEvolution.data('initialMetrics', self.activated); + return false; + }); + + var label = rowEvolutionGetMetricNameFromRow(el); + var metricExists = false; + for (var k = 0; k < self.originalSeries.length; k++) { + if (self.originalSeries[k] && labelMatches(self.originalSeries[k].label, label)) { + metricExists = true; + } + } + + if (!metricExists) { + el.hide(); + } else if ( + (initialMetrics.length === 0 && i == 0) + || (initialMetrics.length > 0 && initialMetrics.indexOf(label) > -1) + || self.initiallyShowAll) { + // show the active series + // if initiallyShowAll, all are active; otherwise only the first one + if (!el.hasClass('hiddenByDefault')) { + el.show(); + } + el.find('td').css('opacity', ''); + self.activated.push(rowEvolutionGetMetricNameFromRow(el)); + } else { + if (!el.hasClass('hiddenByDefault')) { + el.show(); + } + // fade out the others + el.find('td').css('opacity', .5); + } + + // the API outputs the label double encoded when it shouldn't. so when looking for a matching label we have + // to check if one is double encoded. + function labelMatches(lhs, rhs) { + return lhs === rhs || piwikHelper.htmlDecode(lhs) === rhs || lhs === piwikHelper.htmlDecode(rhs); + } + }); +}; + +RowEvolutionSeriesToggle.prototype.beforeReplot = function () { + var self = this; + // fade out if not activated + this.seriesPickers.find('td').css('opacity', .5); + this.seriesPickers.each(function (i) { + var name = rowEvolutionGetMetricNameFromRow(this); + if (self.activated.indexOf(name) > -1) { + $(this).find('td').css('opacity', 1); + } + }); +}; + +// ------------------------------------------------------------ +// PIWIK NUMBERFORMATTER PLUGIN FOR JQPLOT +// ------------------------------------------------------------ +(function($){ + + $.jqplot.NumberFormatter = function (format, value) { + + if (!$.isNumeric(value)) { + return format.replace(/%s/, value); + } + return format.replace(/%s/, NumberFormatter.formatNumber(value)); + } + +})(jQuery); + + +// ------------------------------------------------------------ +// PIWIK TICKS PLUGIN FOR JQPLOT +// Handle ticks the piwik way... +// ------------------------------------------------------------ + +(function ($) { + + $.jqplot.PiwikTicks = function (options) { + // canvas for the grid + this.piwikTicksCanvas = null; + // canvas for the highlight + this.piwikHighlightCanvas = null; + // renderer used to draw the marker of the highlighted point + this.markerRenderer = new $.jqplot.MarkerRenderer({ + shadow: false + }); + // the x tick the mouse is over + this.currentXTick = false; + // show the highlight around markers + this.showHighlight = false; + // show the grid + this.showGrid = false; + // show the ticks + this.showTicks = false; + + $.extend(true, this, options); + }; + + $.jqplot.PiwikTicks.init = function (target, data, opts) { + // add plugin as an attribute to the plot + var options = opts || {}; + this.plugins.piwikTicks = new $.jqplot.PiwikTicks(options.piwikTicks); + + if (typeof $.jqplot.PiwikTicks.init.eventsBound == 'undefined') { + $.jqplot.PiwikTicks.init.eventsBound = true; + $.jqplot.eventListenerHooks.push(['jqplotMouseMove', handleMouseMove]); + $.jqplot.eventListenerHooks.push(['jqplotMouseLeave', handleMouseLeave]); + } + }; + + // draw the grid + // called with context of plot + $.jqplot.PiwikTicks.postDraw = function () { + var c = this.plugins.piwikTicks; + + // highligh canvas + if (c.showHighlight) { + c.piwikHighlightCanvas = new $.jqplot.GenericCanvas(); + + this.eventCanvas._elem.before(c.piwikHighlightCanvas.createElement( + this._gridPadding, 'jqplot-piwik-highlight-canvas', this._plotDimensions, this)); + c.piwikHighlightCanvas.setContext(); + } + + // grid canvas + if (c.showTicks) { + var dimensions = this._plotDimensions; + dimensions.height += 6; + c.piwikTicksCanvas = new $.jqplot.GenericCanvas(); + this.series[0].shadowCanvas._elem.before(c.piwikTicksCanvas.createElement( + this._gridPadding, 'jqplot-piwik-ticks-canvas', dimensions, this)); + c.piwikTicksCanvas.setContext(); + + var ctx = c.piwikTicksCanvas._ctx; + + var ticks = this.data[0]; + var totalWidth = ctx.canvas.width; + var tickWidth = totalWidth / ticks.length; + + var xaxisLabels = this.axes.xaxis.ticks; + + for (var i = 0; i < ticks.length; i++) { + var pos = Math.round(i * tickWidth + tickWidth / 2); + var full = xaxisLabels[i] && xaxisLabels[i] != ' '; + drawLine(ctx, pos, full, c.showGrid, c.tickColor); + } + } + }; + + $.jqplot.preInitHooks.push($.jqplot.PiwikTicks.init); + $.jqplot.postDrawHooks.push($.jqplot.PiwikTicks.postDraw); + + // draw a 1px line + function drawLine(ctx, x, full, showGrid, color) { + ctx.save(); + ctx.strokeStyle = color; + + ctx.beginPath(); + ctx.lineWidth = 2; + var top = 0; + if ((full && !showGrid) || !full) { + top = ctx.canvas.height - 5; + } + ctx.moveTo(x, top); + ctx.lineTo(x, full ? ctx.canvas.height : ctx.canvas.height - 2); + ctx.stroke(); + + // canvas renders line slightly too large + ctx.clearRect(x, 0, x + 1, ctx.canvas.height); + + ctx.restore(); + } + + // trigger the event jqplotPiwikTickOver when the mouse enters + // and new tick. this is used for tooltips. + function handleMouseMove(ev, gridpos, datapos, neighbor, plot) { + var c = plot.plugins.piwikTicks; + + var tick = Math.floor(datapos.xaxis + 0.5) - 1; + if (tick !== c.currentXTick) { + c.currentXTick = tick; + plot.target.trigger('jqplotPiwikTickOver', [tick]); + highlight(plot, tick); + } + } + + function handleMouseLeave(ev, gridpos, datapos, neighbor, plot) { + unHighlight(plot); + plot.plugins.piwikTicks.currentXTick = false; + } + + // highlight a marker + function highlight(plot, tick) { + var c = plot.plugins.piwikTicks; + + if (!c.showHighlight) { + return; + } + + unHighlight(plot); + + for (var i = 0; i < plot.series.length; i++) { + var series = plot.series[i]; + var seriesMarkerRenderer = series.markerRenderer; + + c.markerRenderer.style = seriesMarkerRenderer.style; + c.markerRenderer.size = 8; + + var rgba = $.jqplot.getColorComponents(seriesMarkerRenderer.color); + var newrgb = [rgba[0], rgba[1], rgba[2]]; + var alpha = rgba[3]; + c.markerRenderer.color = 'rgba(' + newrgb[0] + ',' + newrgb[1] + ',' + newrgb[2] + ',' + alpha + ')'; + c.markerRenderer.init(); + + var position = series.gridData[tick]; + if (typeof position !== 'undefined') { + c.markerRenderer.draw(position[0], position[1], c.piwikHighlightCanvas._ctx); + } + } + } + + function unHighlight(plot) { + var canvas = plot.plugins.piwikTicks.piwikHighlightCanvas; + if (canvas !== null) { + var ctx = canvas._ctx; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } + } + +})(jQuery); + +// ------------------------------------------------------------ +// LEGEND PLUGIN FOR JQPLOT +// Render legend below the graph +// ------------------------------------------------------------ + +(function ($) { + + $.jqplot.CanvasLegendRenderer = function (options) { + // is it a legend for a single metric only (pie chart)? + this.singleMetric = false; + // render the legend? + this.show = false; + + $.extend(true, this, options); + }; + + $.jqplot.CanvasLegendRenderer.init = function (target, data, opts) { + // add plugin as an attribute to the plot + var options = opts || {}; + this.plugins.canvasLegend = new $.jqplot.CanvasLegendRenderer(options.canvasLegend); + + if (this.plugins.canvasLegend.show) { + options.gridPadding = $.extend({}, options.gridPadding, { + top: Math.max((options.gridPadding && options.gridPadding.top) || 0, 21) + }); + } + }; + + // render the legend + $.jqplot.CanvasLegendRenderer.postDraw = function () { + var plot = this; + var legend = plot.plugins.canvasLegend; + + if (!legend.show) { + return; + } + + var $target = $(plot.targetId); + var $dataTable = $target.closest('.dataTable'); + var $legendFooter = getOrCreateLegendFooter($dataTable); + var $legendContainer = $legendFooter.find('.jqplot-legend-items'); + if (!$legendContainer.length) { + return; + } + + $legendFooter.removeClass('has-legend'); + $legendContainer.empty(); + + var series = plot.legend && plot.legend._series ? plot.legend._series : []; + for (var i = 0; i < series.length; i++) { + var s = series[i]; + var label = ''; + if (legend.labels && legend.labels[i]) { + label = legend.labels[i]; + } else if (typeof s.label !== 'undefined' && s.label !== null) { + label = s.label.toString(); + } + + if (!label) { + continue; + } + + var color = s.color; + if (legend.singleMetric && legend.singleMetricColor) { + color = legend.singleMetricColor; + } + + $('
', {'class': 'jqplot-legend-item'}) + .append( + $('', {'class': 'jqplot-legend-swatch'}).css('background-color', color), + $('', { + 'class': 'jqplot-legend-label', + 'data-original-label': label + }).text(label) + ) + .appendTo($legendContainer); + } + + if ($legendContainer.children().length) { + $legendFooter.addClass('has-legend'); + limitLegendRows($legendContainer, MAX_FOOTER_LEGEND_ROWS); + } + + updateLegendFooterStacking($legendFooter); + }; + + $.jqplot.preInitHooks.push($.jqplot.CanvasLegendRenderer.init); + $.jqplot.postDrawHooks.push($.jqplot.CanvasLegendRenderer.postDraw); + +})(jQuery); + +// ------------------------------------------------------------ +// SERIES PICKER +// ------------------------------------------------------------ + +(function ($, require) { + $.jqplot.preInitHooks.push(function (target, data, options) { + // create the series picker + var dataTable = $('#' + target).closest('.dataTable').data('uiControlObject'); + if (!dataTable) { // if we're not dealing w/ a DataTable visualization, don't add the series picker + return; + } + + var SeriesPicker = require('piwik/DataTableVisualizations/Widgets').SeriesPicker; + var seriesPicker = new SeriesPicker(dataTable); + + // render the "Choose metrics" button variant and place it in the legend + // footer instead of above the chart + seriesPicker.useChooseMetricsButton = true; + + // handle placeSeriesPicker event + var plot = this; + $(seriesPicker).bind('placeSeriesPicker', function () { + var $dataTable = $(plot.targetId).closest('.dataTable'); + var $legendFooter = getOrCreateLegendFooter($dataTable); + var $pickerSlot = $legendFooter.find('.jqplot-legend-picker'); + if (!$pickerSlot.length) { + $pickerSlot = $('
').prependTo($legendFooter); + } + // replace any previously rendered picker so redraws don't stack duplicates + $pickerSlot.empty().append(this.domElem); + $legendFooter.addClass('has-picker'); + updateLegendFooterStacking($legendFooter); + }); + + // handle seriesPicked event + $(seriesPicker).bind('seriesPicked', function (e, columns, rows) { + dataTable.changeSeries(columns, rows); + }); + + this.plugins.seriesPicker = seriesPicker; + }); + + $.jqplot.postDrawHooks.push(function () { + this.plugins.seriesPicker.init(); + + // The legend footer renders before this hook, so its row limit was computed + // without the "Choose metrics" button. Now that the button is placed and + // mounted (and has taken its share of the footer width), recompute the limit + // so the legend items wrap against the real available width. + applyFooterLegendRowLimit($(this.targetId).closest('.dataTable')); + }); +})(jQuery, require); + +// ------------------------------------------------------------ +// PIE CHART LEGEND PLUGIN FOR JQPLOT +// Render legend inside the pie graph +// ------------------------------------------------------------ + +(function ($) { + + $.jqplot.PieLegend = function (options) { + // canvas for the legend + this.pieLegendCanvas = null; + // render the legend? + this.show = false; + + $.extend(true, this, options); + }; + + $.jqplot.PieLegend.init = function (target, data, opts) { + // add plugin as an attribute to the plot + var options = opts || {}; + this.plugins.pieLegend = new $.jqplot.PieLegend(options.pieLegend); + }; + + // render the legend + $.jqplot.PieLegend.postDraw = function () { + var plot = this; + var legend = plot.plugins.pieLegend; + + if (!legend.show) { + return; + } + + var series = plot.series[0]; + var angles = series._sliceAngles; + var radius = series._diameter / 2; + var center = series._center; + var colors = this.seriesColors; + + // concentric line angles + var lineAngles = []; + for (var i = 0; i < angles.length; i++) { + lineAngles.push((angles[i][0] + angles[i][1]) / 2 + Math.PI / 2); + } + + // labels + var labels = []; + var data = series._plotData; + for (i = 0; i < data.length; i++) { + labels.push(data[i][0]); + } + + // initialize legend canvas + legend.pieLegendCanvas = new $.jqplot.GenericCanvas(); + plot.series[0].canvas._elem.before(legend.pieLegendCanvas.createElement( + plot._gridPadding, 'jqplot-pie-legend-canvas', plot._plotDimensions, plot)); + legend.pieLegendCanvas.setContext(); + + var ctx = legend.pieLegendCanvas._ctx; + ctx.save(); + + ctx.font = '11px ' + require('piwik/UI').getLabelFontFamily() + + // render labels + var height = legend.pieLegendCanvas._elem.height(); + var x1, x2, y1, y2, lastY2 = false, right, lastRight = false; + for (i = 0; i < labels.length; i++) { + var label = labels[i]; + + ctx.strokeStyle = colors[i % colors.length]; + ctx.lineCap = 'round'; + ctx.lineWidth = 1; + + // concentric line + x1 = center[0] + Math.sin(lineAngles[i]) * (radius); + y1 = center[1] - Math.cos(lineAngles[i]) * (radius); + + x2 = center[0] + Math.sin(lineAngles[i]) * (radius + 7); + y2 = center[1] - Math.cos(lineAngles[i]) * (radius + 7); + + right = x2 > center[0]; + + // move close labels + if (lastY2 !== false && lastRight == right && ( + (right && y2 - lastY2 < 13) || + (!right && lastY2 - y2 < 13))) { + + if (x1 > center[0]) { + // move down if the label is in the right half of the graph + y2 = lastY2 + 13; + } else { + // move up if in left halt + y2 = lastY2 - 13; + } + } + + if (y2 < 4 || y2 + 4 > height) { + continue; + } + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + + ctx.closePath(); + ctx.stroke(); + + // horizontal line + ctx.beginPath(); + ctx.moveTo(x2, y2); + if (right) { + ctx.lineTo(x2 + 5, y2); + } else { + ctx.lineTo(x2 - 5, y2); + } + + ctx.closePath(); + ctx.stroke(); + + lastY2 = y2; + lastRight = right; + + // text + if (right) { + var x = x2 + 9; + } else { + var x = x2 - 9 - ctx.measureText(label).width; + } + + ctx.fillStyle = legend.labelColor; + ctx.fillText(label, x, y2 + 3); + } + + ctx.restore(); + }; + + $.jqplot.preInitHooks.push($.jqplot.PieLegend.init); + $.jqplot.postDrawHooks.push($.jqplot.PieLegend.postDraw); + +})(jQuery, require); + +// ------------------------------------------------------------ +// MATOMO INCOMPLETE DATA INDICATOR FOR JQPLOT +// Show a dashed line to the last point to indicate incomplete +// ------------------------------------------------------------ + +(function ($) { + + $.jqplot.LineRenderer.prototype.draw = function(ctx, gd, options, plot) { + var i; + // get a copy of the options, so we don't modify the original object. + var opts = $.extend(true, {}, options); + var shadow = (opts.shadow != undefined) ? opts.shadow : this.shadow; + var showLine = (opts.showLine != undefined) ? opts.showLine : this.showLine; + var fill = (opts.fill != undefined) ? opts.fill : this.fill; + var fillAndStroke = (opts.fillAndStroke != undefined) ? opts.fillAndStroke : this.fillAndStroke; + var xmin, ymin, xmax, ymax; + + // Only change in this overridden method, to pass the option to the renderers + if (plot.options.hasOwnProperty('dataStates')) { + opts.dataStates = plot.options.dataStates; + } + + if (!Array.isArray(opts.dataStates)) { + opts.dataStates = []; + } + + ctx.save(); + if (gd.length) { + if (showLine) { + // if we fill, we'll have to add points to close the curve. + if (fill) { + if (this.fillToZero) { + // have to break line up into shapes at axis crossings + var negativeColor = this.negativeColor; + if (! this.useNegativeColors) { + negativeColor = opts.fillStyle; + } + var isnegative = false; + var posfs = opts.fillStyle; + + // if stoking line as well as filling, get a copy of line data. + if (fillAndStroke) { + var fasgd = gd.slice(0); + } + // if not stacked, fill down to axis + if (this.index == 0 || !this._stack) { + + var tempgd = []; + var pd = (this.renderer.smooth) ? this.renderer._smoothedPlotData : this._plotData; + this._areaPoints = []; + var pyzero = this._yaxis.series_u2p(this.fillToValue); + var pxzero = this._xaxis.series_u2p(this.fillToValue); + + opts.closePath = true; + + if (this.fillAxis == 'y') { + tempgd.push([gd[0][0], pyzero]); + this._areaPoints.push([gd[0][0], pyzero]); + + for (var i=0; i0; i--) { + gd.push(prev[i-1]); + // this._areaPoints.push(prev[i-1]); + } + if (shadow) { + this.renderer.shadowRenderer.draw(ctx, gd, opts); + } + this._areaPoints = gd; + this.renderer.shapeRenderer.draw(ctx, gd, opts); + } + } + ///////////////////////// + // Not filled to zero + //////////////////////// + else { + // if stoking line as well as filling, get a copy of line data. + if (fillAndStroke) { + var fasgd = gd.slice(0); + } + // if not stacked, fill down to axis + if (this.index == 0 || !this._stack) { + // var gridymin = this._yaxis.series_u2p(this._yaxis.min) - this.gridBorderWidth / 2; + var gridymin = ctx.canvas.height; + // IE doesn't return new length on unshift + gd.unshift([gd[0][0], gridymin]); + var len = gd.length; + gd.push([gd[len - 1][0], gridymin]); + } + // if stacked, fill to line below + else { + var prev = this._prevGridData; + for (var i=prev.length; i>0; i--) { + gd.push(prev[i-1]); + } + } + this._areaPoints = gd; + + if (shadow) { + this.renderer.shadowRenderer.draw(ctx, gd, opts); + } + + this.renderer.shapeRenderer.draw(ctx, gd, opts); + } + if (fillAndStroke) { + var fasopts = $.extend(true, {}, opts, {fill:false, closePath:false}); + this.renderer.shapeRenderer.draw(ctx, fasgd, fasopts); + // now draw the markers + if (this.markerRenderer.show) { + if (this.renderer.smooth) { + fasgd = this.gridData; + } + for (i=0; i p[0] || xmin == null) { + xmin = p[0]; + } + if (ymax < p[1] || ymax == null) { + ymax = p[1]; + } + if (xmax < p[0] || xmax == null) { + xmax = p[0]; + } + if (ymin > p[1] || ymin == null) { + ymin = p[1]; + } + } + + if (this.type === 'line' && this.renderer.bands.show) { + ymax = this._yaxis.series_u2p(this.renderer.bands._min); + ymin = this._yaxis.series_u2p(this.renderer.bands._max); + } + + this._boundingBox = [[xmin, ymax], [xmax, ymin]]; + + // now draw the markers + if (this.markerRenderer.show && !fill) { + if (this.renderer.smooth) { + gd = this.gridData; + } + for (i = 0; i < gd.length; i++) { + if (gd[i][0] === null || gd[i][1] === null) { + continue; + } + + const markerOptions = opts.markerOptions || {}; + + markerOptions.isIncomplete = opts.dataStates[i] && opts.dataStates[i] !== 'complete'; + markerOptions.incompleteFillColor = plot.grid.background; + + this.markerRenderer.draw(gd[i][0], gd[i][1], ctx, markerOptions); + } + } + } + + ctx.restore(); + }; + + $.jqplot.ShapeRenderer.prototype.draw = function(ctx, points, options) { + ctx.save(); + var opts = (options != null) ? options : {}; + var fill = (opts.fill != null) ? opts.fill : this.fill; + var closePath = (opts.closePath != null) ? opts.closePath : this.closePath; + var fillRect = (opts.fillRect != null) ? opts.fillRect : this.fillRect; + var strokeRect = (opts.strokeRect != null) ? opts.strokeRect : this.strokeRect; + var clearRect = (opts.clearRect != null) ? opts.clearRect : this.clearRect; + var isarc = (opts.isarc != null) ? opts.isarc : this.isarc; + var linePattern = (opts.linePattern != null) ? opts.linePattern : this.linePattern; + var ctxPattern = $.jqplot.LinePattern(ctx, linePattern); + ctx.lineWidth = opts.lineWidth || this.lineWidth; + ctx.lineJoin = opts.lineJoin || this.lineJoin; + ctx.lineCap = opts.lineCap || this.lineCap; + ctx.strokeStyle = (opts.strokeStyle || opts.color) || this.strokeStyle; + ctx.fillStyle = opts.fillStyle || this.fillStyle; + ctx.beginPath(); + + let dataStates = []; + + if (!closePath && !fill && Array.isArray(opts.dataStates)) { + // only do the incomplete visualization for line charts + dataStates = opts.dataStates; + } + + if (isarc) { + ctx.arc(points[0], points[1], points[2], points[3], points[4], true); + + if (closePath) { + ctx.closePath(); + } + + if (fill) { + ctx.fill(); + } + else { + ctx.stroke(); + } + + if (opts.isIncomplete && opts.incompleteFillColor) { + // graph lines reach into the point + // render inner point filled with background color to avoid showing them + ctx.beginPath(); + ctx.arc(points[0], points[1], points[2] / 8, points[3], points[4], true); + ctx.strokeStyle = opts.incompleteFillColor; + ctx.stroke(); + ctx.closePath(); + } + + ctx.restore(); + return; + } + else if (clearRect) { + ctx.clearRect(points[0], points[1], points[2], points[3]); + ctx.restore(); + return; + } + else if (fillRect || strokeRect) { + if (fillRect) { + ctx.fillRect(points[0], points[1], points[2], points[3]); + } + if (strokeRect) { + ctx.strokeRect(points[0], points[1], points[2], points[3]); + ctx.restore(); + return; + } + } + + if (!points || !points.length) { + return; + } + + let move = true; + + for (let i = 0; i < points.length; i++) { + // skip to the first non-null point and move to it. + if (null === points[i][0] && null === points[i][1]) { + continue; + } + + if (move) { + move = false; + + ctxPattern.moveTo(points[i][0], points[i][1]); + continue; + } + + // draw line to current point or skip if incomplete data point + if (dataStates[i] && 'complete' !== dataStates[i]) { + ctxPattern.moveTo(points[i][0], points[i][1]); + } else { + ctxPattern.lineTo(points[i][0], points[i][1]); + } + } + + if (closePath) { + ctxPattern.closePath(); + } + + if (fill) { + ctx.fill(); + } else { + ctx.stroke(); + } + + // draw dashed lines for incomplete data points + ctx.beginPath(); + ctx.setLineDash([3, 3]); + + move = true; + + for (let i = 0; i < points.length; i++) { + // skip to the first non-null point and move to it. + if (points[i][0] === null && points[i][1] === null) { + continue; + } + + if (move) { + move = false; + + ctxPattern.moveTo(points[i][0], points[i][1]); + continue; + } + + // draw dashed line to current point or skip if not incomplete data point + if (!dataStates[i] || 'complete' === dataStates[i]) { + ctxPattern.moveTo(points[i][0], points[i][1]); + } else { + ctxPattern.lineTo(points[i][0], points[i][1]); + } + } + + ctx.stroke(); + ctx.closePath(); + ctx.restore(); + }; + + // Only overriding this method to prevent drawing the shadow for the last line segment + $.jqplot.ShadowRenderer.prototype.draw = function(ctx, points, options) { + ctx.save(); + var opts = (options != null) ? options : {}; + var fill = (opts.fill != null) ? opts.fill : this.fill; + var fillRect = (opts.fillRect != null) ? opts.fillRect : this.fillRect; + var closePath = (opts.closePath != null) ? opts.closePath : this.closePath; + var offset = (opts.offset != null) ? opts.offset : this.offset; + var alpha = (opts.alpha != null) ? opts.alpha : this.alpha; + var depth = (opts.depth != null) ? opts.depth : this.depth; + var isarc = (opts.isarc != null) ? opts.isarc : this.isarc; + var linePattern = (opts.linePattern != null) ? opts.linePattern : this.linePattern; + ctx.lineWidth = (opts.lineWidth != null) ? opts.lineWidth : this.lineWidth; + ctx.lineJoin = (opts.lineJoin != null) ? opts.lineJoin : this.lineJoin; + ctx.lineCap = (opts.lineCap != null) ? opts.lineCap : this.lineCap; + ctx.strokeStyle = opts.strokeStyle || this.strokeStyle || 'rgba(0,0,0,'+alpha+')'; + ctx.fillStyle = opts.fillStyle || this.fillStyle || 'rgba(0,0,0,'+alpha+')'; + + let dataStates = []; + + if (!closePath && !fill && Array.isArray(opts.dataStates)) { + // only do the incomplete visualization for line charts + dataStates = opts.dataStates; + } + + for (let j= 0; j < depth; j++) { + const ctxPattern = $.jqplot.LinePattern(ctx, linePattern); + + ctx.translate(Math.cos(this.angle*Math.PI/180)*offset, Math.sin(this.angle*Math.PI/180)*offset); + ctxPattern.beginPath(); + + if (isarc) { + ctx.arc(points[0], points[1], points[2], points[3], points[4], true); + } + else if (fillRect) { + ctx.fillRect(points[0], points[1], points[2], points[3]); + } + else if (points && points.length) { + let move = true; + + for (let i = 0; i < points.length; i++) { + // skip to the first non-null point and move to it. + if (points[i][0] === null && points[i][1] === null) { + continue; + } + + if (move) { + move = false; + + ctxPattern.moveTo(points[i][0], points[i][1]); + continue; + } + + // draw shadow line to current point or skip if incomplete data point + if (dataStates[i] && 'complete' !== dataStates[i]) { + ctxPattern.moveTo(points[i][0], points[i][1]); + } else { + ctxPattern.lineTo(points[i][0], points[i][1]); + } + } + } + + if (closePath) { + ctxPattern.closePath(); + } + + if (fill) { + ctx.fill(); + } + else { + ctx.stroke(); + } + } + + ctx.restore(); + }; +})(jQuery); diff --git a/plugins/CoreVisualizations/javascripts/jqplotBarGraph.js b/plugins/CoreVisualizations/javascripts/jqplotBarGraph.js new file mode 100644 index 0000000000000000000000000000000000000000..839776358b3098adbbd3a79674a6caad5d4db816 --- /dev/null +++ b/plugins/CoreVisualizations/javascripts/jqplotBarGraph.js @@ -0,0 +1,98 @@ +/** + * Matomo - free/libre analytics platform + * + * DataTable UI class for JqplotGraph/Bar. + * + * @link http://www.jqplot.com + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'), + JqplotGraphDataTable = exports.JqplotGraphDataTable; + + exports.JqplotBarGraphDataTable = function (element) { + JqplotGraphDataTable.call(this, element); + }; + + $.extend(exports.JqplotBarGraphDataTable.prototype, JqplotGraphDataTable.prototype, { + + _setJqplotParameters: function (params) { + JqplotGraphDataTable.prototype._setJqplotParameters.call(this, params); + + var barMargin = this.data[0].length > 10 ? 2 : 10; + var minBarWidth = 10; + + this.jqplotParams.seriesDefaults = { + renderer: $.jqplot.BarRenderer, + rendererOptions: { + shadowOffset: 1, + shadowDepth: 2, + shadowAlpha: .2, + fillToZero: true, + barMargin: barMargin + } + }; + + this.jqplotParams.piwikTicks = { + showTicks: true, + showGrid: false, + showHighlight: false, + tickColor: this.tickColor + }; + + this.jqplotParams.axes.xaxis.renderer = $.jqplot.CategoryAxisRenderer; + this.jqplotParams.axes.xaxis.tickOptions = { + showGridline: false + }; + + this.jqplotParams.canvasLegend = { + show: true + }; + + var comparisonService = window.CoreHome.ComparisonsStoreInstance; + if (comparisonService.isComparing()) { + var seriesCount = this.jqplotParams.series.length; + var dataCount = this.data[0].length; + + var totalBars = seriesCount * dataCount; + var totalMinWidth = (minBarWidth + barMargin) * totalBars + 50; + + this.$element.find('.piwik-graph').css('min-width', totalMinWidth + 'px'); + this.$element.css('overflow-x', 'scroll'); + this.$element.addClass('isComparingBarViz'); + } else { + this.$element.removeClass('isComparingBarViz'); + } + }, + + _bindEvents: function () { + this.setYTicks(); + JqplotGraphDataTable.prototype._bindEvents.call(this); + }, + + _showDataPointTooltip: function (element, seriesIndex, valueIndex) { + var value = this.formatY(this.data[seriesIndex][valueIndex], seriesIndex); + var series = this.jqplotParams.series[seriesIndex].label; + + var percentage = ''; + if (typeof this.tooltip.percentages != 'undefined') { + percentage = this.tooltip.percentages[seriesIndex][valueIndex]; + percentage = ' (' + NumberFormatter.formatPercent(percentage) + ')'; + } + + var label = this.jqplotParams.axes.xaxis.labels[valueIndex]; + var text = '' + value + ' ' + piwikHelper.htmlEntities(series) + percentage; + $(element).tooltip({ + track: true, + items: '*', + content: '

' + label + '

' + text, + show: false, + hide: false + }).trigger('mouseover'); + } + }); + +})(jQuery, require); diff --git a/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js b/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js new file mode 100644 index 0000000000000000000000000000000000000000..bd028e0bbea9b83856dcc5900044b8ecb7601092 --- /dev/null +++ b/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js @@ -0,0 +1,212 @@ +/** + * Matomo - free/libre analytics platform + * + * DataTable UI class for JqplotGraph/Evolution. + * + * @link http://www.jqplot.com + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + const exports = require('piwik/UI'); + const JqplotGraphDataTable = exports.JqplotGraphDataTable; + const JqplotGraphDataTablePrototype = JqplotGraphDataTable.prototype; + + exports.JqplotEvolutionGraphDataTable = function (element) { + JqplotGraphDataTable.call(this, element); + }; + + $.extend(exports.JqplotEvolutionGraphDataTable.prototype, JqplotGraphDataTablePrototype, { + _setJqplotParameters: function (params) { + JqplotGraphDataTablePrototype._setJqplotParameters.call(this, params); + + const defaultParams = { + axes: { + xaxis: { + pad: 1.0, + renderer: $.jqplot.CategoryAxisRenderer, + tickOptions: { + showGridline: false + } + } + }, + piwikTicks: { + showTicks: true, + showGrid: true, + showHighlight: true, + tickColor: this.tickColor + } + }; + + if (this.props.show_line_graph) { + defaultParams.seriesDefaults = { + lineWidth: 2, + shadow: false, + markerOptions: { + show: false, + style: "filledCircle", + size: 6, + shadow: false + } + }; + } else { + defaultParams.seriesDefaults = { + renderer: $.jqplot.BarRenderer, + rendererOptions: { + shadowOffset: 1, + shadowDepth: 2, + shadowAlpha: .2, + fillToZero: true, + barMargin: this.data[0].length > 10 ? 2 : 10 + } + }; + } + + const overrideParams = { + legend: { + show: false + }, + canvasLegend: { + show: true + } + }; + + this.jqplotParams = $.extend(true, {}, defaultParams, this.jqplotParams, overrideParams); + }, + + _bindEvents: function () { + JqplotGraphDataTablePrototype._bindEvents.call(this); + + const self = this; + + $('#' + this.targetDivId) + .on('jqplotMouseLeave', function (e, s, i, d) { + $(this).css('cursor', 'default'); + JqplotGraphDataTablePrototype._destroyDataPointTooltip.call(this, $(this)); + }) + .on('jqplotClick', function (e, s, i, d) { + if (!self.jqplotParams.axes.xaxis.onclick || + !self._plot.plugins.piwikTicks || + typeof self._plot.plugins.piwikTicks.currentXTick !== 'number' + ) { + return; + } + + const tick = self._plot.plugins.piwikTicks.currentXTick; + + if (typeof self.jqplotParams.axes.xaxis.onclick[tick] !== 'string') { + return; + } + + const url = self.jqplotParams.axes.xaxis.onclick[tick]; + + broadcast.propagateNewPage(url); + }) + .on('jqplotPiwikTickOver', function (e, tick) { + const dataByAxis = {}; + + for (let d = 0; d < self.data.length; ++d) { + const valueUnformatted = self.data[d][tick]; + + if (typeof valueUnformatted === 'undefined' || valueUnformatted === null) { + continue; + } + + const axis = self.jqplotParams.series[d]._xaxis || 'xaxis'; + + if (!dataByAxis[axis]) { + dataByAxis[axis] = []; + } + + const value = self.formatY(valueUnformatted, d); + const series = self.jqplotParams.series[d].label; + const seriesColor = self.jqplotParams.seriesColors[d]; + + dataByAxis[axis].push( + `` + + `${value} ${piwikHelper.htmlEntities(series)}` + ); + } + + let xAxisCount = 0; + + Object.keys(self.jqplotParams.axes).forEach(function (axis) { + if (!axis.startsWith('x')) { + return; + } + + ++xAxisCount; + }); + + let content = ''; + + for (let i = 0; i < xAxisCount; ++i) { + const axisName = i === 0 ? 'xaxis' : `x${i + 1}axis`; + + if (!dataByAxis[axisName] || !dataByAxis[axisName].length) { + continue; + } + + let label; + + if (typeof self.jqplotParams.axes[axisName].labels !== 'undefined') { + label = self.jqplotParams.axes[axisName].labels[tick]; + } else { + label = self.jqplotParams.axes[axisName].ticks[tick]; + } + + if (typeof label === 'undefined') { + // sanity check + continue; + } + + content += ` +

${piwikHelper.htmlEntities(label)}

+ ${dataByAxis[axisName].join('
')} + `; + } + + switch (self.jqplotParams.dataStates[tick]) { + case 'incomplete': + content += `
(${self._lang.incompletePeriod})`; + break; + + case 'invalidated': + content += `
(${self._lang.invalidatedPeriod})`; + break; + } + + $(this).tooltip({ + track: true, + items: 'div', + content: content, + show: false, + hide: false + }).trigger('mouseover'); + + if (typeof self.jqplotParams.axes.xaxis.onclick !== 'undefined' && + typeof self.jqplotParams.axes.xaxis.onclick[tick] === 'string' + ) { + $(this).css('cursor', 'pointer'); + } + }); + + this.setYTicks(); + }, + + _destroyDataPointTooltip: function () { + // do nothing, tooltips are destroyed in the jqplotMouseLeave event + }, + + render: function () { + JqplotGraphDataTablePrototype.render.call(this); + + if (!initializeSparklines) { + return; + } + + initializeSparklines(); + } + }); +})(jQuery, require); diff --git a/plugins/CoreVisualizations/javascripts/jqplotPieGraph.js b/plugins/CoreVisualizations/javascripts/jqplotPieGraph.js new file mode 100644 index 0000000000000000000000000000000000000000..201c4791119cfdd581a8da988f7f041ecd759964 --- /dev/null +++ b/plugins/CoreVisualizations/javascripts/jqplotPieGraph.js @@ -0,0 +1,81 @@ +/** + * Matomo - free/libre analytics platform + * + * DataTable UI class for JqplotGraph/Pie. + * + * @link http://www.jqplot.com + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'), + JqplotGraphDataTable = exports.JqplotGraphDataTable; + + exports.JqplotPieGraphDataTable = function (element) { + JqplotGraphDataTable.call(this, element); + }; + + $.extend(exports.JqplotPieGraphDataTable.prototype, JqplotGraphDataTable.prototype, { + + _setJqplotParameters: function (params) { + JqplotGraphDataTable.prototype._setJqplotParameters.call(this, params); + + this.jqplotParams.seriesDefaults = { + renderer: $.jqplot.PieRenderer, + rendererOptions: { + shadow: false, + showDataLabels: false, + sliceMargin: 1, + startAngle: 35 + } + }; + + this.jqplotParams.piwikTicks = { + showTicks: false, + showGrid: false, + showHighlight: false, + tickColor: this.tickColor + }; + + this.jqplotParams.legend = { + show: false + }; + this.jqplotParams.pieLegend = { + show: true, + labelColor: this.singleMetricColor + }; + this.jqplotParams.canvasLegend = { + show: true, + singleMetric: true, + singleMetricColor: this.singleMetricColor + }; + + // pie charts have a different data format + if (!(this.data[0][0] instanceof Array)) { // check if already in different format + for (var i = 0; i < this.data[0].length; i++) { + this.data[0][i] = [this.jqplotParams.axes.xaxis.ticks[i], this.data[0][i]]; + } + } + }, + + _showDataPointTooltip: function (element, seriesIndex, valueIndex) { + var value = this.formatY(this.data[0][valueIndex][1], 0); + var series = piwikHelper.htmlEntities(this.jqplotParams.series[0].label); + var percentage = this.tooltip.percentages[0][valueIndex]; + + var label = this.data[0][valueIndex][0]; + + var text = '' + NumberFormatter.formatPercent(percentage) + ' (' + value + ' ' + series + ')'; + $(element).tooltip({ + track: true, + items: '*', + content: '

' + label + '

' + text, + show: false, + hide: false + }).trigger('mouseover'); + } + }); + +})(jQuery, require); diff --git a/plugins/CoreVisualizations/javascripts/seriesPicker.js b/plugins/CoreVisualizations/javascripts/seriesPicker.js new file mode 100644 index 0000000000000000000000000000000000000000..c570f74bb39521c72596036dd9efbd22ea5ea3e5 --- /dev/null +++ b/plugins/CoreVisualizations/javascripts/seriesPicker.js @@ -0,0 +1,177 @@ +/** + * Matomo - free/libre analytics platform + * + * Series Picker control addition for DataTable visualizations. + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + /** + * This class creates and manages the Series Picker for certain DataTable visualizations. + * + * To add the series picker to your DataTable visualization, create a SeriesPicker instance + * and after your visualization has been rendered, call the 'init' method. + * + * To customize SeriesPicker placement and behavior, you can bind callbacks to the following + * events before calling 'init': + * + * 'placeSeriesPicker': Triggered after the DOM element for the series picker link is created. + * You must use this event to add the link to the dataTable. YOu can also + * use this event to position the link however you want. + * + * Callback Signature: function () {} + * + * 'seriesPicked': Triggered when the user selects one or more columns/rows. + * + * Callback Signature: function (eventInfo, columns, rows) {} + * + * Events are triggered via jQuery, so you bind callbacks to them like this: + * + * var picker = new SeriesPicker(dataTable); + * $(picker).bind('placeSeriesPicker', function () { + * $(this.domElem).doSomething(...); + * }); + * + * @param {dataTable} dataTable The dataTable instance to add a series picker to. + * @constructor + * @deprecated use the SeriesPicker Vue component instead + */ + var SeriesPicker = function (dataTable) { + this.domElem = null; + this.app = null; + this.dataTableId = dataTable.workingDivId; + + // the columns that can be selected + this.selectableColumns = dataTable.props.selectable_columns; + + // the rows that can be selected + this.selectableRows = dataTable.props.selectable_rows; + + // render the picker? + this.show = !! dataTable.props.show_series_picker + && (this.selectableColumns || this.selectableRows); + + // can multiple rows we selected? + this.multiSelect = !! dataTable.props.allow_multi_select_series_picker; + + // render the new "Choose metrics" button variant instead of the legacy "+" popover. + // Opted into by jqplot graphs. + this.useChooseMetricsButton = false; + }; + + SeriesPicker.prototype = { + + /** + * Initializes the series picker by creating the element. Must be called when + * the datatable the picker is being attached to is ready for it to be drawn. + */ + init: function () { + if (!this.show) { + return; + } + + var self = this; + + var selectedColumns = this.selectableColumns + .filter(isItemDisplayed) + .map(function (columnConfig) { + return columnConfig.column; + }); + + var selectedRows = this.selectableRows + .filter(isItemDisplayed) + .map(function (rowConfig) { + return rowConfig.matcher; + }); + + // initialize dom element + this.domElem = $('
'); + + $(this).trigger('placeSeriesPicker'); + + var createVNode = Vue.createVNode; + var createVueApp = CoreHome.createVueApp; + var PickerComponent = self.useChooseMetricsButton + ? CoreVisualizations.MetricsPicker + : CoreVisualizations.SeriesPicker; + + this.app = createVueApp({ + render: function () { + return createVNode(PickerComponent, { + multiselect: self.multiSelect, + selectableColumns: self.selectableColumns, + selectableRows: self.selectableRows, + selectedColumns: selectedColumns, + selectedRows: selectedRows, + onSelect: function selectionChanged(event) { + var columns = event.columns, rows = event.rows; + if (columns.length === 0 && rows.length === 0) { + return; + } + + rows = rows.map(encodeURIComponent); + + $(self).trigger('seriesPicked', [columns, rows]); + + // inform dashboard widget about changed parameters (to be restored on reload) + var UI = require('piwik/UI'); + var params = { + columns: columns, + columns_to_display: columns, + rows: rows, + rows_to_display: rows + }; + + var tableNode = $('#' + self.dataTableId); + UI.DataTable.prototype.notifyWidgetParametersChange(tableNode, params); + } + }); + } + }); + this.app.mount(this.domElem.children()[0]); + + function isItemDisplayed(columnOrRowConfig) { + return columnOrRowConfig.displayed; + } + }, + + /** + * Unmounts the picker's Vue app so directive listeners (e.g. ExpandOnClick) + * are removed and it can be garbage collected. Safe to call when unmounted. + */ + destroy: function () { + if (this.app) { + this.app.unmount(); + this.app = null; + } + $(this).off(); + if (this.domElem) { + this.domElem.remove(); + this.domElem = null; + } + }, + + /** + * Returns the translation of a metric that can be selected. + * + * @param {String} metric The name of the metric, ie, 'nb_visits' or 'nb_actions'. + * @return {String} The metric translation. If one cannot be found, the metric itself + * is returned. + */ + getMetricTranslation: function (metric) { + for (var i = 0; i !== this.selectableColumns.length; ++i) { + if (this.selectableColumns[i].column === metric) { + return this.selectableColumns[i].translation; + } + } + return metric; + } + }; + + var exports = require('piwik/DataTableVisualizations/Widgets'); + exports.SeriesPicker = SeriesPicker; + +})(jQuery, require); diff --git a/plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less b/plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less new file mode 100644 index 0000000000000000000000000000000000000000..98707768a296dc8db2a535bb6021e35fe6a36ca9 --- /dev/null +++ b/plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less @@ -0,0 +1,174 @@ +.jqplot-target { + color: @theme-color-text; +} + +.jqplot-loading { + background-color: @theme-color-background-contrast; +} + +a.rowevolution-startmulti { + color: @theme-color-link !important; +} + +.rowevolution table.metrics td.text { + color: @theme-color-text-light; +} + +.rowevolution table.metrics td.text span.details, +.rowevolution .metric-selectbox select { + color: @theme-color-text; +} + +.piwik-graph { + height: 250px; +} + +.jqplot-legend-footer { + display: none; +} + +.jqplot-legend-footer.has-legend, +.jqplot-legend-footer.has-picker { + // Used by jqplot.js to match the exported image legend layout. + --jqplot-legend-footer-height: 38px; + --jqplot-legend-swatch-size: 12px; + --jqplot-legend-item-gap: 8px; + --jqplot-legend-row-gap: 24px; + --jqplot-legend-line-gap: 6px; + --jqplot-legend-horizontal-padding: 12px; + + height: var(--jqplot-legend-footer-height); + position: relative; + display: flex; + justify-content: center; + align-items: center; + margin-top: 10px; +} + +// Pie graphs already label their slices via the in-chart pieLegend, so the footer +// legend (a single-metric row) is redundant. Hide just the legend items and keep the +// footer (and its "Choose metrics" picker, which is how the displayed metric is switched). +.dataTableVizPie .jqplot-legend-footer .jqplot-legend-items { + display: none; +} + +// When evolution annotations are shown they sit above the legend footer and add +// their own spacing, so drop the default margin to avoid doubling the gap. +.evolution-annotations ~ .jqplot-legend-footer { + margin-top: 0; +} + +.jqplot-legend-picker { + display: flex; + align-items: center; + flex: none; +} + +.jqplot-legend-items { + display: flex; + justify-content: center; + flex-wrap: wrap; + align-content: safe center; + gap: var(--jqplot-legend-line-gap) var(--jqplot-legend-row-gap); + height: var(--jqplot-legend-footer-height); + overflow: hidden; + flex: auto; +} + +// One row stays centered (rule above); once the legend wraps to two rows, left-align it — +// but only in the wide layout. The narrow/stacked layout (.is-narrow) keeps it centered. +// Left-aligned, the legend butts up against the "Choose metrics" picker, so add a gap between them. +.jqplot-legend-footer.is-multi-row:not(.is-narrow) .jqplot-legend-items { + justify-content: flex-start; + margin-left: 18px; +} + +.jqplot-legend-item { + display: inline-flex; + align-items: center; + gap: var(--jqplot-legend-item-gap); + max-width: 100%; + min-width: 0; +} + +.jqplot-legend-item.jqplot-legend-item-hidden { + display: none; +} + +.jqplot-legend-swatch { + width: var(--jqplot-legend-swatch-size); + height: var(--jqplot-legend-swatch-size); + min-width: var(--jqplot-legend-swatch-size); + border-radius: 50%; + flex: none; +} + +.jqplot-legend-label { + color: @theme-color-text-lighter; + font-size: 12px; + line-height: 16px; + min-width: 0; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +// When the report itself is narrow — its own width, not the viewport, so this also applies +// to reports shown as narrow dashboard widgets — the "Choose metrics" button and the legend +// can't sit side by side, so stack them: the legend keeps its row(s) on top and the picker +// drops onto its own full-width line below. jqplot.js measures the footer width and toggles +// `is-narrow` (a stand-in for a CSS container query, which less.php can't compile). +.jqplot-legend-footer.is-narrow { + height: auto; + flex-direction: column; + align-items: stretch; + gap: var(--jqplot-legend-line-gap); +} + +.jqplot-legend-footer.is-narrow .jqplot-legend-items { + order: 1; +} + +.jqplot-legend-footer.is-narrow .jqplot-legend-picker { + order: 2; + display: block; + width: 100%; +} + +.dataTableVizEvolution { + > .dataTableWrapper { + width: 100%; + } + + .piwik-graph { + height: 170px; + } +} + +// With the redesigned legend, widgetized evolution charts match the 20px left/right +// padding of the reports page. +.widget .dataTableVizEvolution { + padding-left: 20px; + padding-right: 20px; +} + +.widget .tagCloud { + padding: 10px; +} +.widget .dataTableVizBar .jqplot-graph { + padding: 0 10px 10px 10px; +} + +.tooltip-series-color { + display: inline-block; + width: 11px; + height: 11px; + border-radius: 6px; + margin-right: 3px; + position: relative; + top: 1px; +} + +.ui-tooltip h3.evolution-tooltip-header { + margin-top: 4px; +} diff --git a/plugins/CoreVisualizations/stylesheets/jqplot.less b/plugins/CoreVisualizations/stylesheets/jqplot.less new file mode 100644 index 0000000000000000000000000000000000000000..40373a9d3c622714776eb9c7f99324829d52c25e --- /dev/null +++ b/plugins/CoreVisualizations/stylesheets/jqplot.less @@ -0,0 +1,297 @@ +.jqplot-loading { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + z-index: 10; +} + +.jqplot-target { + position: relative; + color: #333; + font-size: 1em; +} + +.jqplot-axis { + font-size: 0.75em; +} + +.jqplot-xaxis { + margin-top: 10px; +} + +.jqplot-yaxis { + margin-right: 10px; +} + +.jqplot-y2axis, +.jqplot-y3axis { + margin: 0 3px 0 7px; +} + +.jqplot-axis-tick, .jqplot-xaxis-tick, .jqplot-yaxis-tick { + position: absolute; +} + +.jqplot-xaxis-tick { + top: 0; + left: 15px; + vertical-align: top; +} + +.jqplot-yaxis-tick { + right: 0; + top: 15px; + text-align: right; +} + +.jqplot-yaxis-tick.jqplot-breakTick { + right: -20px; + margin-right: 0; + padding: 1px 5px 1px 5px; + z-index: 2; + font-size: 1.5em; +} + +.jqplot-xaxis-label { + margin-top: 10px; + font-size: 11pt; + position: absolute; +} + +.jqplot-yaxis-label { + margin-right: 10px; + font-size: 11pt; + position: absolute; +} + +.jqplot-title { + top: 0; + left: 0; + padding-bottom: 0.5em; + font-size: 1.2em; +} + +/** + * ROW EVOLUTION POPUP + */ + +.rowevolution { + position: relative; + text-align: left; +} + +#Piwik_Popover .rowevolution .alert-info { + margin-left: 0; + margin-right: 0; +} + +a.rowevolution-startmulti { + color: #4183C4 !important; + font-size: 12px; + font-weight: bold; + text-decoration: none; +} + +.rowevolution h2 { + font-size: 16px; + margin: 0; + padding: 0; + border: none; +} + +.rowevolution .metrics-container { + padding: 11px 0 5px 0; +} + +.rowevolution table.metrics { + border-spacing: 0; +} + +.multirowevolution table.metrics { + margin-bottom: 12px; +} + +.rowevolution table.metrics, +.multirowevolution table.metrics { + /* prevent select for shift-click on metric toggles */ + user-select: none; /* CSS3 */ + -moz-user-select: none; /* Gecko (Firefox) */ + -khtml-user-select: none; /* Webkit (Safari, Chrome) */ +} + +.rowevolution table.metrics td.sparkline, +.multirowevolution table.metrics td.sparkline { + float:none; +} + +.rowevolution table.metrics tr { + margin: 0; + padding: 0; + cursor: pointer; +} + +.rowevolution table.metrics td { + vertical-align: middle; + text-align: left; + margin: 0; + padding: 4px 0; + cursor: pointer; +} + +.rowevolution table.metrics td.sparkline { + width: 120px; +} + +.multirowevolution table.metrics td.sparkline { + padding-top: 15px; +} + +/** IE7 does not support inline image data, which is needed for spark lines */ +*+html .multirowevolution table.metrics td.sparkline, +*+html .rowevolution table.metrics td.sparkline { + display: none; +} + +.rowevolution table.metrics td.text { + font-size: 13px; + line-height: 18px; + color: #7E7363; + font-weight: bold; +} + +.rowevolution table.metrics td.text img { + max-width: 16px; + max-height: 16px; +} + +.multirowevolution table.metrics td.text { + padding-top: 8px; +} + +.rowevolution table.metrics td.text span.details { + font-weight: normal; + color: #444; +} + +.rowevolution table.metrics td.text span.change { + display: block; + float: left; + padding-left: 15px; +} + +.rowevolution table.metrics td.text span.good { + color: #43a047; +} + +.rowevolution table.metrics td.text span.bad { + color: #d2312d; +} + +.rowevolution .metric-selectbox, +.rowevolution .compare-container { + padding: 15px 0 5px 0; +} + +.rowevolution .metric-selectbox select { + font-size: 13px; + color: #444; + margin: 8px 0 0 0; + padding: 0; + display: block; +} + +/** + * SERIES PICKER FOR CHARTS + */ + +.jqplot-seriespicker { + display: block; + z-index: 9; + width: 24px; + height: 16px; + margin-top: 3px; + background: url(../../Morpheus/images/chart_line_edit.svg) no-repeat center center; +} + +.jqplot-seriespicker-popover { + display: block; + position: absolute; + z-index: 1010; /* must be above ui dialog */ + margin-top: -2px; + background: url(../../Morpheus/images/chart_line_edit.svg) no-repeat 7px 4px #f7f7f7; + font-size: 11px; + font-weight: normal; + border: 1px solid #e4e5e4; + padding: 6px 9px; + border-radius: 4px; + -moz-box-shadow: 1px 1px 2px #666; + -webkit-box-shadow: 1px 1px 2px #666; + box-shadow: 1px 1px 2px #666; +} + +.jqplot-seriespicker-popover p { + margin: 0; + padding: 0 4px 0 0; + line-height: 15px; + vertical-align: middle; + white-space: nowrap; +} + +.jqplot-seriespicker-popover p.headline { + font-weight: bold; + font-size: 12px; + padding: 0 0 6px 22px; + color: #7E7363; +} + +.jqplot-seriespicker-popover p.headline.recordsToPlot { + padding: 8px 0 3px 0; +} + +.jqplot-seriespicker-popover.alignright p.headline { + padding: 0 22px 6px 0; +} + +.jqplot-seriespicker-popover input.select { + margin-right: 8px; + min-height: 5px; +} + +.jqplot-seriespicker-popover p.pickColumn, +.jqplot-seriespicker-popover p.pickRow { + cursor: pointer; +} + +.inDarkMode({ + .jqplot-seriespicker { + background-image: url(../../Morpheus/images/chart_line_edit_dark.svg); + } + + .jqplot-seriespicker-popover { + background-image: url(../../Morpheus/images/chart_line_edit_dark.svg); + background-color: @theme-color-background-contrast; + border-color: @theme-color-background-highContrast; + box-shadow: 1px 1px 2px @theme-color-boxShadow; + color: @theme-color-text; + } + + .jqplot-seriespicker-popover p, + .jqplot-seriespicker-popover label, + .jqplot-seriespicker-popover span { + color: @theme-color-text; + } + + .jqplot-seriespicker-popover p.headline { + color: @theme-color-text-light; + } + + .jqplot-seriespicker-popover input.select { + accent-color: @theme-color-link; + } + + .jqplot-seriespicker-popover p.pickColumn:hover, + .jqplot-seriespicker-popover p.pickRow:hover { + background-color: @theme-color-background-tinyContrast; + } +}); diff --git a/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig b/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig new file mode 100644 index 0000000000000000000000000000000000000000..f0b8a87205e73fab8d105e437cda12de512cb002 --- /dev/null +++ b/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig @@ -0,0 +1,94 @@ +{%- set subtablesAreDisabled = properties.show_goals_columns|default(false) + and properties.disable_subtable_when_show_goals|default(false) -%} +{%- set showingEmbeddedSubtable = properties.show_embedded_subtable is not empty + and idSubtable|default(false) -%} +{% if error is defined %} + {{ error.message }} +{% else %} + {%- if not showingEmbeddedSubtable -%} +
+ + {% include "@CoreHome/_dataTableHead.twig" %} + + + {%- endif -%} + {% if showingEmbeddedSubtable and dataTableHasNoData %} + {% if clientSideParameters is not defined or clientSideParameters.filter_pattern_recursive is not defined or not clientSideParameters.filter_pattern_recursive %} + + + + {% endif %} + {% else %} + {% set rowIndex = properties.filter_offset|default(0) + 1 %} + {%- for rowId, row in dataTable.getRows() -%} + {%- set rowHasSubtable = not subtablesAreDisabled and row.getIdSubDataTable() and properties.subtable_controller_action is not null -%} + {%- set rowSubtableId = row.getMetadata('idsubdatatable_in_db')|default(row.getIdSubDataTable()) -%} + {%- set isSummaryRow = rowId == constant('Piwik\\DataTable::ID_SUMMARY_ROW') or row.getMetadata('is_summary') -%} + {%- set shouldHighlightRow = isSummaryRow and properties.highlight_summary_row -%} + {% set dimensions = dataTable.getMetadata('dimensions')|default([]) %} + + {# display this row if it doesn't have a subtable or if we don't replace the row with the subtable #} + {%- set showRow = subtablesAreDisabled + or not rowHasSubtable + or not properties.show_expanded|default(false) + or not properties.replace_row_with_subtable|default(false) -%} + + {% if showRow %} + + {% for column in properties.columns_to_display %} + {% set cellAttributes = visualization.getCellHtmlAttributes(row, column) %} + + {% endfor %} + + + {% if row.getComparisons() %} + {% include "@CoreVisualizations/_dataTableViz_htmlTable_comparisons.twig" with { + 'comparedRow': row, + 'dataTable': row.getComparisons(), + 'rootDataTable': dataTable, + 'dimensions': dimensions, + } %} + {% endif %} + {% endif %} + + {# display subtable if present and showing expanded datatable #} + {% if properties.show_expanded|default(false) and rowHasSubtable %} + {% include "@CoreVisualizations/_dataTableViz_htmlTable.twig" with {'dataTable': row.getSubtable(), 'idSubtable': rowSubtableId} %} + {% endif %} + + {% set rowIndex = rowIndex + 1 %} + {%- endfor -%} + {% if dataTable.getTotalsRow and properties.show_totals_row %} + {% set row = dataTable.getTotalsRow %} + {% set rowId = 'totalsRow' %} + + {% for column in properties.columns_to_display %} + + {% endfor %} + + {% endif %} + {% endif %} + {%- if not showingEmbeddedSubtable -%} + +
{{ 'CoreHome_CategoryNoData'|translate }}
+ {% if isComparing and column == 'label' %} + {{ rowIndex }}. + {% endif %} + + {% include "@CoreHome/_dataTableCell.twig" with properties %} +
+ {% include "@CoreHome/_dataTableCell.twig" with properties %} +
+
+ {%- endif -%} +{% endif %} \ No newline at end of file diff --git a/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_comparisons.twig b/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_comparisons.twig new file mode 100644 index 0000000000000000000000000000000000000000..0ba49ea99b48f4e0eb9bf5a4ad6c5e18f361f9ff --- /dev/null +++ b/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_comparisons.twig @@ -0,0 +1,85 @@ +{% if properties.columns_to_display is not empty %} +{% set lastSeenComparePeriod = false %} +{% set comparedPeriodPretty = (dataTable.getRows()|last).getMetadata('comparePeriodPretty') %} +{% set isComparingSegments = rootDataTable.getMetadata('compareSegments')|default([])|length > 1 %} +{% set isComparingPeriods = rootDataTable.getMetadata('comparePeriods')|default([])|length > 1 %} +{%- for rowId, row in dataTable.getRows() -%} + {% set comparePeriod = row.getMetadata('comparePeriodPretty') %} + {% if lastSeenComparePeriod != comparePeriod and isComparingSegments and isComparingPeriods %} + + + {{ comparePeriod }} + + + {# add extra empty columns for sticky column scrolling to work #} + {% for column in properties.columns_to_display %} + {% if column != 'label' %} +   + {% endif %} + {% endfor %} + + {% endif %} + {% set overrideParams = {} %} + + {% if row.getMetadata('compareSegment')|default is not empty %}{% set overrideParams = overrideParams|merge({ segment: row.getMetadata('compareSegment'), compareSegments: '' }) %}{% endif %} + {% if row.getMetadata('comparePeriod')|default is not empty %}{% set overrideParams = overrideParams|merge({ period: row.getMetadata('comparePeriod'), comparePeriods: '' }) %}{% endif %} + {% if row.getMetadata('compareDate')|default is not empty %}{% set overrideParams = overrideParams|merge({ date: row.getMetadata('compareDate'), compareDates: '' }) %}{% endif %} + {% set idSubtable = row.getMetadata('idsubdatatable') %} + {% set seriesIndex = loop.index0 %} + + + {% if isComparingSegments %} + {%- set comparisonLabel = row.getMetadata('compareSegmentPretty') -%} + {% else %} + {%- set comparisonLabel = comparePeriod -%} + {% endif %} + {{ comparisonLabel }} + + {% for dimension in dimensions %} + {% if loop.index0 != 0 and dimension in properties.columns_to_display %} + +   + + {% endif %} + {% endfor %} + {% for column in properties.columns_to_display %} + {% if column != 'label' and column not in dimensions %} + {% set columnValue = row.getColumn(column) %} + + {% set rowComparisonTotals = comparisonTotals[seriesIndex].totals|default(null) %} + + {% set comparisonTooltipSuffix = '' %} + {% set columnChange = false %} + + {% if row.getColumn(column ~ '_change')|default is not empty %} + {% set columnChange = row.getColumn(column ~ '_change')|default('+0%') %} + {% set comparisonTooltipSuffix = 'General_ComparisonRatioTooltip'|translate(columnChange, row.getMetadata('compareSegmentPretty'), comparedPeriodPretty) %} + {% endif %} + + {% include "@CoreVisualizations/_dataTableViz_htmlTable_ratio.twig" with { + 'changePercentage': columnChange, + 'totals': rowComparisonTotals, + 'label': comparedRow.getColumn('label'), + 'labelColumn': properties.columns_to_display|first, + 'forceZero': true, + 'tooltipSuffix': comparisonTooltipSuffix, + 'translations': properties.translations, + 'segmentTitlePretty': row.getMetadata('compareSegmentPretty'), + 'periodTitlePretty': row.getMetadata('comparePeriodPretty') + } %} + {{ columnValue|default(0)|number(2,0)|rawSafeDecoded }} + + {% endif %} + {% endfor %} + + {% set lastSeenComparePeriod = comparePeriod %} +{%- endfor -%} +{% endif %} \ No newline at end of file diff --git a/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_ratio.twig b/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_ratio.twig new file mode 100644 index 0000000000000000000000000000000000000000..5bae880ad7722c6fac560ed615b26288e7c9ad19 --- /dev/null +++ b/plugins/CoreVisualizations/templates/_dataTableViz_htmlTable_ratio.twig @@ -0,0 +1,41 @@ +{% if column in properties.report_ratio_columns and (column in totals|keys or forceZero|default) -%} + {% set reportTotal = totals[column]|default(0) %} + + {% if siteTotalRow is defined and siteTotalRow is not empty %} + {% set siteTotal = siteTotalRow.getColumn(column) %} + {% else %} + {% set siteTotal = 0 %} + {% endif %} + + {% if rowPercentage|default is empty %} + {% if row.getMetadata(column ~ '_row_percentage') != false %} + {% set rowPercentage = row.getMetadata(column ~ '_row_percentage') %} + {% elseif row.getColumn(column)|default(0) is numeric and reportTotal is numeric %} + {% set rowPercentage = row.getColumn(column)|percentage(reportTotal, 1) %} + {% else %} + {% set rowPercentage = '' %} {# should not happen #} + {% endif %} + {% endif %} + + {% set metricTitle = translations[column]|default(column) %} + + {% set reportRatioTooltip = 'General_ReportRatioTooltip'|translate(label, rowPercentage|e('html_attr'), reportTotal|e('html_attr'), metricTitle|e('html_attr'), '"' ~ segmentTitlePretty ~ '"', translations[labelColumn]|default(labelColumn)|e('html_attr')) %} + + {% if totalPercentage|default is empty %} + {% if row.getMetadata(column ~ '_site_total_percentage') != false %} + {% set totalPercentage = row.getMetadata(column ~ '_site_total_percentage') %} + {% elseif row.getColumn(column)|default(0) is numeric and siteTotal is numeric %} + {% set totalPercentage = row.getColumn(column)|percentage(siteTotal, 1) %} + {% endif %} + {% endif %} + + {% if totalPercentage|default is not empty %} + {% set totalRatioTooltip = 'General_TotalRatioTooltip'|translate(totalPercentage, siteTotal|number(2,0), column == 'nb_visits' ? 'General_ColumnNbVisits'|translate : metricTitle, periodTitlePretty) %} + {% else %} + {% set totalRatioTooltip = '' %} + {% endif %} + +  {{ rowPercentage }} {% if changePercentage|default is not empty %}({{ changePercentage }}){% endif %} +{%- endif %} diff --git a/plugins/CoreVisualizations/templates/_dataTableViz_jqplotGraph.twig b/plugins/CoreVisualizations/templates/_dataTableViz_jqplotGraph.twig new file mode 100644 index 0000000000000000000000000000000000000000..aee1a052419a1c55eb8573c19e2581b99ba431b2 --- /dev/null +++ b/plugins/CoreVisualizations/templates/_dataTableViz_jqplotGraph.twig @@ -0,0 +1,3 @@ +
+
+
diff --git a/plugins/CoreVisualizations/templates/_dataTableViz_sparklines.twig b/plugins/CoreVisualizations/templates/_dataTableViz_sparklines.twig new file mode 100644 index 0000000000000000000000000000000000000000..3855ae3d2b78900ebe63265ceac2074e955f19fb --- /dev/null +++ b/plugins/CoreVisualizations/templates/_dataTableViz_sparklines.twig @@ -0,0 +1,79 @@ +{% import '@CoreVisualizations/macros.twig' as macros %} + +{% if not isWidget %} +
+{% endif %} + {% if title is not empty %}

{{ title }}

{% endif %} + {% if useNewSparklinesGrid %} +
+ {% else %} + {% if not isWidget %} +
+
+ {% endif %} + {% if sparklines|length == 1 %} + {% for key, sparkline in sparklines|first %} + {% if loop.index0 is even %} + {{ macros.singleSparkline(sparkline, allMetricsDocumentation, areSparklinesLinkable) }} + {% endif %} + {% endfor %} + {% else %} + {% for group in sparklines %} + {% if loop.index0 is even %} +
+ {% for key, sparkline in group %} + {{ macros.singleSparkline(sparkline, allMetricsDocumentation, areSparklinesLinkable) }} + {% endfor %} +
+ {% endif %} + {% endfor %} + {% endif %} + + {% if not isWidget %} +
+
+
+ {% endif %} + + {% if sparklines|length == 1 %} + {% for key, sparkline in sparklines %} + {% if loop.index0 is odd %} + {{ macros.singleSparkline(sparkline, allMetricsDocumentation, areSparklinesLinkable) }} + {% endif %} + {% endfor %} + {% else %} + {% for group in sparklines %} + {% if loop.index0 is odd %} +
+ {% for key, sparkline in group %} + {{ macros.singleSparkline(sparkline, allMetricsDocumentation, areSparklinesLinkable) }} + {% endfor %} +
+ {% endif %} + {% endfor %} + {% endif %} + +
+ + {% if not isWidget %} +
+
+ {% endif %} + {% endif %} + + {% if areSparklinesLinkable %} + {% include "_sparklineFooter.twig" %} + {% endif %} + + {% if footerMessage is not empty %} +
{{ footerMessage | raw }}
+ {% endif %} +{% if not isWidget %} +
+{% endif %} diff --git a/plugins/CoreVisualizations/templates/_dataTableViz_tagCloud.twig b/plugins/CoreVisualizations/templates/_dataTableViz_tagCloud.twig new file mode 100644 index 0000000000000000000000000000000000000000..827135ba89da355e0fca30cf3faf3e27b419e6d5 --- /dev/null +++ b/plugins/CoreVisualizations/templates/_dataTableViz_tagCloud.twig @@ -0,0 +1,17 @@ + diff --git a/plugins/CoreVisualizations/templates/macros.twig b/plugins/CoreVisualizations/templates/macros.twig new file mode 100644 index 0000000000000000000000000000000000000000..8336fec403c7c46bce3e55a54936480f5aa40408 --- /dev/null +++ b/plugins/CoreVisualizations/templates/macros.twig @@ -0,0 +1,56 @@ + +{% macro sparklineEvolution(evolution) %} + {% set isLowerValueBetter = evolution.isLowerValueBetter is defined ? evolution.isLowerValueBetter : false %} + {% set evolutionPretty = evolution.percent %} + {% set compareValue = evolution.trend is defined ? evolution.trend : evolution.percent %} + {% if compareValue < 0 %} + {% set evolutionClass = isLowerValueBetter ? 'positive-evolution' : 'negative-evolution' %} + {% set evolutionIcon = isLowerValueBetter ? 'arrow_down_green.svg' : 'arrow_down.svg' %} + {% elseif compareValue == 0 or compareValue == '0%' %} + {% set evolutionClass = 'neutral-evolution' %} + {% set evolutionIcon = 'stop.svg' %} + {% else %} + {% set evolutionClass = isLowerValueBetter ? 'negative-evolution' : 'positive-evolution' %} + {% set evolutionIcon = isLowerValueBetter ? 'arrow_up_red.svg' : 'arrow_up.svg' %} + {% set evolutionPretty = '+' ~ evolution.percent %} + {% endif %} + + + + +{% endmacro %} + +{% macro singleSparkline(sparkline, allMetricsDocumentation, areSparklinesLinkable) %} + +
+
+ {% if sparkline.title|default is not empty %}
{{ sparkline.title }}
{% endif %} + {% if sparkline.url %}{{ sparkline(sparkline.url)|raw }}{% endif %} +
+
+ {% for groupName, group in sparkline.metrics %} + {% if groupName is not empty %}{{ groupName }}{% endif %} + {% for metric in group %} + + {% if '%s' in metric.description -%} + {{ metric.description|translate(""~metric.value|number(2)~"")|raw }} + {%- else %} + {{ metric.value|number(2) }} {{ metric.description }} + {%- endif %}{% if not loop.last %}, {% endif %} + + {% if metric.evolution is defined %} + {{ _self.sparklineEvolution(metric.evolution) }} + {% endif %} + {% endfor %} + {% endfor %} + {% if sparkline.evolution is defined %} + {{ _self.sparklineEvolution(sparkline.evolution) }} + {% endif %} +
+
+{% endmacro %} diff --git a/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.js b/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.js new file mode 100644 index 0000000000000000000000000000000000000000..3c16741587cfc5358c1f30165493ab328259998e --- /dev/null +++ b/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.js @@ -0,0 +1,1403 @@ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(require("CoreHome"), require("vue")); + else if(typeof define === 'function' && define.amd) + define(["CoreHome", ], factory); + else if(typeof exports === 'object') + exports["CoreVisualizations"] = factory(require("CoreHome"), require("vue")); + else + root["CoreVisualizations"] = factory(root["CoreHome"], root["Vue"]); +})((typeof self !== 'undefined' ? self : this), function(__WEBPACK_EXTERNAL_MODULE__19dc__, __WEBPACK_EXTERNAL_MODULE__8bbf__) { +return /******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = "plugins/CoreVisualizations/vue/dist/"; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = "fae3"); +/******/ }) +/************************************************************************/ +/******/ ({ + +/***/ "19dc": +/***/ (function(module, exports) { + +module.exports = __WEBPACK_EXTERNAL_MODULE__19dc__; + +/***/ }), + +/***/ "8bbf": +/***/ (function(module, exports) { + +module.exports = __WEBPACK_EXTERNAL_MODULE__8bbf__; + +/***/ }), + +/***/ "fae3": +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +// ESM COMPAT FLAG +__webpack_require__.r(__webpack_exports__); + +// EXPORTS +__webpack_require__.d(__webpack_exports__, "EvolutionBadge", function() { return /* reexport */ EvolutionBadge; }); +__webpack_require__.d(__webpack_exports__, "MetricValue", function() { return /* reexport */ MetricValue; }); +__webpack_require__.d(__webpack_exports__, "SeriesPicker", function() { return /* reexport */ SeriesPicker; }); +__webpack_require__.d(__webpack_exports__, "MetricsPicker", function() { return /* reexport */ MetricsPicker; }); +__webpack_require__.d(__webpack_exports__, "SingleMetricView", function() { return /* reexport */ SingleMetricView; }); +__webpack_require__.d(__webpack_exports__, "SparklinesGrid", function() { return /* reexport */ SparklinesGrid; }); + +// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/setPublicPath.js +// This file is imported into lib/wc client bundles. + +if (typeof window !== 'undefined') { + var currentScript = window.document.currentScript + if (false) { var getCurrentScript; } + + var src = currentScript && currentScript.src.match(/(.+\/)[^/]+\.js(\?.*)?$/) + if (src) { + __webpack_require__.p = src[1] // eslint-disable-line + } +} + +// Indicate to webpack that this file can be concatenated +/* harmony default export */ var setPublicPath = (null); + +// EXTERNAL MODULE: external {"commonjs":"vue","commonjs2":"vue","root":"Vue"} +var external_commonjs_vue_commonjs2_vue_root_Vue_ = __webpack_require__("8bbf"); + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue?vue&type=template&id=94a77dd2 + +const _hoisted_1 = ["title"]; +const _hoisted_2 = { + class: "evolutionBadge__icon", + "aria-hidden": "true" +}; +const _hoisted_3 = { + class: "evolutionBadge__value" +}; +function render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_EvolutionTrendIcon = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("EvolutionTrendIcon"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["evolutionBadge", _ctx.directionClass]), + title: _ctx.tooltip || undefined + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_EvolutionTrendIcon, { + class: "evolutionTrendIcon", + direction: _ctx.direction + }, null, 8, ["direction"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", _hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.formattedPercent), 1)], 10, _hoisted_1); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue?vue&type=template&id=94a77dd2 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue?vue&type=template&id=14185072 + +const EvolutionTrendIconvue_type_template_id_14185072_hoisted_1 = { + key: 0, + viewBox: "0 0 16 16" +}; +const EvolutionTrendIconvue_type_template_id_14185072_hoisted_2 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("path", { + d: "M3.77344 11L8.27344 5L12.7734 11H3.77344Z", + fill: "currentColor" +}, null, -1); +const EvolutionTrendIconvue_type_template_id_14185072_hoisted_3 = [EvolutionTrendIconvue_type_template_id_14185072_hoisted_2]; +const _hoisted_4 = { + key: 1, + viewBox: "0 0 16 16" +}; +const _hoisted_5 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("path", { + d: "M3.77344 6L8.27344 12L12.7734 6H3.77344Z", + fill: "currentColor" +}, null, -1); +const _hoisted_6 = [_hoisted_5]; +const _hoisted_7 = { + key: 2, + viewBox: "0 0 16 16" +}; +const _hoisted_8 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("rect", { + x: "3", + y: "7", + width: "10", + height: "2", + fill: "currentColor" +}, null, -1); +const _hoisted_9 = [_hoisted_8]; +function EvolutionTrendIconvue_type_template_id_14185072_render(_ctx, _cache, $props, $setup, $data, $options) { + return _ctx.direction === 'up' ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("svg", EvolutionTrendIconvue_type_template_id_14185072_hoisted_1, EvolutionTrendIconvue_type_template_id_14185072_hoisted_3)) : _ctx.direction === 'down' ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("svg", _hoisted_4, _hoisted_6)) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("svg", _hoisted_7, _hoisted_9)); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue?vue&type=template&id=14185072 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue?vue&type=script&lang=ts + +/* harmony default export */ var EvolutionTrendIconvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'EvolutionTrendIcon', + props: { + direction: { + type: String, + required: true, + validator: value => ['up', 'down', 'neutral'].indexOf(value) !== -1 + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue + + + +EvolutionTrendIconvue_type_script_lang_ts.render = EvolutionTrendIconvue_type_template_id_14185072_render + +/* harmony default export */ var EvolutionTrendIcon = (EvolutionTrendIconvue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue?vue&type=script&lang=ts + + +/* harmony default export */ var EvolutionBadgevue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'EvolutionBadge', + components: { + EvolutionTrendIcon: EvolutionTrendIcon + }, + props: { + // the change to display, either a number (eg 4, -4) or a pre-formatted + // string as emitted by Sparklines/Config.php (eg "4%", "-4%") + percent: { + type: [Number, String], + required: true + }, + // when true the colour is inverted, so a decrease reads as positive (eg bounce rate) + isLowerValueBetter: { + type: Boolean, + default: false + }, + // raw value difference (currentValue - pastValue); the authoritative source of the + // arrow direction when available, falling back to the sign of percent otherwise + trend: { + type: Number, + default: undefined + }, + tooltip: { + type: String, + default: '' + } + }, + setup(props) { + const changeValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (typeof props.trend === 'number' && !Number.isNaN(props.trend)) { + return props.trend; + } + // only the sign matters here. Fold the localised minus (U+2212, eg fi/sv) + // to ASCII so a coarse parse of the formatted percent gets the sign right. + const numeric = parseFloat(String(props.percent).replace('\u2212', '-').replace(',', '.').replace(/[^0-9.+-]/g, '')); + return Number.isNaN(numeric) ? 0 : numeric; + }); + const direction = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (changeValue.value > 0) { + return 'up'; + } + if (changeValue.value < 0) { + return 'down'; + } + return 'neutral'; + }); + // the arrow direction always reflects the actual value change, while the colour + // (positive/negative) reflects whether that change is good or bad for the metric + const directionClass = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (direction.value === 'neutral') { + return 'evolutionBadge--neutral'; + } + const increased = direction.value === 'up'; + const isPositive = props.isLowerValueBetter ? !increased : increased; + return isPositive ? 'evolutionBadge--positive' : 'evolutionBadge--negative'; + }); + const formattedPercent = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + const label = typeof props.percent === 'number' ? `${props.percent}%` : String(props.percent).trim(); + const sign = label.charAt(0); + if (changeValue.value > 0 && sign !== '+' && sign !== '-') { + return `+${label}`; + } + return label; + }); + return { + direction, + directionClass, + formattedPercent + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue + + + +EvolutionBadgevue_type_script_lang_ts.render = render + +/* harmony default export */ var EvolutionBadge = (EvolutionBadgevue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=template&id=20798b0f + +const MetricValuevue_type_template_id_20798b0f_hoisted_1 = { + class: "metricValue" +}; +const MetricValuevue_type_template_id_20798b0f_hoisted_2 = ["title"]; +const MetricValuevue_type_template_id_20798b0f_hoisted_3 = { + class: "metricValue__primary" +}; +const MetricValuevue_type_template_id_20798b0f_hoisted_4 = { + class: "metricValue__number" +}; +const MetricValuevue_type_template_id_20798b0f_hoisted_5 = { + key: 0, + class: "metricValue__secondary" +}; +const MetricValuevue_type_template_id_20798b0f_hoisted_6 = { + class: "metricValue__secondaryValue" +}; +const MetricValuevue_type_template_id_20798b0f_hoisted_7 = { + key: 0, + class: "metricValue__secondaryLabel" +}; +function MetricValuevue_type_template_id_20798b0f_render(_ctx, _cache, $props, $setup, $data, $options) { + const _directive_tooltips = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("tooltips"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", MetricValuevue_type_template_id_20798b0f_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["metricValue__title", { + 'metricValue__title--documented': !!_ctx.documentation + }]), + title: _ctx.documentation || _ctx.title + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.title), 1)], 10, MetricValuevue_type_template_id_20798b0f_hoisted_2)), [[_directive_tooltips, { + duration: 200, + delay: 200 + }]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", MetricValuevue_type_template_id_20798b0f_hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricValuevue_type_template_id_20798b0f_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.value), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderSlot"])(_ctx.$slots, "evolution")]), _ctx.hasSecondary ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", MetricValuevue_type_template_id_20798b0f_hoisted_5, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricValuevue_type_template_id_20798b0f_hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.secondaryValue), 1), _ctx.secondaryLabel ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", MetricValuevue_type_template_id_20798b0f_hoisted_7, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.secondaryLabel), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=template&id=20798b0f + +// EXTERNAL MODULE: external "CoreHome" +var external_CoreHome_ = __webpack_require__("19dc"); + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=script&lang=ts + + +/* harmony default export */ var MetricValuevue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'MetricValue', + directives: { + Tooltips: external_CoreHome_["Tooltips"] + }, + props: { + title: { + type: String, + required: true + }, + // Pre-formatted value (e.g. "9,527" or "4min 22s"); rendered verbatim, no formatting here. + value: { + type: [String, Number], + required: true + }, + // Optional secondary line. Value and label are kept separate so they can be + // styled independently (e.g. "9,527" darker, "unique visitors" grey). Matomo + // hands these out separately as metric.value + metric.description. + secondaryValue: [String, Number], + secondaryLabel: String, + // Optional metric documentation; when set it is shown as the title tooltip (otherwise the + // tooltip falls back to the full title so a clipped title stays recoverable on hover). + documentation: String + }, + computed: { + hasSecondary() { + return this.secondaryValue !== undefined && this.secondaryValue !== null && this.secondaryValue !== ''; + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue + + + +MetricValuevue_type_script_lang_ts.render = MetricValuevue_type_template_id_20798b0f_render + +/* harmony default export */ var MetricValue = (MetricValuevue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue?vue&type=template&id=7c1adaf7 + +const SeriesPickervue_type_template_id_7c1adaf7_hoisted_1 = { + key: 0, + class: "jqplot-seriespicker-popover" +}; +const SeriesPickervue_type_template_id_7c1adaf7_hoisted_2 = { + class: "headline" +}; +const SeriesPickervue_type_template_id_7c1adaf7_hoisted_3 = ["onClick"]; +const SeriesPickervue_type_template_id_7c1adaf7_hoisted_4 = ["type", "checked"]; +const SeriesPickervue_type_template_id_7c1adaf7_hoisted_5 = { + key: 0, + class: "headline recordsToPlot" +}; +const SeriesPickervue_type_template_id_7c1adaf7_hoisted_6 = ["onClick"]; +const SeriesPickervue_type_template_id_7c1adaf7_hoisted_7 = ["type", "checked"]; +function SeriesPickervue_type_template_id_7c1adaf7_render(_ctx, _cache, $props, $setup, $data, $options) { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["jqplot-seriespicker", { + open: _ctx.isPopupVisible + }]), + onMouseenter: _cache[1] || (_cache[1] = $event => _ctx.isPopupVisible = true), + onMouseleave: _cache[2] || (_cache[2] = $event => _ctx.onLeavePopup()) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", { + href: "#", + onClick: _cache[0] || (_cache[0] = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])(() => {}, ["prevent", "stop"])) + }, " + "), _ctx.isPopupVisible ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", SeriesPickervue_type_template_id_7c1adaf7_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", SeriesPickervue_type_template_id_7c1adaf7_hoisted_2, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate(_ctx.multiselect ? 'General_MetricsToPlot' : 'General_MetricToPlot')), 1), (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.selectableColumns, columnConfig => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("p", { + class: "pickColumn", + onClick: $event => _ctx.optionSelected(columnConfig.column, _ctx.columnStates), + key: columnConfig.column + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("label", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + class: "select", + type: _ctx.multiselect ? 'checkbox' : 'radio', + checked: !!_ctx.columnStates[columnConfig.column] + }, null, 8, SeriesPickervue_type_template_id_7c1adaf7_hoisted_4), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(columnConfig.translation), 1)])], 8, SeriesPickervue_type_template_id_7c1adaf7_hoisted_3); + }), 128)), _ctx.selectableRows.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("p", SeriesPickervue_type_template_id_7c1adaf7_hoisted_5, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_RecordsToPlot')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.selectableRows, rowConfig => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("p", { + class: "pickRow", + onClick: $event => _ctx.optionSelected(rowConfig.matcher, _ctx.rowStates), + key: rowConfig.matcher + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("label", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + class: "select", + type: _ctx.multiselect ? 'checkbox' : 'radio', + checked: !!_ctx.rowStates[rowConfig.matcher] + }, null, 8, SeriesPickervue_type_template_id_7c1adaf7_hoisted_7), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(rowConfig.label), 1)])], 8, SeriesPickervue_type_template_id_7c1adaf7_hoisted_6); + }), 128))])) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 34); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue?vue&type=template&id=7c1adaf7 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue?vue&type=script&lang=ts + + +function getInitialOptionStates(allOptions, selectedOptions) { + const states = {}; + allOptions.forEach(columnConfig => { + const name = columnConfig.column || columnConfig.matcher; + states[name] = false; + }); + selectedOptions.forEach(column => { + states[column] = true; + }); + return states; +} +function arrayEqual(lhs, rhs) { + if (lhs.length !== rhs.length) { + return false; + } + return lhs.filter(element => rhs.indexOf(element) === -1).length === 0; +} +function unselectOptions(optionStates) { + Object.keys(optionStates).forEach(optionName => { + optionStates[optionName] = false; + }); +} +function getSelected(optionStates) { + return Object.keys(optionStates).filter(optionName => !!optionStates[optionName]); +} +/* harmony default export */ var SeriesPickervue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + multiselect: Boolean, + selectableColumns: { + type: Array, + default: () => [] + }, + selectableRows: { + type: Array, + default: () => [] + }, + selectedColumns: { + type: Array, + default: () => [] + }, + selectedRows: { + type: Array, + default: () => [] + } + }, + data() { + return { + isPopupVisible: false, + columnStates: getInitialOptionStates(this.selectableColumns, this.selectedColumns), + rowStates: getInitialOptionStates(this.selectableRows, this.selectedRows) + }; + }, + emits: ['select'], + created() { + this.optionSelected = Object(external_CoreHome_["debounce"])(this.optionSelected, 0); + }, + methods: { + optionSelected(optionValue, optionStates) { + if (!this.multiselect) { + unselectOptions(this.columnStates); + unselectOptions(this.rowStates); + } + optionStates[optionValue] = !optionStates[optionValue]; + this.triggerOnSelectAndClose(); + }, + onLeavePopup() { + this.isPopupVisible = false; + if (this.optionsChanged()) { + this.triggerOnSelectAndClose(); + } + }, + triggerOnSelectAndClose() { + this.isPopupVisible = false; + this.$emit('select', { + columns: getSelected(this.columnStates), + rows: getSelected(this.rowStates) + }); + }, + optionsChanged() { + return !arrayEqual(getSelected(this.columnStates), this.selectedColumns) || !arrayEqual(getSelected(this.rowStates), this.selectedRows); + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue + + + +SeriesPickervue_type_script_lang_ts.render = SeriesPickervue_type_template_id_7c1adaf7_render + +/* harmony default export */ var SeriesPicker = (SeriesPickervue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue?vue&type=template&id=5b298d15 + +const MetricsPickervue_type_template_id_5b298d15_hoisted_1 = { + ref: "root", + class: "metrics-picker" +}; +const MetricsPickervue_type_template_id_5b298d15_hoisted_2 = { + ref: "expander", + type: "button", + class: "metrics-picker__toggle" +}; +const MetricsPickervue_type_template_id_5b298d15_hoisted_3 = { + class: "metrics-picker__toggle-label" +}; +const MetricsPickervue_type_template_id_5b298d15_hoisted_4 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: "icon-chevron-down metrics-picker__chevron" +}, null, -1); +const MetricsPickervue_type_template_id_5b298d15_hoisted_5 = { + class: "metrics-picker__dropdown" +}; +function MetricsPickervue_type_template_id_5b298d15_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_MetricsPickerOptions = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("MetricsPickerOptions"); + const _directive_expand_on_click = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("expand-on-click"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", MetricsPickervue_type_template_id_5b298d15_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("button", MetricsPickervue_type_template_id_5b298d15_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricsPickervue_type_template_id_5b298d15_hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_ChooseMetrics')), 1), MetricsPickervue_type_template_id_5b298d15_hoisted_4], 512), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", MetricsPickervue_type_template_id_5b298d15_hoisted_5, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_MetricsPickerOptions, { + multiselect: _ctx.multiselect, + "selectable-columns": _ctx.selectableColumns, + "selectable-rows": _ctx.selectableRows, + "selected-columns": _ctx.selectedColumns, + "selected-rows": _ctx.selectedRows, + onSelect: _cache[0] || (_cache[0] = $event => _ctx.onSelect($event)) + }, null, 8, ["multiselect", "selectable-columns", "selectable-rows", "selected-columns", "selected-rows"])])])), [[_directive_expand_on_click, { + expander: 'expander' + }]]); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue?vue&type=template&id=5b298d15 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue?vue&type=template&id=0bdfb6f4 + +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_1 = ["role", "aria-label"]; +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_2 = ["type", "checked", "onChange", "onKeydown"]; +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_3 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + "aria-hidden": "true" +}, null, -1); +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_4 = { + class: "metrics-picker__title" +}; +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_5 = { + key: 0, + class: "metrics-picker__headline" +}; +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_6 = ["type", "checked", "onChange", "onKeydown"]; +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_7 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + "aria-hidden": "true" +}, null, -1); +const MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_8 = { + class: "metrics-picker__title" +}; +function MetricsPickerOptionsvue_type_template_id_0bdfb6f4_render(_ctx, _cache, $props, $setup, $data, $options) { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: "metrics-picker__options", + role: _ctx.multiselect ? 'group' : 'radiogroup', + "aria-label": _ctx.translate('General_ChooseMetrics') + }, [(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.selectableColumns, columnConfig => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("label", { + class: "metrics-picker__column metrics-picker__label", + key: columnConfig.column + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + class: "filled-in", + type: _ctx.multiselect ? 'checkbox' : 'radio', + checked: !!_ctx.columnStates[columnConfig.column], + onChange: $event => _ctx.optionSelected(columnConfig.column, _ctx.columnStates), + onKeydown: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withKeys"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])($event => _ctx.optionSelected(columnConfig.column, _ctx.columnStates), ["prevent"]), ["enter"]) + }, null, 40, MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_2), MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_4, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(columnConfig.translation), 1)]); + }), 128)), _ctx.selectableRows.length ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("p", MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_5, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_RecordsToPlot')), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.selectableRows, rowConfig => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("label", { + class: "metrics-picker__row metrics-picker__label", + key: rowConfig.matcher + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", { + class: "filled-in", + type: _ctx.multiselect ? 'checkbox' : 'radio', + checked: !!_ctx.rowStates[rowConfig.matcher], + onChange: $event => _ctx.optionSelected(rowConfig.matcher, _ctx.rowStates), + onKeydown: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withKeys"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withModifiers"])($event => _ctx.optionSelected(rowConfig.matcher, _ctx.rowStates), ["prevent"]), ["enter"]) + }, null, 40, MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_6), MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_7, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_8, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(rowConfig.label), 1)]); + }), 128))], 8, MetricsPickerOptionsvue_type_template_id_0bdfb6f4_hoisted_1); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue?vue&type=template&id=0bdfb6f4 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue?vue&type=script&lang=ts + +// Declared outside the component because it is needed inside data(), before the +// component's methods are available. +function MetricsPickerOptionsvue_type_script_lang_ts_getInitialOptionStates(allOptions, selectedOptions) { + const states = {}; + allOptions.forEach(columnConfig => { + const name = columnConfig.column || columnConfig.matcher; + states[name] = false; + }); + selectedOptions.forEach(column => { + states[column] = true; + }); + return states; +} +/* harmony default export */ var MetricsPickerOptionsvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + multiselect: Boolean, + selectableColumns: { + type: Array, + default: () => [] + }, + selectableRows: { + type: Array, + default: () => [] + }, + selectedColumns: { + type: Array, + default: () => [] + }, + selectedRows: { + type: Array, + default: () => [] + } + }, + data() { + return { + columnStates: MetricsPickerOptionsvue_type_script_lang_ts_getInitialOptionStates(this.selectableColumns, this.selectedColumns), + rowStates: MetricsPickerOptionsvue_type_script_lang_ts_getInitialOptionStates(this.selectableRows, this.selectedRows) + }; + }, + emits: ['select'], + methods: { + unselectOptions(optionStates) { + Object.keys(optionStates).forEach(optionName => { + optionStates[optionName] = false; + }); + }, + getSelected(optionStates) { + return Object.keys(optionStates).filter(optionName => !!optionStates[optionName]); + }, + optionSelected(optionValue, optionStates) { + if (!this.multiselect) { + this.unselectOptions(this.columnStates); + this.unselectOptions(this.rowStates); + } + optionStates[optionValue] = !optionStates[optionValue]; + this.$emit('select', { + columns: this.getSelected(this.columnStates), + rows: this.getSelected(this.rowStates) + }); + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue + + + +MetricsPickerOptionsvue_type_script_lang_ts.render = MetricsPickerOptionsvue_type_template_id_0bdfb6f4_render + +/* harmony default export */ var MetricsPickerOptions = (MetricsPickerOptionsvue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue?vue&type=script&lang=ts + + + +/* harmony default export */ var MetricsPickervue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + multiselect: Boolean, + selectableColumns: { + type: Array, + default: () => [] + }, + selectableRows: { + type: Array, + default: () => [] + }, + selectedColumns: { + type: Array, + default: () => [] + }, + selectedRows: { + type: Array, + default: () => [] + } + }, + components: { + MetricsPickerOptions: MetricsPickerOptions + }, + directives: { + ExpandOnClick: external_CoreHome_["ExpandOnClick"] + }, + emits: ['select'], + methods: { + onSelect(selected) { + this.$emit('select', selected); + // selecting a metric applies the change and closes the dropdown + this.$refs.root.classList.remove('expanded'); + } + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue + + + +MetricsPickervue_type_script_lang_ts.render = MetricsPickervue_type_template_id_5b298d15_render + +/* harmony default export */ var MetricsPicker = (MetricsPickervue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=template&id=21624034 + +const SingleMetricViewvue_type_template_id_21624034_hoisted_1 = { + class: "metric-sparkline" +}; +const SingleMetricViewvue_type_template_id_21624034_hoisted_2 = { + class: "metric-value" +}; +const SingleMetricViewvue_type_template_id_21624034_hoisted_3 = ["title"]; +const SingleMetricViewvue_type_template_id_21624034_hoisted_4 = ["title"]; +function SingleMetricViewvue_type_template_id_21624034_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_Sparkline = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Sparkline"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["singleMetricView", { + 'loading': _ctx.isLoading + }]), + ref: "root" + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", SingleMetricViewvue_type_template_id_21624034_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Sparkline, { + params: _ctx.sparklineParams + }, null, 8, ["params"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", SingleMetricViewvue_type_template_id_21624034_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + title: _ctx.metricDocumentation + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("strong", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.metricValue), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])((_ctx.metricTranslation || '').toLowerCase()), 1)], 8, SingleMetricViewvue_type_template_id_21624034_hoisted_3), _ctx.pastValue !== null ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("span", { + key: 0, + class: "metricEvolution", + title: _ctx.translate('General_EvolutionSummaryGeneric', _ctx.metricValue, _ctx.currentPeriod, _ctx.pastValue, _ctx.pastPeriod, _ctx.metricChangePercent) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(_ctx.evolutionClass) + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.metricChangePercent), 3)], 8, SingleMetricViewvue_type_template_id_21624034_hoisted_4)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)])], 2); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=template&id=21624034 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=script&lang=ts + + + +function getPastPeriodStr() { + const { + startDate + } = external_CoreHome_["Range"].getLastNRange(external_CoreHome_["Matomo"].period, 2, external_CoreHome_["Matomo"].currentDateString); + const dateRange = external_CoreHome_["Periods"].get(external_CoreHome_["Matomo"].period).parse(startDate).getDateRange(); + return `${Object(external_CoreHome_["format"])(dateRange[0])},${Object(external_CoreHome_["format"])(dateRange[1])}`; +} +const { + $ +} = window; +/* harmony default export */ var SingleMetricViewvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + props: { + metric: { + type: String, + required: true + }, + idGoal: [String, Number], + metricTranslations: { + type: Object, + required: true + }, + metricDocumentations: Object, + goals: { + type: Object, + required: true + }, + goalMetrics: Array, + lowerIsBetterMetrics: { + type: Array, + default: () => [] + } + }, + components: { + Sparkline: external_CoreHome_["Sparkline"] + }, + setup(props) { + const root = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + const isLoading = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(false); + const responses = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(null); + const actualMetric = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(props.metric); + const actualIdGoal = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])(props.idGoal); + const selectedColumns = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => [actualIdGoal.value ? `goal${actualIdGoal.value}_${actualMetric.value}` : actualMetric.value]); + const metricValueUnformatted = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _responses$value; + if (!((_responses$value = responses.value) !== null && _responses$value !== void 0 && _responses$value[1])) { + return null; + } + // a metric that is missing for the current period is treated as 0, just like + // the past value below, so an evolution down to zero is still calculated + return responses.value[1][actualMetric.value] || 0; + }); + const pastValueUnformatted = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _responses$value2; + if (!((_responses$value2 = responses.value) !== null && _responses$value2 !== void 0 && _responses$value2[2])) { + return null; + } + return responses.value[2][actualMetric.value] || 0; + }); + const isLowerValueBetter = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => props.lowerIsBetterMetrics.indexOf(actualMetric.value) !== -1); + const evolutionClass = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (metricValueUnformatted.value === null || pastValueUnformatted.value === null || metricValueUnformatted.value === pastValueUnformatted.value) { + return []; + } + // arrow direction always reflects the actual value change, while the colour + // (positive/negative) reflects whether that change is good or bad for the metric + const increased = metricValueUnformatted.value > pastValueUnformatted.value; + const isPositive = isLowerValueBetter.value ? !increased : increased; + return [increased ? 'evolution-up' : 'evolution-down', isPositive ? 'positive-evolution' : 'negative-evolution']; + }); + const metricChangePercent = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (metricValueUnformatted.value === null || metricValueUnformatted.value === undefined || pastValueUnformatted.value === null || pastValueUnformatted.value === undefined) { + return null; + } + const currentValue = typeof metricValueUnformatted.value === 'string' ? parseFloat(metricValueUnformatted.value) : metricValueUnformatted.value; + const pastValue = typeof pastValueUnformatted.value === 'string' ? parseFloat(pastValueUnformatted.value) : pastValueUnformatted.value; + const evolution = external_CoreHome_["Matomo"].helper.calculateEvolution(currentValue, pastValue); + return `${(evolution * 100).toFixed(2)} %`; + }); + const pastValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _responses$value3; + if (!((_responses$value3 = responses.value) !== null && _responses$value3 !== void 0 && _responses$value3[3])) { + return null; + } + const pastDataFormatted = responses.value[3]; + return pastDataFormatted[actualMetric.value] || 0; + }); + const metricValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _responses$value4; + if (!((_responses$value4 = responses.value) !== null && _responses$value4 !== void 0 && _responses$value4[0])) { + return null; + } + const currentData = responses.value[0]; + return currentData[actualMetric.value] || 0; + }); + const metricTranslation = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _props$metricTranslat; + if (!((_props$metricTranslat = props.metricTranslations) !== null && _props$metricTranslat !== void 0 && _props$metricTranslat[actualMetric.value])) { + return ''; + } + return props.metricTranslations[actualMetric.value]; + }); + const metricDocumentation = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _props$metricDocument; + if (!((_props$metricDocument = props.metricDocumentations) !== null && _props$metricDocument !== void 0 && _props$metricDocument[actualMetric.value])) { + return ''; + } + return props.metricDocumentations[actualMetric.value]; + }); + const currentPeriod = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (external_CoreHome_["Matomo"].startDateString === external_CoreHome_["Matomo"].endDateString) { + return external_CoreHome_["Matomo"].endDateString; + } + return `${external_CoreHome_["Matomo"].startDateString}, ${external_CoreHome_["Matomo"].endDateString}`; + }); + function isIdGoalSet() { + return actualIdGoal.value || actualIdGoal.value === 0; + } + const sparklineParams = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + const params = { + module: 'API', + action: 'get', + columns: actualMetric.value + }; + if (isIdGoalSet()) { + params.idGoal = actualIdGoal.value; + params.module = 'Goals'; + } + return params; + }); + const pastPeriod = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + if (external_CoreHome_["Matomo"].period === 'range') { + return undefined; + } + return getPastPeriodStr(); + }); + const selectableColumns = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + const result = []; + Object.keys(props.metricTranslations).forEach(column => { + result.push({ + column, + translation: props.metricTranslations[column] + }); + }); + Object.values(props.goals || {}).forEach(goal => { + props.goalMetrics.forEach(column => { + result.push({ + column: `goal${goal.idgoal}_${column}`, + translation: `${goal.name} - ${props.metricTranslations[column]}` + }); + }); + }); + return result; + }); + function setWidgetTitle() { + let title = metricTranslation.value; + if (isIdGoalSet()) { + var _props$goals$actualId; + const goalName = ((_props$goals$actualId = props.goals[actualIdGoal.value]) === null || _props$goals$actualId === void 0 ? void 0 : _props$goals$actualId.name) || Object(external_CoreHome_["translate"])('General_Unknown'); + title = `${goalName} - ${title}`; + } + $(root.value).closest('div.widget').find('.widgetTop > .widgetName > span').text(title); + } + function getLastPeriodDate() { + const range = external_CoreHome_["Range"].getLastNRange(external_CoreHome_["Matomo"].period, 2, external_CoreHome_["Matomo"].currentDateString); + return Object(external_CoreHome_["format"])(range.startDate); + } + function fetchData() { + isLoading.value = true; + const promises = []; + let apiModule = 'API'; + let apiAction = 'get'; + const extraParams = {}; + if (isIdGoalSet()) { + // the conversion rate added by the AddColumnsProcessedMetrics filter conflicts w/ + // the goals one, so don't run it + extraParams.idGoal = actualIdGoal.value; + extraParams.filter_add_columns_when_show_all_columns = 0; + apiModule = 'Goals'; + apiAction = 'get'; + } + const method = `${apiModule}.${apiAction}`; + // first request for formatted data + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + format_metrics: 'all' + }, extraParams))); + if (external_CoreHome_["Matomo"].period !== 'range') { + // second request for unformatted data so we can calculate evolution + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + format_metrics: '0' + }, extraParams))); + // third request for past data (unformatted) + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + date: getLastPeriodDate(), + format_metrics: '0' + }, extraParams))); + // fourth request for past data (formatted for tooltip display) + promises.push(external_CoreHome_["AjaxHelper"].fetch(Object.assign({ + method, + date: getLastPeriodDate(), + format_metrics: 'all' + }, extraParams))); + } + return Promise.all(promises).then(r => { + responses.value = r; + isLoading.value = false; + }); + } + function onMetricChanged(newMetric) { + actualMetric.value = newMetric; + fetchData().then(setWidgetTitle); // notify widget of parameter change so it is replaced + $(root.value).closest('[widgetId]').trigger('setParameters', { + column: actualMetric.value, + idGoal: actualIdGoal.value + }); + } + function setMetric(newColumn) { + let idGoal = undefined; + let actualColumn = newColumn; + const m = newColumn.match(/^goal([0-9]+)_(.*)/); + if (m) { + idGoal = +m[1]; + [,, actualColumn] = m; + } + if (actualMetric.value !== actualColumn || idGoal !== actualIdGoal.value) { + actualMetric.value = actualColumn; + actualIdGoal.value = idGoal; + onMetricChanged(actualColumn); + } + } + function createSeriesPicker() { + const element = $(root.value); + const $widgetName = element.closest('div.widget').find('.widgetTop > .widgetName'); + const $seriesPickerElem = $('
'); + const app = Object(external_CoreHome_["createVueApp"])({ + render: () => Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(SeriesPicker, { + multiselect: false, + selectableColumns: selectableColumns.value, + selectableRows: [], + selectedColumns: selectedColumns.value, + selectedRows: [], + onSelect: ({ + columns + }) => { + setMetric(columns[0]); + } + }) + }); + $widgetName.append($seriesPickerElem); + app.mount($seriesPickerElem.children()[0]); + return app; + } + let seriesPickerApp; + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["onMounted"])(() => { + seriesPickerApp = createSeriesPicker(); + }); + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["onBeforeUnmount"])(() => { + $(root.value).closest('.widgetContent').off('widget:destroy').off('widget:reload'); + $(root.value).closest('div.widget').find('.single-metric-view-picker').remove(); + seriesPickerApp.unmount(); + }); + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["watch"])(() => props.metric, () => { + onMetricChanged(props.metric); + }); + onMetricChanged(props.metric); + return { + root, + metricValue, + isLoading, + selectedColumns, + responses, + metricValueUnformatted, + pastValueUnformatted, + evolutionClass, + metricChangePercent, + pastValue, + metricTranslation, + metricDocumentation, + sparklineParams, + pastPeriod, + selectableColumns, + currentPeriod + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue + + + +SingleMetricViewvue_type_script_lang_ts.render = SingleMetricViewvue_type_template_id_21624034_render + +/* harmony default export */ var SingleMetricView = (SingleMetricViewvue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=template&id=708ec4a6 + +const SparklinesGridvue_type_template_id_708ec4a6_hoisted_1 = { + class: "row sparklinesGrid" +}; +function SparklinesGridvue_type_template_id_708ec4a6_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_SparklineCard = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("SparklineCard"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", SparklinesGridvue_type_template_id_708ec4a6_hoisted_1, [(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.flatSparklines, (sparkline, index) => { + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + key: index, + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(_ctx.columnClasses) + }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_SparklineCard, { + sparkline: sparkline, + "are-sparklines-linkable": _ctx.areSparklinesLinkable, + "all-metrics-documentation": _ctx.allMetricsDocumentation + }, null, 8, ["sparkline", "are-sparklines-linkable", "all-metrics-documentation"])], 2); + }), 128))]); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=template&id=708ec4a6 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=template&id=4bca8c84 + +const SparklineCardvue_type_template_id_4bca8c84_hoisted_1 = ["data-graph-params", "data-series-indices"]; +const SparklineCardvue_type_template_id_4bca8c84_hoisted_2 = { + key: 0, + class: "sparklineCard__title" +}; +function SparklineCardvue_type_template_id_4bca8c84_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_NoComparison = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("NoComparison"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", { + class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(["sparkline sparklineCard", { + notLinkable: !_ctx.areSparklinesLinkable + }]), + "data-graph-params": _ctx.graphParamsAttr, + "data-series-indices": _ctx.seriesIndicesAttr + }, [_ctx.sparkline.title ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", SparklineCardvue_type_template_id_4bca8c84_hoisted_2, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.sparkline.title), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_NoComparison, { + sparkline: _ctx.sparkline, + "all-metrics-documentation": _ctx.allMetricsDocumentation + }, null, 8, ["sparkline", "all-metrics-documentation"])], 10, SparklineCardvue_type_template_id_4bca8c84_hoisted_1); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=template&id=4bca8c84 + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=template&id=240ad3ea + +const NoComparisonvue_type_template_id_240ad3ea_hoisted_1 = { + class: "noComparison" +}; +const NoComparisonvue_type_template_id_240ad3ea_hoisted_2 = { + class: "sparklineSlot" +}; +function NoComparisonvue_type_template_id_240ad3ea_render(_ctx, _cache, $props, $setup, $data, $options) { + const _component_EvolutionBadge = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("EvolutionBadge"); + const _component_MetricValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("MetricValue"); + const _component_Sparkline = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Sparkline"); + return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", NoComparisonvue_type_template_id_240ad3ea_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_MetricValue, { + title: _ctx.title, + value: _ctx.primaryValue, + "secondary-value": _ctx.secondaryValue, + "secondary-label": _ctx.secondaryLabel, + documentation: _ctx.documentation + }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createSlots"])({ + _: 2 + }, [_ctx.sparkline.evolution ? { + name: "evolution", + fn: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_EvolutionBadge, { + percent: _ctx.sparkline.evolution.percent, + trend: _ctx.sparkline.evolution.trend, + "is-lower-value-better": _ctx.sparkline.evolution.isLowerValueBetter, + tooltip: _ctx.sparkline.evolution.tooltip || '' + }, null, 8, ["percent", "trend", "is-lower-value-better", "tooltip"])]), + key: "0" + } : undefined]), 1032, ["title", "value", "secondary-value", "secondary-label", "documentation"]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", NoComparisonvue_type_template_id_240ad3ea_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Sparkline, { + width: 380, + height: 40, + params: _ctx.sparkline.url, + "series-indices": _ctx.sparkline.seriesIndices + }, null, 8, ["params", "series-indices"])])]); +} +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=template&id=240ad3ea + +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=script&lang=ts + + + + +/** + * No-comparison body for a sparkline card. Composes the MetricValue + EvolutionBadge + * atoms and the reused Sparkline. In no-comparison mode the metrics live under the '' + * group key: the first is the primary value, an optional second is the "unique" line. + */ +/* harmony default export */ var NoComparisonvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'NoComparison', + components: { + MetricValue: MetricValue, + EvolutionBadge: EvolutionBadge, + Sparkline: external_CoreHome_["Sparkline"] + }, + props: { + sparkline: { + type: Object, + required: true + }, + // Backend map of metric column -> documentation string (from Sparklines.php). + allMetricsDocumentation: { + type: Object, + default: () => ({}) + } + }, + setup(props) { + const primaryMetric = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _props$sparkline$metr; + return (_props$sparkline$metr = props.sparkline.metrics) === null || _props$sparkline$metr === void 0 || (_props$sparkline$metr = _props$sparkline$metr['']) === null || _props$sparkline$metr === void 0 ? void 0 : _props$sparkline$metr[0]; + }); + // Optional secondary metric (eg "9,527 unique"), shown only when the report adds a + // second metric to the same sparkline. MetricValue hides the line when it is absent. + const secondaryMetric = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _props$sparkline$metr2; + return (_props$sparkline$metr2 = props.sparkline.metrics) === null || _props$sparkline$metr2 === void 0 || (_props$sparkline$metr2 = _props$sparkline$metr2['']) === null || _props$sparkline$metr2 === void 0 ? void 0 : _props$sparkline$metr2[1]; + }); + // Prefer the metric's column name (eg "Bounce Rate"), falling back to the label. + const title = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _primaryMetric$value, _primaryMetric$value2; + return ((_primaryMetric$value = primaryMetric.value) === null || _primaryMetric$value === void 0 ? void 0 : _primaryMetric$value.title) || ((_primaryMetric$value2 = primaryMetric.value) === null || _primaryMetric$value2 === void 0 ? void 0 : _primaryMetric$value2.description) || ''; + }); + // Documentation for the primary metric, looked up by its column. Empty for sparklines added + // without a column (column === '') or metrics with no registered documentation. + const documentation = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _primaryMetric$value$, _primaryMetric$value3; + return props.allMetricsDocumentation[(_primaryMetric$value$ = (_primaryMetric$value3 = primaryMetric.value) === null || _primaryMetric$value3 === void 0 ? void 0 : _primaryMetric$value3.column) !== null && _primaryMetric$value$ !== void 0 ? _primaryMetric$value$ : ''] || undefined; + }); + // Format raw numbers (plain metrics) but leave already-formatted strings (eg "50%") untouched. + const formatValue = value => typeof value === 'number' ? external_CoreHome_["NumberFormatter"].formatNumber(value, 2) : value; + const primaryValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _formatValue, _primaryMetric$value4; + return (_formatValue = formatValue((_primaryMetric$value4 = primaryMetric.value) === null || _primaryMetric$value4 === void 0 ? void 0 : _primaryMetric$value4.value)) !== null && _formatValue !== void 0 ? _formatValue : ''; + }); + const secondaryValue = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _secondaryMetric$valu; + return formatValue((_secondaryMetric$valu = secondaryMetric.value) === null || _secondaryMetric$valu === void 0 ? void 0 : _secondaryMetric$valu.value); + }); + const secondaryLabel = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + var _secondaryMetric$valu2; + return (_secondaryMetric$valu2 = secondaryMetric.value) === null || _secondaryMetric$valu2 === void 0 ? void 0 : _secondaryMetric$valu2.description; + }); + return { + title, + documentation, + primaryValue, + secondaryValue, + secondaryLabel + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue + + + +NoComparisonvue_type_script_lang_ts.render = NoComparisonvue_type_template_id_240ad3ea_render + +/* harmony default export */ var NoComparison = (NoComparisonvue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=script&lang=ts + + + +/** + * Card shell: the frame around a sparkline body. Owns the legacy `.sparkline` wrapper and + * its evolution-graph data attributes; delegates the content to a body component. Only the + * no-comparison body exists today; Phase 3 adds the comparison body behind the same shell. + */ +/* harmony default export */ var SparklineCardvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'SparklineCard', + components: { + NoComparison: NoComparison + }, + props: { + sparkline: { + type: Object, + required: true + }, + areSparklinesLinkable: { + type: Boolean, + default: true + }, + // Backend map of metric column -> documentation string, forwarded to the body component. + allMetricsDocumentation: { + type: Object, + default: () => ({}) + } + }, + setup(props) { + // The legacy click-to-evolution wiring (window.initializeSparklines) reads these + // attributes off the .sparkline wrapper, so only emit them when populated. + const graphParamsAttr = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + const { + graphParams, + url + } = props.sparkline; + // Prefer explicit backend graphParams (set for comparison/segment sparklines). + if (graphParams && Object.keys(graphParams).length) { + return JSON.stringify(graphParams); + } + // Otherwise derive the evolution-graph reload params from the sparkline url. The reused + // CoreHome Sparkline renders the image with `src` (no `data-src`), so the click handler's + // own url fallback (sparkline.js) can't read the columns off the img — we supply them here + // so data-graph-params is always populated. Mirrors the columns/rows/idGoal the legacy + // fallback would have parsed. + if (url) { + const parsed = external_CoreHome_["MatomoUrl"].parse(url.substring(url.indexOf('?') + 1)); + const derived = {}; + ['columns', 'rows', 'idGoal'].forEach(key => { + if (parsed[key]) { + derived[key] = parsed[key]; + } + }); + if (Object.keys(derived).length) { + return JSON.stringify(derived); + } + } + return null; + }); + const seriesIndicesAttr = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => { + const { + seriesIndices + } = props.sparkline; + return seriesIndices && seriesIndices.length ? JSON.stringify(seriesIndices) : null; + }); + return { + graphParamsAttr, + seriesIndicesAttr + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue + + + +SparklineCardvue_type_script_lang_ts.render = SparklineCardvue_type_template_id_4bca8c84_render + +/* harmony default export */ var SparklineCard = (SparklineCardvue_type_script_lang_ts); +// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=script&lang=ts + + +/* harmony default export */ var SparklinesGridvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({ + name: 'SparklinesGrid', + components: { + SparklineCard: SparklineCard + }, + props: { + sparklines: { + type: Object, + required: true + }, + areSparklinesLinkable: { + type: Boolean, + default: true + }, + // Backend map of metric column -> documentation string, forwarded to each card for the + // metric-title tooltip. + allMetricsDocumentation: { + type: Object, + default: () => ({}) + }, + isWidget: { + type: Boolean, + default: false + } + }, + setup(props) { + // `order` is the backend's source of truth for display order: a total order across + // all cards (even comparison metrics/segments). Flatten every group and sort by it. + // Drop placeholders (Config::addPlaceholder()): no url, they only padded the legacy + // 2-column layout and would render as empty cards here. + const flatSparklines = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => [].concat(...Object.values(props.sparklines || {})).filter(sparkline => !!sparkline.url).sort((a, b) => a.order - b.order)); + // Widgets show two columns; reporting pages use a responsive grid (2/3/4/5 cols). + // Keep xl3 so SparklinesGrid.less can widen it to 5 cols above 1920px. + const columnClasses = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["computed"])(() => props.isWidget ? 'col s6' : 'col s6 m6 l4 xl3'); + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["onMounted"])(() => { + // Re-wire each sparkline to its evolution graph once the cards are in the DOM. + // Safe to re-run (it unbinds first); CoreHome ships sparkline.js in the global JS bundle. + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["nextTick"])(() => { + window.initializeSparklines(); + }); + }); + return { + flatSparklines, + columnClasses + }; + } +})); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue?vue&type=script&lang=ts + +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue + + + +SparklinesGridvue_type_script_lang_ts.render = SparklinesGridvue_type_template_id_708ec4a6_render + +/* harmony default export */ var SparklinesGrid = (SparklinesGridvue_type_script_lang_ts); +// CONCATENATED MODULE: ./plugins/CoreVisualizations/vue/src/index.ts +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + + + + + + +// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/entry-lib-no-default.js + + + + +/***/ }) + +/******/ }); +}); +//# sourceMappingURL=CoreVisualizations.umd.js.map \ No newline at end of file diff --git a/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.min.js b/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.min.js new file mode 100644 index 0000000000000000000000000000000000000000..6dfa6f32e9014be2dd7186bd3ad29528577be125 --- /dev/null +++ b/plugins/CoreVisualizations/vue/dist/CoreVisualizations.umd.min.js @@ -0,0 +1,8 @@ +(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t(require("CoreHome"),require("vue")):"function"===typeof define&&define.amd?define(["CoreHome"],t):"object"===typeof exports?exports["CoreVisualizations"]=t(require("CoreHome"),require("vue")):e["CoreVisualizations"]=t(e["CoreHome"],e["Vue"])})("undefined"!==typeof self?self:this,(function(e,t){return function(e){var t={};function n(o){if(t[o])return t[o].exports;var l=t[o]={i:o,l:!1,exports:{}};return e[o].call(l.exports,l,l.exports,n),l.l=!0,l.exports}return n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)n.d(o,l,function(t){return e[t]}.bind(null,l));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="plugins/CoreVisualizations/vue/dist/",n(n.s="fae3")}({"19dc":function(t,n){t.exports=e},"8bbf":function(e,n){e.exports=t},fae3:function(e,t,n){"use strict";if(n.r(t),n.d(t,"EvolutionBadge",(function(){return S})),n.d(t,"MetricValue",(function(){return P})),n.d(t,"SeriesPicker",(function(){return J})),n.d(t,"MetricsPicker",(function(){return be})),n.d(t,"SingleMetricView",(function(){return Se})),n.d(t,"SparklinesGrid",(function(){return Re})),"undefined"!==typeof window){var o=window.document.currentScript,l=o&&o.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);l&&(n.p=l[1])}var r=n("8bbf");const c=["title"],a={class:"evolutionBadge__icon","aria-hidden":"true"},i={class:"evolutionBadge__value"};function s(e,t,n,o,l,s){const u=Object(r["resolveComponent"])("EvolutionTrendIcon");return Object(r["openBlock"])(),Object(r["createElementBlock"])("span",{class:Object(r["normalizeClass"])(["evolutionBadge",e.directionClass]),title:e.tooltip||void 0},[Object(r["createElementVNode"])("span",a,[Object(r["createVNode"])(u,{class:"evolutionTrendIcon",direction:e.direction},null,8,["direction"])]),Object(r["createElementVNode"])("span",i,Object(r["toDisplayString"])(e.formattedPercent),1)],10,c)}const u={key:0,viewBox:"0 0 16 16"},d=Object(r["createElementVNode"])("path",{d:"M3.77344 11L8.27344 5L12.7734 11H3.77344Z",fill:"currentColor"},null,-1),p=[d],m={key:1,viewBox:"0 0 16 16"},b=Object(r["createElementVNode"])("path",{d:"M3.77344 6L8.27344 12L12.7734 6H3.77344Z",fill:"currentColor"},null,-1),v=[b],O={key:2,viewBox:"0 0 16 16"},j=Object(r["createElementVNode"])("rect",{x:"3",y:"7",width:"10",height:"2",fill:"currentColor"},null,-1),f=[j];function k(e,t,n,o,l,c){return"up"===e.direction?(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",u,p)):"down"===e.direction?(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",m,v)):(Object(r["openBlock"])(),Object(r["createElementBlock"])("svg",O,f))}var g=Object(r["defineComponent"])({name:"EvolutionTrendIcon",props:{direction:{type:String,required:!0,validator:e=>-1!==["up","down","neutral"].indexOf(e)}}});g.render=k;var y=g,h=Object(r["defineComponent"])({name:"EvolutionBadge",components:{EvolutionTrendIcon:y},props:{percent:{type:[Number,String],required:!0},isLowerValueBetter:{type:Boolean,default:!1},trend:{type:Number,default:void 0},tooltip:{type:String,default:""}},setup(e){const t=Object(r["computed"])(()=>{if("number"===typeof e.trend&&!Number.isNaN(e.trend))return e.trend;const t=parseFloat(String(e.percent).replace("−","-").replace(",",".").replace(/[^0-9.+-]/g,""));return Number.isNaN(t)?0:t}),n=Object(r["computed"])(()=>t.value>0?"up":t.value<0?"down":"neutral"),o=Object(r["computed"])(()=>{if("neutral"===n.value)return"evolutionBadge--neutral";const t="up"===n.value,o=e.isLowerValueBetter?!t:t;return o?"evolutionBadge--positive":"evolutionBadge--negative"}),l=Object(r["computed"])(()=>{const n="number"===typeof e.percent?e.percent+"%":String(e.percent).trim(),o=n.charAt(0);return t.value>0&&"+"!==o&&"-"!==o?"+"+n:n});return{direction:n,directionClass:o,formattedPercent:l}}});h.render=s;var S=h;const C={class:"metricValue"},w=["title"],V={class:"metricValue__primary"},B={class:"metricValue__number"},E={key:0,class:"metricValue__secondary"},N={class:"metricValue__secondaryValue"},_={key:0,class:"metricValue__secondaryLabel"};function M(e,t,n,o,l,c){const a=Object(r["resolveDirective"])("tooltips");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",C,[Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["metricValue__title",{"metricValue__title--documented":!!e.documentation}]),title:e.documentation||e.title},[Object(r["createTextVNode"])(Object(r["toDisplayString"])(e.title),1)],10,w)),[[a,{duration:200,delay:200}]]),Object(r["createElementVNode"])("div",V,[Object(r["createElementVNode"])("span",B,Object(r["toDisplayString"])(e.value),1),Object(r["renderSlot"])(e.$slots,"evolution")]),e.hasSecondary?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",E,[Object(r["createElementVNode"])("span",N,Object(r["toDisplayString"])(e.secondaryValue),1),e.secondaryLabel?(Object(r["openBlock"])(),Object(r["createElementBlock"])("span",_,Object(r["toDisplayString"])(e.secondaryLabel),1)):Object(r["createCommentVNode"])("",!0)])):Object(r["createCommentVNode"])("",!0)])}var D=n("19dc"),x=Object(r["defineComponent"])({name:"MetricValue",directives:{Tooltips:D["Tooltips"]},props:{title:{type:String,required:!0},value:{type:[String,Number],required:!0},secondaryValue:[String,Number],secondaryLabel:String,documentation:String},computed:{hasSecondary(){return void 0!==this.secondaryValue&&null!==this.secondaryValue&&""!==this.secondaryValue}}});x.render=M;var P=x;const L={key:0,class:"jqplot-seriespicker-popover"},A={class:"headline"},R=["onClick"],T=["type","checked"],$={key:0,class:"headline recordsToPlot"},G=["onClick"],q=["type","checked"];function I(e,t,n,o,l,c){return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["jqplot-seriespicker",{open:e.isPopupVisible}]),onMouseenter:t[1]||(t[1]=t=>e.isPopupVisible=!0),onMouseleave:t[2]||(t[2]=t=>e.onLeavePopup())},[Object(r["createElementVNode"])("a",{href:"#",onClick:t[0]||(t[0]=Object(r["withModifiers"])(()=>{},["prevent","stop"]))}," + "),e.isPopupVisible?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",L,[Object(r["createElementVNode"])("p",A,Object(r["toDisplayString"])(e.translate(e.multiselect?"General_MetricsToPlot":"General_MetricToPlot")),1),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableColumns,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",{class:"pickColumn",onClick:n=>e.optionSelected(t.column,e.columnStates),key:t.column},[Object(r["createElementVNode"])("label",null,[Object(r["createElementVNode"])("input",{class:"select",type:e.multiselect?"checkbox":"radio",checked:!!e.columnStates[t.column]},null,8,T),Object(r["createElementVNode"])("span",null,Object(r["toDisplayString"])(t.translation),1)])],8,R))),128)),e.selectableRows.length?(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",$,Object(r["toDisplayString"])(e.translate("General_RecordsToPlot")),1)):Object(r["createCommentVNode"])("",!0),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableRows,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",{class:"pickRow",onClick:n=>e.optionSelected(t.matcher,e.rowStates),key:t.matcher},[Object(r["createElementVNode"])("label",null,[Object(r["createElementVNode"])("input",{class:"select",type:e.multiselect?"checkbox":"radio",checked:!!e.rowStates[t.matcher]},null,8,q),Object(r["createElementVNode"])("span",null,Object(r["toDisplayString"])(t.label),1)])],8,G))),128))])):Object(r["createCommentVNode"])("",!0)],34)}function z(e,t){const n={};return e.forEach(e=>{const t=e.column||e.matcher;n[t]=!1}),t.forEach(e=>{n[e]=!0}),n}function F(e,t){return e.length===t.length&&0===e.filter(e=>-1===t.indexOf(e)).length}function H(e){Object.keys(e).forEach(t=>{e[t]=!1})}function K(e){return Object.keys(e).filter(t=>!!e[t])}var U=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},data(){return{isPopupVisible:!1,columnStates:z(this.selectableColumns,this.selectedColumns),rowStates:z(this.selectableRows,this.selectedRows)}},emits:["select"],created(){this.optionSelected=Object(D["debounce"])(this.optionSelected,0)},methods:{optionSelected(e,t){this.multiselect||(H(this.columnStates),H(this.rowStates)),t[e]=!t[e],this.triggerOnSelectAndClose()},onLeavePopup(){this.isPopupVisible=!1,this.optionsChanged()&&this.triggerOnSelectAndClose()},triggerOnSelectAndClose(){this.isPopupVisible=!1,this.$emit("select",{columns:K(this.columnStates),rows:K(this.rowStates)})},optionsChanged(){return!F(K(this.columnStates),this.selectedColumns)||!F(K(this.rowStates),this.selectedRows)}}});U.render=I;var J=U;const W={ref:"root",class:"metrics-picker"},Z={ref:"expander",type:"button",class:"metrics-picker__toggle"},Q={class:"metrics-picker__toggle-label"},X=Object(r["createElementVNode"])("span",{class:"icon-chevron-down metrics-picker__chevron"},null,-1),Y={class:"metrics-picker__dropdown"};function ee(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("MetricsPickerOptions"),i=Object(r["resolveDirective"])("expand-on-click");return Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("div",W,[Object(r["createElementVNode"])("button",Z,[Object(r["createElementVNode"])("span",Q,Object(r["toDisplayString"])(e.translate("General_ChooseMetrics")),1),X],512),Object(r["createElementVNode"])("div",Y,[Object(r["createVNode"])(a,{multiselect:e.multiselect,"selectable-columns":e.selectableColumns,"selectable-rows":e.selectableRows,"selected-columns":e.selectedColumns,"selected-rows":e.selectedRows,onSelect:t[0]||(t[0]=t=>e.onSelect(t))},null,8,["multiselect","selectable-columns","selectable-rows","selected-columns","selected-rows"])])])),[[i,{expander:"expander"}]])}const te=["role","aria-label"],ne=["type","checked","onChange","onKeydown"],oe=Object(r["createElementVNode"])("span",{"aria-hidden":"true"},null,-1),le={class:"metrics-picker__title"},re={key:0,class:"metrics-picker__headline"},ce=["type","checked","onChange","onKeydown"],ae=Object(r["createElementVNode"])("span",{"aria-hidden":"true"},null,-1),ie={class:"metrics-picker__title"};function se(e,t,n,o,l,c){return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:"metrics-picker__options",role:e.multiselect?"group":"radiogroup","aria-label":e.translate("General_ChooseMetrics")},[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableColumns,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("label",{class:"metrics-picker__column metrics-picker__label",key:t.column},[Object(r["createElementVNode"])("input",{class:"filled-in",type:e.multiselect?"checkbox":"radio",checked:!!e.columnStates[t.column],onChange:n=>e.optionSelected(t.column,e.columnStates),onKeydown:Object(r["withKeys"])(Object(r["withModifiers"])(n=>e.optionSelected(t.column,e.columnStates),["prevent"]),["enter"])},null,40,ne),oe,Object(r["createElementVNode"])("span",le,Object(r["toDisplayString"])(t.translation),1)]))),128)),e.selectableRows.length?(Object(r["openBlock"])(),Object(r["createElementBlock"])("p",re,Object(r["toDisplayString"])(e.translate("General_RecordsToPlot")),1)):Object(r["createCommentVNode"])("",!0),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.selectableRows,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("label",{class:"metrics-picker__row metrics-picker__label",key:t.matcher},[Object(r["createElementVNode"])("input",{class:"filled-in",type:e.multiselect?"checkbox":"radio",checked:!!e.rowStates[t.matcher],onChange:n=>e.optionSelected(t.matcher,e.rowStates),onKeydown:Object(r["withKeys"])(Object(r["withModifiers"])(n=>e.optionSelected(t.matcher,e.rowStates),["prevent"]),["enter"])},null,40,ce),ae,Object(r["createElementVNode"])("span",ie,Object(r["toDisplayString"])(t.label),1)]))),128))],8,te)}function ue(e,t){const n={};return e.forEach(e=>{const t=e.column||e.matcher;n[t]=!1}),t.forEach(e=>{n[e]=!0}),n}var de=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},data(){return{columnStates:ue(this.selectableColumns,this.selectedColumns),rowStates:ue(this.selectableRows,this.selectedRows)}},emits:["select"],methods:{unselectOptions(e){Object.keys(e).forEach(t=>{e[t]=!1})},getSelected(e){return Object.keys(e).filter(t=>!!e[t])},optionSelected(e,t){this.multiselect||(this.unselectOptions(this.columnStates),this.unselectOptions(this.rowStates)),t[e]=!t[e],this.$emit("select",{columns:this.getSelected(this.columnStates),rows:this.getSelected(this.rowStates)})}}});de.render=se;var pe=de,me=Object(r["defineComponent"])({props:{multiselect:Boolean,selectableColumns:{type:Array,default:()=>[]},selectableRows:{type:Array,default:()=>[]},selectedColumns:{type:Array,default:()=>[]},selectedRows:{type:Array,default:()=>[]}},components:{MetricsPickerOptions:pe},directives:{ExpandOnClick:D["ExpandOnClick"]},emits:["select"],methods:{onSelect(e){this.$emit("select",e),this.$refs.root.classList.remove("expanded")}}});me.render=ee;var be=me;const ve={class:"metric-sparkline"},Oe={class:"metric-value"},je=["title"],fe=["title"];function ke(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("Sparkline");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["singleMetricView",{loading:e.isLoading}]),ref:"root"},[Object(r["createElementVNode"])("div",ve,[Object(r["createVNode"])(a,{params:e.sparklineParams},null,8,["params"])]),Object(r["createElementVNode"])("div",Oe,[Object(r["createElementVNode"])("span",{title:e.metricDocumentation},[Object(r["createElementVNode"])("strong",null,Object(r["toDisplayString"])(e.metricValue),1),Object(r["createTextVNode"])(" "+Object(r["toDisplayString"])((e.metricTranslation||"").toLowerCase()),1)],8,je),null!==e.pastValue?(Object(r["openBlock"])(),Object(r["createElementBlock"])("span",{key:0,class:"metricEvolution",title:e.translate("General_EvolutionSummaryGeneric",e.metricValue,e.currentPeriod,e.pastValue,e.pastPeriod,e.metricChangePercent)},[Object(r["createElementVNode"])("span",{class:Object(r["normalizeClass"])(e.evolutionClass)},Object(r["toDisplayString"])(e.metricChangePercent),3)],8,fe)):Object(r["createCommentVNode"])("",!0)])],2)}function ge(){const{startDate:e}=D["Range"].getLastNRange(D["Matomo"].period,2,D["Matomo"].currentDateString),t=D["Periods"].get(D["Matomo"].period).parse(e).getDateRange();return`${Object(D["format"])(t[0])},${Object(D["format"])(t[1])}`}const{$:ye}=window;var he=Object(r["defineComponent"])({props:{metric:{type:String,required:!0},idGoal:[String,Number],metricTranslations:{type:Object,required:!0},metricDocumentations:Object,goals:{type:Object,required:!0},goalMetrics:Array,lowerIsBetterMetrics:{type:Array,default:()=>[]}},components:{Sparkline:D["Sparkline"]},setup(e){const t=Object(r["ref"])(null),n=Object(r["ref"])(!1),o=Object(r["ref"])(null),l=Object(r["ref"])(e.metric),c=Object(r["ref"])(e.idGoal),a=Object(r["computed"])(()=>[c.value?`goal${c.value}_${l.value}`:l.value]),i=Object(r["computed"])(()=>{var e;return null!==(e=o.value)&&void 0!==e&&e[1]?o.value[1][l.value]||0:null}),s=Object(r["computed"])(()=>{var e;return null!==(e=o.value)&&void 0!==e&&e[2]?o.value[2][l.value]||0:null}),u=Object(r["computed"])(()=>-1!==e.lowerIsBetterMetrics.indexOf(l.value)),d=Object(r["computed"])(()=>{if(null===i.value||null===s.value||i.value===s.value)return[];const e=i.value>s.value,t=u.value?!e:e;return[e?"evolution-up":"evolution-down",t?"positive-evolution":"negative-evolution"]}),p=Object(r["computed"])(()=>{if(null===i.value||void 0===i.value||null===s.value||void 0===s.value)return null;const e="string"===typeof i.value?parseFloat(i.value):i.value,t="string"===typeof s.value?parseFloat(s.value):s.value,n=D["Matomo"].helper.calculateEvolution(e,t);return(100*n).toFixed(2)+" %"}),m=Object(r["computed"])(()=>{var e;if(null===(e=o.value)||void 0===e||!e[3])return null;const t=o.value[3];return t[l.value]||0}),b=Object(r["computed"])(()=>{var e;if(null===(e=o.value)||void 0===e||!e[0])return null;const t=o.value[0];return t[l.value]||0}),v=Object(r["computed"])(()=>{var t;return null!==(t=e.metricTranslations)&&void 0!==t&&t[l.value]?e.metricTranslations[l.value]:""}),O=Object(r["computed"])(()=>{var t;return null!==(t=e.metricDocumentations)&&void 0!==t&&t[l.value]?e.metricDocumentations[l.value]:""}),j=Object(r["computed"])(()=>D["Matomo"].startDateString===D["Matomo"].endDateString?D["Matomo"].endDateString:`${D["Matomo"].startDateString}, ${D["Matomo"].endDateString}`);function f(){return c.value||0===c.value}const k=Object(r["computed"])(()=>{const e={module:"API",action:"get",columns:l.value};return f()&&(e.idGoal=c.value,e.module="Goals"),e}),g=Object(r["computed"])(()=>{if("range"!==D["Matomo"].period)return ge()}),y=Object(r["computed"])(()=>{const t=[];return Object.keys(e.metricTranslations).forEach(n=>{t.push({column:n,translation:e.metricTranslations[n]})}),Object.values(e.goals||{}).forEach(n=>{e.goalMetrics.forEach(o=>{t.push({column:`goal${n.idgoal}_${o}`,translation:`${n.name} - ${e.metricTranslations[o]}`})})}),t});function h(){let n=v.value;if(f()){var o;const t=(null===(o=e.goals[c.value])||void 0===o?void 0:o.name)||Object(D["translate"])("General_Unknown");n=`${t} - ${n}`}ye(t.value).closest("div.widget").find(".widgetTop > .widgetName > span").text(n)}function S(){const e=D["Range"].getLastNRange(D["Matomo"].period,2,D["Matomo"].currentDateString);return Object(D["format"])(e.startDate)}function C(){n.value=!0;const e=[];let t="API",l="get";const r={};f()&&(r.idGoal=c.value,r.filter_add_columns_when_show_all_columns=0,t="Goals",l="get");const a=`${t}.${l}`;return e.push(D["AjaxHelper"].fetch(Object.assign({method:a,format_metrics:"all"},r))),"range"!==D["Matomo"].period&&(e.push(D["AjaxHelper"].fetch(Object.assign({method:a,format_metrics:"0"},r))),e.push(D["AjaxHelper"].fetch(Object.assign({method:a,date:S(),format_metrics:"0"},r))),e.push(D["AjaxHelper"].fetch(Object.assign({method:a,date:S(),format_metrics:"all"},r)))),Promise.all(e).then(e=>{o.value=e,n.value=!1})}function w(e){l.value=e,C().then(h),ye(t.value).closest("[widgetId]").trigger("setParameters",{column:l.value,idGoal:c.value})}function V(e){let t=void 0,n=e;const o=e.match(/^goal([0-9]+)_(.*)/);o&&(t=+o[1],[,,n]=o),l.value===n&&t===c.value||(l.value=n,c.value=t,w(n))}function B(){const e=ye(t.value),n=e.closest("div.widget").find(".widgetTop > .widgetName"),o=ye('
'),l=Object(D["createVueApp"])({render:()=>Object(r["createVNode"])(J,{multiselect:!1,selectableColumns:y.value,selectableRows:[],selectedColumns:a.value,selectedRows:[],onSelect:({columns:e})=>{V(e[0])}})});return n.append(o),l.mount(o.children()[0]),l}let E;return Object(r["onMounted"])(()=>{E=B()}),Object(r["onBeforeUnmount"])(()=>{ye(t.value).closest(".widgetContent").off("widget:destroy").off("widget:reload"),ye(t.value).closest("div.widget").find(".single-metric-view-picker").remove(),E.unmount()}),Object(r["watch"])(()=>e.metric,()=>{w(e.metric)}),w(e.metric),{root:t,metricValue:b,isLoading:n,selectedColumns:a,responses:o,metricValueUnformatted:i,pastValueUnformatted:s,evolutionClass:d,metricChangePercent:p,pastValue:m,metricTranslation:v,metricDocumentation:O,sparklineParams:k,pastPeriod:g,selectableColumns:y,currentPeriod:j}}});he.render=ke;var Se=he;const Ce={class:"row sparklinesGrid"};function we(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("SparklineCard");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Ce,[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.flatSparklines,(t,n)=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{key:n,class:Object(r["normalizeClass"])(e.columnClasses)},[Object(r["createVNode"])(a,{sparkline:t,"are-sparklines-linkable":e.areSparklinesLinkable,"all-metrics-documentation":e.allMetricsDocumentation},null,8,["sparkline","are-sparklines-linkable","all-metrics-documentation"])],2))),128))])}const Ve=["data-graph-params","data-series-indices"],Be={key:0,class:"sparklineCard__title"};function Ee(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("NoComparison");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])(["sparkline sparklineCard",{notLinkable:!e.areSparklinesLinkable}]),"data-graph-params":e.graphParamsAttr,"data-series-indices":e.seriesIndicesAttr},[e.sparkline.title?(Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Be,Object(r["toDisplayString"])(e.sparkline.title),1)):Object(r["createCommentVNode"])("",!0),Object(r["createVNode"])(a,{sparkline:e.sparkline,"all-metrics-documentation":e.allMetricsDocumentation},null,8,["sparkline","all-metrics-documentation"])],10,Ve)}const Ne={class:"noComparison"},_e={class:"sparklineSlot"};function Me(e,t,n,o,l,c){const a=Object(r["resolveComponent"])("EvolutionBadge"),i=Object(r["resolveComponent"])("MetricValue"),s=Object(r["resolveComponent"])("Sparkline");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",Ne,[Object(r["createVNode"])(i,{title:e.title,value:e.primaryValue,"secondary-value":e.secondaryValue,"secondary-label":e.secondaryLabel,documentation:e.documentation},Object(r["createSlots"])({_:2},[e.sparkline.evolution?{name:"evolution",fn:Object(r["withCtx"])(()=>[Object(r["createVNode"])(a,{percent:e.sparkline.evolution.percent,trend:e.sparkline.evolution.trend,"is-lower-value-better":e.sparkline.evolution.isLowerValueBetter,tooltip:e.sparkline.evolution.tooltip||""},null,8,["percent","trend","is-lower-value-better","tooltip"])]),key:"0"}:void 0]),1032,["title","value","secondary-value","secondary-label","documentation"]),Object(r["createElementVNode"])("div",_e,[Object(r["createVNode"])(s,{width:380,height:40,params:e.sparkline.url,"series-indices":e.sparkline.seriesIndices},null,8,["params","series-indices"])])])}var De=Object(r["defineComponent"])({name:"NoComparison",components:{MetricValue:P,EvolutionBadge:S,Sparkline:D["Sparkline"]},props:{sparkline:{type:Object,required:!0},allMetricsDocumentation:{type:Object,default:()=>({})}},setup(e){const t=Object(r["computed"])(()=>{var t;return null===(t=e.sparkline.metrics)||void 0===t||null===(t=t[""])||void 0===t?void 0:t[0]}),n=Object(r["computed"])(()=>{var t;return null===(t=e.sparkline.metrics)||void 0===t||null===(t=t[""])||void 0===t?void 0:t[1]}),o=Object(r["computed"])(()=>{var e,n;return(null===(e=t.value)||void 0===e?void 0:e.title)||(null===(n=t.value)||void 0===n?void 0:n.description)||""}),l=Object(r["computed"])(()=>{var n,o;return e.allMetricsDocumentation[null!==(n=null===(o=t.value)||void 0===o?void 0:o.column)&&void 0!==n?n:""]||void 0}),c=e=>"number"===typeof e?D["NumberFormatter"].formatNumber(e,2):e,a=Object(r["computed"])(()=>{var e,n;return null!==(e=c(null===(n=t.value)||void 0===n?void 0:n.value))&&void 0!==e?e:""}),i=Object(r["computed"])(()=>{var e;return c(null===(e=n.value)||void 0===e?void 0:e.value)}),s=Object(r["computed"])(()=>{var e;return null===(e=n.value)||void 0===e?void 0:e.description});return{title:o,documentation:l,primaryValue:a,secondaryValue:i,secondaryLabel:s}}});De.render=Me;var xe=De,Pe=Object(r["defineComponent"])({name:"SparklineCard",components:{NoComparison:xe},props:{sparkline:{type:Object,required:!0},areSparklinesLinkable:{type:Boolean,default:!0},allMetricsDocumentation:{type:Object,default:()=>({})}},setup(e){const t=Object(r["computed"])(()=>{const{graphParams:t,url:n}=e.sparkline;if(t&&Object.keys(t).length)return JSON.stringify(t);if(n){const e=D["MatomoUrl"].parse(n.substring(n.indexOf("?")+1)),t={};if(["columns","rows","idGoal"].forEach(n=>{e[n]&&(t[n]=e[n])}),Object.keys(t).length)return JSON.stringify(t)}return null}),n=Object(r["computed"])(()=>{const{seriesIndices:t}=e.sparkline;return t&&t.length?JSON.stringify(t):null});return{graphParamsAttr:t,seriesIndicesAttr:n}}});Pe.render=Ee;var Le=Pe,Ae=Object(r["defineComponent"])({name:"SparklinesGrid",components:{SparklineCard:Le},props:{sparklines:{type:Object,required:!0},areSparklinesLinkable:{type:Boolean,default:!0},allMetricsDocumentation:{type:Object,default:()=>({})},isWidget:{type:Boolean,default:!1}},setup(e){const t=Object(r["computed"])(()=>[].concat(...Object.values(e.sparklines||{})).filter(e=>!!e.url).sort((e,t)=>e.order-t.order)),n=Object(r["computed"])(()=>e.isWidget?"col s6":"col s6 m6 l4 xl3");return Object(r["onMounted"])(()=>{Object(r["nextTick"])(()=>{window.initializeSparklines()})}),{flatSparklines:t,columnClasses:n}}});Ae.render=we;var Re=Ae; +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */}})})); +//# sourceMappingURL=CoreVisualizations.umd.min.js.map \ No newline at end of file diff --git a/plugins/CoreVisualizations/vue/dist/umd.metadata.json b/plugins/CoreVisualizations/vue/dist/umd.metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..9ecfcc0456204cf8e8e80416ebc95ef42d4fbd28 --- /dev/null +++ b/plugins/CoreVisualizations/vue/dist/umd.metadata.json @@ -0,0 +1,5 @@ +{ + "dependsOn": [ + "CoreHome" + ] +} \ No newline at end of file diff --git a/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less new file mode 100644 index 0000000000000000000000000000000000000000..8116e1c5ce4be695c95ec823ce1af3f1d2b51e34 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.less @@ -0,0 +1,73 @@ +.evolutionBadge { + // local placeholder for a brand green not yet in the shared palette; swap for a + // @color-* variable once #33B343 lands in plugins/Morpheus/stylesheets/base/colors.less + @_evolution-green: #33B343; + + display: inline-flex; + align-items: center; + padding-left: 2px; + padding-right: 6px; + border-radius: 40px; + font-size: 12px; + font-variant: tabular-nums; + line-height: 20px; + white-space: nowrap; + vertical-align: middle; + height: 20px; + + .evolutionBadge__icon { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + + svg { + display: block; + width: 100%; + height: 100%; + } + } + + &.evolutionBadge--positive { + color: @_evolution-green; + // opaque pale-green tint; lighten keeps the green's saturation, so tune the + // percentage to taste (higher = paler) + background-color: lighten(@_evolution-green, 50%); + } + + &.evolutionBadge--negative { + // theme primary series red; it resolves to a CSS var(--...) and LESS colour + // functions (lighten/darken) can't operate on a var(), so mix toward white with + // CSS color-mix() for an opaque pale tint (the var-safe equivalent of lighten) + color: @theme-color-base-series; + background-color: color-mix(in srgb, @theme-color-base-series 5%, white); + } + + &.evolutionBadge--neutral { + // silver dash + value on an opaque silver tint; same grey family, mixed toward + // white so it reads as a pale pill on the light surface + color: @theme-color-text-lighter; + background-color: color-mix(in srgb, @theme-color-text-lighter 5%, white); + } + + // brand green is a touch dark on a dark background, so lighten the text to keep + // the pill legible + .inDarkMode({ + &.evolutionBadge--positive { + // mirror of light mode: lighten goes toward white for a pale tint on white, + // so on the dark surface darken the green into an opaque dark-green tint instead + color: lighten(@_evolution-green, 10%); + background-color: darken(@_evolution-green, 28%); + } + + &.evolutionBadge--negative { + // mix toward black for an opaque dark tint on the dark surface (the var-safe + // equivalent of darken) + background-color: color-mix(in srgb, @theme-color-base-series 18%, black); + } + + &.evolutionBadge--neutral { + background-color: color-mix(in srgb, @theme-color-text-lighter 14%, black); + } + }); +} diff --git a/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.spec.ts b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fb72c9dd2c3b5557dcd377730786012e29d060e --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.spec.ts @@ -0,0 +1,106 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; +import EvolutionBadge from './EvolutionBadge.vue'; + +function mountBadge(props: Record) { + return mount(EvolutionBadge, { props }); +} + +describe('CoreVisualizations/EvolutionBadge.vue', () => { + it('renders an increase as a positive (green) badge pointing up', () => { + const wrapper = mountBadge({ percent: 4 }); + + expect(wrapper.vm.direction).toBe('up'); + expect(wrapper.classes()).toContain('evolutionBadge'); + expect(wrapper.classes()).toContain('evolutionBadge--positive'); + expect(wrapper.find('.evolutionBadge__icon path').exists()).toBe(true); + expect(wrapper.find('.evolutionBadge__value').text()).toBe('+4%'); + }); + + it('renders a decrease as a negative (red) badge pointing down', () => { + const wrapper = mountBadge({ percent: -4 }); + + expect(wrapper.vm.direction).toBe('down'); + expect(wrapper.classes()).toContain('evolutionBadge--negative'); + expect(wrapper.find('.evolutionBadge__icon path').exists()).toBe(true); + expect(wrapper.find('.evolutionBadge__value').text()).toBe('-4%'); + }); + + it('renders no change as a neutral (grey) badge with a dash', () => { + const wrapper = mountBadge({ percent: 0 }); + + expect(wrapper.vm.direction).toBe('neutral'); + expect(wrapper.classes()).toContain('evolutionBadge--neutral'); + // the neutral state uses a dash (rect), not an arrow (path) + expect(wrapper.find('.evolutionBadge__icon rect').exists()).toBe(true); + expect(wrapper.find('.evolutionBadge__icon path').exists()).toBe(false); + expect(wrapper.find('.evolutionBadge__value').text()).toBe('0%'); + }); + + it('inverts the colour when isLowerValueBetter, keeping the arrow direction', () => { + const increased = mountBadge({ percent: 4, isLowerValueBetter: true }); + // value went up, but for a lower-is-better metric that is a bad change + expect(increased.vm.direction).toBe('up'); + expect(increased.classes()).toContain('evolutionBadge--negative'); + + const decreased = mountBadge({ percent: -4, isLowerValueBetter: true }); + // value went down, which is a good change for a lower-is-better metric + expect(decreased.vm.direction).toBe('down'); + expect(decreased.classes()).toContain('evolutionBadge--positive'); + }); + + it('uses the trend prop as the authoritative direction over the percent sign', () => { + // a negative trend wins even though the percent string carries no sign + const wrapper = mountBadge({ percent: '4%', trend: -10 }); + + expect(wrapper.vm.direction).toBe('down'); + expect(wrapper.classes()).toContain('evolutionBadge--negative'); + // direction is down, so no leading "+" is added to the formatted value + expect(wrapper.find('.evolutionBadge__value').text()).toBe('4%'); + }); + + it('accepts a pre-formatted percent string and prefixes a "+" for increases', () => { + const wrapper = mountBadge({ percent: '4%' }); + + expect(wrapper.vm.direction).toBe('up'); + expect(wrapper.find('.evolutionBadge__value').text()).toBe('+4%'); + }); + + it('keeps an existing sign on a pre-formatted negative percent string', () => { + const wrapper = mountBadge({ percent: '-4%' }); + + expect(wrapper.vm.direction).toBe('down'); + expect(wrapper.find('.evolutionBadge__value').text()).toBe('-4%'); + }); + + it('reads a localised minus (U+2212) as a decrease when no trend is given', () => { + // fi/sv/sl/et and others format negatives with U+2212 "−", not an ASCII hyphen + const wrapper = mountBadge({ percent: '\u22124%' }); + + expect(wrapper.vm.direction).toBe('down'); + expect(wrapper.classes()).toContain('evolutionBadge--negative'); + // the localised sign is kept as-is, with no spurious "+" prepended + expect(wrapper.find('.evolutionBadge__value').text()).toBe('\u22124%'); + }); + + it('exposes the tooltip as the title attribute and omits it when empty', () => { + const withTooltip = mountBadge({ percent: 4, tooltip: '10 visits vs 8 visits' }); + expect(withTooltip.attributes('title')).toBe('10 visits vs 8 visits'); + + const withoutTooltip = mountBadge({ percent: 4 }); + expect(withoutTooltip.attributes('title')).toBeUndefined(); + }); + + it('renders a long value on a single line without truncating it', () => { + const wrapper = mountBadge({ percent: '+1,234,567%', trend: 5 }); + + expect(wrapper.vm.direction).toBe('up'); + expect(wrapper.find('.evolutionBadge__value').text()).toBe('+1,234,567%'); + }); +}); diff --git a/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue new file mode 100644 index 0000000000000000000000000000000000000000..a2d4ad2673495291d78458c7c5a3564b10f0ec22 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionBadge.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.spec.ts b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f09ce65014c784a616a90943c629747a95682f62 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.spec.ts @@ -0,0 +1,41 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; +import EvolutionTrendIcon from './EvolutionTrendIcon.vue'; + +const UP_PATH = 'M3.77344 11L8.27344 5L12.7734 11H3.77344Z'; +const DOWN_PATH = 'M3.77344 6L8.27344 12L12.7734 6H3.77344Z'; + +describe('CoreVisualizations/EvolutionTrendIcon.vue', () => { + it('renders the up triangle for direction "up"', () => { + const wrapper = mount(EvolutionTrendIcon, { props: { direction: 'up' } }); + + expect(wrapper.find('path').attributes('d')).toBe(UP_PATH); + expect(wrapper.find('rect').exists()).toBe(false); + }); + + it('renders the down triangle for direction "down"', () => { + const wrapper = mount(EvolutionTrendIcon, { props: { direction: 'down' } }); + + expect(wrapper.find('path').attributes('d')).toBe(DOWN_PATH); + expect(wrapper.find('rect').exists()).toBe(false); + }); + + it('renders the dash (rect) and no path for direction "neutral"', () => { + const wrapper = mount(EvolutionTrendIcon, { props: { direction: 'neutral' } }); + + expect(wrapper.find('rect').exists()).toBe(true); + expect(wrapper.find('path').exists()).toBe(false); + }); + + it('colours the shape with currentColor so it inherits the badge colour', () => { + const wrapper = mount(EvolutionTrendIcon, { props: { direction: 'up' } }); + + expect(wrapper.find('path').attributes('fill')).toBe('currentColor'); + }); +}); diff --git a/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue new file mode 100644 index 0000000000000000000000000000000000000000..63775043df4b3190db116a4cce04ff45c694b776 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/EvolutionBadge/EvolutionTrendIcon.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less b/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less new file mode 100644 index 0000000000000000000000000000000000000000..ffb771ae03951f2d386d80a1ee9c7b904aae34cc --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.less @@ -0,0 +1,38 @@ +.metricValue { + &__title { + color: @theme-color-text; + font-size: 18px; + line-height: 24px; + + // Signals the title carries a documentation tooltip. + &--documented { + cursor: help; + } + } + + &__primary { + display: flex; + align-items: baseline; + gap: 8px; + } + + &__number { + font-size: 28px; + font-weight: 600; + line-height: 36px; + color: @theme-color-text; + } + + &__secondaryValue { + color: @theme-color-text; + font-size: 14px; + line-height: 24px; + } + + &__secondaryLabel { + margin-left: 4px; + font-size: 12px; + line-height: 24px; + color: @theme-color-text-lighter; + } +} diff --git a/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.spec.ts b/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b21310cbd3d45ebc4d22ada0efbf87ef0323124 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.spec.ts @@ -0,0 +1,113 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; + +// CoreHome is a package-style cross-plugin import with no jest module mapping, +// so it must be virtually mocked. Tooltips is used only as a (no-op here) directive. +jest.mock('CoreHome', () => ({ + Tooltips: {}, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const MetricValue = require('./MetricValue.vue').default; + +describe('CoreVisualizations/MetricValue', () => { + it('renders the title and the pre-formatted value', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Searches', + value: '190', + }, + }); + + expect(wrapper.find('.metricValue__title').text()).toBe('Searches'); + expect(wrapper.find('.metricValue__number').text()).toBe('190'); + }); + + it('renders the secondary value and label as separate elements', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Visits', + value: '10,558', + secondaryValue: '9,527', + secondaryLabel: 'unique visitors', + }, + }); + + expect(wrapper.find('.metricValue__secondary').exists()).toBe(true); + expect(wrapper.find('.metricValue__secondaryValue').text()).toBe('9,527'); + expect(wrapper.find('.metricValue__secondaryLabel').text()).toBe('unique visitors'); + }); + + it('renders the secondary value without a label when no label is given', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Visits', + value: '10,558', + secondaryValue: '9,527', + }, + }); + + expect(wrapper.find('.metricValue__secondaryValue').text()).toBe('9,527'); + expect(wrapper.find('.metricValue__secondaryLabel').exists()).toBe(false); + }); + + it('omits the secondary line entirely when no secondary value is provided', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Average visit duration', + value: '4min 22s', + }, + }); + + expect(wrapper.find('.metricValue__secondary').exists()).toBe(false); + }); + + it('exposes documentation as the title tooltip and flags the title as documented', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Searches', + value: '190', + documentation: 'The number of searches.', + }, + }); + + const title = wrapper.find('.metricValue__title'); + expect(title.attributes('title')).toBe('The number of searches.'); + expect(title.classes()).toContain('metricValue__title--documented'); + }); + + it('falls back to the full title as the tooltip and sets no documented class without documentation', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Searches', + value: '190', + }, + }); + + const title = wrapper.find('.metricValue__title'); + expect(title.attributes('title')).toBe('Searches'); + expect(title.classes()).not.toContain('metricValue__title--documented'); + }); + + it('renders content passed to the evolution slot next to the value', () => { + const wrapper = mount(MetricValue as any, { + props: { + title: 'Searches', + value: '190', + }, + slots: { + evolution: '-4%', + }, + }); + + const primary = wrapper.find('.metricValue__primary'); + expect(primary.find('.fake-badge').exists()).toBe(true); + expect(primary.find('.fake-badge').text()).toBe('-4%'); + }); +}); diff --git a/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue b/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue new file mode 100644 index 0000000000000000000000000000000000000000..efb53ec04688e7c0483d6c6e72f60567df29dace --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/MetricValue/MetricValue.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.less b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.less new file mode 100644 index 0000000000000000000000000000000000000000..1d24cb84a4dfb5205b7fce78de0cfaf0753f09fe --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.less @@ -0,0 +1,224 @@ +.metrics-picker { + // Declared inside the selector so they stay scoped to .metrics-picker. + @_dropdown-shadow: ~"0 10px 40px 0 #A5B1CA4D, 0 0 3px 0 #A5B1CA4D"; + @_dropdown-shadow-dark: ~"0 10px 40px 0 #00000080, 0 0 3px 0 #00000080"; + + // 1px solid — shared by the toggle/dropdown/checkbox borders and the focus outline + @_border-stroke: ~"1px solid"; + @_border: ~"@{_border-stroke} @{theme-color-border-alternative}"; + + // hover, and focus states + @_accent-color: @theme-color-focus-ring; + @_option-hover-bg: @theme-color-background-base; + // checked state + @_checked-color: @theme-color-new-brand; + + @_border-radius: 8px; + @_checkbox-size: 18px; + @_checkbox-border-radius: 6px; + @_text-size: 14px; + @_text-line-height: 20px; + + display: inline-block; + position: relative; + + // In a narrow legend footer the picker stacks onto its own full-width row, so the + // component stretches to fill it. The inline-block wrapper around this component is + // widened separately in dataTableVisualizations.less (it lives outside this component). + .jqplot-legend-footer.is-narrow & { + width: 100%; + } + + .metrics-picker__toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: @theme-color-background-contrast; + color: @theme-color-text; + border: @_border; + border-radius: @_border-radius; + font-size: @_text-size; + line-height: @_text-line-height; + cursor: pointer; + height: 38px; + + &:hover, &:focus-visible { + border-color: @_accent-color; + } + &:focus-visible { + outline: @_border-stroke @_accent-color; + } + + &:focus, &:hover { + outline: none; + } + + // In a narrow legend footer the toggle fills its full-width row and centres its label. + .jqplot-legend-footer.is-narrow & { + width: 100%; + justify-content: center; + } + } + .metrics-picker__toggle-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; // the important one — see below + } + + .metrics-picker__chevron { + font-size: 12px; + transition: transform .15s ease; + flex: none; // keep the chevron from being squashed + } + + &.expanded .metrics-picker__chevron { + transform: rotate(180deg); + } + + .metrics-picker__dropdown { + display: none; + position: absolute; + top: 100%; + margin-top: 8px; + left: 0; + z-index: 1010; /* must be above ui dialog, matches series picker popover */ + overflow-y: auto; + padding: 8px; + /* the legend footer lives inside .dataTableFeatures (text-align: center) - + reset so the metric checkboxes align to the left */ + text-align: left; + background: @theme-color-background-contrast; + border: @_border; + border-radius: @_border-radius; + box-shadow: @_dropdown-shadow; + + .inDarkMode({ + box-shadow: @_dropdown-shadow-dark; + }) + } + + &.expanded .metrics-picker__dropdown { + display: block; + width: 254px; + } + + .metrics-picker__column, + .metrics-picker__row { + padding: 2px 8px; + cursor: pointer; + } + + .metrics-picker__label { + display: flex; + align-items: flex-start; + min-height: 32px; + color: @theme-color-text-lighter; + border-radius: 5px; + } + + // hover just tints the option's background + .metrics-picker__label:hover { + background-color: @_option-hover-bg; + } + + // keyboard navigation also draws a focus ring (the real radio/checkbox is visually + // hidden by .filled-in, so the affordance lives on the option itself) + .metrics-picker__label:has(input:focus-visible) { + background-color: @_option-hover-bg; + outline: 2px solid @_accent-color; + outline-offset: 0; + } + + // Materialize gives both the checkbox and radio "+ span" a padding-left: 35px; reset it + // to a compact box so the marker sits tight against the title in either select mode. + [type="checkbox"] + span:not(.lever), + [type="radio"] + span:not(.lever) { + width: @_checkbox-size; + height: @_checkbox-size; + padding: 0; + margin-top: 4px; + } + + .metrics-picker__title { + font-size: @_text-size; + line-height: @_text-line-height; + margin-left: 8px; + overflow-wrap: anywhere; + word-break: break-word; + margin-top: 3px; + } + + [type="checkbox"].filled-in:checked + span:not(.lever)::after { + border-color: @_checked-color; + background-color: @_checked-color; + } + + // The check + [type="checkbox"].filled-in:checked + span:not(.lever)::before { + border-right-color: @color-white; + border-bottom-color: @color-white; + width: 7px; + height: 10px; + } + [type="checkbox"].filled-in:not(:checked) + span:not(.lever)::after { + border: @_border; + } + + /* compact 18px box with rounded corners (Materialize defaults to 20px / 2px) */ + [type="checkbox"].filled-in:checked + span:not(.lever)::after, + [type="checkbox"].filled-in:not(:checked) + span:not(.lever)::after { + width: @_checkbox-size; + height: @_checkbox-size; + border-radius: @_checkbox-border-radius; + } + + // Radio marker — match the checkbox box: a 18px circle aligned to the same top-left + // origin (Materialize insets the circle by margin: 4px and sizes it 16px), with the + // same border colour when empty and accent fill when selected. + [type="radio"] + span:not(.lever)::before, + [type="radio"] + span:not(.lever)::after { + margin: 0; + width: @_checkbox-size; + height: @_checkbox-size; + } + + [type="radio"]:not(:checked) + span:not(.lever)::before { + border: @_border; + } + + [type="radio"]:checked + span:not(.lever)::after { + border-color: @_checked-color; + background-color: @_checked-color; + } + + // The option's focus affordance is the label outline (see + // .metrics-picker__label:has(input:focus-visible)). Neutralise Materialize's per-marker + // focus styling — the 10px halo, grey/secondary fill and squared corners it adds on + // .tabbed:focus — so the box/circle looks identical whether focused or not. + [type="checkbox"].filled-in.tabbed:focus + span:not(.lever)::after, + [type="radio"].tabbed:focus + span:not(.lever)::before { + box-shadow: none; + } + + [type="checkbox"].filled-in:not(:checked).tabbed:focus + span:not(.lever)::after { + border: @_border; + border-radius: @_checkbox-border-radius; + background-color: transparent; + } + + [type="checkbox"].filled-in:checked.tabbed:focus + span:not(.lever)::after { + border-color: @_checked-color; + background-color: @_checked-color; + border-radius: @_checkbox-border-radius; + } + + .metrics-picker__headline { + margin: 0; // reset the default

margin + padding: 10px 16px 4px; + font-weight: bold; + font-size: 12px; + color: @theme-color-text-light; + } +} diff --git a/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue new file mode 100644 index 0000000000000000000000000000000000000000..0131b21bc843b08d5513cc408b1f084ba377d6cc --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPicker.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7af0d1867e2f011eefcd1eb28eee4c836732bb5 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue.spec.ts b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9521c4f8a7d8768c02c5610a7872fc7c207e9ca9 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/MetricsPicker/MetricsPickerOptions.vue.spec.ts @@ -0,0 +1,68 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; + +// translate just echoes the key. +jest.mock('CoreHome', () => ({ + translate: (key: string) => key, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const MetricsPickerOptions = require('./MetricsPickerOptions.vue').default; + +const selectableColumns = [ + { column: 'nb_visits', translation: 'Visits' }, + { column: 'nb_uniq_visitors', translation: 'Unique visitors' }, +]; + +const selectableRows = [ + { matcher: 'Row 1', label: 'Row 1' }, + { matcher: 'Row 2', label: 'Row 2' }, +]; + +function mountOptions(props: Record = {}) { + return mount(MetricsPickerOptions, { + props: { selectableColumns, selectableRows, ...props }, + global: { mocks: { translate: (key: string) => key } }, + }); +} + +function lastSelect(wrapper: ReturnType) { + const events = wrapper.emitted('select') as unknown[][]; + return events[events.length - 1][0]; +} + +describe('CoreVisualizations/MetricsPickerOptions.vue', () => { + it('in single-select mode, picking an option clears any other selection and emits just that one', async () => { + const wrapper = mountOptions({ multiselect: false, selectedColumns: ['nb_visits'] }); + + await wrapper.findAll('.metrics-picker__row')[0].find('input').trigger('change'); + + expect(lastSelect(wrapper)).toEqual({ columns: [], rows: ['Row 1'] }); + }); + + it('in multiselect mode, selections accumulate across columns and rows', async () => { + const wrapper = mountOptions({ multiselect: true, selectedColumns: ['nb_visits'] }); + + await wrapper.findAll('.metrics-picker__column')[1].find('input').trigger('change'); + await wrapper.findAll('.metrics-picker__row')[0].find('input').trigger('change'); + + expect(lastSelect(wrapper)).toEqual({ + columns: ['nb_visits', 'nb_uniq_visitors'], + rows: ['Row 1'], + }); + }); + + it('in multiselect mode, clicking a selected option toggles it off', async () => { + const wrapper = mountOptions({ multiselect: true, selectedColumns: ['nb_visits'] }); + + await wrapper.findAll('.metrics-picker__column')[0].find('input').trigger('change'); + + expect(lastSelect(wrapper)).toEqual({ columns: [], rows: [] }); + }); +}); diff --git a/plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.less b/plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.less new file mode 100644 index 0000000000000000000000000000000000000000..6afa3d647286b750c964180d5ca46f632796fc05 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.less @@ -0,0 +1,36 @@ +[vue-entry="CoreVisualizations.SeriesPicker"] { + display: inline-block; +} + +.jqplot-seriespicker { + display: inline-block; + + &:not(.open) { + opacity: .55; + } + + &.open { // while open, make sure we're above other series picker icons + z-index: 1000; + } + + > a { + display: inline-block; + opacity: 0; + position: absolute; + } + + position: relative; +} + +.jqplot-seriespicker-popover { + position: absolute; + + top: -3px; + left: -4px; +} + +// In a narrow legend footer the picker stacks onto its own full-width row; widen this +// inline-block wrapper (built in seriesPicker.js) so the picker inside can fill the line. +.jqplot-legend-footer.is-narrow .jqplot-legend-picker > .series-picker-wrapper { + width: 100%; +} diff --git a/plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue b/plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue new file mode 100644 index 0000000000000000000000000000000000000000..42018bf66aa3374b150d63f06b6e054f1a8be1cb --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/SeriesPicker/SeriesPicker.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.less b/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.less new file mode 100644 index 0000000000000000000000000000000000000000..3aa56d3c99324c1406c17880d7cd6ed53aedbf0f --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.less @@ -0,0 +1,76 @@ +.singleMetricView { + margin: 5px 12px 10px; + display: inline-block; + + &.loading { + opacity: 0.5; + } + + span, .metric-sparkline { + margin-right: 3.5px; + } + + .metric-value { + display: inline-block; + font-size: 14px; + line-height: 25px; + vertical-align: top; + margin-left: 3px; + } + + .metric-sparkline { + display: inline-block; + vertical-align: top; + img { + width: 100px; + height: 25px; + } + } + + .metricEvolution { + font-weight: bold; + font-size: 12px; + + > span { + display: inline-block; + + &:not(.positive-evolution):not(.negative-evolution) { + margin-left: 8px; + } + } + + .evolution-up::before, + .evolution-down::before { + content: ""; + display: inline-block; + height: 7px; + width: 12px; + margin-right: 3.5px; + background: no-repeat center center; + } + + // arrow points up/down depending on the value change, its colour matches + // whether that change is good (positive) or bad (negative) for the metric + .evolution-up.positive-evolution::before { + background-image: url(plugins/MultiSites/images/arrow_up.svg); + } + .evolution-up.negative-evolution::before { + background-image: url(plugins/MultiSites/images/arrow_up_red.svg); + } + .evolution-down.positive-evolution::before { + background-image: url(plugins/MultiSites/images/arrow_down_green.svg); + } + .evolution-down.negative-evolution::before { + background-image: url(plugins/MultiSites/images/arrow_down.svg); + } + } +} + +[vue-entry="CoreVisualizations.SingleMetricView"] { + display: block; +} + +.single-metric-view-picker { + margin-left: 6px; + display: inline-block; +} diff --git a/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue b/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue new file mode 100644 index 0000000000000000000000000000000000000000..2a37e8ea120a3c24e894223a737d0be77011df5f --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/SingleMetricView/SingleMetricView.vue @@ -0,0 +1,442 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.spec.ts b/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..db675a6c1a2f1667941f2e072a0146aaea20d381 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.spec.ts @@ -0,0 +1,139 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; + +// CoreHome is a package-style cross-plugin import with no jest module mapping, so it must be +// virtually mocked. Sparkline becomes a stub that declares its props (so they can be asserted), +// and Tooltips is the (no-op here) directive used by the real MetricValue this component mounts. +jest.mock('CoreHome', () => ({ + Tooltips: {}, + Sparkline: { + name: 'Sparkline', + props: ['params', 'seriesIndices'], + template: '', + }, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const NoComparison = require('./NoComparison.vue').default; + +function makeSparkline(overrides = {}) { + return { + url: '?module=API&action=get&columns=nb_visits', + metrics: { '': [{ value: '10,558', description: 'Visits', column: 'nb_visits' }] }, + order: 1, + title: null, + group: '0', + seriesIndices: null, + graphParams: null, + ...overrides, + }; +} + +function createWrapper(sparkline: unknown, allMetricsDocumentation: Record = {}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(NoComparison as any, { props: { sparkline, allMetricsDocumentation } }); +} + +describe('CoreVisualizations/NoComparison', () => { + it('passes the primary metric to MetricValue as title + value', () => { + const wrapper = createWrapper(makeSparkline()); + + const metricValue = wrapper.findComponent({ name: 'MetricValue' }); + expect(metricValue.props('title')).toBe('Visits'); + expect(metricValue.props('value')).toBe('10,558'); + }); + + it('resolves the primary metric documentation by column and passes it to MetricValue', () => { + const wrapper = createWrapper( + makeSparkline(), + { nb_visits: 'The number of visits.' }, + ); + + expect(wrapper.findComponent({ name: 'MetricValue' }).props('documentation')) + .toBe('The number of visits.'); + }); + + it('passes undefined documentation when the metric column is unknown or empty', () => { + const wrapper = createWrapper( + makeSparkline({ metrics: { '': [{ value: '10,558', description: 'Visits', column: '' }] } }), + { nb_visits: 'The number of visits.' }, + ); + + expect(wrapper.findComponent({ name: 'MetricValue' }).props('documentation')).toBeUndefined(); + }); + + it('passes a second metric to MetricValue as the secondary value + label', () => { + const wrapper = createWrapper(makeSparkline({ + metrics: { + '': [ + { value: '10,558', description: 'Visits' }, + { value: '9,527', description: 'unique' }, + ], + }, + })); + + const metricValue = wrapper.findComponent({ name: 'MetricValue' }); + expect(metricValue.props('secondaryValue')).toBe('9,527'); + expect(metricValue.props('secondaryLabel')).toBe('unique'); + expect(wrapper.find('.metricValue__secondaryValue').text()).toBe('9,527'); + }); + + it('omits the secondary line when there is only one metric', () => { + const wrapper = createWrapper(makeSparkline()); + + const metricValue = wrapper.findComponent({ name: 'MetricValue' }); + expect(metricValue.props('secondaryValue')).toBeUndefined(); + expect(wrapper.find('.metricValue__secondary').exists()).toBe(false); + }); + + it('renders an EvolutionBadge with the entry evolution props when present', () => { + const wrapper = createWrapper(makeSparkline({ + evolution: { + percent: '+0.5%', + trend: 53, + isLowerValueBetter: false, + tooltip: 'since last period', + }, + })); + + const badge = wrapper.findComponent({ name: 'EvolutionBadge' }); + expect(badge.exists()).toBe(true); + expect(badge.props('percent')).toBe('+0.5%'); + expect(badge.props('trend')).toBe(53); + expect(badge.props('isLowerValueBetter')).toBe(false); + expect(badge.props('tooltip')).toBe('since last period'); + }); + + it('renders no EvolutionBadge when the entry has no evolution', () => { + const wrapper = createWrapper(makeSparkline()); + + expect(wrapper.findComponent({ name: 'EvolutionBadge' }).exists()).toBe(false); + }); + + it('coerces a null evolution tooltip to an empty string for the badge', () => { + const wrapper = createWrapper(makeSparkline({ + evolution: { + percent: '-2%', + trend: -10, + isLowerValueBetter: false, + tooltip: null, + }, + })); + + expect(wrapper.findComponent({ name: 'EvolutionBadge' }).props('tooltip')).toBe(''); + }); + + it('passes the sparkline url and series indices to the Sparkline', () => { + const wrapper = createWrapper(makeSparkline({ seriesIndices: [0, 1] })); + + const sparkline = wrapper.findComponent({ name: 'Sparkline' }); + expect(sparkline.props('params')).toBe('?module=API&action=get&columns=nb_visits'); + expect(sparkline.props('seriesIndices')).toEqual([0, 1]); + }); +}); diff --git a/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue b/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue new file mode 100644 index 0000000000000000000000000000000000000000..034c23a47435518a505dba9d39fb819de8b91381 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/Sparklines/NoComparison.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less b/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less new file mode 100644 index 0000000000000000000000000000000000000000..16070c003aff0d3ebdfaf0991a647dfc4a669a7c --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.less @@ -0,0 +1,79 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +// Card frame. .sparklineCard is a new class, so it's safe to style directly; the element keeps +// the legacy .sparkline class only for initializeSparklines() click-to-evolution wiring. +.widget .widgetContent .sparkline.sparklineCard, +.sparkline.sparklineCard { + // Override legacy `div.sparkline { display: flex }` (Morpheus main.less) so the card stacks in + // block flow. As a flex item its min-width:auto would size to the nowrap title, so long titles + // would overflow instead of truncating. + display: block; + box-sizing: border-box; + padding: 16px; + background: @theme-color-widget-background; + border: 1px solid @theme-color-border-alternative; + border-radius: 8px; + width: 100%; + margin-top: 0; + margin-bottom: 0; + + // Clickable cards (initializeSparklines adds .linked): pointer cursor + hover border so they + // read as interactive. Theme tokens keep it correct in light and dark mode. + &.linked { + cursor: pointer; + border-bottom-color: @theme-color-border-alternative; + + &:hover, + &:focus { + border-color: @theme-color-focus-ring-alternative; + border-bottom: 1px solid @theme-color-focus-ring-alternative; + } + } +} + +// No-comparison body: metric value stacked over a full-width sparkline. +.noComparison { + // Sparkline dimensions. The height is the fixed slot height; the width is the base size the + // image scales from, and must match the passed in NoComparison.vue. + @_sparkline-height: 40px; + @_sparkline-width: 380px; + + // Fixed height (title 24 + primary 36 + secondary 24) so cards line up whether or not the + // optional secondary line is present. + .metricValue { + height: 84px; + margin-bottom: 12px; + } + + // Clamp the title to one line so it can't grow the card; the full title shows on hover. + .metricValue__title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + height: 24px; + } + + // Fixed-height slot keeps the card height constant regardless of the image. Centering the image + // sits it mid-slot once it becomes shorter than the slot; align-items also stops flex's default + // `stretch` from forcing the image back to full height. + .sparklineSlot { + height: @_sparkline-height; + display: flex; + align-items: center; + } + + // Fluid width with height derived from the 380x40 aspect ratio (via the img's width/height + // attributes) so the sparkline scales without distortion, becoming shorter than the slot on + // narrow cards. Capping the width at the base stops it rendering taller than the fixed slot on + // very wide monitors. The 2x (760x80) source stays crisp as it scales. + .sparklineImg { + display: block; + width: 100%; + height: auto; + max-width: @_sparkline-width; + } +} diff --git a/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.spec.ts b/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..72ff8ec031f52185dcf758328f3d57f9c49a0519 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.spec.ts @@ -0,0 +1,133 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { mount } from '@vue/test-utils'; + +// The shell renders the real NoComparison body, which mounts the real MetricValue (Tooltips +// directive) and the Sparkline. CoreHome has no jest module mapping, so mock it virtually. +jest.mock('CoreHome', () => ({ + Tooltips: {}, + Sparkline: { + name: 'Sparkline', + props: ['params', 'seriesIndices'], + template: '', + }, + // SparklineCard derives graph-params from the sparkline url; parse a query string like the real + // MatomoUrl.parse (which receives the query string without its leading '?'). + MatomoUrl: { + parse: (search: string) => { + const params: Record = {}; + new URLSearchParams(search).forEach((value, key) => { + params[key] = value; + }); + return params; + }, + }, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const SparklineCard = require('./SparklineCard.vue').default; + +describe('CoreVisualizations/SparklineCard', () => { + const baseSparkline = { + url: '?module=API&action=get&columns=nb_visits', + metrics: { '': [{ value: '1,234', description: 'Visits', column: 'nb_visits' }] }, + order: 1, + title: null, + group: '0', + seriesIndices: null, + graphParams: null, + }; + + function createWrapper( + sparkline: unknown = baseSparkline, + areSparklinesLinkable = true, + allMetricsDocumentation: Record = {}, + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(SparklineCard as any, { + props: { sparkline, areSparklinesLinkable, allMetricsDocumentation }, + }); + } + + it('renders the no-comparison body and forwards the sparkline to it', () => { + const wrapper = createWrapper(); + + const body = wrapper.findComponent({ name: 'NoComparison' }); + expect(body.exists()).toBe(true); + expect(body.props('sparkline')).toEqual(baseSparkline); + }); + + it('forwards allMetricsDocumentation to the body so the title shows the metric tooltip', () => { + const wrapper = createWrapper(baseSparkline, true, { nb_visits: 'The number of visits.' }); + + const body = wrapper.findComponent({ name: 'NoComparison' }); + expect(body.props('allMetricsDocumentation')).toEqual({ nb_visits: 'The number of visits.' }); + expect(wrapper.find('.metricValue__title').attributes('title')).toBe('The number of visits.'); + }); + + it('renders the card frame classes and composes the primary value + sparkline', () => { + const wrapper = createWrapper(); + + expect(wrapper.classes()).toContain('sparkline'); + expect(wrapper.classes()).toContain('sparklineCard'); + expect(wrapper.classes()).not.toContain('notLinkable'); + expect(wrapper.find('.metricValue__title').text()).toBe('Visits'); + expect(wrapper.find('.metricValue__number').text()).toBe('1,234'); + expect(wrapper.find('.sparkline-stub').exists()).toBe(true); + }); + + it('does not render the segment title region in no-comparison mode', () => { + const wrapper = createWrapper(); + + expect(wrapper.find('.sparklineCard__title').exists()).toBe(false); + }); + + it('renders the segment title region when sparkline.title is set', () => { + const wrapper = createWrapper({ ...baseSparkline, title: 'Firefox' }); + + expect(wrapper.find('.sparklineCard__title').text()).toBe('Firefox'); + }); + + it('omits graph-params / series-indices when none is set nor derivable from the url', () => { + const wrapper = createWrapper({ ...baseSparkline, url: '?module=API&action=get' }); + + expect(wrapper.attributes('data-graph-params')).toBeUndefined(); + expect(wrapper.attributes('data-series-indices')).toBeUndefined(); + }); + + it('derives graph-params columns/rows/idGoal from the url when graphParams is empty', () => { + // The reused Sparkline renders its image with `src` (no `data-src`), so the legacy click + // handler can't read the reload columns off the img; the card supplies them from its url. + const wrapper = createWrapper({ + ...baseSparkline, + url: '?module=API&action=get&columns=nb_visits&rows=Search&idGoal=1', + }); + + expect(wrapper.attributes('data-graph-params')).toBe( + '{"columns":"nb_visits","rows":"Search","idGoal":"1"}', + ); + }); + + it('emits explicit graphParams verbatim, taking precedence over the url', () => { + const wrapper = createWrapper({ + ...baseSparkline, + // url carries nb_visits, but explicit graphParams wins. + graphParams: { columns: 'nb_actions' }, + seriesIndices: [0, 1], + }); + + expect(wrapper.attributes('data-graph-params')).toBe('{"columns":"nb_actions"}'); + expect(wrapper.attributes('data-series-indices')).toBe('[0,1]'); + }); + + it('adds the notLinkable class when sparklines are not linkable', () => { + const wrapper = createWrapper(baseSparkline, false); + + expect(wrapper.classes()).toContain('notLinkable'); + }); +}); diff --git a/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue b/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..21b160198111464ca4fb4afed14801fe7d05e796 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/Sparklines/SparklineCard.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/Sparklines/types.ts b/plugins/CoreVisualizations/vue/src/Sparklines/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..b60c14b155f7ce641fd2c5a010f7f8d32a79eecb --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/Sparklines/types.ts @@ -0,0 +1,49 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * One metric shown on a sparkline card. Plain metrics arrive as raw numbers and are locale-formatted + * at render time; processed metrics and revenue arrive as already-formatted strings. + */ +export interface SparklineMetric { + value: string | number; + description: string; + column?: string; + // Clean column name (eg "Bounce Rate"), used as the card title; falls back to `description`. + title?: string; +} + +/** + * Evolution shown next to a sparkline. Attached at the entry level by + * Sparklines\Config::addSparkline() (only when the report supplies evolution data), + * and maps directly onto the EvolutionBadge props. + */ +export interface SparklineEvolution { + // pre-formatted percent string emitted by the backend (eg "+5.2%", "-4%") + percent: string | number; + isLowerValueBetter: boolean; + tooltip: string | null; + // raw value difference (currentValue - pastValue), drives the arrow direction + trend: number; +} + +/** + * A single sparkline entry as produced by Sparklines\Config::getSortedSparklines(). + * In no-comparison mode the metrics live under the '' group key, and `title` / + * `seriesIndices` are null. + */ +export interface SparklineEntry { + url: string; + tooltip?: string; + metrics: Record; + order: number; + title: string | null; + group: string; + seriesIndices: number[] | null; + graphParams: Record | null; + evolution?: SparklineEvolution; +} diff --git a/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.less b/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.less new file mode 100644 index 0000000000000000000000000000000000000000..e39c30a6ed7ecd45f8993d5a1d1bd8c0df42b2b9 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.less @@ -0,0 +1,63 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +// Scoped to .sparklinesGrid — Matomo concatenates all LESS into one sheet, so this avoids +// colliding with the legacy .sparkline styles still used when the feature flag is off. +.sparklinesGrid { + // Row gap: each column's bottom padding, cancelled on the last row by an equal negative + // margin on the grid. Shared so the two stay in sync. + @_row-gap: 16px; + + // Columns come from Materialize's float classes (col s6 m6 l4 xl3 = 2/2/4/4); the media + // queries below add a 5th tier. Floats keep an incomplete last row packed left (flex broke + // that). The card frame itself lives in SparklineCard.less. + .col { + padding: 0 8px @_row-gap; + } + + // The grid is itself the .row; pull it up one gap to cancel the last row's bottom padding + // (the same offset idiom Materialize uses for the horizontal .col padding). + &.row { + margin-bottom: -@_row-gap; + } + + // 5-column tier above xl (1201px). 5 isn't a native col fraction (12/5), so set the width + // directly; the selector needs .xl3 (4 classes) to outrank Materialize's `.row .col.xl3`. + @media (min-width: 1920px) { + &.row > .col.xl3 { + width: 20%; + } + } + + // Single column below 320px, same direct-width override; .s6 outranks `.row .col.s6`. + @media (max-width: 320px) { + &.row > .col.s6 { + width: 100%; + } + } +} + +// Widget overrides so the cards sit neatly inside a dashboard widget. +.widget .sparklinesGrid { + .col { + // Inter-card gutter: 4px on every side, so 8px shows between adjacent cards. + padding: 4px; + } + + .sparkline.sparklineCard { + margin-left: 0; + } + + // The widget container adds no padding of its own, so the grid supplies an even frame around + // the cards: 16px of row padding plus each column's 4px gutter gives 20px on every side. The + // base .sparklinesGrid.row negative bottom margin is reset (margin: 0) so it can't pull the + // frame in and push the last row past the widget's bottom edge. + &.row { + margin: 0; + padding: 16px; + } +} diff --git a/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.spec.ts b/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..144105860186d38af7e0a9eb07e1b5f3f022a286 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.spec.ts @@ -0,0 +1,147 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { flushPromises, mount } from '@vue/test-utils'; + +// The grid mounts the real SparklineCard -> NoComparison -> MetricValue chain, so the mock +// provides everything that chain pulls from CoreHome: the Tooltips directive (MetricValue), +// MatomoUrl (SparklineCard derives graph-params from the url) and NumberFormatter +// (NoComparison formats raw numeric metric values). CoreHome has no jest module mapping. +jest.mock('CoreHome', () => ({ + Tooltips: {}, + Sparkline: { template: '' }, + // SparklineCard calls MatomoUrl.parse to derive data-graph-params, but no test here + // asserts on it and the fixture urls carry no columns/rows/idGoal, so an empty object + // is enough to satisfy the `parsed[key]` lookups without crashing. + MatomoUrl: { + parse: () => ({}), + }, + NumberFormatter: { + formatNumber: (value: number) => String(value), + }, +}), { virtual: true }); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const SparklinesGrid = require('./SparklinesGrid.vue').default; + +describe('CoreVisualizations/SparklinesGrid', () => { + let initializeSparklinesSpy: jest.Mock; + + beforeEach(() => { + // sparkline.js (which defines window.initializeSparklines) is loaded on every real + // Matomo page but not in the jest bootstrap, so stub it here. + initializeSparklinesSpy = jest.fn(); + window.initializeSparklines = initializeSparklinesSpy; + }); + + function entry(description: string, order = 1) { + return { + url: '?module=API&action=get', + metrics: { '': [{ value: '1', description }] }, + order, + title: null, + group: '0', + seriesIndices: null, + graphParams: null, + }; + } + + function placeholder(order: number) { + // Mirrors Config::addPlaceholder(): empty url + no metrics, used only for legacy layout. + return { + url: '', + metrics: {}, + order, + title: null, + group: `placeholder${order}`, + seriesIndices: null, + graphParams: null, + }; + } + + function createWrapper(props: Record = {}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mount(SparklinesGrid as any, { + props: { + sparklines: { 0: [entry('Visits')], 1: [entry('Actions'), entry('Bounce rate')] }, + ...props, + }, + }); + } + + it('flattens grouped sparklines into one card per entry', () => { + const wrapper = createWrapper(); + + expect(wrapper.findAllComponents({ name: 'SparklineCard' }).length).toBe(3); + }); + + it('drops layout placeholders so they do not render as empty cards', () => { + // The flagship Visits Overview adds placeholders (order 10/31/50) purely for the legacy + // 2-column layout; they have an empty url and no metrics and must not reach the grid. + const wrapper = createWrapper({ + sparklines: { + 0: [entry('Visits', 1)], + 1: [placeholder(10)], + 2: [entry('Actions', 20)], + }, + }); + + const titles = wrapper.findAll('.metricValue__title').map((node) => node.text()); + expect(wrapper.findAllComponents({ name: 'SparklineCard' }).length).toBe(2); + expect(titles).toEqual(['Visits', 'Actions']); + }); + + it('forwards allMetricsDocumentation to each card, surfacing it as the title tooltip', () => { + const wrapper = createWrapper({ + sparklines: { 0: [{ ...entry('Visits'), metrics: { '': [{ value: '1', description: 'Visits', column: 'nb_visits' }] } }] }, + allMetricsDocumentation: { nb_visits: 'The number of visits.' }, + }); + + const card = wrapper.findComponent({ name: 'SparklineCard' }); + expect(card.props('allMetricsDocumentation')).toEqual({ nb_visits: 'The number of visits.' }); + expect(wrapper.find('.metricValue__title').attributes('title')).toBe('The number of visits.'); + }); + + it('uses the responsive grid columns (s6 m6 l4 xl3) on reporting pages', () => { + const wrapper = createWrapper(); + const col = wrapper.find('.row.sparklinesGrid > div'); + + expect(col.classes()).toEqual(expect.arrayContaining(['col', 's6', 'm6', 'l4', 'xl3'])); + }); + + it('orders cards by backend `order`, not by numeric group-key iteration order', () => { + // The group keys are the metric index, and getSortedSparklines() may insert them out + // of numeric order (e.g. a later metric with a lower `order`). Object.values() iterates + // numeric keys ascending, so without the explicit sort the cards would render as + // Third, First, Second here instead of in `order`. + const wrapper = createWrapper({ + sparklines: { + 1: [entry('First', 1)], + 0: [entry('Third', 30)], + 2: [entry('Second', 20)], + }, + }); + + const titles = wrapper.findAll('.metricValue__title').map((node) => node.text()); + expect(titles).toEqual(['First', 'Second', 'Third']); + }); + + it('collapses to two columns in widget mode', () => { + const wrapper = createWrapper({ isWidget: true }); + const col = wrapper.find('.row.sparklinesGrid > div'); + + expect(col.classes()).toContain('s6'); + expect(col.classes()).not.toContain('xl3'); + }); + + it('re-runs the sparkline click-to-evolution wiring after mount', async () => { + createWrapper(); + await flushPromises(); + + expect(initializeSparklinesSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue b/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue new file mode 100644 index 0000000000000000000000000000000000000000..604d57164b90e7a26bab408c79e3f45f1982424c --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/SparklinesGrid/SparklinesGrid.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/plugins/CoreVisualizations/vue/src/index.ts b/plugins/CoreVisualizations/vue/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d9ee08a2b3a9859b4c6e2a339b6d940bfe4bd38 --- /dev/null +++ b/plugins/CoreVisualizations/vue/src/index.ts @@ -0,0 +1,13 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +export { default as EvolutionBadge } from './EvolutionBadge/EvolutionBadge.vue'; +export { default as MetricValue } from './MetricValue/MetricValue.vue'; +export { default as SeriesPicker } from './SeriesPicker/SeriesPicker.vue'; +export { default as MetricsPicker } from './MetricsPicker/MetricsPicker.vue'; +export { default as SingleMetricView } from './SingleMetricView/SingleMetricView.vue'; +export { default as SparklinesGrid } from './SparklinesGrid/SparklinesGrid.vue'; diff --git a/plugins/ExampleLogTables/Columns/GroupAttributeAdmin.php b/plugins/ExampleLogTables/Columns/GroupAttributeAdmin.php new file mode 100644 index 0000000000000000000000000000000000000000..56abfef581a55a165cd10b95d228192525a75c0a --- /dev/null +++ b/plugins/ExampleLogTables/Columns/GroupAttributeAdmin.php @@ -0,0 +1,15 @@ +tablePrefixed = Common::prefixTable($this->table); + } + + public function install() + { + DbHelper::createTable($this->table, " + `group` VARCHAR(30) NOT NULL, + `is_admin` TINYINT(1) NOT NULL, + PRIMARY KEY (`group`)"); + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + private function getDb() + { + return Db::get(); + } + + public function getAllRecords() + { + return $this->getDb()->fetchAll('SELECT * FROM ' . $this->tablePrefixed); + } + + public function addGroupInformation($group, $isAdmin) + { + $columns = array( + 'group' => $group, + 'is_admin' => $isAdmin, + ); + + $bind = array_values($columns); + $placeholder = Common::getSqlStringFieldsArray($columns); + + $sql = sprintf( + 'INSERT INTO `%s` (`%s`) VALUES(%s)', + $this->tablePrefixed, + implode('`,`', array_keys($columns)), + $placeholder + ); + + $db = $this->getDb(); + + $db->query($sql, $bind); + } +} diff --git a/plugins/ExampleLogTables/Dao/CustomUserLog.php b/plugins/ExampleLogTables/Dao/CustomUserLog.php new file mode 100644 index 0000000000000000000000000000000000000000..8b95688ae411b012302145bc557f2d1a8a4417dc --- /dev/null +++ b/plugins/ExampleLogTables/Dao/CustomUserLog.php @@ -0,0 +1,72 @@ +tablePrefixed = Common::prefixTable($this->table); + } + + public function install() + { + DbHelper::createTable($this->table, " + `user_id` VARCHAR(191) NOT NULL, + `gender` VARCHAR(30) NOT NULL, + `group` VARCHAR(30) NOT NULL, + PRIMARY KEY (user_id)"); + } + + public function uninstall() + { + Db::query(sprintf('DROP TABLE IF EXISTS `%s`', $this->tablePrefixed)); + } + + private function getDb() + { + return Db::get(); + } + + public function getAllRecords() + { + return $this->getDb()->fetchAll('SELECT * FROM ' . $this->tablePrefixed); + } + + public function addUserInformation($userId, $group, $gender) + { + $columns = array( + 'user_id' => $userId, + 'group' => $group, + 'gender' => $gender, + ); + + $bind = array_values($columns); + $placeholder = Common::getSqlStringFieldsArray($columns); + + $sql = sprintf( + 'INSERT INTO `%s` (`%s`) VALUES(%s)', + $this->tablePrefixed, + implode('`,`', array_keys($columns)), + $placeholder + ); + + $db = $this->getDb(); + + $db->query($sql, $bind); + } +} diff --git a/plugins/ExampleLogTables/ExampleLogTables.php b/plugins/ExampleLogTables/ExampleLogTables.php new file mode 100644 index 0000000000000000000000000000000000000000..a86ed1164bc54a68fd423a7f23e0166f4a0e1c32 --- /dev/null +++ b/plugins/ExampleLogTables/ExampleLogTables.php @@ -0,0 +1,44 @@ + 'getTablesInstalled', + ]; + } + + public function install() + { + // Install custom log table [disabled as example only] + + // $userLog = new CustomUserLog(); + // $userLog->install(); + + // $userLog = new CustomGroupLog(); + // $userLog->install(); + } + + /** + * Register the new tables, so Matomo knows about them. + * + * @param array $allTablesInstalled + */ + public function getTablesInstalled(&$allTablesInstalled) + { + $allTablesInstalled[] = Common::prefixTable('log_group'); + $allTablesInstalled[] = Common::prefixTable('log_custom'); + } +} diff --git a/plugins/ExampleLogTables/Tracker/LogTable/CustomGroupLog.php b/plugins/ExampleLogTables/Tracker/LogTable/CustomGroupLog.php new file mode 100644 index 0000000000000000000000000000000000000000..e5ad3ea7132d901bb2cca5f7794817f19bf09a1a --- /dev/null +++ b/plugins/ExampleLogTables/Tracker/LogTable/CustomGroupLog.php @@ -0,0 +1,35 @@ + 'group']; + } +} diff --git a/plugins/ExampleLogTables/Tracker/LogTable/CustomUserLog.php b/plugins/ExampleLogTables/Tracker/LogTable/CustomUserLog.php new file mode 100644 index 0000000000000000000000000000000000000000..4fd50ee62227b6dde1d3ba65325c24ed9f715b48 --- /dev/null +++ b/plugins/ExampleLogTables/Tracker/LogTable/CustomUserLog.php @@ -0,0 +1,35 @@ + 'user_id']; + } +} diff --git a/plugins/ExampleLogTables/config/config.php b/plugins/ExampleLogTables/config/config.php new file mode 100644 index 0000000000000000000000000000000000000000..d5be5de26f92a935f318ed4c0a04fe01434bfe66 --- /dev/null +++ b/plugins/ExampleLogTables/config/config.php @@ -0,0 +1,3 @@ +=5.0.0-b1,<6.0.0-b1" + }, + "authors": [ + { + "name": "Matomo", + "email": "", + "homepage": "" + } + ], + "support": { + "email": "", + "issues": "", + "forum": "", + "irc": "", + "wiki": "", + "source": "", + "docs": "", + "rss": "" + }, + "homepage": "", + "license": "GPL v3+", + "keywords": [] +} diff --git a/plugins/Resolution/API.php b/plugins/Resolution/API.php new file mode 100644 index 0000000000000000000000000000000000000000..18451d685acbf033dca9dd1c6c2ac7bf51d766e4 --- /dev/null +++ b/plugins/Resolution/API.php @@ -0,0 +1,107 @@ +getDataTable($name); + $dataTable->queueFilter('ReplaceColumnNames'); + $dataTable->queueFilter('ReplaceSummaryRowLabel'); + return $dataTable; + } + + /** + * Returns visitor screen resolution metrics for the requested site and period. + * + * @param int|string|int[] $idSite Website ID(s) to query. + * - Single site ID (e.g. 1) + * - Multiple site IDs (e.g. [1, 4, 5]) + * - Comma-separated list ("1,4,5") or "all" + * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period + * containing the specified date. + * @param string $date The date or date range to process. + * 'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear), + * or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX). + * @param string|null|false $segment Custom segment to filter the report. + * Example: "referrerName==example.com" + * Supports AND (;) and OR (,) operators. + * @return DataTable|DataTable\Map Screen resolution metrics for the requested site and period. + */ + public function getResolution($idSite, string $period, string $date, $segment = false) + { + Piwik::checkUserHasViewAccess($idSite); + + $idSites = Site::getIdSitesFromIdSitesString($idSite); + + // filter sites where model report disabled by compliance + $idSitesFiltered = array_filter($idSites, function ($idSite) { + return !Resolution::isScreenResolutionDetectionDisabledByCompliancePolicy((int)$idSite); + }); + + // show an error only if none of the requested sites is left + if (count($idSitesFiltered) === 0 && count($idSites) !== count($idSitesFiltered)) { + throw new Exception(Piwik::translate('Resolution_ScreenResolutionReportDisabledByCompliancePolicy')); + } + + $dataTable = $this->getDataTable(Archiver::RESOLUTION_RECORD_NAME, $idSitesFiltered, $period, $date, $segment); + $dataTable->filter('AddSegmentValue'); + return $dataTable; + } + + /** + * Returns visitor device configuration metrics for the requested site and period. + * + * @param int|string|int[] $idSite Website ID(s) to query. + * - Single site ID (e.g. 1) + * - Multiple site IDs (e.g. [1, 4, 5]) + * - Comma-separated list ("1,4,5") or "all" + * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period + * containing the specified date. + * @param string $date The date or date range to process. + * 'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear), + * or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX). + * @param string|null|false $segment Custom segment to filter the report. + * Example: "referrerName==example.com" + * Supports AND (;) and OR (,) operators. + * @return DataTable|DataTable\Map Device configuration metrics for the requested site and period. + */ + public function getConfiguration($idSite, string $period, string $date, $segment = false) + { + $dataTable = $this->getDataTable(Archiver::CONFIGURATION_RECORD_NAME, $idSite, $period, $date, $segment); + // use GroupBy filter to avoid duplicate rows if old reports are displayed + $dataTable->filter('GroupBy', array('label', __NAMESPACE__ . '\getConfigurationLabel')); + return $dataTable; + } +} diff --git a/plugins/Resolution/Archiver.php b/plugins/Resolution/Archiver.php new file mode 100644 index 0000000000000000000000000000000000000000..fe470a0b51475c139edf8cbb235ef6162bb1a539 --- /dev/null +++ b/plugins/Resolution/Archiver.php @@ -0,0 +1,23 @@ +getIdSiteIfExists())) { + return Request::UNKNOWN_RESOLUTION; + } + + $resolution = $request->getParam('res'); + + if (!empty($resolution)) { + return substr($resolution, 0, 9); + } + + return $resolution; + } + /** + * @param Action|null $action + * @return mixed + */ + public function onExistingVisit(Request $request, Visitor $visitor, $action) + { + // In case the value was initially unknown, update it from a subsequent action + if ($visitor->getVisitorColumn($this->columnName) === Request::UNKNOWN_RESOLUTION) { + return $this->onNewVisit($request, $visitor, $action); + } else { + return false; + } + } +} diff --git a/plugins/Resolution/RecordBuilders/Configuration.php b/plugins/Resolution/RecordBuilders/Configuration.php new file mode 100644 index 0000000000000000000000000000000000000000..1452463c640e63ac8669c70ed3fba3cb589834d3 --- /dev/null +++ b/plugins/Resolution/RecordBuilders/Configuration.php @@ -0,0 +1,58 @@ +maxRowsInTable = PiwikConfig::getInstance()->General['datatable_archiving_maximum_rows_standard']; + $this->columnToSortByBeforeTruncation = Metrics::INDEX_NB_VISITS; + } + + public function getRecordMetadata(ArchiveProcessor $archiveProcessor): array + { + return [ + Record::make(Record::TYPE_BLOB, Archiver::CONFIGURATION_RECORD_NAME), + ]; + } + + protected function aggregate(ArchiveProcessor $archiveProcessor): array + { + $record = new DataTable(); + + $cursor = $archiveProcessor->getLogAggregator()->queryVisitsByDimension(['label' => Archiver::CONFIGURATION_DIMENSION]); + while ($row = $cursor->fetch()) { + $columns = [ + Metrics::INDEX_NB_UNIQ_VISITORS => $row[Metrics::INDEX_NB_UNIQ_VISITORS], + Metrics::INDEX_NB_VISITS => $row[Metrics::INDEX_NB_VISITS], + Metrics::INDEX_NB_ACTIONS => $row[Metrics::INDEX_NB_ACTIONS], + Metrics::INDEX_NB_USERS => $row[Metrics::INDEX_NB_USERS], + Metrics::INDEX_MAX_ACTIONS => $row[Metrics::INDEX_MAX_ACTIONS], + Metrics::INDEX_SUM_VISIT_LENGTH => $row[Metrics::INDEX_SUM_VISIT_LENGTH], + Metrics::INDEX_BOUNCE_COUNT => $row[Metrics::INDEX_BOUNCE_COUNT], + Metrics::INDEX_NB_VISITS_CONVERTED => $row[Metrics::INDEX_NB_VISITS_CONVERTED], + ]; + + $record->sumRowWithLabel($row['label'], $columns); + } + return [Archiver::CONFIGURATION_RECORD_NAME => $record]; + } +} diff --git a/plugins/Resolution/RecordBuilders/Resolution.php b/plugins/Resolution/RecordBuilders/Resolution.php new file mode 100644 index 0000000000000000000000000000000000000000..1bcae5fec4206d009e18f2fe2d73d3bb29e8c79e --- /dev/null +++ b/plugins/Resolution/RecordBuilders/Resolution.php @@ -0,0 +1,63 @@ +maxRowsInTable = PiwikConfig::getInstance()->General['datatable_archiving_maximum_rows_standard']; + $this->columnToSortByBeforeTruncation = Metrics::INDEX_NB_VISITS; + } + + public function getRecordMetadata(ArchiveProcessor $archiveProcessor): array + { + return [ + Record::make(Record::TYPE_BLOB, Archiver::RESOLUTION_RECORD_NAME), + ]; + } + + protected function aggregate(ArchiveProcessor $archiveProcessor): array + { + $record = new DataTable(); + + $cursor = $archiveProcessor->getLogAggregator()->queryVisitsByDimension(['label' => Archiver::RESOLUTION_DIMENSION]); + while ($row = $cursor->fetch()) { + $columns = [ + Metrics::INDEX_NB_UNIQ_VISITORS => $row[Metrics::INDEX_NB_UNIQ_VISITORS], + Metrics::INDEX_NB_VISITS => $row[Metrics::INDEX_NB_VISITS], + Metrics::INDEX_NB_ACTIONS => $row[Metrics::INDEX_NB_ACTIONS], + Metrics::INDEX_NB_USERS => $row[Metrics::INDEX_NB_USERS], + Metrics::INDEX_MAX_ACTIONS => $row[Metrics::INDEX_MAX_ACTIONS], + Metrics::INDEX_SUM_VISIT_LENGTH => $row[Metrics::INDEX_SUM_VISIT_LENGTH], + Metrics::INDEX_BOUNCE_COUNT => $row[Metrics::INDEX_BOUNCE_COUNT], + Metrics::INDEX_NB_VISITS_CONVERTED => $row[Metrics::INDEX_NB_VISITS_CONVERTED], + ]; + + $record->sumRowWithLabel($row['label'], $columns); + } + + $record->filter(DataTable\Filter\ColumnCallbackDeleteRow::class, ['label', function ($value) { + return strlen($value) <= 5; + }]); + + return [Archiver::RESOLUTION_RECORD_NAME => $record]; + } +} diff --git a/plugins/Resolution/Resolution.php b/plugins/Resolution/Resolution.php new file mode 100644 index 0000000000000000000000000000000000000000..2f37d41a6f002c2607e054a32a9e1419ffff374a --- /dev/null +++ b/plugins/Resolution/Resolution.php @@ -0,0 +1,53 @@ + 'filterSegments', + ]; + } + + /** + * Check if compliance policy disables screen resolution detection + * + * @throws \Piwik\Exception\DI\DependencyException + * @throws \Piwik\Exception\DI\NotFoundException + */ + public static function isScreenResolutionDetectionDisabledByCompliancePolicy(?int $idSite = null): bool + { + $cache = TrackerCache::getCacheWebsiteAttributes($idSite); + $cacheKey = ScreenResolutionDetectionDisabled::class; + return (($cache[$cacheKey] ?? false) === true); + } + + public function filterSegments(SegmentsList &$list, array $idSites) + { + $limitSegmentsSettingEnabled = false; + if (empty($idSites)) { + $limitSegmentsSettingEnabled = LimitSegments::getInstance()->getValue(); + } else { + foreach ($idSites as $idsite) { + $limitSegmentsSettingEnabled |= LimitSegments::getInstance($idsite)->getValue(); + } + } + if ($limitSegmentsSettingEnabled) { + $list->remove('General_Visitors', 'resolution'); + } + } +} diff --git a/plugins/Resolution/Settings/ScreenResolutionDetectionDisabled.php b/plugins/Resolution/Settings/ScreenResolutionDetectionDisabled.php new file mode 100644 index 0000000000000000000000000000000000000000..0988085c99307157c766e03780303323b5cf42c6 --- /dev/null +++ b/plugins/Resolution/Settings/ScreenResolutionDetectionDisabled.php @@ -0,0 +1,27 @@ + true, + ]; + } +} diff --git a/plugins/Resolution/VisitorDetails.php b/plugins/Resolution/VisitorDetails.php new file mode 100644 index 0000000000000000000000000000000000000000..e82d729a9f3ab47ce88773efa230ed634516db23 --- /dev/null +++ b/plugins/Resolution/VisitorDetails.php @@ -0,0 +1,29 @@ +getResolution(); + } + + protected function getResolution() + { + if (!array_key_exists('config_resolution', $this->details)) { + return null; + } + + return $this->details['config_resolution']; + } +} diff --git a/plugins/Resolution/config/config.php b/plugins/Resolution/config/config.php new file mode 100644 index 0000000000000000000000000000000000000000..d5be5de26f92a935f318ed4c0a04fe01434bfe66 --- /dev/null +++ b/plugins/Resolution/config/config.php @@ -0,0 +1,3 @@ +Managing Websites in Matomo. + * + * @phpstan-type SiteData array{ + * idsite: int, + * name: string, + * description: string, + * main_url: string, + * ts_created: string, + * timezone: string, + * currency: string, + * ecommerce: int, + * sitesearch: int, + * exclude_unknown_urls: int, + * excluded_ips: string, + * excluded_parameters: string, + * excluded_user_agents: string, + * group: string, + * type: string, + * keep_url_fragment: int, + * creator_login?: string, + * timezone_name?: string, + * currency_name?: string + * } + * @phpstan-type SettingValues array> + * + * @method static \Piwik\Plugins\SitesManager\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + public const DEFAULT_SEARCH_KEYWORD_PARAMETERS = 'q,query,s,search,searchword,k,keyword,keywords'; + public const OPTION_EXCLUDED_IPS_GLOBAL = 'SitesManager_ExcludedIpsGlobal'; + public const OPTION_DEFAULT_TIMEZONE = 'SitesManager_DefaultTimezone'; + public const OPTION_DEFAULT_CURRENCY = 'SitesManager_DefaultCurrency'; + public const OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL = 'SitesManager_ExcludedQueryParameters'; + public const OPTION_SEARCH_KEYWORD_QUERY_PARAMETERS_GLOBAL = 'SitesManager_SearchKeywordParameters'; + public const OPTION_SEARCH_CATEGORY_QUERY_PARAMETERS_GLOBAL = 'SitesManager_SearchCategoryParameters'; + public const OPTION_EXCLUDED_USER_AGENTS_GLOBAL = 'SitesManager_ExcludedUserAgentsGlobal'; + public const OPTION_EXCLUDED_REFERRERS_GLOBAL = 'SitesManager_ExcludedReferrersGlobal'; + public const OPTION_KEEP_URL_FRAGMENTS_GLOBAL = 'SitesManager_KeepURLFragmentsGlobal'; + public const OPTION_EXCLUDE_TYPE_QUERY_PARAMS_GLOBAL = 'SitesManager_ExcludeTypeQueryParamsGlobal'; + + /** + * @var SettingsProvider + */ + private $settingsProvider; + + /** + * @var SettingsMetadata + */ + private $settingsMetadata; + + /** + * @var Translator + */ + private $translator; + + /** @var array */ + private $timezoneNameCache = []; + + /** @var SiteContentDetector */ + private $siteContentDetector; + + /** @var TypeManager */ + private $typeManager; + + public function __construct( + SettingsProvider $provider, + SettingsMetadata $settingsMetadata, + Translator $translator, + SiteContentDetector $siteContentDetector, + TypeManager $typeManager + ) { + $this->settingsProvider = $provider; + $this->settingsMetadata = $settingsMetadata; + $this->translator = $translator; + $this->siteContentDetector = $siteContentDetector; + $this->typeManager = $typeManager; + } + + /** + * Returns the JavaScript tracking tag for the given website. + * + * This tag must be included on every page to be tracked by Matomo. The generated code + * initialises the Matomo JavaScript tracker with the specified options. + * + * @param int $idSite The numeric ID of the website to generate tracking code for. + * @param string $piwikUrl The domain and URL path to the Matomo installation. Defaults to the current Matomo URL. + * @param bool $mergeSubdomains Whether to track visitors across all subdomains of the site. + * @param bool $groupPageTitlesByDomain Whether to prepend the site domain to the page title when tracking. + * @param bool $mergeAliasUrls Whether to treat alias URLs as part of the same website for outlink detection. + * @param array $visitorCustomVariables Custom visitor-scope variables as `array(array('name', 'value'), ...)`. + * @param array $pageCustomVariables Custom page-scope variables as `array(array('name', 'value'), ...)`. + * @param string $customCampaignNameQueryParam Custom campaign name query parameter name, overriding the default. + * @param string $customCampaignKeywordParam Custom campaign keyword query parameter name, overriding the default. + * @param bool $doNotTrack Whether to respect the browser's Do-Not-Track setting. + * @param bool $disableCookies Whether to disable all first-party cookies. + * @param bool $trackNoScript Whether to include a `

%s

', + Piwik::translate( + 'SitesManager_SiteWithoutDataGoogleTagManagerDescription', + [ + Url::getExternalLinkTag('https://matomo.org/faq/new-to-piwik/how-do-i-use-matomo-analytics-within-gtm-google-tag-manager'), + '', + ] + ) + ); + } +} diff --git a/plugins/SitesManager/SiteContentDetection/Joomla.php b/plugins/SitesManager/SiteContentDetection/Joomla.php new file mode 100644 index 0000000000000000000000000000000000000000..36cc80e80516cb1bce12b50abccde17917a57f46 --- /dev/null +++ b/plugins/SitesManager/SiteContentDetection/Joomla.php @@ -0,0 +1,41 @@ +getIntegerParameter('idSite'); + $jsTag = Request::processRequest('SitesManager.getJavascriptTag', ['idSite' => $idSite, 'piwikUrl' => $matomoUrl]); + + if (Manager::getInstance()->isPluginActivated('CustomVariables')) { + $maxCustomVariables = CustomVariables::getNumUsableCustomVariables(); + } + + $view->jsTag = $jsTag; + $view->isJsTrackerInstallCheckAvailable = Manager::getInstance()->isPluginActivated('JsTrackerInstallCheck'); + $view->serverSideDoNotTrackEnabled = $dntChecker->isActive(); + $view->maxCustomVariables = $maxCustomVariables; + $view->defaultSiteDecoded = [ + 'id' => $idSite, + 'name' => Common::unsanitizeInputValue(Site::getNameFor($idSite)), + ]; + $view->notificationMessage = $this->getConsentManagerNotification($detector); + return $view->render(); + } + + public function isRecommended(SiteContentDetector $detector): bool + { + return false; // do not recommend this, as it's used as fall back + } + + public function getRecommendationDetails(SiteContentDetector $detector): array + { + $details = parent::getRecommendationDetails($detector); + $details['text'] = Piwik::translate('SitesManager_SetupMatomoTracker'); + return $details; + } + + private function getConsentManagerNotification(SiteContentDetector $detector): string + { + $consentManagers = $detector->getDetectsByType(SiteContentDetectionAbstract::TYPE_CONSENT_MANAGER); + + if (empty($consentManagers)) { + return ''; + } + + $consentManagerId = reset($consentManagers); + $consentManager = $detector->getSiteContentDetectionById($consentManagerId); + + if (empty($consentManager)) { + return ''; + } + + $consentManagerName = $consentManager::getName(); + $consentManagerUrl = $consentManager::getInstructionUrl(); + $consentManagerIsConnected = in_array($consentManagerId, $detector->connectedConsentManagers); + $notificationMessage = '

' . Piwik::translate('PrivacyManager_ConsentManagerDetected', [$consentManagerName, Url::getExternalLinkTag($consentManagerUrl), '']) . '

'; + + if (!empty($consentManagerIsConnected)) { + $notificationMessage .= '

' . Piwik::translate('SitesManager_ConsentManagerConnected', [$consentManagerName]) . '

'; + } + + return $notificationMessage; + } +} diff --git a/plugins/SitesManager/SiteContentDetection/MatomoTagManager.php b/plugins/SitesManager/SiteContentDetection/MatomoTagManager.php new file mode 100644 index 0000000000000000000000000000000000000000..7f8976799e05ece87b6c2523886ed93da2d4074c --- /dev/null +++ b/plugins/SitesManager/SiteContentDetection/MatomoTagManager.php @@ -0,0 +1,58 @@ +' . Piwik::translate('SitesManager_SiteWithoutDataMatomoTagManager') . ' +

' . Piwik::translate( + 'SitesManager_SiteWithoutDataMatomoTagManagerNotActive', + [Url::getExternalLinkTag('https://matomo.org/docs/tag-manager/'), ''] + ) . '

'; + } +} diff --git a/plugins/SitesManager/SiteContentDetection/Osano.php b/plugins/SitesManager/SiteContentDetection/Osano.php new file mode 100644 index 0000000000000000000000000000000000000000..79d686c98b48ab94e81222ec8ab455d8f293e42d --- /dev/null +++ b/plugins/SitesManager/SiteContentDetection/Osano.php @@ -0,0 +1,37 @@ + { console.log('cm-change'); consentSet(change); });"; + return (strpos($data, $needle) !== false); + } +} diff --git a/plugins/SitesManager/SiteContentDetection/RaygunCrashReporting.php b/plugins/SitesManager/SiteContentDetection/RaygunCrashReporting.php new file mode 100644 index 0000000000000000000000000000000000000000..b26e38399a73af96e267896c6b62688c2d2c6861 --- /dev/null +++ b/plugins/SitesManager/SiteContentDetection/RaygunCrashReporting.php @@ -0,0 +1,31 @@ +wasDetected(self::getId())) { + return ''; // don't show on others page if tab is being displayed + } + + return sprintf( + '

%s

', + Piwik::translate( + 'SitesManager_SiteWithoutDataReactDescription', + [ + Url::getExternalLinkTag('https://matomo.org/guide/tag-manager/'), + '', + Url::getExternalLinkTag('https://matomo.org/faq/new-to-piwik/how-do-i-start-tracking-data-with-matomo-on-websites-that-use-react/'), + '', + ] + ) + ); + } +} diff --git a/plugins/SitesManager/SiteContentDetection/Rollbar.php b/plugins/SitesManager/SiteContentDetection/Rollbar.php new file mode 100644 index 0000000000000000000000000000000000000000..ed19fba0d7c9a572bf925eb9bd459f3a9bd4b0bd --- /dev/null +++ b/plugins/SitesManager/SiteContentDetection/Rollbar.php @@ -0,0 +1,30 @@ +|null $headers + */ + abstract public function isDetected(?string $data = null, ?array $headers = null): bool; + + /** + * Returns the content that should be rendered into a new Tab on the no data page + */ + public function renderInstructionsTab(SiteContentDetector $detector): string + { + return ''; + } + + /** + * Returns the content that should be displayed in the Others tab on the no data page + */ + public function renderOthersInstruction(SiteContentDetector $detector): string + { + return ''; + } + + /** + * Returns if the method should be recommended. Returns true if the method was detected + */ + public function isRecommended(SiteContentDetector $detector): bool + { + return $detector->wasDetected(static::getId()); + } + + /** + * Returns details used to render the recommendation on no data screen + * + * @return array{title: string, text: string, button: string} + */ + public function getRecommendationDetails(SiteContentDetector $detector): array + { + return [ + 'title' => Piwik::translate('SitesManager_SiteWithoutDataInstallWithXRecommendation', [static::getName()]), + 'text' => Piwik::translate('SitesManager_SiteWithoutDataRecommendationText', [static::getName()]), + 'button' => Piwik::translate('SitesManager_SiteWithoutDataInstallWithX', [static::getName()]), + ]; + } +} diff --git a/plugins/SitesManager/SiteContentDetection/Squarespace.php b/plugins/SitesManager/SiteContentDetection/Squarespace.php new file mode 100644 index 0000000000000000000000000000000000000000..0416d473b6b15a5891273d3d459cfb7bbdfe563a --- /dev/null +++ b/plugins/SitesManager/SiteContentDetection/Squarespace.php @@ -0,0 +1,36 @@ +'; + return (strpos($data, $needle) !== false); + } +} diff --git a/plugins/SitesManager/SiteContentDetection/TarteAuCitron.php b/plugins/SitesManager/SiteContentDetection/TarteAuCitron.php new file mode 100644 index 0000000000000000000000000000000000000000..aad866f1e8817cd5ac53b5a9517111a94edeae7f --- /dev/null +++ b/plugins/SitesManager/SiteContentDetection/TarteAuCitron.php @@ -0,0 +1,40 @@ +sendHeadersWhenRendering = false; + $view->wasDetected = $detector->wasDetected(self::getId()); + $view->SiteWithoutDataVueFollowStepNote2Key = StaticContainer::get('SitesManager.SiteWithoutDataVueFollowStepNote2'); + $view->vue3Code = $this->getVueInitializeCode(3); + $view->vue2Code = $this->getVueInitializeCode(2); + return $view->render(); + } + + public function renderOthersInstruction(SiteContentDetector $detector): string + { + if ($detector->wasDetected(self::getId())) { + return ''; // don't show on others page if tab is being displayed + } + + return sprintf( + '

%s

', + Piwik::translate( + 'SitesManager_SiteWithoutDataVueDescription', + [ + Url::getExternalLinkTag('https://github.com/AmazingDreams/vue-matomo') . 'vue-matomo', + Url::getExternalLinkTag('https://matomo.org/faq/new-to-piwik/how-do-i-install-the-matomo-tracking-code-on-websites-that-use-vue-js/'), + '', + ] + ) + ); + } + + private function getVueInitializeCode(int $vueVersion = 3): string + { + $request = \Piwik\Request::fromRequest(); + $piwikUrl = Url::getCurrentUrlWithoutFileName(); + $siteId = $request->getIntegerParameter('idSite', 1); + $configureComment = Piwik::translate('SitesManager_SiteWithoutDataVueFollowStep2ExampleCodeCommentConfigureMatomo'); + $trackViewComment = Piwik::translate('SitesManager_SiteWithoutDataVueFollowStep2ExampleCodeCommentTrackPageView'); + if ($vueVersion === 2) { + return <<getIntegerParameter('idSite', 0); + $period = $request->getStringParameter('period', 'day'); + $date = $request->getStringParameter('date', 'yesterday'); + $view->authLink = SettingsPiwik::getPiwikUrl() . 'index.php?' . + Url::getQueryStringFromParameters([ + 'idSite' => $idSite, + 'date' => urlencode($date), + 'period' => urlencode($period), + 'module' => 'UsersManager', + 'action' => 'addNewToken', + ]); + $view->faqLink = Url::addCampaignParametersToMatomoLink('https://matomo.org/faq/general/faq_114/'); + $view->sendHeadersWhenRendering = false; + $view->site = ['id' => $idSite, 'name' => '']; + $view->isJsTrackerInstallCheckAvailable = Manager::getInstance()->isPluginActivated('JsTrackerInstallCheck'); + return $view->render(); + } + + public function renderOthersInstruction(SiteContentDetector $detector): string + { + if ($detector->wasDetected(self::getId())) { + return ''; // don't show on others page if tab is being displayed + } + + return sprintf( + '

%s

', + Piwik::translate( + 'SitesManager_SiteWithoutDataWordpressDescription', + [ + Url::getExternalLinkTag('https://matomo.org/faq/new-to-piwik/how-do-i-install-the-matomo-tracking-code-on-wordpress/'), + '', + ] + ) + ); + } +} diff --git a/plugins/SitesManager/SiteUrls.php b/plugins/SitesManager/SiteUrls.php new file mode 100644 index 0000000000000000000000000000000000000000..ae8c22e0b58de55ce3ac85dc84e4d229a44851bd --- /dev/null +++ b/plugins/SitesManager/SiteUrls.php @@ -0,0 +1,249 @@ +delete(self::$cacheId); + } + + /** + * Groups all URLs by host, path and idsite. + * + * @param array $siteUrls An array containing URLs by idsite, + * eg array(array($idSite = 1 => array('apache.piwik', 'apache2.piwik'), 2 => array(), ...)) + * as returned by {@link getAllCachedSiteUrls()} and {@link getAllSiteUrls} + * @return array All urls grouped by host => path => idSites. Path having the most '/' will be listed first + * array( + 'apache.piwik' => array( + '/test/two' => $idsite = array(3), + '/test' => $idsite = array(1), + '/' => $idsite = array(2), + ), + 'test.apache.piwik' => array( + '/test/two' => $idsite = array(3), + '/test' => $idsite = array(1), + '/' => $idsite = array(2, 3), + ), + ); + */ + public function groupUrlsByHost($siteUrls) + { + if (empty($siteUrls)) { + return []; + } + + $allUrls = []; + + foreach ($siteUrls as $idSite => $urls) { + $idSite = (int) $idSite; + foreach ($urls as $url) { + $this->addUrlByHost($allUrls, $idSite, $url); + } + } + + $this->sortUrlsByHost($allUrls); + + return $allUrls; + } + + public function addUrlByHost(&$allUrls, $idSite, $url, $addPath = true) + { + $urlParsed = @parse_url($url); + + if ($urlParsed === false || !isset($urlParsed['host'])) { + return; + } + + $host = $this->toCanonicalHost($urlParsed['host']); + $path = $this->getCanonicalPathFromParsedUrl($urlParsed); + + if (!isset($allUrls[$host])) { + $allUrls[$host] = []; + } + + if (!$addPath) { + $path = '/'; + } + + if (!isset($allUrls[$host][$path])) { + $allUrls[$host][$path] = []; + } + + if (!in_array($idSite, $allUrls[$host][$path])) { + $allUrls[$host][$path][] = $idSite; + } + } + + private function sortUrlsByHost(&$allUrls) + { + foreach ($allUrls as $host => $paths) { + uksort($paths, [$this, 'sortByPathDepth']); + $allUrls[$host] = $paths; + } + } + + public function getIdSitesMatchingUrl($parsedUrl, $urlsGroupedByHost) + { + if (empty($parsedUrl['host'])) { + return null; + } + + $urlHost = $this->toCanonicalHost($parsedUrl['host']); + $urlPath = $this->getCanonicalPathFromParsedUrl($parsedUrl); + + // As wildcard subdomain might be allowed, for e.g. my.sub.example.org we need to check e.g. + // - my.sub.example.org + // - .my.sub.example.org + // - .sub.example.org + // - .example.org + $hostsToCheck = [ + $urlHost, + '.' . $urlHost, + ]; + + while (substr_count($urlHost, '.') >= 2) { + $urlHost = substr($urlHost, strpos($urlHost, '.') + 1); + $hostsToCheck[] = '.' . $urlHost; + } + + foreach ($hostsToCheck as $host) { + if (isset($urlsGroupedByHost[$host])) { + $paths = $urlsGroupedByHost[$host]; + + foreach ($paths as $path => $idSites) { + if (0 === strpos($urlPath, $path)) { + return $idSites; + } + } + + if (isset($paths['/'])) { + return $paths['/']; + } + } + } + + return null; + } + + public function getPathMatchingUrl($parsedUrl, $urlsGroupedByHost) + { + if (empty($parsedUrl['host'])) { + return null; + } + + $urlHost = $this->toCanonicalHost($parsedUrl['host']); + $urlPath = $this->getCanonicalPathFromParsedUrl($parsedUrl); + + if (isset($urlsGroupedByHost[$urlHost])) { + $paths = $urlsGroupedByHost[$urlHost]; + + foreach ($paths as $path => $idSites) { + if (0 === strpos($urlPath, $path)) { + return $path; + } + } + } + } + + public function getAllCachedSiteUrls() + { + $cache = $this->getCache(); + $siteUrls = $cache->fetch(self::$cacheId); + + if (empty($siteUrls)) { + $siteUrls = $this->getAllSiteUrls(); + $cache->save(self::$cacheId, $siteUrls, 1800); + } + + return $siteUrls; + } + + public function getAllSiteUrls() + { + $model = new Model(); + $siteUrls = $model->getAllKnownUrlsForAllSites(); + + if (empty($siteUrls)) { + return []; + } + + $urls = []; + foreach ($siteUrls as $siteUrl) { + $siteId = (int) $siteUrl['idsite']; + + if (!isset($urls[$siteId])) { + $urls[$siteId] = []; + } + + $urls[$siteId][] = $siteUrl['url']; + } + + return $urls; + } + + private static function getCache() + { + return Cache::getLazyCache(); + } + + public function addRequestUrlToSiteUrls(&$allUrls, Request $request) + { + $idSite = $request->getIdSite(); + $url = $request->getParam('url'); + + $this->addUrlByHost($allUrls, $idSite, $url, $addPath = false); + } + + private function sortByPathDepth($pathA, $pathB) + { + // list first the paths with most '/' , and list path = '/' last + $numSlashA = substr_count($pathA, '/'); + $numSlashB = substr_count($pathB, '/'); + + if ($numSlashA === $numSlashB) { + return -1 * strcmp($pathA, $pathB); + } + + return $numSlashA > $numSlashB ? -1 : 1; + } + + private function toCanonicalHost($host) + { + $host = mb_strtolower($host); + if (strpos($host, 'www.') === 0) { + $host = substr($host, 4); + } + + return $host; + } + + private function getCanonicalPathFromParsedUrl($urlParsed) + { + $path = '/'; + + if (isset($urlParsed['path'])) { + $path = mb_strtolower($urlParsed['path']); + if (!Common::stringEndsWith($path, '/')) { + $path .= '/'; + } + } + + return $path; + } +} diff --git a/plugins/SitesManager/SitesManager.php b/plugins/SitesManager/SitesManager.php new file mode 100644 index 0000000000000000000000000000000000000000..8c509027131a294637d022cb6dd104c01a2c5d7e --- /dev/null +++ b/plugins/SitesManager/SitesManager.php @@ -0,0 +1,561 @@ + 'getStylesheetFiles', + 'Tracker.Cache.getSiteAttributes' => ['function' => 'recordWebsiteDataInCache', 'before' => true], + 'Tracker.setTrackerCacheGeneral' => 'setTrackerCacheGeneral', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + 'SitesManager.deleteSite.end' => 'onSiteDeleted', + 'System.addSystemSummaryItems' => 'addSystemSummaryItems', + ]; + } + + public static function isSitesAdminEnabled() + { + return (bool) Config::getInstance()->General['enable_sites_admin']; + } + + public static function dieIfSitesAdminIsDisabled() + { + Piwik::checkUserIsNotAnonymous(); + if (!self::isSitesAdminEnabled()) { + throw new \Exception('Creating, updating, and deleting sites has been disabled.'); + } + } + + public function addSystemSummaryItems(&$systemSummary) + { + if (self::isSitesAdminEnabled()) { + $websites = Request::processRequest('SitesManager.getAllSites', ['filter_limit' => '-1']); + $numWebsites = count($websites); + $systemSummary[] = new SystemSummary\Item( + 'websites', + Piwik::translate('CoreHome_SystemSummaryNWebsites', $numWebsites), + null, + ['module' => 'SitesManager', 'action' => 'index'], + '', + 10 + ); + } + } + + /** + * Returns whether the "site has no data" tracker-setup screen should be shown for the given site. + * Only answers the data question (never tracked, check not suppressed, not dismissed this session); + * the reporting UI decides where to render it, as the active group lives in the URL hash. + */ + public static function shouldShowEmptySiteMessage(int $idSite): bool + { + if (!$idSite) { + return false; + } + + if (!self::shouldPerformEmptySiteCheck($idSite)) { + return false; + } + + $hadTrafficKey = 'SitesManagerHadTrafficInPast_' . $idSite; + $hadTrafficBefore = Option::get($hadTrafficKey); + if (!empty($hadTrafficBefore)) { + // user had traffic at some stage in the past... not needed to show tracking code + return false; + } + + if (self::hasTrackedAnyTraffic($idSite)) { + // remember the user had traffic in the past so we won't show the tracking screen again + // if all visits are deleted for example + Option::set($hadTrafficKey, 1); + return false; + } + + // never had any traffic - the screen can be temporarily hidden for an hour (see Controller) + $session = new SessionNamespace('siteWithoutData'); + if (!empty($session->ignoreMessage)) { + return false; + } + + return true; + } + + public static function hasTrackedAnyTraffic($siteId) + { + $trackerModel = new TrackerModel(); + return !$trackerModel->isSiteEmpty($siteId); + } + + public static function shouldPerformEmptySiteCheck($siteId) + { + $shouldPerformEmptySiteCheck = true; + + /** + * Posted before checking to display the "No data has been recorded yet" message. + * If your Measurable should never have visits, you can use this event to make + * sure that message is never displayed. + * + * @param bool &$shouldPerformEmptySiteCheck Set this value to true to perform the + * check, false if otherwise. + * @param int $siteId The ID of the site we would perform a check for. + */ + Piwik::postEvent('SitesManager.shouldPerformEmptySiteCheck', [&$shouldPerformEmptySiteCheck, $siteId]); + + return $shouldPerformEmptySiteCheck; + } + + public function onSiteDeleted($idSite) + { + // we do not delete logs here on purpose (you can run these queries on the log_ tables to delete all data) + Cache::deleteCacheWebsiteAttributes($idSite); + + $archiveInvalidator = StaticContainer::get('Piwik\Archive\ArchiveInvalidator'); + $archiveInvalidator->forgetRememberedArchivedReportsToInvalidateForSite($idSite); + + MeasurableSettingsTable::removeAllSettingsForSite($idSite); + } + + /** + * Get CSS files + */ + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/SitesManager/stylesheets/SitesManager.less"; + } + + /** + * Hooks when a website tracker cache is flushed (website updated, cache deleted, or empty cache) + * Will record in the tracker config file all data needed for this website in Tracker. + * + * @param array $array + * @param int $idSite + * @return void + */ + public function recordWebsiteDataInCache(&$array, $idSite) + { + $idSite = (int) $idSite; + + $website = API::getInstance()->getSiteFromId($idSite); + $urls = API::getInstance()->getSiteUrlsFromId($idSite); + + // add the 'hosts' entry in the website array + $array['urls'] = $urls; + $array['hosts'] = $this->getTrackerHosts($urls); + + $array['exclude_unknown_urls'] = $website['exclude_unknown_urls']; + $array['excluded_ips'] = $this->getTrackerExcludedIps($website); + $array['excluded_parameters'] = self::getTrackerExcludedQueryParameters($website); + $array['excluded_user_agents'] = self::getExcludedUserAgents($website); + $array['excluded_referrers'] = self::getExcludedReferrers($website); + $array['keep_url_fragment'] = self::shouldKeepURLFragmentsFor($website); + $array['sitesearch'] = $website['sitesearch']; + $array['sitesearch_keyword_parameters'] = $this->getTrackerSearchKeywordParameters($website); + $array['sitesearch_category_parameters'] = $this->getTrackerSearchCategoryParameters($website); + $array['timezone'] = $this->getTimezoneFromWebsite($website); + $array['ts_created'] = $website['ts_created']; + $array['type'] = $website['type']; + + // we make sure to have the fingerprint salts for the last 3 days incl tmrw in the cache so we don't need to + // query the DB directly for these days + $datesToGenerateSalt = [Date::now()->addDay(1), Date::now(), Date::now()->subDay(1), Date::now()->subDay(2)]; + + $fingerprintSaltKey = new FingerprintSalt(); + foreach ($datesToGenerateSalt as $date) { + $dateString = $fingerprintSaltKey->getDateString($date, $array['timezone']); + $array[FingerprintSalt::OPTION_PREFIX . $dateString] = $fingerprintSaltKey->getSalt($dateString, $idSite); + } + } + + public function setTrackerCacheGeneral(&$cache) + { + /* + * Executed as super user, as permissions are required for the used API methods, but it may happen that generating + * the tracker cache is triggered by an event, that does not have high enough privileges. + */ + Access::doAsSuperUser(function () use (&$cache) { + $cache['global_excluded_user_agents'] = self::filterBlankFromCommaSepList(API::getInstance()->getExcludedUserAgentsGlobal()); + $cache['global_excluded_ips'] = self::filterBlankFromCommaSepList(API::getInstance()->getExcludedIpsGlobal()); + $cache['global_excluded_referrers'] = self::filterBlankFromCommaSepList(API::getInstance()->getExcludedReferrersGlobal()); + }); + } + + /** + * Returns the timezone configured for a specific site. + * + * @param array $site DB data for the site. + * @return ?string + */ + private static function getTimezoneFromWebsite($site) + { + if (!empty($site['timezone'])) { + return $site['timezone']; + } + + return null; + } + + /** + * Returns whether we should keep URL fragments for a specific site. + * + * @param array $site DB data for the site. + * @return bool + */ + private static function shouldKeepURLFragmentsFor($site) + { + if ($site['keep_url_fragment'] == self::KEEP_URL_FRAGMENT_YES) { + return true; + } elseif ($site['keep_url_fragment'] == self::KEEP_URL_FRAGMENT_NO) { + return false; + } + + return API::getInstance()->getKeepURLFragmentsGlobal(); + } + + private function getTrackerSearchKeywordParameters($website) + { + $searchParameters = $website['sitesearch_keyword_parameters']; + if (empty($searchParameters)) { + $searchParameters = API::getInstance()->getSearchKeywordParametersGlobal(); + } + return explode(",", $searchParameters); + } + + private function getTrackerSearchCategoryParameters($website) + { + $searchParameters = $website['sitesearch_category_parameters']; + if (empty($searchParameters)) { + $searchParameters = API::getInstance()->getSearchCategoryParametersGlobal(); + } + return explode(",", $searchParameters); + } + + /** + * Returns the array of excluded IPs to save in the config file + * + * @param array $website + * @return array + */ + private function getTrackerExcludedIps($website) + { + $excludedIps = $website['excluded_ips']; + $globalExcludedIps = API::getInstance()->getExcludedIpsGlobal(); + + $excludedIps .= ',' . $globalExcludedIps; + + $ipRanges = []; + foreach (explode(',', $excludedIps) as $ip) { + $ipRange = API::getInstance()->getIpsForRange($ip); + if ($ipRange !== false) { + $ipRanges[] = $ipRange; + } + } + return $ipRanges; + } + + /** + * Returns the array of excluded user agent substrings for a site. Filters out + * any garbage data & trims each entry. + * + * @param array $website The full set of information for a site. + * @return array + */ + private static function getExcludedUserAgents($website) + { + $excludedUserAgents = API::getInstance()->getExcludedUserAgentsGlobal(); + $excludedUserAgents .= ',' . $website['excluded_user_agents']; + return self::filterBlankFromCommaSepList($excludedUserAgents); + } + + /** + * Returns the array of excluded referrers. Filters out + * any garbage data & trims each entry. + * + * @param array $website The full set of information for a site. + * @return array + */ + private static function getExcludedReferrers($website) + { + $excludedReferrers = API::getInstance()->getExcludedReferrersGlobal(); + $excludedReferrers .= ',' . $website['excluded_referrers']; + return self::filterBlankFromCommaSepList($excludedReferrers); + } + + /** + * Returns the array of URL query parameters to exclude from URLs + * + * @param array $website + * @return array + */ + public static function getTrackerExcludedQueryParameters($website) + { + $excludedQueryParameters = $website['excluded_parameters']; + $globalExcludedQueryParameters = API::getInstance()->getExcludedQueryParametersGlobal($website['idsite']); + + $excludedQueryParameters .= ',' . $globalExcludedQueryParameters; + return self::filterBlankFromCommaSepList($excludedQueryParameters); + } + + /** + * Trims each element of a comma-separated list of strings, removes empty elements and + * returns the result (as an array). + * + * @param string $parameters The unfiltered list. + * @return array The filtered list of strings as an array. + */ + private static function filterBlankFromCommaSepList($parameters) + { + $parameters = explode(',', $parameters); + $parameters = array_filter($parameters, 'strlen'); + $parameters = array_unique($parameters); + return $parameters; + } + + /** + * Returns the hosts alias URLs + * @return array + */ + private function getTrackerHosts($urls) + { + $hosts = []; + foreach ($urls as $url) { + $url = parse_url($url); + if (isset($url['host'])) { + $hosts[] = $url['host']; + } + } + return $hosts; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'Actions_SubmenuSitesearch'; + $translationKeys[] = 'General_Actions'; + $translationKeys[] = 'General_Cancel'; + $translationKeys[] = 'General_Description'; + $translationKeys[] = 'General_ClickToSearch'; + $translationKeys[] = 'General_Loading'; + $translationKeys[] = 'General_Measurables'; + $translationKeys[] = 'General_Next'; + $translationKeys[] = 'General_OrCancel'; + $translationKeys[] = 'General_Pagination'; + $translationKeys[] = 'General_PaginationWithoutTotal'; + $translationKeys[] = 'General_Previous'; + $translationKeys[] = 'General_Save'; + $translationKeys[] = 'General_Search'; + $translationKeys[] = 'General_Share'; + $translationKeys[] = 'Goals_Ecommerce'; + $translationKeys[] = 'Goals_Optional'; + $translationKeys[] = 'PrivacyManager_ManagePrivacySettings'; + $translationKeys[] = 'PrivacyManager_TrackingDataAnonymizationSettings'; + $translationKeys[] = 'SitesManager_AddMeasurable'; + $translationKeys[] = 'SitesManager_AddSite'; + $translationKeys[] = 'SitesManager_AdvancedTimezoneSupportNotFound'; + $translationKeys[] = 'SitesManager_AliasUrlHelp'; + $translationKeys[] = 'SitesManager_ChangingYourTimezoneWillOnlyAffectDataForward'; + $translationKeys[] = 'SitesManager_ChooseMeasurableTypeHeadline'; + $translationKeys[] = 'SitesManager_ChooseMeasurableTypeSubheader'; + $translationKeys[] = 'SitesManager_ChooseMeasurableTypeSubheaderRollUp'; + $translationKeys[] = 'SitesManager_Currency'; + $translationKeys[] = 'SitesManager_CurrencySymbolWillBeUsedForGoals'; + $translationKeys[] = 'SitesManager_DefaultCurrencyForNewWebsites'; + $translationKeys[] = 'SitesManager_DefaultTimezoneForNewWebsites'; + $translationKeys[] = 'SitesManager_DeleteConfirm'; + $translationKeys[] = 'SitesManager_DeleteSiteExplanation'; + $translationKeys[] = 'SitesManager_DemoSiteButtonText'; + $translationKeys[] = 'SitesManager_DetectingYourSite'; + $translationKeys[] = 'SitesManager_DisableSiteSearch'; + $translationKeys[] = 'SitesManager_EcommerceHelp'; + $translationKeys[] = 'SitesManager_EmailInstructionsButton'; + $translationKeys[] = 'SitesManager_EmailInstructionsButtonText'; + $translationKeys[] = 'SitesManager_EmailInstructionsSubject'; + $translationKeys[] = 'SitesManager_EnableEcommerce'; + $translationKeys[] = 'SitesManager_EnableSiteSearch'; + $translationKeys[] = 'SitesManager_ExcludedIps'; + $translationKeys[] = 'SitesManager_ExcludedParameters'; + $translationKeys[] = 'SitesManager_ExcludedReferrers'; + $translationKeys[] = 'SitesManager_ExcludedReferrersHelp'; + $translationKeys[] = 'SitesManager_ExcludedReferrersHelpDetails'; + $translationKeys[] = 'SitesManager_ExcludedReferrersHelpExamples'; + $translationKeys[] = 'SitesManager_ExcludedReferrersHelpSubDomains'; + $translationKeys[] = 'SitesManager_ExcludedUserAgents'; + $translationKeys[] = 'SitesManager_GlobalExcludedUserAgentHelp1'; + $translationKeys[] = 'SitesManager_GlobalExcludedUserAgentHelp2'; + $translationKeys[] = 'SitesManager_GlobalExcludedUserAgentHelp3'; + $translationKeys[] = 'SitesManager_GlobalListExcludedIps'; + $translationKeys[] = 'SitesManager_GlobalListExcludedQueryParameters'; + $translationKeys[] = 'SitesManager_GlobalListExcludedReferrers'; + $translationKeys[] = 'SitesManager_GlobalListExcludedReferrersDesc'; + $translationKeys[] = 'SitesManager_GlobalListExcludedUserAgents'; + $translationKeys[] = 'SitesManager_GlobalListExcludedUserAgents_Desc'; + $translationKeys[] = 'SitesManager_GlobalWebsitesSettings'; + $translationKeys[] = 'SitesManager_HelpExcludedIpAddresses'; + $translationKeys[] = 'SitesManager_JsTrackingTagHelp'; + $translationKeys[] = 'SitesManager_KeepURLFragments'; + $translationKeys[] = 'SitesManager_KeepURLFragmentsHelp'; + $translationKeys[] = 'SitesManager_KeepURLFragmentsHelp2'; + $translationKeys[] = 'SitesManager_KeepURLFragmentsLong'; + $translationKeys[] = 'SitesManager_ListOfIpsToBeExcludedOnAllWebsites'; + $translationKeys[] = 'SitesManager_ListOfQueryParametersToBeExcludedOnAllWebsites'; + $translationKeys[] = 'SitesManager_ListOfQueryParametersToExclude'; + $translationKeys[] = 'SitesManager_MainDescription'; + $translationKeys[] = 'SitesManager_MeasurableNameHelpText'; + $translationKeys[] = 'SitesManager_MeasurableNamePlaceholder'; + $translationKeys[] = 'SitesManager_MeasurableDescriptionHelpText'; + $translationKeys[] = 'SitesManager_MeasurableDescriptionPlaceholder'; + $translationKeys[] = 'SitesManager_NotAnEcommerceSite'; + $translationKeys[] = 'SitesManager_NotFound'; + $translationKeys[] = 'SitesManager_OnlyMatchedUrlsAllowed'; + $translationKeys[] = 'SitesManager_OnlyMatchedUrlsAllowedHelp'; + $translationKeys[] = 'SitesManager_OnlyMatchedUrlsAllowedHelpExamples'; + $translationKeys[] = 'SitesManager_OnlyOneSiteAtTime'; + $translationKeys[] = 'SitesManager_PiwikOffersEcommerceAnalytics'; + $translationKeys[] = 'SitesManager_PiwikWillAutomaticallyExcludeCommonSessionParameters'; + $translationKeys[] = 'SitesManager_SearchCategoryDesc'; + $translationKeys[] = 'SitesManager_SearchCategoryLabel'; + $translationKeys[] = 'SitesManager_SearchCategoryParametersDesc'; + $translationKeys[] = 'SitesManager_SearchKeywordLabel'; + $translationKeys[] = 'SitesManager_SearchKeywordParametersDesc'; + $translationKeys[] = 'SitesManager_SearchParametersNote'; + $translationKeys[] = 'SitesManager_SearchParametersNote2'; + $translationKeys[] = 'SitesManager_SearchUseDefault'; + $translationKeys[] = 'SitesManager_SelectDefaultCurrency'; + $translationKeys[] = 'SitesManager_SelectDefaultTimezone'; + $translationKeys[] = 'SitesManager_ShowTrackingTag'; + $translationKeys[] = 'SitesManager_SiteSearchUse'; + $translationKeys[] = 'SitesManager_SiteWithoutDataChooseTrackingMethod'; + $translationKeys[] = 'SitesManager_SiteWithoutDataGoogleTagManager'; + $translationKeys[] = 'SitesManager_SiteWithoutDataGoogleTagManagerDescription'; + $translationKeys[] = 'SitesManager_SiteWithoutDataHidePageForHour'; + $translationKeys[] = 'SitesManager_SiteWithoutDataNotYetReady'; + $translationKeys[] = 'SitesManager_SiteWithoutDataOtherInstallMethodsIntro'; + $translationKeys[] = 'SitesManager_SiteWithoutDataReactDescription'; + $translationKeys[] = 'SitesManager_SiteWithoutDataTemporarilyHidePage'; + $translationKeys[] = 'SitesManager_SiteWithoutDataVueDescription'; + $translationKeys[] = 'SitesManager_SiteWithoutDataWordpressDescription'; + $translationKeys[] = 'SitesManager_Sites'; + $translationKeys[] = 'SitesManager_StepByStepGuide'; + $translationKeys[] = 'SitesManager_SuperUserAccessCan'; + $translationKeys[] = 'SitesManager_Timezone'; + $translationKeys[] = 'SitesManager_TrackingSiteSearch'; + $translationKeys[] = 'SitesManager_Type'; + $translationKeys[] = 'SitesManager_UTCTimeIs'; + $translationKeys[] = 'SitesManager_Urls'; + $translationKeys[] = 'SitesManager_WebsiteCreated'; + $translationKeys[] = 'SitesManager_WebsiteUpdated'; + $translationKeys[] = 'SitesManager_WebsitesManagement'; + $translationKeys[] = 'SitesManager_XManagement'; + $translationKeys[] = 'SitesManager_YouCurrentlyHaveAccessToNWebsites'; + $translationKeys[] = 'SitesManager_YourCurrentIpAddressIs'; + $translationKeys[] = 'UsersManager_InviteTeamMember'; + $translationKeys[] = 'SitesManager_SiteWithoutDataOtherInstallMethods'; + $translationKeys[] = 'Mobile_NavigationBack'; + $translationKeys[] = 'SitesManager_SiteWithoutDataInstallWithX'; + $translationKeys[] = 'SitesManager_ExclusionTypeOptionCommonSessionParameters'; + $translationKeys[] = 'SitesManager_ExclusionTypeOptionMatomoRecommendedPII'; + $translationKeys[] = 'SitesManager_ExclusionTypeOptionCustom'; + $translationKeys[] = 'SitesManager_ExclusionTypeDescriptionCommonSessionParameters'; + $translationKeys[] = 'SitesManager_ExclusionTypeDescriptionMatomoRecommendedPII'; + $translationKeys[] = 'SitesManager_ExclusionTypeDescriptionCustom'; + $translationKeys[] = 'SitesManager_ExclusionViewListLink'; + $translationKeys[] = 'SitesManager_AddSensibleExclusionsToMyCustomListButtonText'; + $translationKeys[] = 'SitesManager_MatomoWillAutomaticallyExcludeCommonSessionParametersInAddition'; + } + + public static function renderTrackingCodeEmail(int $idSite) + { + $javascriptGenerator = new TrackerCodeGenerator(); + $javascriptGenerator->forceMatomoEndpoint(); + $matomoUrl = Url::getCurrentUrlWithoutFileName(); + + $jsTag = Request::processRequest( + 'SitesManager.getJavascriptTag', + ['idSite' => $idSite, 'piwikUrl' => $matomoUrl] + ); + + // Strip off open and close diff --git a/plugins/SitesManager/vue/src/ManageGlobalSettings/ManageGlobalSettings.vue b/plugins/SitesManager/vue/src/ManageGlobalSettings/ManageGlobalSettings.vue new file mode 100644 index 0000000000000000000000000000000000000000..822d6c09bd1714d18b773f365e6923f32b1cb133 --- /dev/null +++ b/plugins/SitesManager/vue/src/ManageGlobalSettings/ManageGlobalSettings.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/plugins/SitesManager/vue/src/SiteFields/SiteFields.vue b/plugins/SitesManager/vue/src/SiteFields/SiteFields.vue new file mode 100644 index 0000000000000000000000000000000000000000..300e86c63b10a6066a7e6b3cbe8c77e83be1b6c5 --- /dev/null +++ b/plugins/SitesManager/vue/src/SiteFields/SiteFields.vue @@ -0,0 +1,651 @@ + + + + + diff --git a/plugins/SitesManager/vue/src/SiteTypesStore/SiteType.ts b/plugins/SitesManager/vue/src/SiteTypesStore/SiteType.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f3f72773f804bbd1c462fc129206681824818e2 --- /dev/null +++ b/plugins/SitesManager/vue/src/SiteTypesStore/SiteType.ts @@ -0,0 +1,19 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { + SettingsForSinglePlugin, +} from 'CorePluginsAdmin'; + +interface SiteType { + id: string; + name: string; + howToSetupUrl?: string; + settings?: SettingsForSinglePlugin[]; +} + +export default SiteType; diff --git a/plugins/SitesManager/vue/src/SiteTypesStore/SiteTypesStore.ts b/plugins/SitesManager/vue/src/SiteTypesStore/SiteTypesStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb5022d57958857890f5aa29ab0e6833e289a173 --- /dev/null +++ b/plugins/SitesManager/vue/src/SiteTypesStore/SiteTypesStore.ts @@ -0,0 +1,90 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { + reactive, + readonly, + computed, +} from 'vue'; +import { AjaxHelper, MatomoUrl } from 'CoreHome'; +import SiteType from './SiteType'; + +interface SiteTypesStoreState { + isLoading: boolean; + typesById: Record; +} + +type AvailableTypesResponse = SiteType[]; + +const { $ } = window; + +class SiteTypesStore { + private state = reactive({ + isLoading: false, + typesById: {}, + }); + + readonly typesById = computed(() => readonly(this.state).typesById); + + readonly isLoading = computed(() => readonly(this.state).isLoading); + + readonly types = computed(() => Object.values(this.typesById.value)); + + private response?: Promise; + + init() { + return this.fetchAvailableTypes(); + } + + fetchAvailableTypes(): Promise { + if (this.response) { + return Promise.resolve(this.response); + } + + this.state.isLoading = true; + this.response = AjaxHelper.fetch({ + method: 'API.getAvailableMeasurableTypes', + filter_limit: '-1', + }).then((types) => { + types.forEach((type) => { + this.state.typesById[type.id] = type; + }); + + return this.types.value; + }).finally(() => { + this.state.isLoading = false; + }); + + return this.response; + } + + getEditSiteIdParameter(): string|undefined { + // parse query directly because #/editsiteid=N was supported alongside #/?editsiteid=N + const m = MatomoUrl.hashQuery.value.match(/editsiteid=([0-9]+)/); + if (!m) { + return undefined; + } + + const isShowAddSite = MatomoUrl.urlParsed.value.showaddsite === '1' + || MatomoUrl.urlParsed.value.showaddsite === 'true'; + + const editsiteid = m[1]; + if (editsiteid && $.isNumeric(editsiteid) && !isShowAddSite) { + return editsiteid; + } + + return undefined; + } + + removeEditSiteIdParameterFromHash(): void { + const params = { ...MatomoUrl.hashParsed.value }; + delete params.editsiteid; + MatomoUrl.updateHash(params); + } +} + +export default new SiteTypesStore(); diff --git a/plugins/SitesManager/vue/src/SiteWithoutData/SiteWithoutData.vue b/plugins/SitesManager/vue/src/SiteWithoutData/SiteWithoutData.vue new file mode 100644 index 0000000000000000000000000000000000000000..63720b3532651fc6ad537eeb0f4eba360d2a2c16 --- /dev/null +++ b/plugins/SitesManager/vue/src/SiteWithoutData/SiteWithoutData.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/plugins/SitesManager/vue/src/SitesManagement/ButtonBar.vue b/plugins/SitesManager/vue/src/SitesManagement/ButtonBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..777e93d05dd3875f3cf0b53553dd0a6150528ee3 --- /dev/null +++ b/plugins/SitesManager/vue/src/SitesManagement/ButtonBar.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/plugins/SitesManager/vue/src/SitesManagement/SitesManagement.vue b/plugins/SitesManager/vue/src/SitesManagement/SitesManagement.vue new file mode 100644 index 0000000000000000000000000000000000000000..63506618838d751402ed89ad15d59996a2e6b0bc --- /dev/null +++ b/plugins/SitesManager/vue/src/SitesManagement/SitesManagement.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/plugins/SitesManager/vue/src/TimezoneStore/TimezoneStore.ts b/plugins/SitesManager/vue/src/TimezoneStore/TimezoneStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6390579619971aa6777c68e2da365d184bb76f7 --- /dev/null +++ b/plugins/SitesManager/vue/src/TimezoneStore/TimezoneStore.ts @@ -0,0 +1,87 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { reactive, readonly, computed } from 'vue'; +import { AjaxHelper } from 'CoreHome'; + +interface Timezone { + group: string; + label: string; + code: string; +} + +interface TimezoneStoreState { + isLoading: boolean; + timezones: Timezone[]; + timezoneSupportEnabled: boolean; +} + +type GetTimezoneListResponse = Record>; + +interface IsTimezoneSupportedResponse { + value: boolean; +} + +class TimezoneStore { + private privateState = reactive({ + isLoading: false, + timezones: [], + timezoneSupportEnabled: false, + }); + + private readonly state = computed(() => readonly(this.privateState)); + + readonly timezones = computed(() => this.state.value.timezones); + + readonly timezoneSupportEnabled = computed(() => this.state.value.timezoneSupportEnabled); + + readonly isLoading = computed(() => this.state.value.isLoading); + + private initializePromise: Promise|null = null; + + init() { + if (!this.initializePromise) { + this.privateState.isLoading = true; + this.initializePromise = Promise.all([ + this.checkTimezoneSupportEnabled(), + this.fetchTimezones(), + ]).finally(() => { + this.privateState.isLoading = false; + }) as unknown as Promise; + } + + return this.initializePromise; + } + + private fetchTimezones() { + return AjaxHelper.fetch({ + method: 'SitesManager.getTimezonesList', + }).then((grouped) => { + const flattened: Timezone[] = []; + Object.entries(grouped).forEach(([group, timezonesGroup]) => { + Object.entries(timezonesGroup).forEach(([label, code]) => { + flattened.push({ + group, + label, + code, + }); + }); + }); + this.privateState.timezones = flattened; + }); + } + + private checkTimezoneSupportEnabled() { + return AjaxHelper.fetch({ + method: 'SitesManager.isTimezoneSupportEnabled', + }).then((response) => { + this.privateState.timezoneSupportEnabled = response.value; + }); + } +} + +export default new TimezoneStore(); diff --git a/plugins/SitesManager/vue/src/index.ts b/plugins/SitesManager/vue/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dea67eec6573af6f6a7004eb5468c8ccfcbf00c --- /dev/null +++ b/plugins/SitesManager/vue/src/index.ts @@ -0,0 +1,15 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +export { default as SiteType } from './SiteTypesStore/SiteType'; +export { default as SiteTypesStore } from './SiteTypesStore/SiteTypesStore'; +export { default as CurrencyStore } from './CurrencyStore/CurrencyStore'; +export { default as TimezoneStore } from './TimezoneStore/TimezoneStore'; +export { default as SitesManagement } from './SitesManagement/SitesManagement.vue'; +export { default as ManageGlobalSettings } from './ManageGlobalSettings/ManageGlobalSettings.vue'; +export { default as ExcludeQueryParameterSettings } from './ManageGlobalSettings/ExcludeQueryParameterSettings.vue'; +export { default as SiteWithoutData } from './SiteWithoutData/SiteWithoutData.vue';