]>
Commit | Line | Data |
---|---|---|
3eda9b77 C |
1 | import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' |
2 | import zoomPlugin from 'chartjs-plugin-zoom' | |
384ba8b7 C |
3 | import { Observable, of } from 'rxjs' |
4 | import { Component, OnInit } from '@angular/core' | |
5 | import { ActivatedRoute } from '@angular/router' | |
3eda9b77 | 6 | import { Notifier, PeerTubeRouterService } from '@app/core' |
384ba8b7 C |
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' | |
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 | }) | |
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', | |
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 | } |