@vpalmisano/webrtcperf 4.4.12 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.min.js +1 -1
- package/build/src/app.js +12 -0
- package/build/src/app.js.map +1 -1
- package/build/src/config.js +2 -1
- package/build/src/config.js.map +1 -1
- package/build/src/docker.js +1 -0
- package/build/src/docker.js.map +1 -1
- package/build/src/plot.d.ts +76 -8
- package/build/src/plot.js +496 -32
- package/build/src/plot.js.map +1 -1
- package/build/src/scenarios.d.ts +7 -5
- package/build/src/scenarios.js +51 -8
- package/build/src/scenarios.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/app.ts +12 -0
- package/src/config.ts +2 -1
- package/src/docker.ts +1 -0
- package/src/plot.ts +525 -46
- package/src/scenarios.ts +65 -17
package/src/plot.ts
CHANGED
|
@@ -1,22 +1,102 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
|
|
1
3
|
import { logger } from './utils'
|
|
4
|
+
import json5 from 'json5'
|
|
5
|
+
import { formatThrottleRule, parseStatsFile, parseThrottleRule, StatsRow } from './scenarios'
|
|
6
|
+
import path from 'path'
|
|
2
7
|
|
|
3
8
|
const log = logger('webrtcperf:plot')
|
|
4
9
|
|
|
5
|
-
export type PlotData = {
|
|
6
|
-
x: (string | number)[]
|
|
7
|
-
y: number[]
|
|
8
|
-
label: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
10
|
export type PlotOptions = {
|
|
12
11
|
type?: string
|
|
13
12
|
title?: string
|
|
13
|
+
xLabel?: string
|
|
14
|
+
yLabel?: string
|
|
15
|
+
yMin?: number
|
|
16
|
+
yMax?: number
|
|
17
|
+
labels?: (string | number)[]
|
|
14
18
|
filePath?: string
|
|
15
|
-
min?: number
|
|
16
|
-
max?: number
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
export
|
|
21
|
+
export type PlotData = {
|
|
22
|
+
label: string
|
|
23
|
+
data: { x: number | string; y: number; yMin?: number; yMax?: number }[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SERIES_COLORS = [
|
|
27
|
+
'rgba(33, 150, 243, 1)',
|
|
28
|
+
'rgba(244, 67, 54, 1)',
|
|
29
|
+
'rgba(76, 175, 80, 1)',
|
|
30
|
+
'rgba(255, 193, 7, 1)',
|
|
31
|
+
'rgba(156, 39, 176, 1)',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export function plotConfig(options: PlotOptions, series: PlotData[]) {
|
|
35
|
+
log.debug('plotConfig')
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
type: (options.type || 'line') as any,
|
|
40
|
+
options: {
|
|
41
|
+
plugins: {
|
|
42
|
+
title: options.title
|
|
43
|
+
? {
|
|
44
|
+
display: true,
|
|
45
|
+
text: options.title,
|
|
46
|
+
}
|
|
47
|
+
: undefined,
|
|
48
|
+
zoom: {
|
|
49
|
+
pan: {
|
|
50
|
+
enabled: true,
|
|
51
|
+
mode: 'x',
|
|
52
|
+
modifierKey: 'ctrl',
|
|
53
|
+
},
|
|
54
|
+
zoom: {
|
|
55
|
+
drag: {
|
|
56
|
+
enabled: true,
|
|
57
|
+
},
|
|
58
|
+
mode: 'x',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
scales: {
|
|
63
|
+
x: {
|
|
64
|
+
type: 'category',
|
|
65
|
+
title: options.xLabel
|
|
66
|
+
? {
|
|
67
|
+
display: true,
|
|
68
|
+
text: options.xLabel,
|
|
69
|
+
}
|
|
70
|
+
: undefined,
|
|
71
|
+
},
|
|
72
|
+
y: {
|
|
73
|
+
type: 'linear',
|
|
74
|
+
title: options.yLabel
|
|
75
|
+
? {
|
|
76
|
+
display: true,
|
|
77
|
+
text: options.yLabel,
|
|
78
|
+
}
|
|
79
|
+
: undefined,
|
|
80
|
+
min: options.yMin,
|
|
81
|
+
max: options.yMax,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
data: {
|
|
86
|
+
labels: options.labels,
|
|
87
|
+
datasets: series.map((s, i) => ({
|
|
88
|
+
fill: false,
|
|
89
|
+
backgroundColor: SERIES_COLORS[i % SERIES_COLORS.length],
|
|
90
|
+
borderColor: SERIES_COLORS[i % SERIES_COLORS.length],
|
|
91
|
+
borderWidth: 1,
|
|
92
|
+
pointRadius: 0,
|
|
93
|
+
...s,
|
|
94
|
+
})),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function plot(options: PlotOptions, series: PlotData[]) {
|
|
20
100
|
const {
|
|
21
101
|
CategoryScale,
|
|
22
102
|
Chart,
|
|
@@ -28,10 +108,8 @@ export async function plot(data: PlotData, options: PlotOptions) {
|
|
|
28
108
|
PointElement,
|
|
29
109
|
Legend,
|
|
30
110
|
Title,
|
|
31
|
-
|
|
32
|
-
} =
|
|
33
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
-
const ErrorBarsPlugin = require('chartjs-chart-error-bars')
|
|
111
|
+
} = await import('chart.js')
|
|
112
|
+
const { BarWithErrorBar, BarWithErrorBarsController } = await import('chartjs-chart-error-bars')
|
|
35
113
|
Chart.register(
|
|
36
114
|
CategoryScale,
|
|
37
115
|
LineController,
|
|
@@ -42,44 +120,445 @@ export async function plot(data: PlotData, options: PlotOptions) {
|
|
|
42
120
|
PointElement,
|
|
43
121
|
Legend,
|
|
44
122
|
Title,
|
|
45
|
-
|
|
123
|
+
BarWithErrorBar,
|
|
124
|
+
BarWithErrorBarsController,
|
|
46
125
|
)
|
|
47
|
-
|
|
48
|
-
const { Canvas } = require('skia-canvas')
|
|
126
|
+
const { Canvas } = await import('skia-canvas')
|
|
49
127
|
|
|
50
128
|
log.debug('plot')
|
|
51
|
-
|
|
129
|
+
const config = plotConfig(options, series)
|
|
52
130
|
const canvas = new Canvas(1280, 720)
|
|
53
|
-
const chart = new Chart(canvas,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
131
|
+
const chart = new Chart(canvas as unknown as HTMLCanvasElement, config)
|
|
132
|
+
await canvas.toFile(options.filePath || 'plot.png', { format: 'png', matte: 'white' })
|
|
133
|
+
chart.destroy()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function plotHtml(options: PlotOptions, series: PlotData[]) {
|
|
137
|
+
log.debug('plotHtml')
|
|
138
|
+
const config = plotConfig(options, series)
|
|
139
|
+
|
|
140
|
+
const data = `
|
|
141
|
+
<!DOCTYPE html>
|
|
142
|
+
<html lang="en">
|
|
143
|
+
<head>
|
|
144
|
+
<meta charset="UTF-8">
|
|
145
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
146
|
+
<title>${options.title || 'Plot'}</title>
|
|
147
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
148
|
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-error-bars"></script>
|
|
149
|
+
<script src="https://cdn.jsdelivr.net/npm/hammerjs"></script>
|
|
150
|
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom"></script>
|
|
151
|
+
</head>
|
|
152
|
+
<body>
|
|
153
|
+
<div>
|
|
154
|
+
<canvas id="chart"></canvas>
|
|
155
|
+
</div>
|
|
156
|
+
<script>
|
|
157
|
+
const ctx = document.getElementById('chart');
|
|
158
|
+
const chart = new Chart(ctx, ${json5.stringify(config)});
|
|
159
|
+
chart.options.onClick = e => e.chart.resetZoom();
|
|
160
|
+
addEventListener('resize', () => chart.resize());
|
|
161
|
+
</script>
|
|
162
|
+
</body>
|
|
163
|
+
</html>`
|
|
164
|
+
await fs.promises.writeFile(options.filePath || 'plot.html', data)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function groupByParticipant(rows: StatsRow[]) {
|
|
168
|
+
const m = new Map<string, StatsRow[]>()
|
|
169
|
+
for (const r of rows) {
|
|
170
|
+
if (!r.participantName) continue
|
|
171
|
+
const p = r.participantName as string
|
|
172
|
+
if (!m.has(p)) m.set(p, [])
|
|
173
|
+
m.get(p)!.push(r)
|
|
174
|
+
}
|
|
175
|
+
return m
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function plotDetailedStatsDashboard(statsFile: string, outFile = 'plot.html') {
|
|
179
|
+
const rows = await parseStatsFile(statsFile)
|
|
180
|
+
if (rows.length === 0) {
|
|
181
|
+
log.warn('No stats found')
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const byParticipant = groupByParticipant(rows)
|
|
186
|
+
const participants = Array.from(byParticipant.keys()).sort()
|
|
187
|
+
|
|
188
|
+
type ChartSpec = { id: string; title?: string; yLabel?: string; datasets?: PlotData[]; width?: number }
|
|
189
|
+
const charts: ChartSpec[] = []
|
|
190
|
+
|
|
191
|
+
const build = (
|
|
192
|
+
id: string,
|
|
193
|
+
title?: string,
|
|
194
|
+
yLabel?: string,
|
|
195
|
+
processValue?: (value: number) => number,
|
|
196
|
+
width?: number,
|
|
197
|
+
) => {
|
|
198
|
+
const series: PlotData[] = []
|
|
199
|
+
if (!id.startsWith('_')) {
|
|
200
|
+
for (const [participant, rows] of byParticipant.entries()) {
|
|
201
|
+
const dataPerTrack = new Map<string, { x: number; y: number }[]>()
|
|
202
|
+
for (const r of rows) {
|
|
203
|
+
const trackId = (r.trackId as string) || ''
|
|
204
|
+
if (!dataPerTrack.has(trackId)) dataPerTrack.set(trackId, [])
|
|
205
|
+
const data = dataPerTrack.get(trackId)!
|
|
206
|
+
const v = r[id] as number
|
|
207
|
+
if (v !== undefined) data.push({ x: r.datetime as number, y: processValue ? processValue(v) : v })
|
|
208
|
+
}
|
|
209
|
+
dataPerTrack.forEach((data, trackId) => {
|
|
210
|
+
if (data.length) series.push({ label: `${participant}${trackId ? ` (${trackId})` : ''}`, data: data })
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
charts.push({ id, title, yLabel, datasets: series, width })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const kbps = (v: number) => v / 1000
|
|
218
|
+
const percent = (v: number) => v * 100
|
|
219
|
+
const ms = (v: number) => v * 1000
|
|
220
|
+
|
|
221
|
+
;[
|
|
222
|
+
{ id: '_throttle', title: 'Throttle settings' },
|
|
223
|
+
{ id: 'throttleUpRate', title: 'Throttle up rate', yLabel: 'Kbps', processValue: kbps, width: 2 },
|
|
224
|
+
{ id: 'throttleUpDelay', title: 'Throttle up delay', yLabel: 'ms', width: 2 },
|
|
225
|
+
{ id: 'throttleUpLoss', title: 'Throttle up loss', yLabel: '%', width: 2 },
|
|
226
|
+
{ id: 'throttleDownRate', title: 'Throttle down rate', yLabel: 'Kbps', processValue: kbps, width: 2 },
|
|
227
|
+
{ id: 'throttleDownDelay', title: 'Throttle down delay', yLabel: 'ms', width: 2 },
|
|
228
|
+
{ id: 'throttleDownLoss', title: 'Throttle down loss', yLabel: '%', width: 2 },
|
|
229
|
+
// Performance / Connectivity
|
|
230
|
+
{ id: '_performance', title: 'Performance / Connectivity' },
|
|
231
|
+
{ id: 'pageCpu', title: 'Page CPU', yLabel: '%' },
|
|
232
|
+
{ id: 'pageMemory', title: 'Page memory', yLabel: 'MB' },
|
|
233
|
+
{ id: 'peerConnectionConnectionTime', title: 'Peer connection connection time', yLabel: 's' },
|
|
234
|
+
{ id: 'peerConnectionDisconnectionTime', title: 'Peer connection disconnection time', yLabel: 's' },
|
|
235
|
+
// Sent audio
|
|
236
|
+
{ id: '_sentAudio', title: 'Sent audio' },
|
|
237
|
+
{ id: 'audioSentBitrates', title: 'Sent audio bitrate', yLabel: 'Kbps', processValue: kbps },
|
|
238
|
+
{ id: 'audioSentPacketsLossRate', title: 'Send audio loss', yLabel: '%', processValue: percent },
|
|
239
|
+
{ id: 'audioSentRoundTripTime', title: 'Send audio RTT', yLabel: 'ms', processValue: ms },
|
|
240
|
+
{ id: 'audioSentJitter', title: 'Send audio jitter', yLabel: 'ms', processValue: ms },
|
|
241
|
+
// Sent video
|
|
242
|
+
{ id: '_sentVideo', title: 'Sent video' },
|
|
243
|
+
{ id: 'videoSentBitrates', title: 'Sent video bitrate', yLabel: 'Kbps', processValue: kbps },
|
|
244
|
+
{ id: 'videoSentPacketsLossRate', title: 'Send video loss', yLabel: '%', processValue: percent },
|
|
245
|
+
{ id: 'videoSentRoundTripTime', title: 'Send video RTT', yLabel: 'ms', processValue: ms },
|
|
246
|
+
{ id: 'videoSentJitter', title: 'Send video jitter', yLabel: 'ms', processValue: ms },
|
|
247
|
+
{ id: 'videoSentWidth', title: 'Send video width', yLabel: 'px' },
|
|
248
|
+
{ id: 'videoSentHeight', title: 'Send video height', yLabel: 'px' },
|
|
249
|
+
{ id: 'videoSentFps', title: 'Send video framerate', yLabel: 'fps' },
|
|
250
|
+
{
|
|
251
|
+
id: 'transportSentAvailableOutgoingBitrate',
|
|
252
|
+
title: 'Send available bitrate',
|
|
253
|
+
yLabel: 'Kbps',
|
|
254
|
+
processValue: kbps,
|
|
67
255
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
256
|
+
{ id: 'videoQualityLimitationCpu', title: 'Send video CPU limitation', yLabel: '%' },
|
|
257
|
+
{ id: 'videoQualityLimitationBandwidth', title: 'Send video bandwidth limitation', yLabel: '%' },
|
|
258
|
+
{ id: 'videoFirCountReceived', title: 'Send video FIR count', yLabel: 'count' },
|
|
259
|
+
{ id: 'videoPliCountReceived', title: 'Send video PLI count', yLabel: 'count' },
|
|
260
|
+
// Sent screen
|
|
261
|
+
{ id: '_sentScreen', title: 'Sent screen' },
|
|
262
|
+
{ id: 'screenSentBitrates', title: 'Sent screen bitrate', yLabel: 'Kbps', processValue: kbps },
|
|
263
|
+
{ id: 'screenSentPacketsLossRate', title: 'Send screen loss', yLabel: '%', processValue: percent },
|
|
264
|
+
{ id: 'screenSentRoundTripTime', title: 'Send screen RTT', yLabel: 'ms', processValue: ms },
|
|
265
|
+
{ id: 'screenSentJitter', title: 'Send screen jitter', yLabel: 'ms', processValue: ms },
|
|
266
|
+
{ id: 'screenSentWidth', title: 'Send screen width', yLabel: 'px' },
|
|
267
|
+
{ id: 'screenSentHeight', title: 'Send screen height', yLabel: 'px' },
|
|
268
|
+
{ id: 'screenSentFps', title: 'Send screen framerate', yLabel: 'fps' },
|
|
269
|
+
{ id: '' },
|
|
270
|
+
{ id: 'screenQualityLimitationCpu', title: 'Send screen CPU limitation', yLabel: '%' },
|
|
271
|
+
{ id: 'screenQualityLimitationBandwidth', title: 'Send screen bandwidth limitation', yLabel: '%' },
|
|
272
|
+
{ id: 'screenFirCountReceived', title: 'Send screen FIR count', yLabel: 'count' },
|
|
273
|
+
{ id: 'screenPliCountReceived', title: 'Send screen PLI count', yLabel: 'count' },
|
|
274
|
+
// Recv audio
|
|
275
|
+
{ id: '_recvAudio', title: 'Recv audio' },
|
|
276
|
+
{ id: 'audioRecvBitrates', title: 'Recv audio bitrate', yLabel: 'Kbps', processValue: kbps },
|
|
277
|
+
{ id: 'audioRecvPacketsLossRate', title: 'Recv audio loss', yLabel: '%', processValue: percent },
|
|
278
|
+
{ id: 'audioRecvJitter', title: 'Recv audio jitter', yLabel: 'ms', processValue: ms },
|
|
279
|
+
{ id: 'audioRecvAvgJitterBufferDelay', title: 'Recv audio jitter buffer', yLabel: 'ms', processValue: ms },
|
|
280
|
+
{ id: 'audioRecvLevel', title: 'Recv audio level', yLabel: 'db' },
|
|
281
|
+
{ id: 'audioRecvConcealmentEvents', title: 'Recv audio concealment events', yLabel: 'count' },
|
|
282
|
+
{
|
|
283
|
+
id: 'audioRecvInsertedSamplesForDeceleration',
|
|
284
|
+
title: 'Recv audio inserted samples',
|
|
285
|
+
yLabel: 'count',
|
|
81
286
|
},
|
|
287
|
+
{
|
|
288
|
+
id: 'audioRecvRemovedSamplesForAcceleration',
|
|
289
|
+
title: 'Recv audio removed samples',
|
|
290
|
+
yLabel: 'count',
|
|
291
|
+
},
|
|
292
|
+
{ id: 'audioRecvEndToEndDelay', title: 'Recv audio end to end delay', yLabel: 'ms', processValue: ms },
|
|
293
|
+
// Recv video
|
|
294
|
+
{ id: '_recvVideo', title: 'Recv video' },
|
|
295
|
+
{ id: 'videoRecvBitrates', title: 'Recv video bitrate', yLabel: 'Kbps', processValue: kbps },
|
|
296
|
+
{ id: 'videoRecvPacketsLossRate', title: 'Recv video loss', yLabel: '%', processValue: percent },
|
|
297
|
+
{ id: 'videoRecvJitter', title: 'Recv video jitter', yLabel: 'ms', processValue: ms },
|
|
298
|
+
{ id: 'videoRecvAvgJitterBufferDelay', title: 'Recv video jitter buffer', yLabel: 'ms', processValue: ms },
|
|
299
|
+
{ id: 'videoRecvWidth', title: 'Recv video width', yLabel: 'px' },
|
|
300
|
+
{ id: 'videoRecvHeight', title: 'Recv video height', yLabel: 'px' },
|
|
301
|
+
{ id: 'videoRecvFps', title: 'Recv video framerate', yLabel: 'fps' },
|
|
302
|
+
{ id: 'videoTotalFreezesDuration', title: 'Recv video freezes', yLabel: 'count' },
|
|
303
|
+
{ id: 'videoFirCountSent', title: 'Recv video FIR sent', yLabel: 'count' },
|
|
304
|
+
{ id: 'videoPliCountSent', title: 'Recv video PLI sent', yLabel: 'count' },
|
|
305
|
+
{ id: 'videoRecvEndToEndDelay', title: 'Recv video end to end delay', yLabel: 'ms', processValue: ms },
|
|
306
|
+
{ id: '' },
|
|
307
|
+
// Recv screen
|
|
308
|
+
{ id: '_recvScreen', title: 'Recv screen' },
|
|
309
|
+
{ id: 'screenRecvBitrates', title: 'Recv screen bitrate', yLabel: 'Kbps', processValue: kbps },
|
|
310
|
+
{ id: 'screenRecvPacketsLossRate', title: 'Recv screen loss', yLabel: '%', processValue: percent },
|
|
311
|
+
{ id: 'screenRecvJitter', title: 'Recv screen jitter', yLabel: 'ms', processValue: ms },
|
|
312
|
+
{ id: 'screenRecvAvgJitterBufferDelay', title: 'Recv screen jitter buffer', yLabel: 'ms', processValue: ms },
|
|
313
|
+
{ id: 'screenRecvWidth', title: 'Recv screen width', yLabel: 'px' },
|
|
314
|
+
{ id: 'screenRecvHeight', title: 'Recv screen height', yLabel: 'px' },
|
|
315
|
+
{ id: 'screenRecvFps', title: 'Recv screen framerate', yLabel: 'fps' },
|
|
316
|
+
{ id: 'screenTotalFreezesDuration', title: 'Recv screen freezes', yLabel: 'count' },
|
|
317
|
+
{ id: 'screenFirCountSent', title: 'Recv screen FIR sent', yLabel: 'count' },
|
|
318
|
+
{ id: 'screenPliCountSent', title: 'Recv screen PLI sent', yLabel: 'count' },
|
|
319
|
+
{ id: 'screenRecvEndToEndDelay', title: 'Recv screen end to end delay', yLabel: 'ms', processValue: ms },
|
|
320
|
+
{ id: '' },
|
|
321
|
+
].forEach(graph => {
|
|
322
|
+
build(graph.id, graph.title, graph.yLabel, graph.processValue, graph.width)
|
|
82
323
|
})
|
|
83
|
-
|
|
84
|
-
|
|
324
|
+
|
|
325
|
+
const [_, id, scenario] = path.basename(path.dirname(statsFile)).split('_')
|
|
326
|
+
const description = formatThrottleRule(parseThrottleRule(scenario), true, false)
|
|
327
|
+
|
|
328
|
+
const data = `\
|
|
329
|
+
<!DOCTYPE html>
|
|
330
|
+
<html lang="en">
|
|
331
|
+
<head>
|
|
332
|
+
<meta charset="UTF-8">
|
|
333
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
334
|
+
<title>${id} (${description})</title>
|
|
335
|
+
<link rel="icon" href="https://raw.githubusercontent.com/vpalmisano/webrtcperf/devel/media/logo.svg">
|
|
336
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
337
|
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-error-bars"></script>
|
|
338
|
+
<script src="https://cdn.jsdelivr.net/npm/hammerjs"></script>
|
|
339
|
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom"></script>
|
|
340
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
341
|
+
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@7.x/css/materialdesignicons.min.css" rel="stylesheet">
|
|
342
|
+
<link href="https://cdn.jsdelivr.net/npm/vuetify@3.7.2/dist/vuetify.min.css" rel="stylesheet">
|
|
343
|
+
<script src="https://cdn.jsdelivr.net/npm/vuetify@3.7.2/dist/vuetify.min.js"></script>
|
|
344
|
+
</head>
|
|
345
|
+
<body>
|
|
346
|
+
<div id="app">
|
|
347
|
+
<v-app>
|
|
348
|
+
<v-main>
|
|
349
|
+
<v-app-bar color="primary" density="compact">
|
|
350
|
+
<v-app-bar-title><b>${id}</b> (${description})</v-app-bar-title>
|
|
351
|
+
<template v-slot:append>
|
|
352
|
+
<v-select color="primary" :items="participants" v-model="selected" density="compact" variant="solo" hide-details="auto"></v-select>
|
|
353
|
+
</template>
|
|
354
|
+
</v-app-bar>
|
|
355
|
+
<v-container fluid>
|
|
356
|
+
<v-row dense>
|
|
357
|
+
<v-col v-for="c in charts" :key="c.id" cols="12" :md="isExpanded(c.id) ? 12 : c.width || 3">
|
|
358
|
+
<template v-if="c.id">
|
|
359
|
+
<v-card color="primary" :variant="c.id.startsWith('_') ? 'tonal' : 'text'">
|
|
360
|
+
<v-card-title class="text-subtitle-1 d-flex align-center flex-nowrap" @click="toggleExpanded(c)" style="cursor: pointer;">
|
|
361
|
+
<span class="text-truncate">{{ c.title }}</span>
|
|
362
|
+
</v-card-title>
|
|
363
|
+
<v-card-text v-if="!c.id.startsWith('_')" style="min-height: 250px;">
|
|
364
|
+
<canvas :id="c.id"></canvas>
|
|
365
|
+
</v-card-text>
|
|
366
|
+
</v-card>
|
|
367
|
+
</template>
|
|
368
|
+
<template v-else>
|
|
369
|
+
<div class="empty-slot"></div>
|
|
370
|
+
</template>
|
|
371
|
+
</v-col>
|
|
372
|
+
</v-row>
|
|
373
|
+
</v-container>
|
|
374
|
+
<v-footer color="primary" density="compact">
|
|
375
|
+
<v-btn class="text-none" variant="text" density="compact" size="small" href="https://github.com/vpalmisano/webrtcperf" target="_blank">Generated with webrtcperf</v-btn>
|
|
376
|
+
</v-footer>
|
|
377
|
+
</v-main>
|
|
378
|
+
</v-app>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<script>
|
|
382
|
+
const { createApp, onMounted, nextTick, watch, ref } = Vue;
|
|
383
|
+
const vuetify = Vuetify.createVuetify();
|
|
384
|
+
const PARTICIPANTS = ${json5.stringify(['All', ...participants])};
|
|
385
|
+
const CHARTS = ${json5.stringify(charts)};
|
|
386
|
+
const SERIES_COLORS = ${json5.stringify(SERIES_COLORS)};
|
|
387
|
+
|
|
388
|
+
function fmtTime(v) {
|
|
389
|
+
const d = new Date(Number(v));
|
|
390
|
+
if (!isFinite(d.getTime())) return v;
|
|
391
|
+
const pad = n => String(n).padStart(2, '0');
|
|
392
|
+
return pad(d.getHours()) + ':' + pad(d.getMinutes())
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildDatasets(datasets, selected) {
|
|
396
|
+
const filtered = selected === 'All' ? datasets : datasets.filter(d => d.label.startsWith(selected));
|
|
397
|
+
return filtered.map((s, i) => ({
|
|
398
|
+
label: s.label,
|
|
399
|
+
data: s.data,
|
|
400
|
+
fill: false,
|
|
401
|
+
backgroundColor: SERIES_COLORS[i % SERIES_COLORS.length],
|
|
402
|
+
borderColor: SERIES_COLORS[i % SERIES_COLORS.length],
|
|
403
|
+
borderWidth: 1,
|
|
404
|
+
pointRadius: 0,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
createApp({
|
|
409
|
+
setup() {
|
|
410
|
+
const participants = ref(PARTICIPANTS);
|
|
411
|
+
const selected = ref('All');
|
|
412
|
+
const charts = ref(CHARTS);
|
|
413
|
+
const chartInstances = new Map();
|
|
414
|
+
const expanded = ref(new Set());
|
|
415
|
+
|
|
416
|
+
function onPanZoom({ chart }) {
|
|
417
|
+
const x = chart.scales.x;
|
|
418
|
+
if (!x) return;
|
|
419
|
+
// Sync zoom level across all charts
|
|
420
|
+
for (const { chart: otherChart } of chartInstances.values()) {
|
|
421
|
+
if (otherChart !== chart) {
|
|
422
|
+
const otherX = otherChart.scales.x;
|
|
423
|
+
if (otherX) {
|
|
424
|
+
otherX.options.min = x.min;
|
|
425
|
+
otherX.options.max = x.max;
|
|
426
|
+
otherChart.update('none');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function createPanel(chartSpec) {
|
|
433
|
+
const canvas = document.getElementById(chartSpec.id);
|
|
434
|
+
if (!canvas) return;
|
|
435
|
+
const ctx = canvas.getContext('2d');
|
|
436
|
+
const chart = new Chart(ctx, {
|
|
437
|
+
type: 'line',
|
|
438
|
+
options: {
|
|
439
|
+
maintainAspectRatio: false,
|
|
440
|
+
animation: false,
|
|
441
|
+
layout: {
|
|
442
|
+
padding: 0,
|
|
443
|
+
},
|
|
444
|
+
interaction: {
|
|
445
|
+
intersect: false,
|
|
446
|
+
mode: 'x',
|
|
447
|
+
},
|
|
448
|
+
plugins: {
|
|
449
|
+
tooltip: {
|
|
450
|
+
callbacks: {
|
|
451
|
+
title: (context) => {
|
|
452
|
+
if (context[0].parsed.x !== null) {
|
|
453
|
+
return fmtTime(context[0].parsed.x);
|
|
454
|
+
}
|
|
455
|
+
return '';
|
|
456
|
+
},
|
|
457
|
+
label: (context) => {
|
|
458
|
+
return context.parsed.y;
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
legend: {
|
|
463
|
+
display: true,
|
|
464
|
+
position: 'bottom',
|
|
465
|
+
align: 'start',
|
|
466
|
+
maxHeight: 50,
|
|
467
|
+
labels: {
|
|
468
|
+
boxWidth: 8,
|
|
469
|
+
boxHeight: 8,
|
|
470
|
+
font: {
|
|
471
|
+
size: 8,
|
|
472
|
+
lineHeight: 1,
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
zoom: {
|
|
477
|
+
pan: {
|
|
478
|
+
enabled: true,
|
|
479
|
+
mode: 'x',
|
|
480
|
+
modifierKey: 'ctrl',
|
|
481
|
+
onPan: onPanZoom,
|
|
482
|
+
},
|
|
483
|
+
zoom: {
|
|
484
|
+
drag: { enabled: true },
|
|
485
|
+
mode: 'x',
|
|
486
|
+
onZoom: onPanZoom,
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
scales: {
|
|
491
|
+
x: {
|
|
492
|
+
type: 'linear',
|
|
493
|
+
title: { display: false, text: 'Time' },
|
|
494
|
+
ticks: { display: true, callback: (value) => fmtTime(value) },
|
|
495
|
+
},
|
|
496
|
+
y: {
|
|
497
|
+
type: 'linear',
|
|
498
|
+
title: { display: true, text: chartSpec.yLabel },
|
|
499
|
+
min: 0,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
data: { datasets: buildDatasets(chartSpec.datasets, selected.value) },
|
|
504
|
+
});
|
|
505
|
+
chartInstances.set(chartSpec.id, { chart, spec: chartSpec });
|
|
506
|
+
|
|
507
|
+
canvas.addEventListener('contextmenu', e => {
|
|
508
|
+
e.preventDefault();
|
|
509
|
+
resetZoom();
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function rebuildCharts() {
|
|
514
|
+
for (const { chart } of chartInstances.values()) chart.destroy();
|
|
515
|
+
chartInstances.clear();
|
|
516
|
+
nextTick(() => charts.value.forEach(createPanel));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function applyFilter() {
|
|
520
|
+
for (const { chart, spec } of chartInstances.values()) {
|
|
521
|
+
chart.data.datasets = buildDatasets(spec.datasets, selected.value);
|
|
522
|
+
chart.update();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function resetZoom() {
|
|
527
|
+
for (const { chart } of chartInstances.values()) {
|
|
528
|
+
chart.resetZoom('none');
|
|
529
|
+
if (chart.scales.x) {
|
|
530
|
+
chart.scales.x.options.min = undefined;
|
|
531
|
+
chart.scales.x.options.max = undefined;
|
|
532
|
+
}
|
|
533
|
+
chart.update('none');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function toggleExpanded(c) {
|
|
538
|
+
const id = c.id;
|
|
539
|
+
const s = new Set(expanded.value);
|
|
540
|
+
if (s.has(id)) s.delete(id); else s.add(id);
|
|
541
|
+
expanded.value = s;
|
|
542
|
+
nextTick(() => { const entry = chartInstances.get(id); entry?.chart?.resize(); });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function isExpanded(id) {
|
|
546
|
+
return id.startsWith('_') || expanded.value.has(id);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
onMounted(() => {
|
|
550
|
+
nextTick(() => charts.value.forEach(createPanel));
|
|
551
|
+
window.addEventListener('resize', () => { for (const { chart } of chartInstances.values()) chart.resize(); });
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
watch(selected, () => applyFilter());
|
|
555
|
+
|
|
556
|
+
return { participants, selected, charts, resetZoom, toggleExpanded, isExpanded };
|
|
557
|
+
}
|
|
558
|
+
}).use(vuetify).mount('#app');
|
|
559
|
+
</script>
|
|
560
|
+
</body>
|
|
561
|
+
</html>`
|
|
562
|
+
|
|
563
|
+
await fs.promises.writeFile(outFile, data)
|
|
85
564
|
}
|