aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+stats
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-04-08 10:22:56 +0200
committerChocobozzz <chocobozzz@cpy.re>2022-04-15 09:49:35 +0200
commit3eda9b775ae700ac544e8c5588514627796b83cd (patch)
tree97ec1fdfce274e83d976352f5b4154c315ee33d7 /client/src/app/+stats
parent901bcf5c188ea79350fecd499ad76460b866617b (diff)
downloadPeerTube-3eda9b775ae700ac544e8c5588514627796b83cd.tar.gz
PeerTube-3eda9b775ae700ac544e8c5588514627796b83cd.tar.zst
PeerTube-3eda9b775ae700ac544e8c5588514627796b83cd.zip
Support interactive video stats graph
Diffstat (limited to 'client/src/app/+stats')
-rw-r--r--client/src/app/+stats/video/video-stats.component.html9
-rw-r--r--client/src/app/+stats/video/video-stats.component.scss26
-rw-r--r--client/src/app/+stats/video/video-stats.component.ts207
-rw-r--r--client/src/app/+stats/video/video-stats.service.ts17
4 files changed, 230 insertions, 29 deletions
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 @@
22 </a> 22 </a>
23 23
24 <ng-template ngbNavContent> 24 <ng-template ngbNavContent>
25 <div [ngStyle]="{ 'min-height': chartHeight }"> 25 <div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }">
26 <p-chart 26 <p-chart
27 *ngIf="chartOptions[availableChart.id]" 27 *ngIf="chartOptions[availableChart.id]"
28 [height]="chartHeight" [width]="chartWidth" 28 [height]="chartHeight" [width]="chartWidth"
29 [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data" 29 [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
30 [plugins]="chartPlugins"
30 ></p-chart> 31 ></p-chart>
31 </div> 32 </div>
33
34 <div class="zoom-container">
35 <span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span>
36
37 <my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button>
38 </div>
32 </ng-template> 39 </ng-template>
33 </ng-container> 40 </ng-container>
34 </div> 41 </div>
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 @@
21 min-width: 200px; 21 min-width: 200px;
22 margin-right: 15px; 22 margin-right: 15px;
23 background-color: pvar(--submenuBackgroundColor); 23 background-color: pvar(--submenuBackgroundColor);
24 margin-bottom: 15px;
24 25
25 .label, 26 .label,
26 .more-info { 27 .more-info {
@@ -37,6 +38,12 @@
37 font-size: 24px; 38 font-size: 24px;
38 font-weight: $font-semibold; 39 font-weight: $font-semibold;
39 } 40 }
41
42 @media screen and (max-width: $mobile-view) {
43 min-height: fit-content;
44 min-width: fit-content;
45 padding: 15px;
46 }
40} 47}
41 48
42my-embed { 49my-embed {
@@ -45,6 +52,12 @@ my-embed {
45 width: 100%; 52 width: 100%;
46} 53}
47 54
55@include on-small-main-col {
56 my-embed {
57 display: none;
58 }
59}
60
48.tab-content { 61.tab-content {
49 margin-top: 15px; 62 margin-top: 15px;
50} 63}
@@ -52,3 +65,16 @@ my-embed {
52.nav-tabs { 65.nav-tabs {
53 @include peertube-nav-tabs($border-width: 2px); 66 @include peertube-nav-tabs($border-width: 2px);
54} 67}
68
69.chart-container {
70 margin-bottom: 10px;
71}
72
73.zoom-container {
74 display: flex;
75 justify-content: center;
76
77 .description {
78 font-style: italic;
79 }
80}
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 @@
1import { ChartConfiguration, ChartData } from 'chart.js' 1import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
2import zoomPlugin from 'chartjs-plugin-zoom'
2import { Observable, of } from 'rxjs' 3import { Observable, of } from 'rxjs'
3import { Component, OnInit } from '@angular/core' 4import { Component, OnInit } from '@angular/core'
4import { ActivatedRoute } from '@angular/router' 5import { ActivatedRoute } from '@angular/router'
5import { Notifier } from '@app/core' 6import { Notifier, PeerTubeRouterService } from '@app/core'
6import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' 7import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
7import { secondsToTime } from '@shared/core-utils' 8import { secondsToTime } from '@shared/core-utils'
8import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' 9import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
@@ -15,6 +16,7 @@ type CountryData = { name: string, viewers: number }[]
15type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData 16type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
16type ChartBuilderResult = { 17type ChartBuilderResult = {
17 type: 'line' | 'bar' 18 type: 'line' | 'bar'
19 plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
18 data: ChartData<'line' | 'bar'> 20 data: ChartData<'line' | 'bar'>
19 displayLegend: boolean 21 displayLegend: boolean
20} 22}
@@ -34,19 +36,23 @@ export class VideoStatsComponent implements OnInit {
34 availableCharts = [ 36 availableCharts = [
35 { 37 {
36 id: 'viewers', 38 id: 'viewers',
37 label: $localize`Viewers` 39 label: $localize`Viewers`,
40 zoomEnabled: true
38 }, 41 },
39 { 42 {
40 id: 'aggregateWatchTime', 43 id: 'aggregateWatchTime',
41 label: $localize`Watch time` 44 label: $localize`Watch time`,
45 zoomEnabled: true
42 }, 46 },
43 { 47 {
44 id: 'retention', 48 id: 'retention',
45 label: $localize`Retention` 49 label: $localize`Retention`,
50 zoomEnabled: false
46 }, 51 },
47 { 52 {
48 id: 'countries', 53 id: 'countries',
49 label: $localize`Countries` 54 label: $localize`Countries`,
55 zoomEnabled: false
50 } 56 }
51 ] 57 ]
52 58
@@ -56,18 +62,37 @@ export class VideoStatsComponent implements OnInit {
56 62
57 countries: CountryData = [] 63 countries: CountryData = []
58 64
65 chartPlugins = [ zoomPlugin ]
66
67 private timeseriesStartDate: Date
68 private timeseriesEndDate: Date
69
70 private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
71
59 constructor ( 72 constructor (
60 private route: ActivatedRoute, 73 private route: ActivatedRoute,
61 private notifier: Notifier, 74 private notifier: Notifier,
62 private statsService: VideoStatsService, 75 private statsService: VideoStatsService,
76 private peertubeRouter: PeerTubeRouterService,
63 private numberFormatter: NumberFormatterPipe 77 private numberFormatter: NumberFormatterPipe
64 ) {} 78 ) {}
65 79
66 ngOnInit () { 80 ngOnInit () {
67 this.video = this.route.snapshot.data.video 81 this.video = this.route.snapshot.data.video
68 82
83 this.route.queryParams.subscribe(params => {
84 this.timeseriesStartDate = params.startDate
85 ? new Date(params.startDate)
86 : undefined
87
88 this.timeseriesEndDate = params.endDate
89 ? new Date(params.endDate)
90 : undefined
91
92 this.loadChart()
93 })
94
69 this.loadOverallStats() 95 this.loadOverallStats()
70 this.loadChart()
71 } 96 }
72 97
73 hasCountries () { 98 hasCountries () {
@@ -80,6 +105,18 @@ export class VideoStatsComponent implements OnInit {
80 this.loadChart() 105 this.loadChart()
81 } 106 }
82 107
108 resetZoom () {
109 this.peertubeRouter.silentNavigate([], {})
110 }
111
112 hasZoom () {
113 return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId)
114 }
115
116 private isTimeserieGraph (graphId: ActiveGraphId) {
117 return graphId === 'aggregateWatchTime' || graphId === 'viewers'
118 }
119
83 private loadOverallStats () { 120 private loadOverallStats () {
84 this.statsService.getOverallStats(this.video.uuid) 121 this.statsService.getOverallStats(this.video.uuid)
85 .subscribe({ 122 .subscribe({
@@ -125,24 +162,35 @@ export class VideoStatsComponent implements OnInit {
125 private loadChart () { 162 private loadChart () {
126 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = { 163 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
127 retention: this.statsService.getRetentionStats(this.video.uuid), 164 retention: this.statsService.getRetentionStats(this.video.uuid),
128 aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'), 165
129 viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'), 166 aggregateWatchTime: this.statsService.getTimeserieStats({
167 videoId: this.video.uuid,
168 startDate: this.timeseriesStartDate,
169 endDate: this.timeseriesEndDate,
170 metric: 'aggregateWatchTime'
171 }),
172 viewers: this.statsService.getTimeserieStats({
173 videoId: this.video.uuid,
174 startDate: this.timeseriesStartDate,
175 endDate: this.timeseriesEndDate,
176 metric: 'viewers'
177 }),
178
130 countries: of(this.countries) 179 countries: of(this.countries)
131 } 180 }
132 181
133 obsBuilders[this.activeGraphId].subscribe({ 182 obsBuilders[this.activeGraphId].subscribe({
134 next: res => { 183 next: res => {
135 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res) 184 this.chartIngestData[this.activeGraphId] = res
185
186 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
136 }, 187 },
137 188
138 error: err => this.notifier.error(err.message) 189 error: err => this.notifier.error(err.message)
139 }) 190 })
140 } 191 }
141 192
142 private buildChartOptions ( 193 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
143 graphId: ActiveGraphId,
144 rawData: ChartIngestData
145 ): ChartConfiguration<'line' | 'bar'> {
146 const dataBuilders: { 194 const dataBuilders: {
147 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult 195 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
148 } = { 196 } = {
@@ -152,7 +200,9 @@ export class VideoStatsComponent implements OnInit {
152 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) 200 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
153 } 201 }
154 202
155 const { type, data, displayLegend } = dataBuilders[graphId](rawData) 203 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
204
205 const self = this
156 206
157 return { 207 return {
158 type, 208 type,
@@ -162,6 +212,19 @@ export class VideoStatsComponent implements OnInit {
162 responsive: true, 212 responsive: true,
163 213
164 scales: { 214 scales: {
215 x: {
216 ticks: {
217 callback: function (value) {
218 return self.formatXTick({
219 graphId,
220 value,
221 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
222 scale: this
223 })
224 }
225 }
226 },
227
165 y: { 228 y: {
166 beginAtZero: true, 229 beginAtZero: true,
167 230
@@ -170,7 +233,7 @@ export class VideoStatsComponent implements OnInit {
170 : undefined, 233 : undefined,
171 234
172 ticks: { 235 ticks: {
173 callback: value => this.formatTick(graphId, value) 236 callback: value => this.formatYTick({ graphId, value })
174 } 237 }
175 } 238 }
176 }, 239 },
@@ -181,15 +244,18 @@ export class VideoStatsComponent implements OnInit {
181 }, 244 },
182 tooltip: { 245 tooltip: {
183 callbacks: { 246 callbacks: {
184 label: value => this.formatTick(graphId, value.raw as number | string) 247 title: items => this.formatTooltipTitle({ graphId, items }),
248 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
185 } 249 }
186 } 250 },
251
252 ...plugins
187 } 253 }
188 } 254 }
189 } 255 }
190 } 256 }
191 257
192 private buildRetentionChartOptions (rawData: VideoStatsRetention) { 258 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
193 const labels: string[] = [] 259 const labels: string[] = []
194 const data: number[] = [] 260 const data: number[] = []
195 261
@@ -203,6 +269,10 @@ export class VideoStatsComponent implements OnInit {
203 269
204 displayLegend: false, 270 displayLegend: false,
205 271
272 plugins: {
273 ...this.buildDisabledZoomPlugin()
274 },
275
206 data: { 276 data: {
207 labels, 277 labels,
208 datasets: [ 278 datasets: [
@@ -215,12 +285,12 @@ export class VideoStatsComponent implements OnInit {
215 } 285 }
216 } 286 }
217 287
218 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) { 288 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
219 const labels: string[] = [] 289 const labels: string[] = []
220 const data: number[] = [] 290 const data: number[] = []
221 291
222 for (const d of rawData.data) { 292 for (const d of rawData.data) {
223 labels.push(new Date(d.date).toLocaleDateString()) 293 labels.push(d.date)
224 data.push(d.value) 294 data.push(d.value)
225 } 295 }
226 296
@@ -229,6 +299,31 @@ export class VideoStatsComponent implements OnInit {
229 299
230 displayLegend: false, 300 displayLegend: false,
231 301
302 plugins: {
303 zoom: {
304 zoom: {
305 wheel: {
306 enabled: false
307 },
308 drag: {
309 enabled: true
310 },
311 pinch: {
312 enabled: true
313 },
314 mode: 'x',
315 onZoomComplete: ({ chart }) => {
316 const { min, max } = chart.scales.x
317
318 const startDate = rawData.data[min].date
319 const endDate = rawData.data[max].date
320
321 this.peertubeRouter.silentNavigate([], { startDate, endDate })
322 }
323 }
324 }
325 },
326
232 data: { 327 data: {
233 labels, 328 labels,
234 datasets: [ 329 datasets: [
@@ -241,7 +336,7 @@ export class VideoStatsComponent implements OnInit {
241 } 336 }
242 } 337 }
243 338
244 private buildCountryChartOptions (rawData: CountryData) { 339 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
245 const labels: string[] = [] 340 const labels: string[] = []
246 const data: number[] = [] 341 const data: number[] = []
247 342
@@ -255,8 +350,8 @@ export class VideoStatsComponent implements OnInit {
255 350
256 displayLegend: true, 351 displayLegend: true,
257 352
258 options: { 353 plugins: {
259 indexAxis: 'y' 354 ...this.buildDisabledZoomPlugin()
260 }, 355 },
261 356
262 data: { 357 data: {
@@ -277,13 +372,57 @@ export class VideoStatsComponent implements OnInit {
277 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') 372 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
278 } 373 }
279 374
280 private formatTick (graphId: ActiveGraphId, value: number | string) { 375 private formatXTick (options: {
376 graphId: ActiveGraphId
377 value: number | string
378 data: VideoStatsTimeserie
379 scale: Scale
380 }) {
381 const { graphId, value, data, scale } = options
382
383 const label = scale.getLabelForValue(value as number)
384
385 if (!this.isTimeserieGraph(graphId)) {
386 return label
387 }
388
389 const date = new Date(label)
390
391 if (data.groupInterval.match(/ days?$/)) {
392 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
393 }
394
395 if (data.groupInterval.match(/ hours?$/)) {
396 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
397 }
398
399 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
400 }
401
402 private formatYTick (options: {
403 graphId: ActiveGraphId
404 value: number | string
405 }) {
406 const { graphId, value } = options
407
281 if (graphId === 'retention') return value + ' %' 408 if (graphId === 'retention') return value + ' %'
282 if (graphId === 'aggregateWatchTime') return secondsToTime(+value) 409 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
283 410
284 return value.toLocaleString() 411 return value.toLocaleString()
285 } 412 }
286 413
414 private formatTooltipTitle (options: {
415 graphId: ActiveGraphId
416 items: TooltipItem<any>[]
417 }) {
418 const { graphId, items } = options
419 const item = items[0]
420
421 if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString()
422
423 return item.label
424 }
425
287 private countryCodeToName (code: string) { 426 private countryCodeToName (code: string) {
288 const intl: any = Intl 427 const intl: any = Intl
289 if (!intl.DisplayNames) return code 428 if (!intl.DisplayNames) return code
@@ -292,4 +431,22 @@ export class VideoStatsComponent implements OnInit {
292 431
293 return regionNames.of(code) 432 return regionNames.of(code)
294 } 433 }
434
435 private buildDisabledZoomPlugin () {
436 return {
437 zoom: {
438 zoom: {
439 wheel: {
440 enabled: false
441 },
442 drag: {
443 enabled: false
444 },
445 pinch: {
446 enabled: false
447 }
448 }
449 }
450 }
451 }
295} 452}
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 @@
1import { catchError } from 'rxjs' 1import { catchError } from 'rxjs'
2import { environment } from 'src/environments/environment' 2import { environment } from 'src/environments/environment'
3import { HttpClient } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { RestExtractor } from '@app/core' 5import { RestExtractor } from '@app/core'
6import { VideoService } from '@app/shared/shared-main' 6import { VideoService } from '@app/shared/shared-main'
@@ -22,8 +22,19 @@ export class VideoStatsService {
22 .pipe(catchError(err => this.restExtractor.handleError(err))) 22 .pipe(catchError(err => this.restExtractor.handleError(err)))
23 } 23 }
24 24
25 getTimeserieStats (videoId: string, metric: VideoStatsTimeserieMetric) { 25 getTimeserieStats (options: {
26 return this.authHttp.get<VideoStatsTimeserie>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric) 26 videoId: string
27 metric: VideoStatsTimeserieMetric
28 startDate?: Date
29 endDate?: Date
30 }) {
31 const { videoId, metric, startDate, endDate } = options
32
33 let params = new HttpParams()
34 if (startDate) params = params.append('startDate', startDate.toISOString())
35 if (endDate) params = params.append('endDate', endDate.toISOString())
36
37 return this.authHttp.get<VideoStatsTimeserie>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric, { params })
27 .pipe(catchError(err => this.restExtractor.handleError(err))) 38 .pipe(catchError(err => this.restExtractor.handleError(err)))
28 } 39 }
29 40