]>
Commit | Line | Data |
---|---|---|
384ba8b7 C |
1 | import { ChartConfiguration, ChartData } from 'chart.js' |
2 | import { Observable, of } from 'rxjs' | |
3 | import { Component, OnInit } from '@angular/core' | |
4 | import { ActivatedRoute } from '@angular/router' | |
5 | import { Notifier } from '@app/core' | |
6 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' | |
7 | import { secondsToTime } from '@shared/core-utils' | |
8 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' | |
9 | import { VideoStatsService } from './video-stats.service' | |
10 | ||
11 | type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' | |
12 | ||
13 | type CountryData = { name: string, viewers: number }[] | |
14 | ||
15 | type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData | |
16 | type 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 | }) | |
27 | export 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 | } |