1
0
Fork 0
peertube/client/src/app/+stats/video/video-stats.component.ts

453 lines
11 KiB
TypeScript
Raw Normal View History

2022-04-08 08:22:56 +00:00
import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
import zoomPlugin from 'chartjs-plugin-zoom'
2022-04-05 12:03:52 +00:00
import { Observable, of } from 'rxjs'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
2022-04-08 08:22:56 +00:00
import { Notifier, PeerTubeRouterService } from '@app/core'
2022-04-05 12:03:52 +00:00
import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
import { secondsToTime } from '@shared/core-utils'
import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
import { VideoStatsService } from './video-stats.service'
type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
type CountryData = { name: string, viewers: number }[]
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
type ChartBuilderResult = {
type: 'line' | 'bar'
2022-04-08 08:22:56 +00:00
plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
2022-04-05 12:03:52 +00:00
data: ChartData<'line' | 'bar'>
displayLegend: boolean
}
@Component({
templateUrl: './video-stats.component.html',
styleUrls: [ './video-stats.component.scss' ],
providers: [ NumberFormatterPipe ]
})
export class VideoStatsComponent implements OnInit {
overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = []
chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
chartHeight = '300px'
chartWidth: string = null
availableCharts = [
{
id: 'viewers',
2022-04-08 08:22:56 +00:00
label: $localize`Viewers`,
zoomEnabled: true
2022-04-05 12:03:52 +00:00
},
{
id: 'aggregateWatchTime',
2022-04-08 08:22:56 +00:00
label: $localize`Watch time`,
zoomEnabled: true
2022-04-05 12:03:52 +00:00
},
{
id: 'retention',
2022-04-08 08:22:56 +00:00
label: $localize`Retention`,
zoomEnabled: false
2022-04-05 12:03:52 +00:00
},
{
id: 'countries',
2022-04-08 08:22:56 +00:00
label: $localize`Countries`,
zoomEnabled: false
2022-04-05 12:03:52 +00:00
}
]
activeGraphId: ActiveGraphId = 'viewers'
video: VideoDetails
countries: CountryData = []
2022-04-08 08:22:56 +00:00
chartPlugins = [ zoomPlugin ]
private timeseriesStartDate: Date
private timeseriesEndDate: Date
private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
2022-04-05 12:03:52 +00:00
constructor (
private route: ActivatedRoute,
private notifier: Notifier,
private statsService: VideoStatsService,
2022-04-08 08:22:56 +00:00
private peertubeRouter: PeerTubeRouterService,
2022-04-05 12:03:52 +00:00
private numberFormatter: NumberFormatterPipe
) {}
ngOnInit () {
this.video = this.route.snapshot.data.video
2022-04-08 08:22:56 +00:00
this.route.queryParams.subscribe(params => {
this.timeseriesStartDate = params.startDate
? new Date(params.startDate)
: undefined
this.timeseriesEndDate = params.endDate
? new Date(params.endDate)
: undefined
this.loadChart()
})
2022-04-05 12:03:52 +00:00
this.loadOverallStats()
}
hasCountries () {
return this.countries.length !== 0
}
onChartChange (newActive: ActiveGraphId) {
this.activeGraphId = newActive
this.loadChart()
}
2022-04-08 08:22:56 +00:00
resetZoom () {
this.peertubeRouter.silentNavigate([], {})
}
hasZoom () {
return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId)
}
private isTimeserieGraph (graphId: ActiveGraphId) {
return graphId === 'aggregateWatchTime' || graphId === 'viewers'
}
2022-04-05 12:03:52 +00:00
private loadOverallStats () {
this.statsService.getOverallStats(this.video.uuid)
.subscribe({
next: res => {
this.countries = res.countries.slice(0, 10).map(c => ({
name: this.countryCodeToName(c.isoCode),
viewers: c.viewers
}))
this.buildOverallStatCard(res)
},
error: err => this.notifier.error(err.message)
})
}
private buildOverallStatCard (overallStats: VideoStatsOverall) {
this.overallStatCards = [
{
label: $localize`Views`,
value: this.numberFormatter.transform(overallStats.views)
},
{
label: $localize`Comments`,
value: this.numberFormatter.transform(overallStats.comments)
},
{
label: $localize`Likes`,
value: this.numberFormatter.transform(overallStats.likes)
},
{
label: $localize`Average watch time`,
value: secondsToTime(overallStats.averageWatchTime)
},
{
label: $localize`Peak viewers`,
value: this.numberFormatter.transform(overallStats.viewersPeak),
moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
}
]
}
private loadChart () {
const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
retention: this.statsService.getRetentionStats(this.video.uuid),
2022-04-08 08:22:56 +00:00
aggregateWatchTime: this.statsService.getTimeserieStats({
videoId: this.video.uuid,
startDate: this.timeseriesStartDate,
endDate: this.timeseriesEndDate,
metric: 'aggregateWatchTime'
}),
viewers: this.statsService.getTimeserieStats({
videoId: this.video.uuid,
startDate: this.timeseriesStartDate,
endDate: this.timeseriesEndDate,
metric: 'viewers'
}),
2022-04-05 12:03:52 +00:00
countries: of(this.countries)
}
obsBuilders[this.activeGraphId].subscribe({
next: res => {
2022-04-08 08:22:56 +00:00
this.chartIngestData[this.activeGraphId] = res
this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
2022-04-05 12:03:52 +00:00
},
error: err => this.notifier.error(err.message)
})
}
2022-04-08 08:22:56 +00:00
private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
2022-04-05 12:03:52 +00:00
const dataBuilders: {
[ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
} = {
retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
}
2022-04-08 08:22:56 +00:00
const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
const self = this
2022-04-05 12:03:52 +00:00
return {
type,
data,
options: {
responsive: true,
scales: {
2022-04-08 08:22:56 +00:00
x: {
ticks: {
callback: function (value) {
return self.formatXTick({
graphId,
value,
data: self.chartIngestData[graphId] as VideoStatsTimeserie,
scale: this
})
}
}
},
2022-04-05 12:03:52 +00:00
y: {
beginAtZero: true,
max: this.activeGraphId === 'retention'
? 100
: undefined,
ticks: {
2022-04-08 08:22:56 +00:00
callback: value => this.formatYTick({ graphId, value })
2022-04-05 12:03:52 +00:00
}
}
},
plugins: {
legend: {
display: displayLegend
},
tooltip: {
callbacks: {
2022-04-08 08:22:56 +00:00
title: items => this.formatTooltipTitle({ graphId, items }),
label: value => this.formatYTick({ graphId, value: value.raw as number | string })
2022-04-05 12:03:52 +00:00
}
2022-04-08 08:22:56 +00:00
},
...plugins
2022-04-05 12:03:52 +00:00
}
}
}
}
2022-04-08 08:22:56 +00:00
private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
2022-04-05 12:03:52 +00:00
const labels: string[] = []
const data: number[] = []
for (const d of rawData.data) {
labels.push(secondsToTime(d.second))
data.push(d.retentionPercent)
}
return {
type: 'line' as 'line',
displayLegend: false,
2022-04-08 08:22:56 +00:00
plugins: {
...this.buildDisabledZoomPlugin()
},
2022-04-05 12:03:52 +00:00
data: {
labels,
datasets: [
{
data,
borderColor: this.buildChartColor()
}
]
}
}
}
2022-04-08 08:22:56 +00:00
private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
2022-04-05 12:03:52 +00:00
const labels: string[] = []
const data: number[] = []
for (const d of rawData.data) {
2022-04-08 08:22:56 +00:00
labels.push(d.date)
2022-04-05 12:03:52 +00:00
data.push(d.value)
}
return {
type: 'line' as 'line',
displayLegend: false,
2022-04-08 08:22:56 +00:00
plugins: {
zoom: {
zoom: {
wheel: {
enabled: false
},
drag: {
enabled: true
},
pinch: {
enabled: true
},
mode: 'x',
onZoomComplete: ({ chart }) => {
const { min, max } = chart.scales.x
const startDate = rawData.data[min].date
const endDate = rawData.data[max].date
this.peertubeRouter.silentNavigate([], { startDate, endDate })
}
}
}
},
2022-04-05 12:03:52 +00:00
data: {
labels,
datasets: [
{
data,
borderColor: this.buildChartColor()
}
]
}
}
}
2022-04-08 08:22:56 +00:00
private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
2022-04-05 12:03:52 +00:00
const labels: string[] = []
const data: number[] = []
for (const d of rawData) {
labels.push(d.name)
data.push(d.viewers)
}
return {
type: 'bar' as 'bar',
displayLegend: true,
2022-04-08 08:22:56 +00:00
plugins: {
...this.buildDisabledZoomPlugin()
2022-04-05 12:03:52 +00:00
},
data: {
labels,
datasets: [
{
label: $localize`Viewers`,
backgroundColor: this.buildChartColor(),
maxBarThickness: 20,
data
}
]
}
}
}
private buildChartColor () {
return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
}
2022-04-08 08:22:56 +00:00
private formatXTick (options: {
graphId: ActiveGraphId
value: number | string
data: VideoStatsTimeserie
scale: Scale
}) {
const { graphId, value, data, scale } = options
const label = scale.getLabelForValue(value as number)
if (!this.isTimeserieGraph(graphId)) {
return label
}
const date = new Date(label)
if (data.groupInterval.match(/ days?$/)) {
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
}
if (data.groupInterval.match(/ hours?$/)) {
return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
}
return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
}
private formatYTick (options: {
graphId: ActiveGraphId
value: number | string
}) {
const { graphId, value } = options
2022-04-05 12:03:52 +00:00
if (graphId === 'retention') return value + ' %'
if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
return value.toLocaleString()
}
2022-04-08 08:22:56 +00:00
private formatTooltipTitle (options: {
graphId: ActiveGraphId
items: TooltipItem<any>[]
}) {
const { graphId, items } = options
const item = items[0]
if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString()
return item.label
}
2022-04-05 12:03:52 +00:00
private countryCodeToName (code: string) {
const intl: any = Intl
if (!intl.DisplayNames) return code
const regionNames = new intl.DisplayNames([], { type: 'region' })
return regionNames.of(code)
}
2022-04-08 08:22:56 +00:00
private buildDisabledZoomPlugin () {
return {
zoom: {
zoom: {
wheel: {
enabled: false
},
drag: {
enabled: false
},
pinch: {
enabled: false
}
}
}
}
}
2022-04-05 12:03:52 +00:00
}