-import { ChartConfiguration, ChartData } from 'chart.js'
+import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
+import zoomPlugin from 'chartjs-plugin-zoom'
import { Observable, of } from 'rxjs'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
-import { Notifier } from '@app/core'
+import { Notifier, PeerTubeRouterService } from '@app/core'
import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
import { secondsToTime } from '@shared/core-utils'
import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
type ChartBuilderResult = {
type: 'line' | 'bar'
+ plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
data: ChartData<'line' | 'bar'>
displayLegend: boolean
}
availableCharts = [
{
id: 'viewers',
- label: $localize`Viewers`
+ label: $localize`Viewers`,
+ zoomEnabled: true
},
{
id: 'aggregateWatchTime',
- label: $localize`Watch time`
+ label: $localize`Watch time`,
+ zoomEnabled: true
},
{
id: 'retention',
- label: $localize`Retention`
+ label: $localize`Retention`,
+ zoomEnabled: false
},
{
id: 'countries',
- label: $localize`Countries`
+ label: $localize`Countries`,
+ zoomEnabled: false
}
]
countries: CountryData = []
+ chartPlugins = [ zoomPlugin ]
+
+ private timeseriesStartDate: Date
+ private timeseriesEndDate: Date
+
+ private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
+
constructor (
private route: ActivatedRoute,
private notifier: Notifier,
private statsService: VideoStatsService,
+ private peertubeRouter: PeerTubeRouterService,
private numberFormatter: NumberFormatterPipe
) {}
ngOnInit () {
this.video = this.route.snapshot.data.video
+ 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()
+ })
+
this.loadOverallStats()
- this.loadChart()
}
hasCountries () {
this.loadChart()
}
+ resetZoom () {
+ this.peertubeRouter.silentNavigate([], {})
+ }
+
+ hasZoom () {
+ return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId)
+ }
+
+ private isTimeserieGraph (graphId: ActiveGraphId) {
+ return graphId === 'aggregateWatchTime' || graphId === 'viewers'
+ }
+
private loadOverallStats () {
this.statsService.getOverallStats(this.video.uuid)
.subscribe({
private loadChart () {
const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
retention: this.statsService.getRetentionStats(this.video.uuid),
- aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'),
- viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'),
+
+ 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'
+ }),
+
countries: of(this.countries)
}
obsBuilders[this.activeGraphId].subscribe({
next: res => {
- this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res)
+ this.chartIngestData[this.activeGraphId] = res
+
+ this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
},
error: err => this.notifier.error(err.message)
})
}
- private buildChartOptions (
- graphId: ActiveGraphId,
- rawData: ChartIngestData
- ): ChartConfiguration<'line' | 'bar'> {
+ private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
const dataBuilders: {
[ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
} = {
countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
}
- const { type, data, displayLegend } = dataBuilders[graphId](rawData)
+ const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
+
+ const self = this
return {
type,
responsive: true,
scales: {
+ x: {
+ ticks: {
+ callback: function (value) {
+ return self.formatXTick({
+ graphId,
+ value,
+ data: self.chartIngestData[graphId] as VideoStatsTimeserie,
+ scale: this
+ })
+ }
+ }
+ },
+
y: {
beginAtZero: true,
: undefined,
ticks: {
- callback: value => this.formatTick(graphId, value)
+ callback: value => this.formatYTick({ graphId, value })
}
}
},
},
tooltip: {
callbacks: {
- label: value => this.formatTick(graphId, value.raw as number | string)
+ title: items => this.formatTooltipTitle({ graphId, items }),
+ label: value => this.formatYTick({ graphId, value: value.raw as number | string })
}
- }
+ },
+
+ ...plugins
}
}
}
}
- private buildRetentionChartOptions (rawData: VideoStatsRetention) {
+ private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
const labels: string[] = []
const data: number[] = []
displayLegend: false,
+ plugins: {
+ ...this.buildDisabledZoomPlugin()
+ },
+
data: {
labels,
datasets: [
}
}
- private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) {
+ private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
const labels: string[] = []
const data: number[] = []
for (const d of rawData.data) {
- labels.push(new Date(d.date).toLocaleDateString())
+ labels.push(d.date)
data.push(d.value)
}
displayLegend: false,
+ 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 })
+ }
+ }
+ }
+ },
+
data: {
labels,
datasets: [
}
}
- private buildCountryChartOptions (rawData: CountryData) {
+ private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
const labels: string[] = []
const data: number[] = []
displayLegend: true,
- options: {
- indexAxis: 'y'
+ plugins: {
+ ...this.buildDisabledZoomPlugin()
},
data: {
return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
}
- private formatTick (graphId: ActiveGraphId, value: number | string) {
+ 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
+
if (graphId === 'retention') return value + ' %'
if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
return value.toLocaleString()
}
+ 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
+ }
+
private countryCodeToName (code: string) {
const intl: any = Intl
if (!intl.DisplayNames) return code
return regionNames.of(code)
}
+
+ private buildDisabledZoomPlugin () {
+ return {
+ zoom: {
+ zoom: {
+ wheel: {
+ enabled: false
+ },
+ drag: {
+ enabled: false
+ },
+ pinch: {
+ enabled: false
+ }
+ }
+ }
+ }
+ }
}
import { logger } from '@server/helpers/logger'
-import { VideoStatsTimeserieGroupInterval } from '@shared/models'
function buildGroupByAndBoundaries (startDateString: string, endDateString: string) {
const startDate = new Date(startDateString)
const endDate = new Date(endDateString)
- const groupByMatrix: { [ id in VideoStatsTimeserieGroupInterval ]: string } = {
- one_day: '1 day',
- one_hour: '1 hour',
- ten_minutes: '10 minutes',
- one_minute: '1 minute'
- }
const groupInterval = buildGroupInterval(startDate, endDate)
logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate })
// Remove parts of the date we don't need
- if (groupInterval === 'one_day') {
+ if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
startDate.setHours(0, 0, 0, 0)
- } else if (groupInterval === 'one_hour') {
+ } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
startDate.setMinutes(0, 0, 0)
} else {
startDate.setSeconds(0, 0)
return {
groupInterval,
- sqlInterval: groupByMatrix[groupInterval],
startDate,
endDate
}
// ---------------------------------------------------------------------------
-function buildGroupInterval (startDate: Date, endDate: Date): VideoStatsTimeserieGroupInterval {
+function buildGroupInterval (startDate: Date, endDate: Date): string {
const aDay = 86400
const anHour = 3600
const aMinute = 60
const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000
- if (diffSeconds >= 6 * aDay) return 'one_day'
- if (diffSeconds >= 6 * anHour) return 'one_hour'
- if (diffSeconds >= 60 * aMinute) return 'ten_minutes'
+ if (diffSeconds >= 15 * aDay) return '1 day'
+ if (diffSeconds >= 8 * aDay) return '12 hours'
+ if (diffSeconds >= 4 * aDay) return '6 hours'
+ if (diffSeconds >= 15 * anHour) return '1 hour'
+ if (diffSeconds >= 180 * aMinute) return '10 minutes'
- return 'one_minute'
+ return '1 minute'
}
it('Should use a custom start/end date', async function () {
const now = new Date()
- const tenDaysAgo = new Date()
- tenDaysAgo.setDate(tenDaysAgo.getDate() - 9)
+ const twentyDaysAgo = new Date()
+ twentyDaysAgo.setDate(twentyDaysAgo.getDate() - 19)
const result = await servers[0].videoStats.getTimeserieStats({
videoId: vodVideoId,
metric: 'aggregateWatchTime',
- startDate: tenDaysAgo,
+ startDate: twentyDaysAgo,
endDate: now
})
- expect(result.groupInterval).to.equal('one_day')
- expect(result.data).to.have.lengthOf(10)
+ expect(result.groupInterval).to.equal('1 day')
+ expect(result.data).to.have.lengthOf(20)
const first = result.data[0]
- expect(new Date(first.date).toLocaleDateString()).to.equal(tenDaysAgo.toLocaleDateString())
+ expect(new Date(first.date).toLocaleDateString()).to.equal(twentyDaysAgo.toLocaleDateString())
expectInterval(result, 24 * 3600 * 1000)
expectTodayLastValue(result, 9)
endDate: now
})
- expect(result.groupInterval).to.equal('one_hour')
+ expect(result.groupInterval).to.equal('1 hour')
expect(result.data).to.have.length.above(24).and.below(50)
expectInterval(result, 3600 * 1000)
it('Should automatically group by ten minutes', async function () {
const now = new Date()
const twoHoursAgo = new Date()
- twoHoursAgo.setHours(twoHoursAgo.getHours() - 1)
+ twoHoursAgo.setHours(twoHoursAgo.getHours() - 4)
const result = await servers[0].videoStats.getTimeserieStats({
videoId: vodVideoId,
endDate: now
})
- expect(result.groupInterval).to.equal('ten_minutes')
- expect(result.data).to.have.length.above(6).and.below(18)
+ expect(result.groupInterval).to.equal('10 minutes')
+ expect(result.data).to.have.length.above(20).and.below(30)
expectInterval(result, 60 * 10 * 1000)
expectTodayLastValue(result, 9)
endDate: now
})
- expect(result.groupInterval).to.equal('one_minute')
+ expect(result.groupInterval).to.equal('1 minute')
expect(result.data).to.have.length.above(20).and.below(40)
expectInterval(result, 60 * 1000)