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'
12 type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
14 type CountryData = { name: string, viewers: number }[]
16 type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
17 type ChartBuilderResult = {
19 plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
20 data: ChartData<'line' | 'bar'>
21 displayLegend: boolean
25 templateUrl: './video-stats.component.html',
26 styleUrls: [ './video-stats.component.scss' ],
27 providers: [ NumberFormatterPipe ]
29 export class VideoStatsComponent implements OnInit {
30 overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = []
32 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
34 chartWidth: string = null
39 label: $localize`Viewers`,
43 id: 'aggregateWatchTime',
44 label: $localize`Watch time`,
49 label: $localize`Retention`,
54 label: $localize`Countries`,
59 activeGraphId: ActiveGraphId = 'viewers'
63 countries: CountryData = []
65 chartPlugins = [ zoomPlugin ]
67 private timeseriesStartDate: Date
68 private timeseriesEndDate: Date
70 private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
73 private route: ActivatedRoute,
74 private notifier: Notifier,
75 private statsService: VideoStatsService,
76 private peertubeRouter: PeerTubeRouterService,
77 private numberFormatter: NumberFormatterPipe
81 this.video = this.route.snapshot.data.video
83 this.route.queryParams.subscribe(params => {
84 this.timeseriesStartDate = params.startDate
85 ? new Date(params.startDate)
88 this.timeseriesEndDate = params.endDate
89 ? new Date(params.endDate)
95 this.loadOverallStats()
99 return this.countries.length !== 0
102 onChartChange (newActive: ActiveGraphId) {
103 this.activeGraphId = newActive
109 this.peertubeRouter.silentNavigate([], {})
113 return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId)
116 private isTimeserieGraph (graphId: ActiveGraphId) {
117 return graphId === 'aggregateWatchTime' || graphId === 'viewers'
120 private loadOverallStats () {
121 this.statsService.getOverallStats(this.video.uuid)
124 this.countries = res.countries.slice(0, 10).map(c => ({
125 name: this.countryCodeToName(c.isoCode),
129 this.buildOverallStatCard(res)
132 error: err => this.notifier.error(err.message)
136 private buildOverallStatCard (overallStats: VideoStatsOverall) {
137 this.overallStatCards = [
139 label: $localize`Views`,
140 value: this.numberFormatter.transform(overallStats.views)
143 label: $localize`Comments`,
144 value: this.numberFormatter.transform(overallStats.comments)
147 label: $localize`Likes`,
148 value: this.numberFormatter.transform(overallStats.likes)
151 label: $localize`Average watch time`,
152 value: secondsToTime(overallStats.averageWatchTime)
155 label: $localize`Peak viewers`,
156 value: this.numberFormatter.transform(overallStats.viewersPeak),
157 moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
162 private loadChart () {
163 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
164 retention: this.statsService.getRetentionStats(this.video.uuid),
166 aggregateWatchTime: this.statsService.getTimeserieStats({
167 videoId: this.video.uuid,
168 startDate: this.timeseriesStartDate,
169 endDate: this.timeseriesEndDate,
170 metric: 'aggregateWatchTime'
172 viewers: this.statsService.getTimeserieStats({
173 videoId: this.video.uuid,
174 startDate: this.timeseriesStartDate,
175 endDate: this.timeseriesEndDate,
179 countries: of(this.countries)
182 obsBuilders[this.activeGraphId].subscribe({
184 this.chartIngestData[this.activeGraphId] = res
186 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
189 error: err => this.notifier.error(err.message)
193 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
194 const dataBuilders: {
195 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
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)
203 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
217 callback: function (value) {
218 return self.formatXTick({
221 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
231 max: this.activeGraphId === 'retention'
236 callback: value => this.formatYTick({ graphId, value })
243 display: displayLegend
247 title: items => this.formatTooltipTitle({ graphId, items }),
248 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
258 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
259 const labels: string[] = []
260 const data: number[] = []
262 for (const d of rawData.data) {
263 labels.push(secondsToTime(d.second))
264 data.push(d.retentionPercent)
268 type: 'line' as 'line',
270 displayLegend: false,
273 ...this.buildDisabledZoomPlugin()
281 borderColor: this.buildChartColor()
288 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
289 const labels: string[] = []
290 const data: number[] = []
292 for (const d of rawData.data) {
298 type: 'line' as 'line',
300 displayLegend: false,
315 onZoomComplete: ({ chart }) => {
316 const { min, max } = chart.scales.x
318 const startDate = rawData.data[min].date
319 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
321 this.peertubeRouter.silentNavigate([], { startDate, endDate })
332 borderColor: this.buildChartColor()
339 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
340 const labels: string[] = []
341 const data: number[] = []
343 for (const d of rawData) {
349 type: 'bar' as 'bar',
354 ...this.buildDisabledZoomPlugin()
361 label: $localize`Viewers`,
362 backgroundColor: this.buildChartColor(),
371 private buildChartColor () {
372 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
375 private formatXTick (options: {
376 graphId: ActiveGraphId
377 value: number | string
378 data: VideoStatsTimeserie
381 const { graphId, value, data, scale } = options
383 const label = scale.getLabelForValue(value as number)
385 if (!this.isTimeserieGraph(graphId)) {
389 const date = new Date(label)
391 if (data.groupInterval.match(/ days?$/)) {
392 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
395 if (data.groupInterval.match(/ hours?$/)) {
396 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
399 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
402 private formatYTick (options: {
403 graphId: ActiveGraphId
404 value: number | string
406 const { graphId, value } = options
408 if (graphId === 'retention') return value + ' %'
409 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
411 return value.toLocaleString()
414 private formatTooltipTitle (options: {
415 graphId: ActiveGraphId
416 items: TooltipItem<any>[]
418 const { graphId, items } = options
419 const item = items[0]
421 if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString()
426 private countryCodeToName (code: string) {
427 const intl: any = Intl
428 if (!intl.DisplayNames) return code
430 const regionNames = new intl.DisplayNames([], { type: 'region' })
432 return regionNames.of(code)
435 private buildDisabledZoomPlugin () {
453 private buildZoomEndDate (groupInterval: string, last: string) {
454 const date = new Date(last)
456 // Remove parts of the date we don't need
457 if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
458 date.setHours(23, 59, 59)
459 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
460 date.setMinutes(59, 59)
465 return date.toISOString()