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 --- .../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 +- 4 files changed, 230 insertions(+), 29 deletions(-) (limited to 'client/src/app') 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))) } -- cgit v1.2.3