From 3eda9b775ae700ac544e8c5588514627796b83cd Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 8 Apr 2022 10:22:56 +0200 Subject: Support interactive video stats graph --- client/package.json | 1 + .../app/+stats/video/video-stats.component.html | 9 +- .../app/+stats/video/video-stats.component.scss | 26 +++ .../src/app/+stats/video/video-stats.component.ts | 207 ++++++++++++++++++--- client/src/app/+stats/video/video-stats.service.ts | 17 +- client/yarn.lock | 12 ++ server/lib/timeserie.ts | 24 +-- server/models/view/local-video-viewer.ts | 8 +- .../tests/api/views/video-views-timeserie-stats.ts | 22 +-- shared/models/videos/stats/index.ts | 1 - .../video-stats-timeserie-group-interval.type.ts | 1 - .../videos/stats/video-stats-timeserie.model.ts | 4 +- 12 files changed, 268 insertions(+), 64 deletions(-) delete mode 100644 shared/models/videos/stats/video-stats-timeserie-group-interval.type.ts diff --git a/client/package.json b/client/package.json index 7c0732b44..7da61df66 100644 --- a/client/package.json +++ b/client/package.json @@ -83,6 +83,7 @@ "buffer": "^6.0.3", "cache-chunk-store": "^3.0.0", "chart.js": "^3.5.1", + "chartjs-plugin-zoom": "^1.2.1", "chromedriver": "^99.0.0", "core-js": "^3.1.4", "css-loader": "^6.2.0", diff --git a/client/src/app/+stats/video/video-stats.component.html b/client/src/app/+stats/video/video-stats.component.html index ef43c9fba..400c049eb 100644 --- a/client/src/app/+stats/video/video-stats.component.html +++ b/client/src/app/+stats/video/video-stats.component.html @@ -22,13 +22,20 @@ -
+
+ +
+ You can select a part of the graph to zoom in + + Reset zoom +
diff --git a/client/src/app/+stats/video/video-stats.component.scss b/client/src/app/+stats/video/video-stats.component.scss index 190499b5c..e2a74152f 100644 --- a/client/src/app/+stats/video/video-stats.component.scss +++ b/client/src/app/+stats/video/video-stats.component.scss @@ -21,6 +21,7 @@ min-width: 200px; margin-right: 15px; background-color: pvar(--submenuBackgroundColor); + margin-bottom: 15px; .label, .more-info { @@ -37,6 +38,12 @@ font-size: 24px; font-weight: $font-semibold; } + + @media screen and (max-width: $mobile-view) { + min-height: fit-content; + min-width: fit-content; + padding: 15px; + } } my-embed { @@ -45,6 +52,12 @@ my-embed { width: 100%; } +@include on-small-main-col { + my-embed { + display: none; + } +} + .tab-content { margin-top: 15px; } @@ -52,3 +65,16 @@ my-embed { .nav-tabs { @include peertube-nav-tabs($border-width: 2px); } + +.chart-container { + margin-bottom: 10px; +} + +.zoom-container { + display: flex; + justify-content: center; + + .description { + font-style: italic; + } +} diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts index 05319539b..14db31ecf 100644 --- a/client/src/app/+stats/video/video-stats.component.ts +++ b/client/src/app/+stats/video/video-stats.component.ts @@ -1,8 +1,9 @@ -import { ChartConfiguration, ChartData } from 'chart.js' +import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' +import zoomPlugin from 'chartjs-plugin-zoom' import { Observable, of } from 'rxjs' import { Component, OnInit } from '@angular/core' import { ActivatedRoute } from '@angular/router' -import { Notifier } from '@app/core' +import { Notifier, PeerTubeRouterService } from '@app/core' import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' import { secondsToTime } from '@shared/core-utils' import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' @@ -15,6 +16,7 @@ type CountryData = { name: string, viewers: number }[] type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData type ChartBuilderResult = { type: 'line' | 'bar' + plugins: Partial> data: ChartData<'line' | 'bar'> displayLegend: boolean } @@ -34,19 +36,23 @@ export class VideoStatsComponent implements OnInit { availableCharts = [ { id: 'viewers', - label: $localize`Viewers` + label: $localize`Viewers`, + zoomEnabled: true }, { id: 'aggregateWatchTime', - label: $localize`Watch time` + label: $localize`Watch time`, + zoomEnabled: true }, { id: 'retention', - label: $localize`Retention` + label: $localize`Retention`, + zoomEnabled: false }, { id: 'countries', - label: $localize`Countries` + label: $localize`Countries`, + zoomEnabled: false } ] @@ -56,18 +62,37 @@ export class VideoStatsComponent implements OnInit { countries: CountryData = [] + chartPlugins = [ zoomPlugin ] + + private timeseriesStartDate: Date + private timeseriesEndDate: Date + + private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {} + constructor ( private route: ActivatedRoute, private notifier: Notifier, private statsService: VideoStatsService, + private peertubeRouter: PeerTubeRouterService, private numberFormatter: NumberFormatterPipe ) {} ngOnInit () { this.video = this.route.snapshot.data.video + this.route.queryParams.subscribe(params => { + this.timeseriesStartDate = params.startDate + ? new Date(params.startDate) + : undefined + + this.timeseriesEndDate = params.endDate + ? new Date(params.endDate) + : undefined + + this.loadChart() + }) + this.loadOverallStats() - this.loadChart() } hasCountries () { @@ -80,6 +105,18 @@ export class VideoStatsComponent implements OnInit { this.loadChart() } + resetZoom () { + this.peertubeRouter.silentNavigate([], {}) + } + + hasZoom () { + return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId) + } + + private isTimeserieGraph (graphId: ActiveGraphId) { + return graphId === 'aggregateWatchTime' || graphId === 'viewers' + } + private loadOverallStats () { this.statsService.getOverallStats(this.video.uuid) .subscribe({ @@ -125,24 +162,35 @@ export class VideoStatsComponent implements OnInit { private loadChart () { const obsBuilders: { [ id in ActiveGraphId ]: Observable } = { retention: this.statsService.getRetentionStats(this.video.uuid), - aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'), - viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'), + + aggregateWatchTime: this.statsService.getTimeserieStats({ + videoId: this.video.uuid, + startDate: this.timeseriesStartDate, + endDate: this.timeseriesEndDate, + metric: 'aggregateWatchTime' + }), + viewers: this.statsService.getTimeserieStats({ + videoId: this.video.uuid, + startDate: this.timeseriesStartDate, + endDate: this.timeseriesEndDate, + metric: 'viewers' + }), + countries: of(this.countries) } obsBuilders[this.activeGraphId].subscribe({ next: res => { - this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res) + this.chartIngestData[this.activeGraphId] = res + + this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId) }, error: err => this.notifier.error(err.message) }) } - private buildChartOptions ( - graphId: ActiveGraphId, - rawData: ChartIngestData - ): ChartConfiguration<'line' | 'bar'> { + private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> { const dataBuilders: { [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult } = { @@ -152,7 +200,9 @@ export class VideoStatsComponent implements OnInit { countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) } - const { type, data, displayLegend } = dataBuilders[graphId](rawData) + const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId]) + + const self = this return { type, @@ -162,6 +212,19 @@ export class VideoStatsComponent implements OnInit { responsive: true, scales: { + x: { + ticks: { + callback: function (value) { + return self.formatXTick({ + graphId, + value, + data: self.chartIngestData[graphId] as VideoStatsTimeserie, + scale: this + }) + } + } + }, + y: { beginAtZero: true, @@ -170,7 +233,7 @@ export class VideoStatsComponent implements OnInit { : undefined, ticks: { - callback: value => this.formatTick(graphId, value) + callback: value => this.formatYTick({ graphId, value }) } } }, @@ -181,15 +244,18 @@ export class VideoStatsComponent implements OnInit { }, tooltip: { callbacks: { - label: value => this.formatTick(graphId, value.raw as number | string) + title: items => this.formatTooltipTitle({ graphId, items }), + label: value => this.formatYTick({ graphId, value: value.raw as number | string }) } - } + }, + + ...plugins } } } } - private buildRetentionChartOptions (rawData: VideoStatsRetention) { + private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult { const labels: string[] = [] const data: number[] = [] @@ -203,6 +269,10 @@ export class VideoStatsComponent implements OnInit { displayLegend: false, + plugins: { + ...this.buildDisabledZoomPlugin() + }, + data: { labels, datasets: [ @@ -215,12 +285,12 @@ export class VideoStatsComponent implements OnInit { } } - private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) { + private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult { const labels: string[] = [] const data: number[] = [] for (const d of rawData.data) { - labels.push(new Date(d.date).toLocaleDateString()) + labels.push(d.date) data.push(d.value) } @@ -229,6 +299,31 @@ export class VideoStatsComponent implements OnInit { displayLegend: false, + plugins: { + zoom: { + zoom: { + wheel: { + enabled: false + }, + drag: { + enabled: true + }, + pinch: { + enabled: true + }, + mode: 'x', + onZoomComplete: ({ chart }) => { + const { min, max } = chart.scales.x + + const startDate = rawData.data[min].date + const endDate = rawData.data[max].date + + this.peertubeRouter.silentNavigate([], { startDate, endDate }) + } + } + } + }, + data: { labels, datasets: [ @@ -241,7 +336,7 @@ export class VideoStatsComponent implements OnInit { } } - private buildCountryChartOptions (rawData: CountryData) { + private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult { const labels: string[] = [] const data: number[] = [] @@ -255,8 +350,8 @@ export class VideoStatsComponent implements OnInit { displayLegend: true, - options: { - indexAxis: 'y' + plugins: { + ...this.buildDisabledZoomPlugin() }, data: { @@ -277,13 +372,57 @@ export class VideoStatsComponent implements OnInit { return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') } - private formatTick (graphId: ActiveGraphId, value: number | string) { + private formatXTick (options: { + graphId: ActiveGraphId + value: number | string + data: VideoStatsTimeserie + scale: Scale + }) { + const { graphId, value, data, scale } = options + + const label = scale.getLabelForValue(value as number) + + if (!this.isTimeserieGraph(graphId)) { + return label + } + + const date = new Date(label) + + if (data.groupInterval.match(/ days?$/)) { + return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + } + + if (data.groupInterval.match(/ hours?$/)) { + return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' }) + } + + return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' }) + } + + private formatYTick (options: { + graphId: ActiveGraphId + value: number | string + }) { + const { graphId, value } = options + if (graphId === 'retention') return value + ' %' if (graphId === 'aggregateWatchTime') return secondsToTime(+value) return value.toLocaleString() } + private formatTooltipTitle (options: { + graphId: ActiveGraphId + items: TooltipItem[] + }) { + const { graphId, items } = options + const item = items[0] + + if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString() + + return item.label + } + private countryCodeToName (code: string) { const intl: any = Intl if (!intl.DisplayNames) return code @@ -292,4 +431,22 @@ export class VideoStatsComponent implements OnInit { return regionNames.of(code) } + + private buildDisabledZoomPlugin () { + return { + zoom: { + zoom: { + wheel: { + enabled: false + }, + drag: { + enabled: false + }, + pinch: { + enabled: false + } + } + } + } + } } diff --git a/client/src/app/+stats/video/video-stats.service.ts b/client/src/app/+stats/video/video-stats.service.ts index 8f9d48f60..712d03971 100644 --- a/client/src/app/+stats/video/video-stats.service.ts +++ b/client/src/app/+stats/video/video-stats.service.ts @@ -1,6 +1,6 @@ import { catchError } from 'rxjs' import { environment } from 'src/environments/environment' -import { HttpClient } from '@angular/common/http' +import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor } from '@app/core' import { VideoService } from '@app/shared/shared-main' @@ -22,8 +22,19 @@ export class VideoStatsService { .pipe(catchError(err => this.restExtractor.handleError(err))) } - getTimeserieStats (videoId: string, metric: VideoStatsTimeserieMetric) { - return this.authHttp.get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric) + getTimeserieStats (options: { + videoId: string + metric: VideoStatsTimeserieMetric + startDate?: Date + endDate?: Date + }) { + const { videoId, metric, startDate, endDate } = options + + let params = new HttpParams() + if (startDate) params = params.append('startDate', startDate.toISOString()) + if (endDate) params = params.append('endDate', endDate.toISOString()) + + return this.authHttp.get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric, { params }) .pipe(catchError(err => this.restExtractor.handleError(err))) } diff --git a/client/yarn.lock b/client/yarn.lock index 5c6e9f8b9..800c226c2 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3792,6 +3792,13 @@ chart.js@^3.5.1: resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.7.1.tgz#0516f690c6a8680c6c707e31a4c1807a6f400ada" integrity sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA== +chartjs-plugin-zoom@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/chartjs-plugin-zoom/-/chartjs-plugin-zoom-1.2.1.tgz#7e350ba20d907f397d0c055239dcc67d326df705" + integrity sha512-2zbWvw2pljrtMLMXkKw1uxYzAne5PtjJiOZftcut4Lo3Ee8qUt95RpMKDWrZ+pBZxZKQKOD/etdU4pN2jxZUmg== + dependencies: + hammerjs "^2.0.8" + chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.2: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -5961,6 +5968,11 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" +hammerjs@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" + integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE= + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" diff --git a/server/lib/timeserie.ts b/server/lib/timeserie.ts index d8f700a2f..bd3d1c1ca 100644 --- a/server/lib/timeserie.ts +++ b/server/lib/timeserie.ts @@ -1,24 +1,17 @@ import { logger } from '@server/helpers/logger' -import { VideoStatsTimeserieGroupInterval } from '@shared/models' function buildGroupByAndBoundaries (startDateString: string, endDateString: string) { const startDate = new Date(startDateString) const endDate = new Date(endDateString) - const groupByMatrix: { [ id in VideoStatsTimeserieGroupInterval ]: string } = { - one_day: '1 day', - one_hour: '1 hour', - ten_minutes: '10 minutes', - one_minute: '1 minute' - } const groupInterval = buildGroupInterval(startDate, endDate) logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate }) // Remove parts of the date we don't need - if (groupInterval === 'one_day') { + if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) { startDate.setHours(0, 0, 0, 0) - } else if (groupInterval === 'one_hour') { + } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) { startDate.setMinutes(0, 0, 0) } else { startDate.setSeconds(0, 0) @@ -26,7 +19,6 @@ function buildGroupByAndBoundaries (startDateString: string, endDateString: stri return { groupInterval, - sqlInterval: groupByMatrix[groupInterval], startDate, endDate } @@ -40,16 +32,18 @@ export { // --------------------------------------------------------------------------- -function buildGroupInterval (startDate: Date, endDate: Date): VideoStatsTimeserieGroupInterval { +function buildGroupInterval (startDate: Date, endDate: Date): string { const aDay = 86400 const anHour = 3600 const aMinute = 60 const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000 - if (diffSeconds >= 6 * aDay) return 'one_day' - if (diffSeconds >= 6 * anHour) return 'one_hour' - if (diffSeconds >= 60 * aMinute) return 'ten_minutes' + if (diffSeconds >= 15 * aDay) return '1 day' + if (diffSeconds >= 8 * aDay) return '12 hours' + if (diffSeconds >= 4 * aDay) return '6 hours' + if (diffSeconds >= 15 * anHour) return '1 hour' + if (diffSeconds >= 180 * aMinute) return '10 minutes' - return 'one_minute' + return '1 minute' } diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts index ad2ad35ca..b6ddcbb57 100644 --- a/server/models/view/local-video-viewer.ts +++ b/server/models/view/local-video-viewer.ts @@ -221,7 +221,7 @@ export class LocalVideoViewerModel extends Model { const { video, metric } = options - const { groupInterval, sqlInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate) + const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate) const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = { viewers: 'COUNT("localVideoViewer"."id")', @@ -230,9 +230,9 @@ export class LocalVideoViewerModel extends Model