protected function setupStatsRoutes($segment, $routeName, $controller)
{
// Main route is only secured by hash (security by obscurity)
- Route::get($segment . '/stats/{fluidbook_id}_{hash}/{date?}/{period_override?}', $controller . '@statsSummary')
+ Route::get($segment . '/stats/{fluidbook_id}_{hash}/{date?}/{period_override?}', $controller . '@statsLoader')
->withoutMiddleware([CheckIfAdmin::class])
->name('stats'); // Named route is used to generate URLs more consistently using route helper
+ // Since the stats can sometimes be slow to generate, the initial URL displays
+ // a loader while the actual report is fetched via AJAX and injected into the page.
+ Route::get($segment . '/stats-data/{fluidbook_id}_{hash}/{date?}/{period_override?}', $controller . '@statsSummary')
+ ->withoutMiddleware([CheckIfAdmin::class])
+ ->name('stats-report');
+
// API testing tool (intended for superadmins only)
Route::get($segment . '/stats/API', $controller . '@statsAPI');
// Count 2+ years: period = year
$day_count = Carbon::parse($start_date)->diffInDays($end_date);
- if ($day_count <= 50) {
+ if ($day_count <= 60) {
return 'day';
} elseif ($day_count <= 180) {
return 'week';
return 'year';
}
+ protected function statsLoader($fluidbook_id, $hash, $date = null, $period_override = null) {
+ $report_URL = route('stats-report', compact('fluidbook_id', 'hash', 'date', 'period_override'));
+ return view('fluidbook_stats.loader', compact('report_URL'));
+ }
+
protected function statsSummary($fluidbook_id, $hash, $date = null, $period_override = null) {
$locale = app()->getLocale();
// types of reports but for now, they have to be filtered.
// * Note about unique visitors:
- // We added unique visitor stats but found that they were only different to the 'visits' count in our historical
- // data from the old stats system. With Matomo, the uniques always seem to be the same as the visits, so they
- // don't serve any useful purpose here, only highlighting the inconsistencies with the historical stats.
+ // During the transition from the old stats system to Matomo, there has been a proxy in place that transfers
+ // stats from older Fluidbooks to Matomo (necessary because these didn't have the Matomo tracker embedded).
+ // However, the old system is poor at determining unique visitors, meaning that any "uniques" we received from
+ // there should be considered normal visits. When displaying the stats, we can only display unique visitors if
+ // they came from the native Matomo tracker. We'll determine this based on the Fluidbook ID >= 20687, which
+ // was the first one for 2022, when we're sure that the new tracker was in place. More details:
+ // - https://redmine.cubedesigners.com/issues/5474#note-5
+ // - https://redmine.cubedesigners.com/issues/5473
+ $new_stats_cutoff = 20687; // Starting Fluidbook ID for new stats system
//=== CUSTOM EVENT STATISTICS (zooms, bookmarks, shares, links, downloads, prints, etc.)
// This gathers the events based on their categories and segregated by periods (days, months etc).
$page_group = $page_number;
$pages[$page_group] = [
- 'page_group' => $page_group,
- 'page_number' => $page_number, // Used by table column sorter
- 'nb_visits' => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
- //'nb_unique_visits' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 0,
- 'nb_pageviews' => $pageUrls["/page/$page_number"]['nb_hits'] ?? 0,
- 'nb_zooms' => data_get($eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
- 'nb_bookmarks' => data_get($eventsByPage, "bookmark.subtable.$page_number.nb_events", 0),
- 'nb_shares' => data_get($eventsByPage, "share.subtable.$page_number.nb_events", 0),
+ 'page_group' => $page_group,
+ 'page_number' => $page_number, // Used by table column sorter
+ 'nb_visits' => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
+ 'nb_uniq_visitors' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 0,
+ 'nb_pageviews' => $pageUrls["/page/$page_number"]['nb_hits'] ?? 0,
+ 'nb_zooms' => data_get($eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
+ 'nb_bookmarks' => data_get($eventsByPage, "bookmark.subtable.$page_number.nb_events", 0),
+ 'nb_shares' => data_get($eventsByPage, "share.subtable.$page_number.nb_events", 0),
];
// Special case: the first page of double-page Fluidbooks is tracked as "page 0", meaning that
// for our stats labelled as "page 1", we must get certain stats from the "page 0" data...
if (!$pages_are_independent && $page_number === 1) {
$pages[$page_group]['nb_visits'] = $pageUrls["/page/0"]['nb_visits'] ?? 0;
- $pages[$page_group]['nb_unique_visits'] = $pageUrls["/page/0"]['sum_daily_nb_uniq_visitors'] ?? 0;
+ $pages[$page_group]['nb_uniq_visitors'] = $pageUrls["/page/0"]['sum_daily_nb_uniq_visitors'] ?? 0;
$pages[$page_group]['nb_pageviews'] = $pageUrls["/page/0"]['nb_hits'] ?? 0;
$pages[$page_group]['nb_zooms'] = data_get($eventsByPage, "zoom.subtable.0.nb_events", 0);
$page_group = $page_number .'—'. $following_page;
$pages[$page_group] = [
- 'page_group' => $page_group,
- 'page_number' => $page_number, // Used by table column sorter
- 'nb_visits' => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
- // 'nb_unique_visits' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 0,
- 'nb_pageviews' => $pageUrls["/page/$page_number"]['nb_hits'] ?? 0,
- 'nb_zooms' => data_get($eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
+ 'page_group' => $page_group,
+ 'page_number' => $page_number, // Used by table column sorter
+ 'nb_visits' => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
+ 'nb_uniq_visitors' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 0,
+ 'nb_pageviews' => $pageUrls["/page/$page_number"]['nb_hits'] ?? 0,
+ 'nb_zooms' => data_get($eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
// Bookmarks and shares are counted and summed for both pages in the spread
- 'nb_bookmarks' => data_get($eventsByPage, "bookmark.subtable.$page_number.nb_events", 0)
- + data_get($eventsByPage, "bookmark.subtable.$following_page.nb_events", 0),
- 'nb_shares' => data_get($eventsByPage, "share.subtable.$page_number.nb_events", 0)
- + data_get($eventsByPage, "share.subtable.$following_page.nb_events", 0),
+ 'nb_bookmarks' => data_get($eventsByPage, "bookmark.subtable.$page_number.nb_events", 0)
+ + data_get($eventsByPage, "bookmark.subtable.$following_page.nb_events", 0),
+ 'nb_shares' => data_get($eventsByPage, "share.subtable.$page_number.nb_events", 0)
+ + data_get($eventsByPage, "share.subtable.$following_page.nb_events", 0),
];
}
}
//=== MAIN PERIOD STATISTICS
// These are the main statistics used to build the chart and the table below it.
// Statistics are segregated by a certain period (eg. years, months or days)
- // Note: in order to get the total "nb_uniq_visitors" for the pages, we would need to fetch
+ // Note: in order to get the total "nb_uniq_visitors" for the pages, we need to fetch
// the expanded dataset that includes the subtables of pages. For some reason, Matomo
// doesn't aggregrate this value when there are sub pages, so we have to do it ourselves
- $pagesByPeriod = collect($report->getPageUrls(['expanded' => 0]))
- ->map(function ($item, $date) use ($period, $fluidbook_id, $hash, $eventsByPeriod) {
+ $expanded_stats = $fluidbook_id >= $new_stats_cutoff ? 1 : 0; // Only fetch extra data when it will be used
+ $pagesByPeriod = collect($report->getPageUrls(['expanded' => $expanded_stats]))
+ ->map(function ($item, $date) use ($period, $fluidbook_id, $hash, $eventsByPeriod, $new_stats_cutoff) {
if (empty($item)) {
return $item; // Some periods might have no data
// the returned data flatter and easier to process later for sums etc.
$data = $labelled['page'];
- // Unique visitors stats disabled - see notes above.
- // // Matomo doesn't provide an aggregate of unique visitors, so we must do the sum ourselves.
- // // If the period is "day", the number of unique visitors will be in the key 'nb_uniq_visitors'
- // // but if it is a longer period (week, month, year) then the key becomes 'sum_daily_nb_uniq_visitors'
- // $unique_visitors_key = $period === 'day' ? 'nb_uniq_visitors' : 'sum_daily_nb_uniq_visitors';
- // $subpages = collect($data['subtable'] ?? []);
- // $data['nb_uniq_visitors'] = $subpages->sum($unique_visitors_key);
+ //== Unique Visitors
+ // Matomo doesn't provide an aggregate of unique visitors, so we must do the sum ourselves.
+ // If the period is "day", the number of unique visitors will be in the key 'nb_uniq_visitors'
+ // but if it is a longer period (week, month, year) then the key becomes 'sum_daily_nb_uniq_visitors'
+ if($fluidbook_id >= $new_stats_cutoff) {
+ $unique_visitors_key = $period === 'day' ? 'nb_uniq_visitors' : 'sum_daily_nb_uniq_visitors';
+ $subpages = collect($data['subtable'] ?? []);
+ $data['nb_uniq_visitors'] = $subpages->sum($unique_visitors_key);
+ }
$data['raw_date'] = $date; // We still need the raw date for sorting and other formatting purposes
return $data;
});
+ //=== SUMMARY BY PERIOD
+ // Generate a list of available periods, based on the visits API data.
+ // If there are no visits for a certain period, we can assume that there won't be any data for other metrics.
+ // The goal is to find the non-empty results and create a list of Carbon date classes from those
+ // This list is used to generate the links / formatted dates list + detailed data table
+ $period_details = $pagesByPeriod->filter(fn ($value, $key) => !empty($value)); // Remove any empty periods
+
//=== CHART PREPARATION
// Format dates for display as labels on the x-axis
$labels = $pagesByPeriod->keys()->map(function ($label, $index) use ($period, $start_date, $end_date) {
$tooltip_labels = array_combine($labels, $formatted_dates);
- //=== SUMMARY BY PERIOD
- // Generate a list of available periods, based on the visits API data.
- // If there are no visits for a certain period, we can assume that there won't be any data for other metrics.
- // The goal is to find the non-empty results and create a list of Carbon date classes from those
- // This list is used to generate the links / formatted dates list + detailed data table
- $period_details = $pagesByPeriod->filter(fn ($value, $key) => !empty($value)); // Remove any empty periods
+ $chart_datasets = [
+ [
+ 'label' => __('Visits'),
+ 'backgroundColor' => 'hsl(72 100% 38% / 100%)', // Could make bars semi-transparent if desired
+ // borderColor: 'hsl(72 100% 38%)',
+ // borderWidth: 1,
+ 'data' => $pagesByPeriod->pluck('nb_visits')->toArray(),
+ 'order' => 1,
+ 'bar_offset' => -5, // Negative offset shifts bar to left
+ ],
+ [
+ 'label' => __('Page Views'),
+ 'backgroundColor' => 'hsl(0 0% 53%)',
+ 'data' => $pagesByPeriod->pluck('nb_hits')->toArray(),
+ 'order' => 2,
+ 'bar_offset' => 5, // Positive offset shifts bar to right
+ ],
+ ];
+
+ if ($fluidbook_id >= $new_stats_cutoff) {
+ // Insert the unique visitors dataset at the beginning of the array
+ $chart_datasets = array_merge([
+ [
+ 'label' => __('Unique Visitors'),
+ 'backgroundColor' => 'hsl(19 100% 48% / 100%)',
+ 'data' => $pagesByPeriod->pluck('nb_uniq_visitors')->toArray(),
+ 'order' => 1,
+ 'bar_offset' => -8, // Negative offset shifts bar to left
+ ]
+ ], $chart_datasets);
+
+ // Now that we have 3 bars, we need to update the order and bar offsets for the visits and page views
+ $chart_datasets[1]['order'] = 2;
+ $chart_datasets[1]['bar_offset'] = 0; // This will be the middle bar, so no offset
+
+ $chart_datasets[2]['order'] = 3;
+ $chart_datasets[2]['bar_offset'] = 8;
+ }
+
+ $chart_datasets = json_encode($chart_datasets, JSON_UNESCAPED_SLASHES);
// Map of API data to table headings (used to display summaries under the chart)
$table_map = [
// Main summary table
'summary' => [
'formatted_date' => $period_map[$period],
- // 'nb_uniq_visitors' => __('Unique Visitors'),
+ 'nb_uniq_visitors' => __('Unique Visitors'),
'nb_visits' => __('Visits'),
'nb_hits' => __('Pages Viewed'),
'nb_links' => __('Outgoing Links'),
// Per-page detail table
'per-page' => [
'page_group' => __('Pages'),
- // 'nb_unique_visits' => __('Unique Visits'),
+ 'nb_uniq_visitors' => __('Unique Visits'),
'nb_visits' => __('Visits'),
'nb_pageviews' => __('Views'),
'nb_zooms' => __('Zooms'),
],
];
+ // Older Fluidbooks can't show unique visitors (see notes above)
+ if ($fluidbook_id < $new_stats_cutoff) {
+ unset($table_map['summary']['nb_uniq_visitors']);
+ unset($table_map['per-page']['nb_uniq_visitors']);
+ }
+
$formatter = NumberFormatter::create($locale, NumberFormatter::DEFAULT_STYLE);
return view('fluidbook_stats.summary',
'tooltip_labels',
'formatted_date_range',
'chart_heading',
+ 'chart_datasets',
'searches',
'outlinks',
'countries',
'period_map',
'table_map',
'pages',
- 'pagesByPeriod',
'formatter',
'locale',
'base_URL',
if ($crosses_year_boundary) {
$week_formatted = $week_start->isoFormat('Do MMMM, YYYY') .' — '. $week_end->isoFormat('Do MMMM, YYYY');
} elseif ($crosses_month_boundary) {
- $week_formatted = $week_start->isoFormat('Do MMMM') .' — '. $week_end->isoFormat('Do MMMM');
+ $week_formatted = $week_start->isoFormat('Do MMMM') .' — '. $week_end->isoFormat('Do MMMM, YYYY');
} else {
- $week_formatted = $week_start->isoFormat('Do') .'—'. $week_end->isoFormat('Do MMMM');
+ $week_formatted = $week_start->isoFormat('Do') .'—'. $week_end->isoFormat('Do MMMM, YYYY');
}
}
--- /dev/null
+@extends(backpack_view('blank'))
+
+@section('after_styles')
+ <link rel="stylesheet" href="{{ asset('packages/daterangepicker/daterangepicker.css') }}">
+ <style>
+ @keyframes spinner {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ #stats_loader:before {
+ content: '';
+ box-sizing: border-box;
+ width: 3em;
+ height: 3em;
+ border-radius: 50%;
+ border: 2px solid #ccc;
+ border-top-color: #1b2a4e;
+ animation: spinner .6s linear infinite;
+ }
+
+ #stats_loader {
+ display: flex;
+ min-height: 50vh;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 1em;
+ padding: 2em;
+ color: #aaa;
+ font-size: 20px;
+ }
+
+ #stats_error {
+ background-color: #fff5f5;
+ color: #c53030;
+ border: 1px solid #fc8181;
+ border-radius: 0.25em;
+ padding: 0.75em 1em;
+ display: block;
+ width: max-content;
+ margin: 5em auto;
+ }
+
+
+ .periods {
+ display: inline-flex;
+ margin-left: 2em;
+ }
+ .periods > * {
+ padding: 0.2em 0.8em;
+ border: 1px solid #ddd;
+ }
+ .periods > * + * {
+ border-left: 0;
+ }
+ .periods span {
+ background-color: #eaeaea;
+ }
+ .periods a {
+ background-color: #f4f4f4;
+ }
+ .periods a:hover {
+ background-color: #467fcf;
+ color: #fff;
+ text-decoration: none;
+ }
+
+ .summary {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ }
+ .summary dt, .summary dd {
+ margin: 0;
+ padding: 0.5em;
+ }
+ .summary dt {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ padding-right: 2em;
+ }
+ .summary dt:nth-of-type(odd), .summary dd:nth-of-type(odd), table.stats-details tbody tr:nth-of-type(odd) {
+ background-color: #f4f4f4;
+ }
+ .summary dt:nth-of-type(even), .summary dd:nth-of-type(even), table.stats-details tbody tr:nth-of-type(even) {
+ background-color: #eaeaea;
+ }
+
+ table.stats-details {
+ width: 100%;
+ }
+ table.stats-details th, table.stats-details td {
+ padding: 0.5em 1em;
+ }
+ table.stats-details thead tr {
+ position: sticky;
+ top: 0;
+ background-color: #fff;
+ }
+ table.stats-details tbody tr:hover td {
+ background-color: rgba(156, 195, 34, 0.15);
+ }
+
+ .table-columns {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2em;
+ }
+ .table-columns > div {
+ flex: 1;
+ }
+
+ .no-statistics {
+ background-color: #fefce9;
+ color: #854e18;
+ padding: 1.5em;
+ margin-top: 1.5em;
+ }
+
+ .heading-subtitle {
+ opacity: 0.6;
+ }
+ .heading-subtitle:before {
+ content: ' — ';
+ }
+
+ /*=== Date Range Picker ===*/
+ [data-daterangepicker]:hover {
+ color: #467fcf;
+ }
+ .daterangepicker .periods li:hover, .daterangepicker .periods li.active, .daterangepicker .ranges li:hover, .daterangepicker .ranges li.active {
+ background: #263340;
+ }
+ .daterangepicker .periods li, .daterangepicker .ranges li {
+ color: #467fcf;
+ }
+ .daterangepicker .custom-range-buttons button {
+ color: #467fcf;
+ }
+ .daterangepicker .custom-range-buttons button.apply-btn {
+ background: #9cc322;
+ }
+ .daterangepicker .custom-range-buttons button.apply-btn:hover {
+ background: #263340;
+ }
+
+ /*=== Table Column Sorter ===*/
+ .sortable th:not(.sorttable_nosort) {
+ cursor: pointer;
+ white-space: nowrap;
+ }
+ .sortable th:not(.sorttable_nosort):after {
+ visibility: hidden; /* only shows on actively sorted column */
+ content: '';
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: 0.5em;
+ margin-top: -0.1em;
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 0.4em 0.6em 0.4em;
+ border-color: transparent transparent currentColor transparent;
+ opacity: 0.6;
+ }
+
+ .sorttable_sorted_ascending:after {
+ visibility: visible !important;
+ }
+ .sorttable_sorted_descending:after {
+ visibility: visible !important;
+ transform: rotate(180deg);
+ }
+
+ .whitespace-nowrap {
+ white-space: nowrap;
+ }
+
+ </style>
+@endsection
+
+@section('content')
+ <div id="stats_loader">Loading...</div>
+ <div id="stats_wrapper"></div>
+ <div id="stats_error" style="display: none">
+ {{ __('Sorry, an error occured while fetching this report. Please try again later. ') }}
+ </div>
+@endsection
+
+@push('after_scripts')
+ {{-- Fetch the report and inject it into the page when ready... This is to improve the UX --}}
+ <script>
+
+ fetch('{{ $report_URL }}').then(function (response) {
+ if (!response.ok) {
+ throw Error(`${response.statusText} (${response.status}) - ${response.url}`);
+ }
+ return response.text();
+ }).then(function (html) {
+ hideStatsLoader();
+ setInnerHTML(document.getElementById('stats_wrapper'), html);
+ }).catch(function (err) {
+ console.warn('Unable to load stats report', err);
+ showStatsError();
+ });
+
+
+ function hideStatsLoader() {
+ document.getElementById('stats_loader').style.display = 'none';
+ }
+
+ function showStatsError() {
+ hideStatsLoader();
+ document.getElementById('stats_error').style.display = 'block';
+ }
+
+ // Set the inner HTML of an element AND make any script tags executable
+ // Ref: https://stackoverflow.com/a/47614491
+ function setInnerHTML(elm, html) {
+ elm.innerHTML = html;
+ Array.from(elm.querySelectorAll("script")).forEach( oldScript => {
+ const newScript = document.createElement("script");
+ Array.from(oldScript.attributes)
+ .forEach( attr => newScript.setAttribute(attr.name, attr.value) );
+ newScript.appendChild(document.createTextNode(oldScript.innerHTML));
+ oldScript.parentNode.replaceChild(newScript, oldScript);
+ });
+ }
+
+ </script>
+
+ {{-- Include the base libraries for the chart and the date picker here so that they're ready
+ when the report HTML and extra scripts are injected --}}
+
+ {{-- Charting library --}}
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js" integrity="sha512-ElRFoEQdI5Ht6kZvyzXhYG9NqjtkmlkfYk0wr6wHxU9JEHakS7UJZNeml5ALk+8IKlU6jDgMabC3vkumRokgJA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+
+ {{-- Date Range picker: https://sensortower.github.io/daterangepicker/docs --}}
+ <script type="text/javascript" src="{{ asset('packages/moment/min/moment-with-locales.min.js') }}"></script>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.5.1/knockout-latest.min.js" integrity="sha512-vs7+jbztHoMto5Yd/yinM4/y2DOkPLt0fATcN+j+G4ANY2z4faIzZIOMkpBmWdcxt+596FemCh9M18NUJTZwvw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+ <script type="text/javascript" src="{{ asset('packages/daterangepicker/daterangepicker.js') }}"></script>
+@endpush
-@extends(backpack_view('blank'))
-
-@section('after_styles')
- <link rel="stylesheet" href="{{ asset('packages/daterangepicker/daterangepicker.css') }}">
- <style>
- .periods {
- display: inline-flex;
- margin-left: 2em;
- }
- .periods > * {
- padding: 0.3em 1em;
- border: 1px solid #ddd;
- }
- .periods span {
- background-color: #eaeaea;
- }
- .periods a {
- background-color: #f4f4f4;
- }
- .periods a:hover {
- background-color: #467fcf;
- color: #fff;
- text-decoration: none;
- }
-
- .summary {
- display: grid;
- grid-template-columns: max-content 1fr;
- }
- .summary dt, .summary dd {
- margin: 0;
- padding: 0.5em;
- }
- .summary dt {
- display: flex;
- align-items: center;
- white-space: nowrap;
- padding-right: 2em;
- }
- .summary dt:nth-of-type(odd), .summary dd:nth-of-type(odd), table.stats-details tbody tr:nth-of-type(odd) {
- background-color: #f4f4f4;
- }
- .summary dt:nth-of-type(even), .summary dd:nth-of-type(even), table.stats-details tbody tr:nth-of-type(even) {
- background-color: #eaeaea;
- }
-
- table.stats-details {
- width: 100%;
- }
- table.stats-details th, table.stats-details td {
- padding: 0.5em 1em;
- }
- table.stats-details thead tr {
- position: sticky;
- top: 0;
- background-color: #fff;
- }
- table.stats-details tbody tr:hover td {
- background-color: rgba(156, 195, 34, 0.15);
- }
-
- .table-columns {
- display: flex;
- flex-wrap: wrap;
- gap: 2em;
- }
- .table-columns > div {
- flex: 1;
- }
-
- .no-statistics {
- background-color: #fefce9;
- color: #854e18;
- padding: 1.5em;
- margin-top: 1.5em;
- }
-
- .heading-subtitle {
- opacity: 0.6;
- }
- .heading-subtitle:before {
- content: ' — ';
- }
-
- /*=== Date Range Picker ===*/
- [data-daterangepicker]:hover {
- color: #467fcf;
- }
- .daterangepicker .periods li:hover, .daterangepicker .periods li.active, .daterangepicker .ranges li:hover, .daterangepicker .ranges li.active {
- background: #263340;
- }
- .daterangepicker .periods li, .daterangepicker .ranges li {
- color: #467fcf;
- }
- .daterangepicker .custom-range-buttons button {
- color: #467fcf;
- }
- .daterangepicker .custom-range-buttons button.apply-btn {
- background: #9cc322;
- }
- .daterangepicker .custom-range-buttons button.apply-btn:hover {
- background: #263340;
- }
-
- /*=== Table Column Sorter ===*/
- .sortable th:not(.sorttable_nosort) {
- cursor: pointer;
- white-space: nowrap;
- }
- .sortable th:not(.sorttable_nosort):after {
- visibility: hidden; /* only shows on actively sorted column */
- content: '';
- display: inline-block;
- vertical-align: middle;
- margin-left: 0.5em;
- margin-top: -0.1em;
- width: 0;
- height: 0;
- border-style: solid;
- border-width: 0 0.4em 0.6em 0.4em;
- border-color: transparent transparent currentColor transparent;
- opacity: 0.6;
- }
-
- .sorttable_sorted_ascending:after {
- visibility: visible !important;
- }
- .sorttable_sorted_descending:after {
- visibility: visible !important;
- transform: rotate(180deg);
- }
-
- .whitespace-nowrap {
- white-space: nowrap;
- }
-
- </style>
-@endsection
-
-@section('content')
- <h2 class="mt-4">
- {{ __('Statistics') }}
- <span class="heading-subtitle">{{ $fluidbook->name }}</span>
- </h2>
-
- <span data-daterangepicker class="mb-2" style="cursor: pointer" title="{{ __('Statistics Period - click to choose a new range') }}">
- <i class="las la-calendar-week align-middle mr-1" style="font-size: 32px;"></i>
- <span class="date-range-text">
- {!! $formatted_date_range !!}
- @if($report_timespan)
- <span style="opacity: 0.6; display: inline-block; margin-left: 0.5em;">({{ $report_timespan }})</span>
- @endif
- </span>
+{{-- Statistics Report --}}
+{{-- This doesn't extend any templates because it is fetched from loader.blade.php and injected via JS --}}
+
+<h2 class="mt-4">
+ {{ __('Statistics') }}
+ <span class="heading-subtitle">{{ $fluidbook->name }}</span>
+</h2>
+
+<span data-daterangepicker class="mb-2" style="cursor: pointer" title="{{ __('Statistics Period - click to choose a new date range') }}">
+ <i class="las la-calendar-week align-middle mr-1" style="font-size: 32px;"></i>
+ <span class="date-range-text">
+ {!! $formatted_date_range !!}
+ @if($report_timespan)
+ <span style="opacity: 0.6; display: inline-block; margin-left: 0.5em;">({{ $report_timespan }})</span>
+ @endif
</span>
+</span>
+
+<div class="periods mb-4">
+ @foreach($period_map as $period_key => $period_title)
+ @if($period_key === $period)
+ <span>{{ $period_title }}</span>
+ @else
+ <a href="{{ route('stats', compact('fluidbook_id', 'hash') + ['date' => $date ?? '-', 'period_override' => $period_key]) }}">
+ {{ $period_title }}
+ </a>
+ @endif
+ @endforeach
+</div>
+
+
+<dl class="summary">
+ <dt>{{ __('Fluidbook Name') }}</dt>
+ <dd>{{ $fluidbook->name }}</dd>
+
+ <dt>{{ __('Creation Date') }}</dt>
+ <dd>
+ {{ $fluidbook->created_at->isoFormat('dddd, Do MMMM YYYY') }}
+ <span style="opacity: 0.6; display: inline-block; margin-left: 0.5em;">
+ ({{ $fluidbook->created_at->diffForHumans([
+ 'parts' => 2, // How many levels of detail to go into (ie. years, months, days)
+ 'join' => true, // Join string with natural language separators for the locale
+ ])
+ }})
+ </span>
+ </dd>
- <div class="periods mb-4">
- @foreach($period_map as $period_key => $period_title)
- @if($period_key === $period)
- <span>{{ $period_title }}</span>
- @else
- <a href="{{ route('stats', compact('fluidbook_id', 'hash') + ['date' => $date ?? '-', 'period_override' => $period_key]) }}">
- {{ $period_title }}
- </a>
- @endif
- @endforeach
- </div>
-
+ <dt>{{ __('Page Count') }}</dt>
+ <dd>{{ $page_count }}</dd>
- <dl class="summary">
- <dt>{{ __('Fluidbook Name') }}</dt>
- <dd>{{ $fluidbook->name }}</dd>
-
- <dt>{{ __('Creation Date') }}</dt>
- <dd>
- {{ $fluidbook->created_at->isoFormat('dddd, Do MMMM YYYY') }}
- <span style="opacity: 0.6; display: inline-block; margin-left: 0.5em;">
- ({{ $fluidbook->created_at->diffForHumans([
- 'parts' => 2, // How many levels of detail to go into (ie. years, months, days)
- 'join' => true, // Join string with natural language separators for the locale
- ])
- }})
- </span>
- </dd>
-
- <dt>{{ __('Page Count') }}</dt>
- <dd>{{ $page_count }}</dd>
-
- @if($period_details->isNotEmpty())
- @foreach ($table_map['summary'] as $summary_key => $summary_heading)
- @php
- if ($summary_key === 'formatted_date') continue;
- @endphp
- <dt>{{ sprintf(__('Total %s'), $summary_heading) }}</dt>
- <dd>{{ $formatter->format($period_details->sum($summary_key)) }}</dd>
- @endforeach
+ @if($period_details->isNotEmpty())
+ @foreach ($table_map['summary'] as $summary_key => $summary_heading)
+ @php
+ if ($summary_key === 'formatted_date') continue;
+ @endphp
+ <dt>{{ sprintf(__('Total %s'), $summary_heading) }}</dt>
+ <dd>{{ $formatter->format($period_details->sum($summary_key)) }}</dd>
+ @endforeach
- <dt>{{ __('Total Searches') }}</dt>
- <dd>{{ $formatter->format($searches->sum()) }}</dd>
- @endif
- </dl>
+ <dt>{{ __('Total Searches') }}</dt>
+ <dd>{{ $formatter->format($searches->sum()) }}</dd>
+ @endif
+</dl>
- @if($period_details->isNotEmpty())
+@if($period_details->isNotEmpty())
- <h2 class="mt-5">{!! $chart_heading !!}</h2>
+ <h2 class="mt-5">{!! $chart_heading !!}</h2>
- {{-- Chart --}}
- <canvas id="stats_chart"></canvas>
+ {{-- Chart --}}
+ <canvas id="stats_chart"></canvas>
- {{-- Stats for each period entry (year, month or day) --}}
- <table class="sortable stats-details mt-5">
- <thead>
+ {{-- Stats for each period entry (year, month or day) --}}
+ <table class="sortable stats-details mt-5">
+ <thead>
+ <tr>
+ @foreach ($table_map['summary'] as $summary_heading_key => $summary_heading)
+ <th @if($summary_heading_key === 'formatted_date')class="sorttable_sorted_ascending"@endif>
+ {{ $summary_heading }}
+ </th>
+ @endforeach
+ </tr>
+ </thead>
+ <tbody>
+ @foreach($period_details as $date_key => $period_data)
<tr>
- @foreach ($table_map['summary'] as $summary_heading_key => $summary_heading)
- <th @if($summary_heading_key === 'formatted_date')class="sorttable_sorted_ascending"@endif>
- {{ $summary_heading }}
- </th>
+ @foreach (array_keys($table_map['summary']) as $summary_key)
+ <td data-name="{{ $summary_key }}"
+ @if($summary_key === 'formatted_date')data-sort-value="{{ $period_data['raw_date'] }}"@endif>
+ {!! is_int($period_data[$summary_key]) ? $formatter->format($period_data[$summary_key]) : $period_data[$summary_key] !!}
+ </td>
@endforeach
</tr>
- </thead>
- <tbody>
- @foreach($period_details as $date_key => $period_data)
- <tr>
- @foreach (array_keys($table_map['summary']) as $summary_key)
- <td data-name="{{ $summary_key }}"
- @if($summary_key === 'formatted_date')data-sort-value="{{ $period_data['raw_date'] }}"@endif>
- {!! is_int($period_data[$summary_key]) ? $formatter->format($period_data[$summary_key]) : $period_data[$summary_key] !!}
- </td>
- @endforeach
- </tr>
+ @endforeach
+ </tbody>
+ </table>
+
+ {{-- Stats segregated by page number --}}
+ <h3 class="mt-5">{{ __('Details by page') }} <small>({!! $formatted_date_range !!})</small></h3>
+
+ <table class="sortable stats-details mt-3">
+ <thead>
+ <tr>
+ @foreach ($table_map['per-page'] as $page_data_heading_key => $page_data_heading)
+ {{-- In the case of the "page_group" data, we want it to be sorted in ascending order by default, even though it's a numeric column --}}
+ <th @if($page_data_heading_key === 'page_group')class="sorttable_sorted_ascending"
+ data-sort-direction="ascending"@endif>
+ {{ $page_data_heading }}
+ </th>
@endforeach
- </tbody>
- </table>
-
- {{-- Stats segregated by page number --}}
- <h3 class="mt-5">{{ __('Details by page') }} <small>({!! $formatted_date_range !!})</small></h3>
-
- <table class="sortable stats-details mt-3">
- <thead>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach($pages as $page_group => $page_data)
<tr>
- @foreach ($table_map['per-page'] as $page_data_heading_key => $page_data_heading)
- {{-- In the case of the "page_group" data, we want it to be sorted in ascending order by default, even though it's a numeric column --}}
- <th @if($page_data_heading_key === 'page_group')class="sorttable_sorted_ascending"
- data-sort-direction="ascending"@endif>
- {{ $page_data_heading }}
- </th>
+ @foreach (array_keys($table_map['per-page']) as $summary_key)
+ <td data-name="{{ $summary_key }}" @if($summary_key === 'page_group')data-sort-value="{{ $page_data['page_number'] }}"@endif>
+ {!! is_int($page_data[$summary_key]) ? $formatter->format($page_data[$summary_key]) : $page_data[$summary_key] !!}
+ </td>
@endforeach
</tr>
- </thead>
- <tbody>
- @foreach($pages as $page_group => $page_data)
- <tr>
- @foreach (array_keys($table_map['per-page']) as $summary_key)
- <td data-name="{{ $summary_key }}" @if($summary_key === 'page_group')data-sort-value="{{ $page_data['page_number'] }}"@endif>
- {!! is_int($page_data[$summary_key]) ? $formatter->format($page_data[$summary_key]) : $page_data[$summary_key] !!}
- </td>
- @endforeach
- </tr>
- @endforeach
- </tbody>
- </table>
+ @endforeach
+ </tbody>
+ </table>
- {{-- Additional stats tables organised into columns (outgoing links, search keywords and countries) --}}
- {{-- Sometimes there are no stats for certain categories, so the number of columns adapts accordingly --}}
- <div class="table-columns mt-5">
+ {{-- Additional stats tables organised into columns (outgoing links, search keywords and countries) --}}
+ {{-- Sometimes there are no stats for certain categories, so the number of columns adapts accordingly --}}
+ <div class="table-columns mt-5">
- {{-- Outgoing Links --}}
- @if($outlinks->isNotEmpty())
- <div>
- <h3>{{ __('Outgoing Links') }}</h3>
+ {{-- Outgoing Links --}}
+ @if($outlinks->isNotEmpty())
+ <div>
+ <h3>{{ __('Outgoing Links') }}</h3>
- <table class="sortable stats-details mt-3">
- <thead>
+ <table class="sortable stats-details mt-3">
+ <thead>
+ <tr>
+ <th>{{ __('URL') }}</th>
+ <th class="sorttable_sorted_descending">{{ __('Clicks') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach($outlinks as $link)
<tr>
- <th>{{ __('URL') }}</th>
- <th class="sorttable_sorted_descending">{{ __('Clicks') }}</th>
+ <td>
+ <a href="{{ $link['label'] }}" target="_blank" rel="noopener">{{ $link['label'] }}</a>
+ </td>
+ <td>{{ $formatter->format($link['nb_events']) }}</td>
</tr>
- </thead>
- <tbody>
- @foreach($outlinks as $link)
- <tr>
- <td>
- <a href="{{ $link['label'] }}" target="_blank" rel="noopener">{{ $link['label'] }}</a>
- </td>
- <td>{{ $formatter->format($link['nb_events']) }}</td>
- </tr>
- @endforeach
- </tbody>
- </table>
- </div>
- @endif
-
- {{-- Search Keywords --}}
- @if($searches->isNotEmpty())
- <div>
- <h3>{{ __('Search Keywords') }}</h3>
-
- <table class="sortable stats-details mt-3">
- <thead>
+ @endforeach
+ </tbody>
+ </table>
+ </div>
+ @endif
+
+ {{-- Search Keywords --}}
+ @if($searches->isNotEmpty())
+ <div>
+ <h3>{{ __('Search Keywords') }}</h3>
+
+ <table class="sortable stats-details mt-3">
+ <thead>
+ <tr>
+ <th>{{ __('Query') }}</th>
+ <th class="sorttable_sorted_descending">{{ __('Searches') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach($searches as $search_query => $search_count)
<tr>
- <th>{{ __('Query') }}</th>
- <th class="sorttable_sorted_descending">{{ __('Searches') }}</th>
+ <td class="whitespace-nowrap">{{ $search_query }}</td>
+ <td>{{ $formatter->format($search_count) }}</td>
</tr>
- </thead>
- <tbody>
- @foreach($searches as $search_query => $search_count)
- <tr>
- <td class="whitespace-nowrap">{{ $search_query }}</td>
- <td>{{ $formatter->format($search_count) }}</td>
- </tr>
- @endforeach
- </tbody>
- </table>
- </div>
- @endif
-
- {{-- Visitor Countries --}}
- @if($countries->isNotEmpty())
- <div>
- <h3>{{ __('Origin of Visitors') }}</h3>
-
- <table class="sortable stats-details mt-3">
- <thead>
+ @endforeach
+ </tbody>
+ </table>
+ </div>
+ @endif
+
+ {{-- Visitor Countries --}}
+ @if($countries->isNotEmpty())
+ <div>
+ <h3>{{ __('Origin of Visitors') }}</h3>
+
+ <table class="sortable stats-details mt-3">
+ <thead>
+ <tr>
+ <th>{{ __('Country') }}</th>
+ <th class="sorttable_sorted_descending">{{ __('Visits') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach($countries as $country)
<tr>
- <th>{{ __('Country') }}</th>
- <th class="sorttable_sorted_descending">{{ __('Visits') }}</th>
+ <td class="whitespace-nowrap">
+ <img src="{{ $country['flag'] }}" alt="{{ $country['label'] }}" style="width: 1.5em; margin-right: 0.75em;">
+ {{ $country['label'] }}
+ </td>
+ <td>{{ $formatter->format($country['nb_visits']) }}</td>
</tr>
- </thead>
- <tbody>
- @foreach($countries as $country)
- <tr>
- <td class="whitespace-nowrap">
- <img src="{{ $country['flag'] }}" alt="{{ $country['label'] }}" style="width: 1.5em; margin-right: 0.75em;">
- {{ $country['label'] }}
- </td>
- <td>{{ $formatter->format($country['nb_visits']) }}</td>
- </tr>
- @endforeach
- </tbody>
- </table>
- </div>
- @endif
-
- </div>
-
-
- {{-- It's possible for there to be no statistics returned by the API --}}
- @else
- <div class="no-statistics">
- <span style="vertical-align: middle; margin-right: 0.5em;">⚠</span>️
- {{ __('Sorry, no statistics were found for this period.') }}
- </div>
+ @endforeach
+ </tbody>
+ </table>
+ </div>
+ @endif
+
+ </div>
+
+
+{{-- It's possible for there to be no statistics returned by the API --}}
+@else
+ <div class="no-statistics">
+ <span style="vertical-align: middle; margin-right: 0.5em;">⚠</span>️
+ {{ __('Sorry, no statistics were found for this period.') }}
+ </div>
+@endif
+
+
+{{--================== SCRIPTS ==================--}}
+
+{{-- Date Range picker setup: https://sensortower.github.io/daterangepicker/docs --}}
+<script>
+ moment.locale('{{ $locale }}');
+ @if ($locale === 'en')
+ moment.updateLocale('{{ $locale }}', {
+ longDateFormat: {
+ // Date range picker uses the 'L' format for displaying dates
+ L: 'DD/MM/YYYY', // We don't like the default, backwards US date format
+ }
+ });
@endif
-@endsection
-
-@push('after_scripts')
- {{-- Date Range picker: https://sensortower.github.io/daterangepicker/docs --}}
- <script type="text/javascript" src="{{ asset('packages/moment/min/moment-with-locales.min.js') }}"></script>
- <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.5.1/knockout-latest.min.js" integrity="sha512-vs7+jbztHoMto5Yd/yinM4/y2DOkPLt0fATcN+j+G4ANY2z4faIzZIOMkpBmWdcxt+596FemCh9M18NUJTZwvw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
- <script type="text/javascript" src="{{ asset('packages/daterangepicker/daterangepicker.js') }}"></script>
- <script>
- moment.locale('{{ $locale }}');
- @if ($locale === 'en')
- moment.updateLocale('{{ $locale }}', {
- longDateFormat: {
- // Date range picker uses the 'L' format for displaying dates
- L: 'DD/MM/YYYY', // We don't like the default, backwards US date format
- }
- });
- @endif
+ $('[data-daterangepicker]').daterangepicker({
+ callback: function(startDate, endDate, period) {
+ let range = startDate.format('YYYY-MM-DD') + ',' + endDate.format('YYYY-MM-DD');
+ location.href = `{{ $base_URL }}/${range}`;
+
+ // TODO: add loading spinner after changing the date so that we know something is happening while the next page loads...
+
+ },
+ forceUpdate: false,
+ minDate: '{{ $fluidbook->created_at->isoFormat('YYYY-MM-DD') }}', // Creation date of the Fluidbook
+ startDate: '{{ $start_date }}',
+ endDate: '{{ $end_date }}',
+
+ {{-- TODO: add translations - check https://github.com/sensortower/daterangepicker/issues/42 --}}
+ {{--
+ locale: {
+ applyButtonTitle: '{{ __('Apply') }}',
+ cancelButtonTitle: '{{ __('Cancel') }}',
+ startLabel: '{{ __('Start') }}',
+ endLabel: '{{ __('Start') }}',
+ }
+ --}}
+ });
- $('[data-daterangepicker]').daterangepicker({
- callback: function(startDate, endDate, period) {
- let range = startDate.format('YYYY-MM-DD') + ',' + endDate.format('YYYY-MM-DD');
- location.href = `{{ $base_URL }}/${range}`;
- },
- forceUpdate: false,
- minDate: '{{ $fluidbook->created_at->isoFormat('YYYY-MM-DD') }}', // Creation date of the Fluidbook
- startDate: '{{ $start_date }}',
- endDate: '{{ $end_date }}',
-
- {{-- TODO: add translations - check https://github.com/sensortower/daterangepicker/issues/42 --}}
- {{--
- locale: {
- applyButtonTitle: '{{ __('Apply') }}',
- cancelButtonTitle: '{{ __('Cancel') }}',
- startLabel: '{{ __('Start') }}',
- endLabel: '{{ __('Start') }}',
- }
- --}}
- });
-
- </script>
-
- {{--============================================================================================================--}}
-
- {{-- Charting library --}}
- <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js" integrity="sha512-ElRFoEQdI5Ht6kZvyzXhYG9NqjtkmlkfYk0wr6wHxU9JEHakS7UJZNeml5ALk+8IKlU6jDgMabC3vkumRokgJA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
-
- <script>
- //=== Chart Setup
- const labels = {!! json_encode($labels) !!};
- const tooltip_labels = {!! json_encode($tooltip_labels) !!};
- const data = {
- labels: labels,
- datasets: [
- {{-- Unique visitors data was added to graph then removed because Matomo's tracker
- doesn't seem to record any real difference compared to normal visits --}}
- {{--
- {
- label: 'Unique Visitors',
- backgroundColor: 'hsl(19 100% 48% / 100%)',
- // borderColor: 'hsl(19 100% 48%)',
- // borderWidth: 1,
- data: {!! json_encode($pagesByPeriod->pluck('nb_uniq_visitors')->toArray()) !!},
- order: 1,
- bar_offset: -8, // Negative offset shifts bar to left
- },
- --}}
- {
- label: 'Visits',
- backgroundColor: 'hsl(72 100% 38% / 100%)',
- // borderColor: 'hsl(72 100% 38%)',
- // borderWidth: 1,
- data: {!! json_encode($pagesByPeriod->pluck('nb_visits')->toArray()) !!},
- order: 2,
- bar_offset: -5, // Negative offset shifts bar to left
- },
- {
- label: 'Page Views',
- backgroundColor: 'hsl(0 0% 53% / 100%)',
- // borderColor: 'hsl(0 0% 53%)',
- // borderWidth: 1,
- data: {!! json_encode($pagesByPeriod->pluck('nb_hits')->toArray()) !!},
- order: 3,
- bar_offset: 5, // Positive offset shifts bar to right
- },
- ]
- };
-
- //=== Plugins
- //== Offset Bars Plugin: creates a small offset between stacked bars
- const offsetBars = {
- id: 'offsetBars',
- beforeDatasetsDraw(chart, args, options) {
-
- // Create an offset between the stacked bars
- chart.config.data.datasets.forEach(function(dataset, datasetIndex) {
-
- const bar_offset_percentage = dataset.bar_offset || 0;
-
- // Go through each data point (bar) and apply the horizontal positioning offset
- chart.getDatasetMeta(datasetIndex).data.forEach(function(dataPoint, index) {
-
- let offset = Math.round(dataPoint.width * bar_offset_percentage / 100);
-
- // Make sure offset amount isn't too small
- if (offset > 0 && offset < 2) {
- offset = 2;
- } else if(offset < 0 && offset > -2) {
- offset = -2;
- }
+</script>
- dataPoint.x = chart.scales.x.getPixelForValue(index) + offset;
- });
- });
- }
- };
-
- //=== Chart Configuration
- const config = {
- type: 'bar',
- data: data,
- options: {
- // The best way to make the chart responsive is to set the aspect ratio without setting any sizes on
- // the canvas element itself. This way, Chart JS will manage the size, and it will behave much like a
- // responsive image - it will take the available width, scaling the height proportionally.
- // The aspectRatio value is a measure of the width compared to the height. In other words:
- // aspectRatio: 3 = 3x the width compared to height
- // aspectRatio: 1 = square
- // and an aspectRatio less than 1 will result in a canvas that is taller than it is wide.
- aspectRatio: 3,
- responsive: true, // Default for Chart JS but defining it explicitly here for clarity
- maintainAspectRatio: true, // As above, also a library default
- maxBarThickness: 100, // Prevent bars being ridiculously wide when there isn't much data
- scales: {
- x: { stacked: true },
- y: { stacked: false }, // Don't stack y-axis: prevents datasets from adding
- },
- plugins: {
- tooltip: {
- mode: 'index',
- position: 'nearest',
- callbacks: {
- title: function(context) {
- return tooltip_labels[context[0].label];
- }
- },
+{{--============================================================================================================--}}
+
+{{-- Charting library --}}
+<script>
+ //=== Chart Setup
+ const labels = {!! json_encode($labels) !!};
+ const tooltip_labels = {!! json_encode($tooltip_labels) !!};
+ const data = {
+ labels: labels,
+ datasets: {!! $chart_datasets !!}
+ };
+
+ //=== Plugins
+ //== Offset Bars Plugin: creates a small offset between stacked bars
+ const offsetBars = {
+ id: 'offsetBars',
+ beforeDatasetsDraw(chart, args, options) {
+
+ // Create an offset between the stacked bars
+ chart.config.data.datasets.forEach(function(dataset, datasetIndex) {
+
+ const bar_offset_percentage = dataset.bar_offset || 0;
+
+ // Go through each data point (bar) and apply the horizontal positioning offset
+ chart.getDatasetMeta(datasetIndex).data.forEach(function(dataPoint, index) {
+
+ let offset = Math.round(dataPoint.width * bar_offset_percentage / 100);
+
+ // Make sure offset amount isn't too small
+ if (offset > 0 && offset < 2) {
+ offset = 2;
+ } else if(offset < 0 && offset > -2) {
+ offset = -2;
}
- }
- },
- plugins: [offsetBars]
- };
- //=== Render Chart
- const statsChart = new Chart(
- document.getElementById('stats_chart'),
- config
- );
+ dataPoint.x = chart.scales.x.getPixelForValue(index) + offset;
+ });
+ });
+ }
+ };
+
+ //=== Chart Configuration
+ const config = {
+ type: 'bar',
+ data: data,
+ options: {
+ // The best way to make the chart responsive is to set the aspect ratio without setting any sizes on
+ // the canvas element itself. This way, Chart JS will manage the size, and it will behave much like a
+ // responsive image - it will take the available width, scaling the height proportionally.
+ // The aspectRatio value is a measure of the width compared to the height. In other words:
+ // aspectRatio: 3 = 3x the width compared to height
+ // aspectRatio: 1 = square
+ // and an aspectRatio less than 1 will result in a canvas that is taller than it is wide.
+ aspectRatio: 3,
+ responsive: true, // Default for Chart JS but defining it explicitly here for clarity
+ maintainAspectRatio: true, // As above, also a library default
+ maxBarThickness: 100, // Prevent bars being ridiculously wide when there isn't much data
+ scales: {
+ x: { stacked: true },
+ y: { stacked: false }, // Don't stack y-axis: prevents datasets from adding
+ },
+ plugins: {
+ tooltip: {
+ mode: 'index',
+ position: 'nearest',
+ callbacks: {
+ title: function(context) {
+ return tooltip_labels[context[0].label];
+ }
+ },
+ }
+ }
+ },
+ plugins: [offsetBars]
+ };
- </script>
+ //=== Render Chart
+ const statsChart = new Chart(
+ document.getElementById('stats_chart'),
+ config
+ );
- {{--============================================================================================================--}}
+</script>
- {{-- Simple Table Sorter --}}
- <script type="text/javascript" src="{{ asset('packages/sorttable/sorttable.js') }}"></script>
- {{-- This script works on any tables with the "sortable" class. There's no extra setup needed here. --}}
+{{--============================================================================================================--}}
-@endpush
+{{-- Simple Table Sorter --}}
+<script type="text/javascript" src="{{ asset('packages/sorttable/sorttable.js') }}"></script>
+{{-- This script works on any tables with the "sortable" class. There's no extra setup needed here. --}}