]>
Commit | Line | Data |
---|---|---|
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(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), | |
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 | ||
179 | countries: of(this.countries) | |
180 | } | |
181 | ||
182 | obsBuilders[this.activeGraphId].subscribe({ | |
183 | next: res => { | |
184 | this.chartIngestData[this.activeGraphId] = res | |
185 | ||
186 | this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId) | |
187 | }, | |
188 | ||
189 | error: err => this.notifier.error(err.message) | |
190 | }) | |
191 | } | |
192 | ||
193 | private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> { | |
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 | ||
203 | const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId]) | |
204 | ||
205 | const self = this | |
206 | ||
207 | return { | |
208 | type, | |
209 | data, | |
210 | ||
211 | options: { | |
212 | responsive: true, | |
213 | ||
214 | scales: { | |
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 | ||
228 | y: { | |
229 | beginAtZero: true, | |
230 | ||
231 | max: this.activeGraphId === 'retention' | |
232 | ? 100 | |
233 | : undefined, | |
234 | ||
235 | ticks: { | |
236 | callback: value => this.formatYTick({ graphId, value }) | |
237 | } | |
238 | } | |
239 | }, | |
240 | ||
241 | plugins: { | |
242 | legend: { | |
243 | display: displayLegend | |
244 | }, | |
245 | tooltip: { | |
246 | callbacks: { | |
247 | title: items => this.formatTooltipTitle({ graphId, items }), | |
248 | label: value => this.formatYTick({ graphId, value: value.raw as number | string }) | |
249 | } | |
250 | }, | |
251 | ||
252 | ...plugins | |
253 | } | |
254 | } | |
255 | } | |
256 | } | |
257 | ||
258 | private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult { | |
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 | ||
272 | plugins: { | |
273 | ...this.buildDisabledZoomPlugin() | |
274 | }, | |
275 | ||
276 | data: { | |
277 | labels, | |
278 | datasets: [ | |
279 | { | |
280 | data, | |
281 | borderColor: this.buildChartColor() | |
282 | } | |
283 | ] | |
284 | } | |
285 | } | |
286 | } | |
287 | ||
288 | private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult { | |
289 | const labels: string[] = [] | |
290 | const data: number[] = [] | |
291 | ||
292 | for (const d of rawData.data) { | |
293 | labels.push(d.date) | |
294 | data.push(d.value) | |
295 | } | |
296 | ||
297 | return { | |
298 | type: 'line' as 'line', | |
299 | ||
300 | displayLegend: false, | |
301 | ||
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 | ||
327 | data: { | |
328 | labels, | |
329 | datasets: [ | |
330 | { | |
331 | data, | |
332 | borderColor: this.buildChartColor() | |
333 | } | |
334 | ] | |
335 | } | |
336 | } | |
337 | } | |
338 | ||
339 | private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult { | |
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 | ||
353 | plugins: { | |
354 | ...this.buildDisabledZoomPlugin() | |
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 | ||
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 | ||
408 | if (graphId === 'retention') return value + ' %' | |
409 | if (graphId === 'aggregateWatchTime') return secondsToTime(+value) | |
410 | ||
411 | return value.toLocaleString() | |
412 | } | |
413 | ||
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 | ||
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 | } | |
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 | } | |
452 | } |