]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - client/src/app/+stats/video/video-stats.component.ts
Remove comments, rates and views from stats
[github/Chocobozzz/PeerTube.git] / client / src / app / +stats / video / video-stats.component.ts
1 import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
2 import zoomPlugin from 'chartjs-plugin-zoom'
3 import { Observable, of } from 'rxjs'
4 import { Component, OnInit } from '@angular/core'
5 import { ActivatedRoute } from '@angular/router'
6 import { Notifier, PeerTubeRouterService } from '@app/core'
7 import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
8 import { secondsToTime } from '@shared/core-utils'
9 import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
10 import { VideoStatsService } from './video-stats.service'
11
12 type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
13
14 type CountryData = { name: string, viewers: number }[]
15
16 type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
17 type ChartBuilderResult = {
18 type: 'line' | 'bar'
19 plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
20 data: ChartData<'line' | 'bar'>
21 displayLegend: boolean
22 }
23
24 @Component({
25 templateUrl: './video-stats.component.html',
26 styleUrls: [ './video-stats.component.scss' ],
27 providers: [ NumberFormatterPipe ]
28 })
29 export class VideoStatsComponent implements OnInit {
30 overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = []
31
32 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
33 chartHeight = '300px'
34 chartWidth: string = null
35
36 availableCharts = [
37 {
38 id: 'viewers',
39 label: $localize`Viewers`,
40 zoomEnabled: true
41 },
42 {
43 id: 'aggregateWatchTime',
44 label: $localize`Watch time`,
45 zoomEnabled: true
46 },
47 {
48 id: 'retention',
49 label: $localize`Retention`,
50 zoomEnabled: false
51 },
52 {
53 id: 'countries',
54 label: $localize`Countries`,
55 zoomEnabled: false
56 }
57 ]
58
59 activeGraphId: ActiveGraphId = 'viewers'
60
61 video: VideoDetails
62
63 countries: CountryData = []
64
65 chartPlugins = [ zoomPlugin ]
66
67 private timeseriesStartDate: Date
68 private timeseriesEndDate: Date
69
70 private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
71
72 constructor (
73 private route: ActivatedRoute,
74 private notifier: Notifier,
75 private statsService: VideoStatsService,
76 private peertubeRouter: PeerTubeRouterService,
77 private numberFormatter: NumberFormatterPipe
78 ) {}
79
80 ngOnInit () {
81 this.video = this.route.snapshot.data.video
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
95 this.loadOverallStats()
96 }
97
98 hasCountries () {
99 return this.countries.length !== 0
100 }
101
102 onChartChange (newActive: ActiveGraphId) {
103 this.activeGraphId = newActive
104
105 this.loadChart()
106 }
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
120 private loadOverallStats () {
121 this.statsService.getOverallStats(this.video.uuid)
122 .subscribe({
123 next: res => {
124 this.countries = res.countries.slice(0, 10).map(c => ({
125 name: this.countryCodeToName(c.isoCode),
126 viewers: c.viewers
127 }))
128
129 this.buildOverallStatCard(res)
130 },
131
132 error: err => this.notifier.error(err.message)
133 })
134 }
135
136 private buildOverallStatCard (overallStats: VideoStatsOverall) {
137 this.overallStatCards = [
138 {
139 label: $localize`Views`,
140 value: this.numberFormatter.transform(this.video.views)
141 },
142 {
143 label: $localize`Likes`,
144 value: this.numberFormatter.transform(this.video.likes)
145 },
146 {
147 label: $localize`Average watch time`,
148 value: secondsToTime(overallStats.averageWatchTime)
149 },
150 {
151 label: $localize`Peak viewers`,
152 value: this.numberFormatter.transform(overallStats.viewersPeak),
153 moreInfo: overallStats.viewersPeak !== 0
154 ? $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
155 : undefined
156 }
157 ]
158 }
159
160 private loadChart () {
161 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
162 retention: this.statsService.getRetentionStats(this.video.uuid),
163
164 aggregateWatchTime: this.statsService.getTimeserieStats({
165 videoId: this.video.uuid,
166 startDate: this.timeseriesStartDate,
167 endDate: this.timeseriesEndDate,
168 metric: 'aggregateWatchTime'
169 }),
170 viewers: this.statsService.getTimeserieStats({
171 videoId: this.video.uuid,
172 startDate: this.timeseriesStartDate,
173 endDate: this.timeseriesEndDate,
174 metric: 'viewers'
175 }),
176
177 countries: of(this.countries)
178 }
179
180 obsBuilders[this.activeGraphId].subscribe({
181 next: res => {
182 this.chartIngestData[this.activeGraphId] = res
183
184 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
185 },
186
187 error: err => this.notifier.error(err.message)
188 })
189 }
190
191 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
192 const dataBuilders: {
193 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
194 } = {
195 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
196 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
197 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
198 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
199 }
200
201 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
202
203 const self = this
204
205 return {
206 type,
207 data,
208
209 options: {
210 responsive: true,
211
212 scales: {
213 x: {
214 ticks: {
215 callback: function (value) {
216 return self.formatXTick({
217 graphId,
218 value,
219 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
220 scale: this
221 })
222 }
223 }
224 },
225
226 y: {
227 beginAtZero: true,
228
229 max: this.activeGraphId === 'retention'
230 ? 100
231 : undefined,
232
233 ticks: {
234 callback: value => this.formatYTick({ graphId, value })
235 }
236 }
237 },
238
239 plugins: {
240 legend: {
241 display: displayLegend
242 },
243 tooltip: {
244 callbacks: {
245 title: items => this.formatTooltipTitle({ graphId, items }),
246 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
247 }
248 },
249
250 ...plugins
251 }
252 }
253 }
254 }
255
256 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
257 const labels: string[] = []
258 const data: number[] = []
259
260 for (const d of rawData.data) {
261 labels.push(secondsToTime(d.second))
262 data.push(d.retentionPercent)
263 }
264
265 return {
266 type: 'line' as 'line',
267
268 displayLegend: false,
269
270 plugins: {
271 ...this.buildDisabledZoomPlugin()
272 },
273
274 data: {
275 labels,
276 datasets: [
277 {
278 data,
279 borderColor: this.buildChartColor()
280 }
281 ]
282 }
283 }
284 }
285
286 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
287 const labels: string[] = []
288 const data: number[] = []
289
290 for (const d of rawData.data) {
291 labels.push(d.date)
292 data.push(d.value)
293 }
294
295 return {
296 type: 'line' as 'line',
297
298 displayLegend: false,
299
300 plugins: {
301 zoom: {
302 zoom: {
303 wheel: {
304 enabled: false
305 },
306 drag: {
307 enabled: true
308 },
309 pinch: {
310 enabled: true
311 },
312 mode: 'x',
313 onZoomComplete: ({ chart }) => {
314 const { min, max } = chart.scales.x
315
316 const startDate = rawData.data[min].date
317 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
318
319 this.peertubeRouter.silentNavigate([], { startDate, endDate })
320 }
321 }
322 }
323 },
324
325 data: {
326 labels,
327 datasets: [
328 {
329 data,
330 borderColor: this.buildChartColor()
331 }
332 ]
333 }
334 }
335 }
336
337 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
338 const labels: string[] = []
339 const data: number[] = []
340
341 for (const d of rawData) {
342 labels.push(d.name)
343 data.push(d.viewers)
344 }
345
346 return {
347 type: 'bar' as 'bar',
348
349 displayLegend: true,
350
351 plugins: {
352 ...this.buildDisabledZoomPlugin()
353 },
354
355 data: {
356 labels,
357 datasets: [
358 {
359 label: $localize`Viewers`,
360 backgroundColor: this.buildChartColor(),
361 maxBarThickness: 20,
362 data
363 }
364 ]
365 }
366 }
367 }
368
369 private buildChartColor () {
370 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
371 }
372
373 private formatXTick (options: {
374 graphId: ActiveGraphId
375 value: number | string
376 data: VideoStatsTimeserie
377 scale: Scale
378 }) {
379 const { graphId, value, data, scale } = options
380
381 const label = scale.getLabelForValue(value as number)
382
383 if (!this.isTimeserieGraph(graphId)) {
384 return label
385 }
386
387 const date = new Date(label)
388
389 if (data.groupInterval.match(/ days?$/)) {
390 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
391 }
392
393 if (data.groupInterval.match(/ hours?$/)) {
394 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
395 }
396
397 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
398 }
399
400 private formatYTick (options: {
401 graphId: ActiveGraphId
402 value: number | string
403 }) {
404 const { graphId, value } = options
405
406 if (graphId === 'retention') return value + ' %'
407 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
408
409 return value.toLocaleString()
410 }
411
412 private formatTooltipTitle (options: {
413 graphId: ActiveGraphId
414 items: TooltipItem<any>[]
415 }) {
416 const { graphId, items } = options
417 const item = items[0]
418
419 if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString()
420
421 return item.label
422 }
423
424 private countryCodeToName (code: string) {
425 const intl: any = Intl
426 if (!intl.DisplayNames) return code
427
428 const regionNames = new intl.DisplayNames([], { type: 'region' })
429
430 return regionNames.of(code)
431 }
432
433 private buildDisabledZoomPlugin () {
434 return {
435 zoom: {
436 zoom: {
437 wheel: {
438 enabled: false
439 },
440 drag: {
441 enabled: false
442 },
443 pinch: {
444 enabled: false
445 }
446 }
447 }
448 }
449 }
450
451 private buildZoomEndDate (groupInterval: string, last: string) {
452 const date = new Date(last)
453
454 // Remove parts of the date we don't need
455 if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
456 date.setHours(23, 59, 59)
457 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
458 date.setMinutes(59, 59)
459 } else {
460 date.setSeconds(59)
461 }
462
463 return date.toISOString()
464 }
465 }