]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - client/src/app/+stats/video/video-stats.component.ts
Support videos stats in client
[github/Chocobozzz/PeerTube.git] / client / src / app / +stats / video / video-stats.component.ts
CommitLineData
384ba8b7
C
1import { ChartConfiguration, ChartData } from 'chart.js'
2import { Observable, of } from 'rxjs'
3import { Component, OnInit } from '@angular/core'
4import { ActivatedRoute } from '@angular/router'
5import { Notifier } from '@app/core'
6import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
7import { secondsToTime } from '@shared/core-utils'
8import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
9import { VideoStatsService } from './video-stats.service'
10
11type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
12
13type CountryData = { name: string, viewers: number }[]
14
15type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
16type ChartBuilderResult = {
17 type: 'line' | 'bar'
18 data: ChartData<'line' | 'bar'>
19 displayLegend: boolean
20}
21
22@Component({
23 templateUrl: './video-stats.component.html',
24 styleUrls: [ './video-stats.component.scss' ],
25 providers: [ NumberFormatterPipe ]
26})
27export class VideoStatsComponent implements OnInit {
28 overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = []
29
30 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
31 chartHeight = '300px'
32 chartWidth: string = null
33
34 availableCharts = [
35 {
36 id: 'viewers',
37 label: $localize`Viewers`
38 },
39 {
40 id: 'aggregateWatchTime',
41 label: $localize`Watch time`
42 },
43 {
44 id: 'retention',
45 label: $localize`Retention`
46 },
47 {
48 id: 'countries',
49 label: $localize`Countries`
50 }
51 ]
52
53 activeGraphId: ActiveGraphId = 'viewers'
54
55 video: VideoDetails
56
57 countries: CountryData = []
58
59 constructor (
60 private route: ActivatedRoute,
61 private notifier: Notifier,
62 private statsService: VideoStatsService,
63 private numberFormatter: NumberFormatterPipe
64 ) {}
65
66 ngOnInit () {
67 this.video = this.route.snapshot.data.video
68
69 this.loadOverallStats()
70 this.loadChart()
71 }
72
73 hasCountries () {
74 return this.countries.length !== 0
75 }
76
77 onChartChange (newActive: ActiveGraphId) {
78 this.activeGraphId = newActive
79
80 this.loadChart()
81 }
82
83 private loadOverallStats () {
84 this.statsService.getOverallStats(this.video.uuid)
85 .subscribe({
86 next: res => {
87 this.countries = res.countries.slice(0, 10).map(c => ({
88 name: this.countryCodeToName(c.isoCode),
89 viewers: c.viewers
90 }))
91
92 this.buildOverallStatCard(res)
93 },
94
95 error: err => this.notifier.error(err.message)
96 })
97 }
98
99 private buildOverallStatCard (overallStats: VideoStatsOverall) {
100 this.overallStatCards = [
101 {
102 label: $localize`Views`,
103 value: this.numberFormatter.transform(overallStats.views)
104 },
105 {
106 label: $localize`Comments`,
107 value: this.numberFormatter.transform(overallStats.comments)
108 },
109 {
110 label: $localize`Likes`,
111 value: this.numberFormatter.transform(overallStats.likes)
112 },
113 {
114 label: $localize`Average watch time`,
115 value: secondsToTime(overallStats.averageWatchTime)
116 },
117 {
118 label: $localize`Peak viewers`,
119 value: this.numberFormatter.transform(overallStats.viewersPeak),
120 moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
121 }
122 ]
123 }
124
125 private loadChart () {
126 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
127 retention: this.statsService.getRetentionStats(this.video.uuid),
128 aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'),
129 viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'),
130 countries: of(this.countries)
131 }
132
133 obsBuilders[this.activeGraphId].subscribe({
134 next: res => {
135 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res)
136 },
137
138 error: err => this.notifier.error(err.message)
139 })
140 }
141
142 private buildChartOptions (
143 graphId: ActiveGraphId,
144 rawData: ChartIngestData
145 ): ChartConfiguration<'line' | 'bar'> {
146 const dataBuilders: {
147 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
148 } = {
149 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
150 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
151 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
152 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
153 }
154
155 const { type, data, displayLegend } = dataBuilders[graphId](rawData)
156
157 return {
158 type,
159 data,
160
161 options: {
162 responsive: true,
163
164 scales: {
165 y: {
166 beginAtZero: true,
167
168 max: this.activeGraphId === 'retention'
169 ? 100
170 : undefined,
171
172 ticks: {
173 callback: value => this.formatTick(graphId, value)
174 }
175 }
176 },
177
178 plugins: {
179 legend: {
180 display: displayLegend
181 },
182 tooltip: {
183 callbacks: {
184 label: value => this.formatTick(graphId, value.raw as number | string)
185 }
186 }
187 }
188 }
189 }
190 }
191
192 private buildRetentionChartOptions (rawData: VideoStatsRetention) {
193 const labels: string[] = []
194 const data: number[] = []
195
196 for (const d of rawData.data) {
197 labels.push(secondsToTime(d.second))
198 data.push(d.retentionPercent)
199 }
200
201 return {
202 type: 'line' as 'line',
203
204 displayLegend: false,
205
206 data: {
207 labels,
208 datasets: [
209 {
210 data,
211 borderColor: this.buildChartColor()
212 }
213 ]
214 }
215 }
216 }
217
218 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) {
219 const labels: string[] = []
220 const data: number[] = []
221
222 for (const d of rawData.data) {
223 labels.push(new Date(d.date).toLocaleDateString())
224 data.push(d.value)
225 }
226
227 return {
228 type: 'line' as 'line',
229
230 displayLegend: false,
231
232 data: {
233 labels,
234 datasets: [
235 {
236 data,
237 borderColor: this.buildChartColor()
238 }
239 ]
240 }
241 }
242 }
243
244 private buildCountryChartOptions (rawData: CountryData) {
245 const labels: string[] = []
246 const data: number[] = []
247
248 for (const d of rawData) {
249 labels.push(d.name)
250 data.push(d.viewers)
251 }
252
253 return {
254 type: 'bar' as 'bar',
255
256 displayLegend: true,
257
258 options: {
259 indexAxis: 'y'
260 },
261
262 data: {
263 labels,
264 datasets: [
265 {
266 label: $localize`Viewers`,
267 backgroundColor: this.buildChartColor(),
268 maxBarThickness: 20,
269 data
270 }
271 ]
272 }
273 }
274 }
275
276 private buildChartColor () {
277 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
278 }
279
280 private formatTick (graphId: ActiveGraphId, value: number | string) {
281 if (graphId === 'retention') return value + ' %'
282 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
283
284 return value.toLocaleString()
285 }
286
287 private countryCodeToName (code: string) {
288 const intl: any = Intl
289 if (!intl.DisplayNames) return code
290
291 const regionNames = new intl.DisplayNames([], { type: 'region' })
292
293 return regionNames.of(code)
294 }
295}