]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - client/src/app/+stats/video/video-stats.component.ts
Use round for views stats
[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),
157 moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
158 }
159 ]
160 }
161
162 private loadChart () {
163 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
164 retention: this.statsService.getRetentionStats(this.video.uuid),
3eda9b77
C
165
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
384ba8b7
C
179 countries: of(this.countries)
180 }
181
182 obsBuilders[this.activeGraphId].subscribe({
183 next: res => {
3eda9b77
C
184 this.chartIngestData[this.activeGraphId] = res
185
186 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
384ba8b7
C
187 },
188
189 error: err => this.notifier.error(err.message)
190 })
191 }
192
3eda9b77 193 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
384ba8b7
C
194 const dataBuilders: {
195 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
196 } = {
197 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
198 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
199 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
200 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
201 }
202
3eda9b77
C
203 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
204
205 const self = this
384ba8b7
C
206
207 return {
208 type,
209 data,
210
211 options: {
212 responsive: true,
213
214 scales: {
3eda9b77
C
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
384ba8b7
C
228 y: {
229 beginAtZero: true,
230
231 max: this.activeGraphId === 'retention'
232 ? 100
233 : undefined,
234
235 ticks: {
3eda9b77 236 callback: value => this.formatYTick({ graphId, value })
384ba8b7
C
237 }
238 }
239 },
240
241 plugins: {
242 legend: {
243 display: displayLegend
244 },
245 tooltip: {
246 callbacks: {
3eda9b77
C
247 title: items => this.formatTooltipTitle({ graphId, items }),
248 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
384ba8b7 249 }
3eda9b77
C
250 },
251
252 ...plugins
384ba8b7
C
253 }
254 }
255 }
256 }
257
3eda9b77 258 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
384ba8b7
C
259 const labels: string[] = []
260 const data: number[] = []
261
262 for (const d of rawData.data) {
263 labels.push(secondsToTime(d.second))
264 data.push(d.retentionPercent)
265 }
266
267 return {
268 type: 'line' as 'line',
269
270 displayLegend: false,
271
3eda9b77
C
272 plugins: {
273 ...this.buildDisabledZoomPlugin()
274 },
275
384ba8b7
C
276 data: {
277 labels,
278 datasets: [
279 {
280 data,
281 borderColor: this.buildChartColor()
282 }
283 ]
284 }
285 }
286 }
287
3eda9b77 288 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
384ba8b7
C
289 const labels: string[] = []
290 const data: number[] = []
291
292 for (const d of rawData.data) {
3eda9b77 293 labels.push(d.date)
384ba8b7
C
294 data.push(d.value)
295 }
296
297 return {
298 type: 'line' as 'line',
299
300 displayLegend: false,
301
3eda9b77
C
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
384ba8b7
C
327 data: {
328 labels,
329 datasets: [
330 {
331 data,
332 borderColor: this.buildChartColor()
333 }
334 ]
335 }
336 }
337 }
338
3eda9b77 339 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
384ba8b7
C
340 const labels: string[] = []
341 const data: number[] = []
342
343 for (const d of rawData) {
344 labels.push(d.name)
345 data.push(d.viewers)
346 }
347
348 return {
349 type: 'bar' as 'bar',
350
351 displayLegend: true,
352
3eda9b77
C
353 plugins: {
354 ...this.buildDisabledZoomPlugin()
384ba8b7
C
355 },
356
357 data: {
358 labels,
359 datasets: [
360 {
361 label: $localize`Viewers`,
362 backgroundColor: this.buildChartColor(),
363 maxBarThickness: 20,
364 data
365 }
366 ]
367 }
368 }
369 }
370
371 private buildChartColor () {
372 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
373 }
374
3eda9b77
C
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
384ba8b7
C
408 if (graphId === 'retention') return value + ' %'
409 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
410
411 return value.toLocaleString()
412 }
413
3eda9b77
C
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
384ba8b7
C
426 private countryCodeToName (code: string) {
427 const intl: any = Intl
428 if (!intl.DisplayNames) return code
429
430 const regionNames = new intl.DisplayNames([], { type: 'region' })
431
432 return regionNames.of(code)
433 }
3eda9b77
C
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 }
384ba8b7 452}