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