]>
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`, | |
f18a060a | 140 | value: this.numberFormatter.transform(this.video.views) |
384ba8b7 C |
141 | }, |
142 | { | |
143 | label: $localize`Likes`, | |
f18a060a | 144 | value: this.numberFormatter.transform(this.video.likes) |
384ba8b7 C |
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), | |
4f9a20a0 C |
153 | moreInfo: overallStats.viewersPeak !== 0 |
154 | ? $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}` | |
155 | : undefined | |
384ba8b7 C |
156 | } |
157 | ] | |
158 | } | |
159 | ||
160 | private loadChart () { | |
161 | const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = { | |
162 | retention: this.statsService.getRetentionStats(this.video.uuid), | |
3eda9b77 C |
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 | ||
384ba8b7 C |
177 | countries: of(this.countries) |
178 | } | |
179 | ||
180 | obsBuilders[this.activeGraphId].subscribe({ | |
181 | next: res => { | |
3eda9b77 C |
182 | this.chartIngestData[this.activeGraphId] = res |
183 | ||
184 | this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId) | |
384ba8b7 C |
185 | }, |
186 | ||
187 | error: err => this.notifier.error(err.message) | |
188 | }) | |
189 | } | |
190 | ||
3eda9b77 | 191 | private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> { |
384ba8b7 C |
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 | ||
3eda9b77 C |
201 | const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId]) |
202 | ||
203 | const self = this | |
384ba8b7 C |
204 | |
205 | return { | |
206 | type, | |
207 | data, | |
208 | ||
209 | options: { | |
210 | responsive: true, | |
211 | ||
212 | scales: { | |
3eda9b77 C |
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 | ||
384ba8b7 C |
226 | y: { |
227 | beginAtZero: true, | |
228 | ||
229 | max: this.activeGraphId === 'retention' | |
230 | ? 100 | |
231 | : undefined, | |
232 | ||
233 | ticks: { | |
3eda9b77 | 234 | callback: value => this.formatYTick({ graphId, value }) |
384ba8b7 C |
235 | } |
236 | } | |
237 | }, | |
238 | ||
239 | plugins: { | |
240 | legend: { | |
241 | display: displayLegend | |
242 | }, | |
243 | tooltip: { | |
244 | callbacks: { | |
3eda9b77 C |
245 | title: items => this.formatTooltipTitle({ graphId, items }), |
246 | label: value => this.formatYTick({ graphId, value: value.raw as number | string }) | |
384ba8b7 | 247 | } |
3eda9b77 C |
248 | }, |
249 | ||
250 | ...plugins | |
384ba8b7 C |
251 | } |
252 | } | |
253 | } | |
254 | } | |
255 | ||
3eda9b77 | 256 | private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult { |
384ba8b7 C |
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 | ||
3eda9b77 C |
270 | plugins: { |
271 | ...this.buildDisabledZoomPlugin() | |
272 | }, | |
273 | ||
384ba8b7 C |
274 | data: { |
275 | labels, | |
276 | datasets: [ | |
277 | { | |
278 | data, | |
279 | borderColor: this.buildChartColor() | |
280 | } | |
281 | ] | |
282 | } | |
283 | } | |
284 | } | |
285 | ||
3eda9b77 | 286 | private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult { |
384ba8b7 C |
287 | const labels: string[] = [] |
288 | const data: number[] = [] | |
289 | ||
290 | for (const d of rawData.data) { | |
3eda9b77 | 291 | labels.push(d.date) |
384ba8b7 C |
292 | data.push(d.value) |
293 | } | |
294 | ||
295 | return { | |
296 | type: 'line' as 'line', | |
297 | ||
298 | displayLegend: false, | |
299 | ||
3eda9b77 C |
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 | |
b3f84d8d | 317 | const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date) |
3eda9b77 C |
318 | |
319 | this.peertubeRouter.silentNavigate([], { startDate, endDate }) | |
320 | } | |
321 | } | |
322 | } | |
323 | }, | |
324 | ||
384ba8b7 C |
325 | data: { |
326 | labels, | |
327 | datasets: [ | |
328 | { | |
329 | data, | |
330 | borderColor: this.buildChartColor() | |
331 | } | |
332 | ] | |
333 | } | |
334 | } | |
335 | } | |
336 | ||
3eda9b77 | 337 | private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult { |
384ba8b7 C |
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 | ||
3eda9b77 C |
351 | plugins: { |
352 | ...this.buildDisabledZoomPlugin() | |
384ba8b7 C |
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 | ||
3eda9b77 C |
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 | ||
384ba8b7 C |
406 | if (graphId === 'retention') return value + ' %' |
407 | if (graphId === 'aggregateWatchTime') return secondsToTime(+value) | |
408 | ||
409 | return value.toLocaleString() | |
410 | } | |
411 | ||
3eda9b77 C |
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 | ||
384ba8b7 C |
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 | } | |
3eda9b77 C |
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 | } | |
b3f84d8d C |
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 | } | |
384ba8b7 | 465 | } |