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: overallStats.viewersPeak !== 0
158 ? $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
164 private loadChart () {
165 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
166 retention: this.statsService.getRetentionStats(this.video.uuid),
168 aggregateWatchTime: this.statsService.getTimeserieStats({
169 videoId: this.video.uuid,
170 startDate: this.timeseriesStartDate,
171 endDate: this.timeseriesEndDate,
172 metric: 'aggregateWatchTime'
174 viewers: this.statsService.getTimeserieStats({
175 videoId: this.video.uuid,
176 startDate: this.timeseriesStartDate,
177 endDate: this.timeseriesEndDate,
181 countries: of(this.countries)
184 obsBuilders[this.activeGraphId].subscribe({
186 this.chartIngestData[this.activeGraphId] = res
188 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
191 error: err => this.notifier.error(err.message)
195 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
196 const dataBuilders: {
197 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
199 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
200 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
201 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
202 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
205 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
219 callback: function (value) {
220 return self.formatXTick({
223 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
233 max: this.activeGraphId === 'retention'
238 callback: value => this.formatYTick({ graphId, value })
245 display: displayLegend
249 title: items => this.formatTooltipTitle({ graphId, items }),
250 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
260 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
261 const labels: string[] = []
262 const data: number[] = []
264 for (const d of rawData.data) {
265 labels.push(secondsToTime(d.second))
266 data.push(d.retentionPercent)
270 type: 'line' as 'line',
272 displayLegend: false,
275 ...this.buildDisabledZoomPlugin()
283 borderColor: this.buildChartColor()
290 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
291 const labels: string[] = []
292 const data: number[] = []
294 for (const d of rawData.data) {
300 type: 'line' as 'line',
302 displayLegend: false,
317 onZoomComplete: ({ chart }) => {
318 const { min, max } = chart.scales.x
320 const startDate = rawData.data[min].date
321 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
323 this.peertubeRouter.silentNavigate([], { startDate, endDate })
334 borderColor: this.buildChartColor()
341 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
342 const labels: string[] = []
343 const data: number[] = []
345 for (const d of rawData) {
351 type: 'bar' as 'bar',
356 ...this.buildDisabledZoomPlugin()
363 label: $localize`Viewers`,
364 backgroundColor: this.buildChartColor(),
373 private buildChartColor () {
374 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
377 private formatXTick (options: {
378 graphId: ActiveGraphId
379 value: number | string
380 data: VideoStatsTimeserie
383 const { graphId, value, data, scale } = options
385 const label = scale.getLabelForValue(value as number)
387 if (!this.isTimeserieGraph(graphId)) {
391 const date = new Date(label)
393 if (data.groupInterval.match(/ days?$/)) {
394 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
397 if (data.groupInterval.match(/ hours?$/)) {
398 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
401 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
404 private formatYTick (options: {
405 graphId: ActiveGraphId
406 value: number | string
408 const { graphId, value } = options
410 if (graphId === 'retention') return value + ' %'
411 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
413 return value.toLocaleString()
416 private formatTooltipTitle (options: {
417 graphId: ActiveGraphId
418 items: TooltipItem<any>[]
420 const { graphId, items } = options
421 const item = items[0]
423 if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString()
428 private countryCodeToName (code: string) {
429 const intl: any = Intl
430 if (!intl.DisplayNames) return code
432 const regionNames = new intl.DisplayNames([], { type: 'region' })
434 return regionNames.of(code)
437 private buildDisabledZoomPlugin () {
455 private buildZoomEndDate (groupInterval: string, last: string) {
456 const date = new Date(last)
458 // Remove parts of the date we don't need
459 if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
460 date.setHours(23, 59, 59)
461 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
462 date.setMinutes(59, 59)
467 return date.toISOString()