diff options
Diffstat (limited to 'client/src/app/+stats/video/video-stats.component.ts')
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.ts | 207 |
1 files changed, 182 insertions, 25 deletions
diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts index 05319539b..14db31ecf 100644 --- a/client/src/app/+stats/video/video-stats.component.ts +++ b/client/src/app/+stats/video/video-stats.component.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import { ChartConfiguration, ChartData } from 'chart.js' | 1 | import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' |
2 | import zoomPlugin from 'chartjs-plugin-zoom' | ||
2 | import { Observable, of } from 'rxjs' | 3 | import { Observable, of } from 'rxjs' |
3 | import { Component, OnInit } from '@angular/core' | 4 | import { Component, OnInit } from '@angular/core' |
4 | import { ActivatedRoute } from '@angular/router' | 5 | import { ActivatedRoute } from '@angular/router' |
5 | import { Notifier } from '@app/core' | 6 | import { Notifier, PeerTubeRouterService } from '@app/core' |
6 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' | 7 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' |
7 | import { secondsToTime } from '@shared/core-utils' | 8 | import { secondsToTime } from '@shared/core-utils' |
8 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' | 9 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' |
@@ -15,6 +16,7 @@ type CountryData = { name: string, viewers: number }[] | |||
15 | type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData | 16 | type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData |
16 | type ChartBuilderResult = { | 17 | type ChartBuilderResult = { |
17 | type: 'line' | 'bar' | 18 | type: 'line' | 'bar' |
19 | plugins: Partial<PluginOptionsByType<'line' | 'bar'>> | ||
18 | data: ChartData<'line' | 'bar'> | 20 | data: ChartData<'line' | 'bar'> |
19 | displayLegend: boolean | 21 | displayLegend: boolean |
20 | } | 22 | } |
@@ -34,19 +36,23 @@ export class VideoStatsComponent implements OnInit { | |||
34 | availableCharts = [ | 36 | availableCharts = [ |
35 | { | 37 | { |
36 | id: 'viewers', | 38 | id: 'viewers', |
37 | label: $localize`Viewers` | 39 | label: $localize`Viewers`, |
40 | zoomEnabled: true | ||
38 | }, | 41 | }, |
39 | { | 42 | { |
40 | id: 'aggregateWatchTime', | 43 | id: 'aggregateWatchTime', |
41 | label: $localize`Watch time` | 44 | label: $localize`Watch time`, |
45 | zoomEnabled: true | ||
42 | }, | 46 | }, |
43 | { | 47 | { |
44 | id: 'retention', | 48 | id: 'retention', |
45 | label: $localize`Retention` | 49 | label: $localize`Retention`, |
50 | zoomEnabled: false | ||
46 | }, | 51 | }, |
47 | { | 52 | { |
48 | id: 'countries', | 53 | id: 'countries', |
49 | label: $localize`Countries` | 54 | label: $localize`Countries`, |
55 | zoomEnabled: false | ||
50 | } | 56 | } |
51 | ] | 57 | ] |
52 | 58 | ||
@@ -56,18 +62,37 @@ export class VideoStatsComponent implements OnInit { | |||
56 | 62 | ||
57 | countries: CountryData = [] | 63 | countries: CountryData = [] |
58 | 64 | ||
65 | chartPlugins = [ zoomPlugin ] | ||
66 | |||
67 | private timeseriesStartDate: Date | ||
68 | private timeseriesEndDate: Date | ||
69 | |||
70 | private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {} | ||
71 | |||
59 | constructor ( | 72 | constructor ( |
60 | private route: ActivatedRoute, | 73 | private route: ActivatedRoute, |
61 | private notifier: Notifier, | 74 | private notifier: Notifier, |
62 | private statsService: VideoStatsService, | 75 | private statsService: VideoStatsService, |
76 | private peertubeRouter: PeerTubeRouterService, | ||
63 | private numberFormatter: NumberFormatterPipe | 77 | private numberFormatter: NumberFormatterPipe |
64 | ) {} | 78 | ) {} |
65 | 79 | ||
66 | ngOnInit () { | 80 | ngOnInit () { |
67 | this.video = this.route.snapshot.data.video | 81 | this.video = this.route.snapshot.data.video |
68 | 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 | |||
69 | this.loadOverallStats() | 95 | this.loadOverallStats() |
70 | this.loadChart() | ||
71 | } | 96 | } |
72 | 97 | ||
73 | hasCountries () { | 98 | hasCountries () { |
@@ -80,6 +105,18 @@ export class VideoStatsComponent implements OnInit { | |||
80 | this.loadChart() | 105 | this.loadChart() |
81 | } | 106 | } |
82 | 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 | |||
83 | private loadOverallStats () { | 120 | private loadOverallStats () { |
84 | this.statsService.getOverallStats(this.video.uuid) | 121 | this.statsService.getOverallStats(this.video.uuid) |
85 | .subscribe({ | 122 | .subscribe({ |
@@ -125,24 +162,35 @@ export class VideoStatsComponent implements OnInit { | |||
125 | private loadChart () { | 162 | private loadChart () { |
126 | const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = { | 163 | const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = { |
127 | retention: this.statsService.getRetentionStats(this.video.uuid), | 164 | retention: this.statsService.getRetentionStats(this.video.uuid), |
128 | aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'), | 165 | |
129 | viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'), | 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 | |||
130 | countries: of(this.countries) | 179 | countries: of(this.countries) |
131 | } | 180 | } |
132 | 181 | ||
133 | obsBuilders[this.activeGraphId].subscribe({ | 182 | obsBuilders[this.activeGraphId].subscribe({ |
134 | next: res => { | 183 | next: res => { |
135 | this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res) | 184 | this.chartIngestData[this.activeGraphId] = res |
185 | |||
186 | this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId) | ||
136 | }, | 187 | }, |
137 | 188 | ||
138 | error: err => this.notifier.error(err.message) | 189 | error: err => this.notifier.error(err.message) |
139 | }) | 190 | }) |
140 | } | 191 | } |
141 | 192 | ||
142 | private buildChartOptions ( | 193 | private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> { |
143 | graphId: ActiveGraphId, | ||
144 | rawData: ChartIngestData | ||
145 | ): ChartConfiguration<'line' | 'bar'> { | ||
146 | const dataBuilders: { | 194 | const dataBuilders: { |
147 | [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult | 195 | [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult |
148 | } = { | 196 | } = { |
@@ -152,7 +200,9 @@ export class VideoStatsComponent implements OnInit { | |||
152 | countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) | 200 | countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) |
153 | } | 201 | } |
154 | 202 | ||
155 | const { type, data, displayLegend } = dataBuilders[graphId](rawData) | 203 | const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId]) |
204 | |||
205 | const self = this | ||
156 | 206 | ||
157 | return { | 207 | return { |
158 | type, | 208 | type, |
@@ -162,6 +212,19 @@ export class VideoStatsComponent implements OnInit { | |||
162 | responsive: true, | 212 | responsive: true, |
163 | 213 | ||
164 | scales: { | 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 | |||
165 | y: { | 228 | y: { |
166 | beginAtZero: true, | 229 | beginAtZero: true, |
167 | 230 | ||
@@ -170,7 +233,7 @@ export class VideoStatsComponent implements OnInit { | |||
170 | : undefined, | 233 | : undefined, |
171 | 234 | ||
172 | ticks: { | 235 | ticks: { |
173 | callback: value => this.formatTick(graphId, value) | 236 | callback: value => this.formatYTick({ graphId, value }) |
174 | } | 237 | } |
175 | } | 238 | } |
176 | }, | 239 | }, |
@@ -181,15 +244,18 @@ export class VideoStatsComponent implements OnInit { | |||
181 | }, | 244 | }, |
182 | tooltip: { | 245 | tooltip: { |
183 | callbacks: { | 246 | callbacks: { |
184 | label: value => this.formatTick(graphId, value.raw as number | string) | 247 | title: items => this.formatTooltipTitle({ graphId, items }), |
248 | label: value => this.formatYTick({ graphId, value: value.raw as number | string }) | ||
185 | } | 249 | } |
186 | } | 250 | }, |
251 | |||
252 | ...plugins | ||
187 | } | 253 | } |
188 | } | 254 | } |
189 | } | 255 | } |
190 | } | 256 | } |
191 | 257 | ||
192 | private buildRetentionChartOptions (rawData: VideoStatsRetention) { | 258 | private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult { |
193 | const labels: string[] = [] | 259 | const labels: string[] = [] |
194 | const data: number[] = [] | 260 | const data: number[] = [] |
195 | 261 | ||
@@ -203,6 +269,10 @@ export class VideoStatsComponent implements OnInit { | |||
203 | 269 | ||
204 | displayLegend: false, | 270 | displayLegend: false, |
205 | 271 | ||
272 | plugins: { | ||
273 | ...this.buildDisabledZoomPlugin() | ||
274 | }, | ||
275 | |||
206 | data: { | 276 | data: { |
207 | labels, | 277 | labels, |
208 | datasets: [ | 278 | datasets: [ |
@@ -215,12 +285,12 @@ export class VideoStatsComponent implements OnInit { | |||
215 | } | 285 | } |
216 | } | 286 | } |
217 | 287 | ||
218 | private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) { | 288 | private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult { |
219 | const labels: string[] = [] | 289 | const labels: string[] = [] |
220 | const data: number[] = [] | 290 | const data: number[] = [] |
221 | 291 | ||
222 | for (const d of rawData.data) { | 292 | for (const d of rawData.data) { |
223 | labels.push(new Date(d.date).toLocaleDateString()) | 293 | labels.push(d.date) |
224 | data.push(d.value) | 294 | data.push(d.value) |
225 | } | 295 | } |
226 | 296 | ||
@@ -229,6 +299,31 @@ export class VideoStatsComponent implements OnInit { | |||
229 | 299 | ||
230 | displayLegend: false, | 300 | displayLegend: false, |
231 | 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 | |||
232 | data: { | 327 | data: { |
233 | labels, | 328 | labels, |
234 | datasets: [ | 329 | datasets: [ |
@@ -241,7 +336,7 @@ export class VideoStatsComponent implements OnInit { | |||
241 | } | 336 | } |
242 | } | 337 | } |
243 | 338 | ||
244 | private buildCountryChartOptions (rawData: CountryData) { | 339 | private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult { |
245 | const labels: string[] = [] | 340 | const labels: string[] = [] |
246 | const data: number[] = [] | 341 | const data: number[] = [] |
247 | 342 | ||
@@ -255,8 +350,8 @@ export class VideoStatsComponent implements OnInit { | |||
255 | 350 | ||
256 | displayLegend: true, | 351 | displayLegend: true, |
257 | 352 | ||
258 | options: { | 353 | plugins: { |
259 | indexAxis: 'y' | 354 | ...this.buildDisabledZoomPlugin() |
260 | }, | 355 | }, |
261 | 356 | ||
262 | data: { | 357 | data: { |
@@ -277,13 +372,57 @@ export class VideoStatsComponent implements OnInit { | |||
277 | return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') | 372 | return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') |
278 | } | 373 | } |
279 | 374 | ||
280 | private formatTick (graphId: ActiveGraphId, value: number | string) { | 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 | |||
281 | if (graphId === 'retention') return value + ' %' | 408 | if (graphId === 'retention') return value + ' %' |
282 | if (graphId === 'aggregateWatchTime') return secondsToTime(+value) | 409 | if (graphId === 'aggregateWatchTime') return secondsToTime(+value) |
283 | 410 | ||
284 | return value.toLocaleString() | 411 | return value.toLocaleString() |
285 | } | 412 | } |
286 | 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 | |||
287 | private countryCodeToName (code: string) { | 426 | private countryCodeToName (code: string) { |
288 | const intl: any = Intl | 427 | const intl: any = Intl |
289 | if (!intl.DisplayNames) return code | 428 | if (!intl.DisplayNames) return code |
@@ -292,4 +431,22 @@ export class VideoStatsComponent implements OnInit { | |||
292 | 431 | ||
293 | return regionNames.of(code) | 432 | return regionNames.of(code) |
294 | } | 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 | } | ||
295 | } | 452 | } |