Specify file format for speedscope (#83)
This defines a JSON-based file format for speedscope. The motivation for is primarily two things: 1. To enable others to write tools to output profiles which can be read by speedscope 2. To enable others to write tools to handle the output of speedscope, leveraging the variety of importers that speedscope supports Fixes #65
This commit is contained in:
@@ -98,3 +98,5 @@ Once a profile has loaded, the main view is split into two: the top area is the
|
||||
* `2`: Switch to the "Left Heavy" view
|
||||
* `3`: Switch to the "Sandwich" view
|
||||
* `r`: Collapse recursion in the flamegraphs
|
||||
* `Cmd+S`/`Ctrl+S` to save the current profile
|
||||
* `Cmd+O`/`Ctrl+O` to open a new profile
|
||||
|
||||
+63
-11
@@ -18,6 +18,7 @@ import {Color} from './color'
|
||||
import {RowAtlas} from './row-atlas'
|
||||
import {importAsmJsSymbolMap} from './asm-js'
|
||||
import {SandwichView} from './sandwich-view'
|
||||
import {saveToFile} from './file-format'
|
||||
|
||||
const importModule = import('./import')
|
||||
// Force eager loading of the module
|
||||
@@ -62,6 +63,8 @@ interface ApplicationState {
|
||||
|
||||
interface ToolbarProps extends ApplicationState {
|
||||
setViewMode(order: ViewMode): void
|
||||
browseForFile(): void
|
||||
saveFile(): void
|
||||
}
|
||||
|
||||
export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
|
||||
@@ -78,6 +81,11 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const importFile = (
|
||||
<div className={css(style.toolbarTab)} onClick={this.props.browseForFile}>
|
||||
<span className={css(style.emoji)}>⤵️</span>Import
|
||||
</div>
|
||||
)
|
||||
const help = (
|
||||
<div className={css(style.toolbarTab)}>
|
||||
<a
|
||||
@@ -94,7 +102,10 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
|
||||
return (
|
||||
<div className={css(style.toolbar)}>
|
||||
🔬speedscope
|
||||
<div className={css(style.toolbarRight)}>{help}</div>
|
||||
<div className={css(style.toolbarRight)}>
|
||||
{importFile}
|
||||
{help}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -130,7 +141,13 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
|
||||
</div>
|
||||
</div>
|
||||
{this.props.profile.getName()}
|
||||
<div className={css(style.toolbarRight)}>{help}</div>
|
||||
<div className={css(style.toolbarRight)}>
|
||||
<div className={css(style.toolbarTab)} onClick={this.props.saveFile}>
|
||||
<span className={css(style.emoji)}>⤴️</span>Export
|
||||
</div>
|
||||
{importFile}
|
||||
{help}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -381,9 +398,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
|
||||
loadExample = () => {
|
||||
this.loadProfile(async () => {
|
||||
const filename = 'perf-vertx-stacks-01-collapsed-all.txt'
|
||||
return await fetch(exampleProfileURL)
|
||||
.then(resp => resp.text())
|
||||
.then(data => importProfile(filename, data))
|
||||
const data = await fetch(exampleProfileURL).then(resp => resp.text())
|
||||
const profile = await importProfile(filename, data)
|
||||
if (profile && !profile.getName()) {
|
||||
profile.setName(filename)
|
||||
}
|
||||
return profile
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,6 +465,31 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
|
||||
}
|
||||
}
|
||||
|
||||
private saveFile = () => {
|
||||
if (this.state.profile) {
|
||||
saveToFile(this.state.profile)
|
||||
}
|
||||
}
|
||||
|
||||
private browseForFile = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.addEventListener('change', this.onFileSelect)
|
||||
input.click()
|
||||
}
|
||||
|
||||
private onWindowKeyDown = async (ev: KeyboardEvent) => {
|
||||
// This has to be handled on key down in order to prevent the default
|
||||
// page save action.
|
||||
if (ev.key === 's' && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault()
|
||||
this.saveFile()
|
||||
} else if (ev.key === 'o' && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault()
|
||||
this.browseForFile()
|
||||
}
|
||||
}
|
||||
|
||||
onDocumentPaste = (ev: Event) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
@@ -454,11 +499,18 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('keydown', this.onWindowKeyDown)
|
||||
window.addEventListener('keypress', this.onWindowKeyPress)
|
||||
document.addEventListener('paste', this.onDocumentPaste)
|
||||
this.maybeLoadHashParamProfile()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('keydown', this.onWindowKeyDown)
|
||||
window.removeEventListener('keypress', this.onWindowKeyPress)
|
||||
document.removeEventListener('paste', this.onDocumentPaste)
|
||||
}
|
||||
|
||||
async maybeLoadHashParamProfile() {
|
||||
if (this.hashParams.profileURL) {
|
||||
if (!canUseXHR) {
|
||||
@@ -476,11 +528,6 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('keypress', this.onWindowKeyPress)
|
||||
document.removeEventListener('paste', this.onDocumentPaste)
|
||||
}
|
||||
|
||||
flamechartView: FlamechartView | null = null
|
||||
flamechartRef = (view: FlamechartView | null) => (this.flamechartView = view)
|
||||
subcomponents() {
|
||||
@@ -692,7 +739,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
|
||||
className={css(style.root, this.state.dragActive && style.dragTargetRoot)}
|
||||
>
|
||||
<GLCanvas setCanvasContext={this.setCanvasContext} />
|
||||
<Toolbar setViewMode={this.setViewMode} {...this.state} />
|
||||
<Toolbar
|
||||
setViewMode={this.setViewMode}
|
||||
saveFile={this.saveFile}
|
||||
browseForFile={this.browseForFile}
|
||||
{...this.state}
|
||||
/>
|
||||
<div className={css(style.contentContainer)}>{this.renderContent()}</div>
|
||||
{this.state.dragActive && <div className={css(style.dragTarget)} />}
|
||||
</div>
|
||||
|
||||
@@ -17,10 +17,13 @@ node_modules/.bin/tsc --noEmit
|
||||
rm -rf "$OUTDIR"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
# Place info about the current commit into the build dir to easiy identify releases
|
||||
# Place info about the current commit into the build dir to easily identify releases
|
||||
date > "$OUTDIR"/release.txt
|
||||
git rev-parse HEAD >> "$OUTDIR"/release.txt
|
||||
|
||||
# Place a json schema for the file format into the build directory too
|
||||
node generate-file-format-schema-json.js > "$OUTDIR"/file-format-schema.json
|
||||
|
||||
# Build the compiled assets
|
||||
node_modules/.bin/parcel build index.html --no-cache --out-dir "$OUTDIR" --public-url "./" --detailed-report
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// This file contains types which specify the speedscope file format.
|
||||
|
||||
export namespace FileFormat {
|
||||
export interface File {
|
||||
version: string
|
||||
$schema: 'https://www.speedscope.app/file-format-schema.json'
|
||||
shared: {
|
||||
frames: Frame[]
|
||||
}
|
||||
profiles: EventedProfile[]
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
name: string
|
||||
file?: string
|
||||
line?: number
|
||||
col?: number
|
||||
}
|
||||
|
||||
export enum ProfileType {
|
||||
EVENTED = 'evented',
|
||||
}
|
||||
|
||||
export interface IProfile {
|
||||
type: ProfileType
|
||||
}
|
||||
|
||||
export interface EventedProfile extends IProfile {
|
||||
// Type of profile. This will future proof the file format to allow many
|
||||
// different kinds of profiles to be contained and each type to be part of
|
||||
// a discriminated union.
|
||||
type: ProfileType.EVENTED
|
||||
|
||||
// Name of the profile. Typically a filename for the source of the profile.
|
||||
name: string
|
||||
|
||||
// Unit which all value are specified using in the profile.
|
||||
unit: ValueUnit
|
||||
|
||||
// The starting value of the profile. This will typically be a timestamp.
|
||||
// All event values will be relative to this startValue.
|
||||
startValue: number
|
||||
|
||||
// The final value of the profile. This will typically be a timestamp. This
|
||||
// must be greater than or equal to the startValue. This is useful in
|
||||
// situations where the recorded profile extends past the end of the recorded
|
||||
// events, which may happen if nothing was happening at the end of the
|
||||
// profile.
|
||||
endValue: number
|
||||
|
||||
// List of events that occured as part of this profile.
|
||||
// The "at" field of every event must be in non-decreasing order.
|
||||
events: (OpenFrameEvent | CloseFrameEvent)[]
|
||||
}
|
||||
|
||||
export type ValueUnit =
|
||||
| 'none'
|
||||
| 'nanoseconds'
|
||||
| 'microseconds'
|
||||
| 'milliseconds'
|
||||
| 'seconds'
|
||||
| 'bytes'
|
||||
|
||||
export enum EventType {
|
||||
OPEN_FRAME = 'O',
|
||||
CLOSE_FRAME = 'C',
|
||||
}
|
||||
|
||||
interface IEvent {
|
||||
type: EventType
|
||||
at: number
|
||||
}
|
||||
|
||||
// Indicates a stack frame opened. Every opened stack frame must have a
|
||||
// corresponding close frame event, and the ordering must be balanced.
|
||||
interface OpenFrameEvent extends IEvent {
|
||||
type: EventType.OPEN_FRAME
|
||||
// An index into the frames array in the shared data within the profile
|
||||
frame: number
|
||||
}
|
||||
|
||||
interface CloseFrameEvent extends IEvent {
|
||||
type: EventType.CLOSE_FRAME
|
||||
// An index into the frames array in the shared data within the profile
|
||||
frame: number
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import {Profile, CallTreeNode, Frame, CallTreeProfileBuilder, FrameInfo} from './profile'
|
||||
import {TimeFormatter, ByteFormatter, RawValueFormatter} from './value-formatters'
|
||||
import {FileFormat} from './file-format-spec'
|
||||
|
||||
export function exportProfile(profile: Profile): FileFormat.File {
|
||||
const frames: FileFormat.Frame[] = []
|
||||
|
||||
const eventedProfile: FileFormat.EventedProfile = {
|
||||
type: FileFormat.ProfileType.EVENTED,
|
||||
name: profile.getName(),
|
||||
unit: profile.getWeightUnit(),
|
||||
startValue: 0,
|
||||
endValue: profile.getTotalWeight(),
|
||||
events: [],
|
||||
}
|
||||
|
||||
const file: FileFormat.File = {
|
||||
version: '0.0.1',
|
||||
$schema: 'https://www.speedscope.app/file-format-schema.json',
|
||||
shared: {frames},
|
||||
profiles: [eventedProfile],
|
||||
}
|
||||
|
||||
const indexForFrame = new Map<Frame, number>()
|
||||
function getIndexForFrame(frame: Frame): number {
|
||||
let index = indexForFrame.get(frame)
|
||||
if (index == null) {
|
||||
const serializedFrame: FileFormat.Frame = {
|
||||
name: frame.name,
|
||||
}
|
||||
if (frame.file != null) serializedFrame.file = frame.file
|
||||
if (frame.line != null) serializedFrame.line = frame.line
|
||||
if (frame.col != null) serializedFrame.col = frame.col
|
||||
|
||||
index = frames.length
|
||||
indexForFrame.set(frame, index)
|
||||
frames.push(serializedFrame)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
const openFrame = (node: CallTreeNode, value: number) => {
|
||||
eventedProfile.events.push({
|
||||
type: FileFormat.EventType.OPEN_FRAME,
|
||||
frame: getIndexForFrame(node.frame),
|
||||
at: value,
|
||||
})
|
||||
}
|
||||
const closeFrame = (node: CallTreeNode, value: number) => {
|
||||
eventedProfile.events.push({
|
||||
type: FileFormat.EventType.CLOSE_FRAME,
|
||||
frame: getIndexForFrame(node.frame),
|
||||
at: value,
|
||||
})
|
||||
}
|
||||
profile.forEachCall(openFrame, closeFrame)
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
function importSpeedscopeProfile(
|
||||
serialized: FileFormat.EventedProfile,
|
||||
frames: FileFormat.Frame[],
|
||||
): Profile {
|
||||
const {startValue, endValue, name, unit, events} = serialized
|
||||
|
||||
const profile = new CallTreeProfileBuilder(endValue - startValue)
|
||||
|
||||
switch (unit) {
|
||||
case 'nanoseconds':
|
||||
case 'microseconds':
|
||||
case 'milliseconds':
|
||||
case 'seconds':
|
||||
profile.setValueFormatter(new TimeFormatter(unit))
|
||||
break
|
||||
|
||||
case 'bytes':
|
||||
profile.setValueFormatter(new ByteFormatter())
|
||||
break
|
||||
|
||||
case 'none':
|
||||
profile.setValueFormatter(new RawValueFormatter())
|
||||
break
|
||||
}
|
||||
profile.setName(name)
|
||||
|
||||
const frameInfos: FrameInfo[] = frames.map((frame, i) => ({key: i, ...frame}))
|
||||
|
||||
for (let ev of events) {
|
||||
switch (ev.type) {
|
||||
case FileFormat.EventType.OPEN_FRAME: {
|
||||
profile.enterFrame(frameInfos[ev.frame], ev.at - startValue)
|
||||
break
|
||||
}
|
||||
case FileFormat.EventType.CLOSE_FRAME: {
|
||||
profile.leaveFrame(frameInfos[ev.frame], ev.at - startValue)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return profile.build()
|
||||
}
|
||||
|
||||
export function importSingleSpeedscopeProfile(serialized: FileFormat.File): Profile {
|
||||
if (serialized.profiles.length !== 1) {
|
||||
throw new Error(`Unexpected profiles length ${serialized.profiles}`)
|
||||
}
|
||||
return importSpeedscopeProfile(serialized.profiles[0], serialized.shared.frames)
|
||||
}
|
||||
|
||||
export function saveToFile(profile: Profile): void {
|
||||
const blob = new Blob([JSON.stringify(exportProfile(profile))], {type: 'text/json'})
|
||||
|
||||
const nameWithoutExt = profile.getName().split('.')[0]!
|
||||
const filename = `${nameWithoutExt.replace(/\W+/g, '_')}.speedscope.json`
|
||||
|
||||
console.log('Saving', filename)
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.download = filename
|
||||
a.href = window.URL.createObjectURL(blob)
|
||||
a.dataset.downloadurl = ['text/json', a.download, a.href].join(':')
|
||||
|
||||
// For this to work in Firefox, the <a> must be in the DOM
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
@@ -637,7 +637,11 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
|
||||
} else if (ev.key === '-' || ev.key === '_') {
|
||||
this.zoom(new Vec2(width / 2, height / 2), 2)
|
||||
ev.preventDefault()
|
||||
} else if (ev.key === '0') {
|
||||
}
|
||||
|
||||
if (ev.ctrlKey || ev.shiftKey || ev.metaKey) return
|
||||
|
||||
if (ev.key === '0') {
|
||||
this.zoom(new Vec2(width / 2, height / 2), 1e9)
|
||||
} else if (ev.key === 'ArrowRight' || ev.key === 'd') {
|
||||
this.pan(new Vec2(100, 0))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
const child_process = require('child_process')
|
||||
|
||||
// Convert the file-format-spec.ts file into a json schema file
|
||||
let jsonSchema = child_process.execSync(
|
||||
'node_modules/.bin/quicktype --lang schema ./file-format-spec.ts',
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
jsonSchema = JSON.parse(jsonSchema)
|
||||
jsonSchema['$ref'] = '#/definitions/FileFormat.File'
|
||||
console.log(JSON.stringify(jsonSchema, null, 4))
|
||||
+9
-2
@@ -6,10 +6,14 @@ import {importFromStackprof} from './stackprof'
|
||||
import {importFromInstrumentsDeepCopy, importFromInstrumentsTrace} from './instruments'
|
||||
import {importFromBGFlameGraph} from './bg-flamegraph'
|
||||
import {importFromFirefox} from './firefox'
|
||||
import {importSingleSpeedscopeProfile} from '../file-format'
|
||||
|
||||
export async function importProfile(fileName: string, contents: string): Promise<Profile | null> {
|
||||
// First pass: Check known file format names to infer the file type
|
||||
if (fileName.endsWith('.cpuprofile')) {
|
||||
if (fileName.endsWith('.speedscope.json')) {
|
||||
console.log('Importing as speedscope json file')
|
||||
return importSingleSpeedscopeProfile(JSON.parse(contents))
|
||||
} else if (fileName.endsWith('.cpuprofile')) {
|
||||
console.log('Importing as Chrome CPU Profile')
|
||||
return importFromChromeCPUProfile(JSON.parse(contents))
|
||||
} else if (fileName.endsWith('.chrome.json') || /Profile-\d{8}T\d{6}/.exec(fileName)) {
|
||||
@@ -32,7 +36,10 @@ export async function importProfile(fileName: string, contents: string): Promise
|
||||
parsed = JSON.parse(contents)
|
||||
} catch (e) {}
|
||||
if (parsed) {
|
||||
if (parsed['systemHost'] && parsed['systemHost']['name'] == 'Firefox') {
|
||||
if (parsed['$schema'] === 'https://www.speedscope.app/file-format-schema.json') {
|
||||
console.log('Importing as speedscope json file')
|
||||
return importSingleSpeedscopeProfile(parsed)
|
||||
} else if (parsed['systemHost'] && parsed['systemHost']['name'] == 'Firefox') {
|
||||
console.log('Importing as Firefox profile')
|
||||
return importFromFirefox(parsed)
|
||||
} else if (Array.isArray(parsed) && parsed[parsed.length - 1].name === 'CpuProfile') {
|
||||
|
||||
Generated
+980
-246
File diff suppressed because it is too large
Load Diff
+13
-12
@@ -22,25 +22,26 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^22.2.3",
|
||||
"@types/jszip": "^3.1.4",
|
||||
"@types/node": "^10.1.4",
|
||||
"@types/jest": "22.2.3",
|
||||
"@types/jszip": "3.1.4",
|
||||
"@types/node": "10.1.4",
|
||||
"@types/pako": "1.0.0",
|
||||
"aphrodite": "2.1.0",
|
||||
"coveralls": "^3.0.1",
|
||||
"eslint": "^4.19.1",
|
||||
"eslint-plugin-prettier": "^2.6.0",
|
||||
"jest": "^23.0.1",
|
||||
"jsverify": "^0.8.3",
|
||||
"jszip": "^3.1.5",
|
||||
"coveralls": "3.0.1",
|
||||
"eslint": "4.19.1",
|
||||
"eslint-plugin-prettier": "2.6.0",
|
||||
"jest": "23.0.1",
|
||||
"jsverify": "0.8.3",
|
||||
"jszip": "3.1.5",
|
||||
"pako": "1.0.6",
|
||||
"parcel-bundler": "1.9.2",
|
||||
"preact": "8.2.7",
|
||||
"prettier": "^1.12.0",
|
||||
"prettier": "1.12.0",
|
||||
"quicktype": "15.0.45",
|
||||
"regl": "1.3.1",
|
||||
"ts-jest": "^22.4.6",
|
||||
"ts-jest": "22.4.6",
|
||||
"typescript": "2.8.1",
|
||||
"typescript-eslint-parser": "^14.0.0",
|
||||
"typescript-eslint-parser": "14.0.0",
|
||||
"uglify-es": "3.2.2"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
+10
@@ -1,5 +1,6 @@
|
||||
import {lastOf, KeyedSet} from './utils'
|
||||
import {ValueFormatter, RawValueFormatter} from './value-formatters'
|
||||
import {FileFormat} from './file-format-spec'
|
||||
const demangleCppModule = import('./demangle-cpp')
|
||||
|
||||
// Force eager loading of the module
|
||||
@@ -119,6 +120,9 @@ export class Profile {
|
||||
setValueFormatter(f: ValueFormatter) {
|
||||
this.valueFormatter = f
|
||||
}
|
||||
getWeightUnit(): FileFormat.ValueUnit {
|
||||
return this.valueFormatter.unit
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.name
|
||||
@@ -223,6 +227,12 @@ export class Profile {
|
||||
this.frames.forEach(fn)
|
||||
}
|
||||
|
||||
forEachSample(fn: (sample: CallTreeNode, weight: number) => void) {
|
||||
for (let i = 0; i < this.samples.length; i++) {
|
||||
fn(this.samples[i], this.weights[i])
|
||||
}
|
||||
}
|
||||
|
||||
getProfileWithRecursionFlattened(): Profile {
|
||||
const builder = new CallTreeProfileBuilder()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import {Profile, CallTreeNode, Frame} from './profile'
|
||||
import {importProfile} from './import'
|
||||
import {exportProfile, importSingleSpeedscopeProfile} from './file-format'
|
||||
|
||||
interface DumpedProfile {
|
||||
stacks: string[]
|
||||
@@ -45,10 +46,22 @@ export function dumpProfile(profile: Profile): any {
|
||||
|
||||
export async function checkProfileSnapshot(filepath: string) {
|
||||
const input = fs.readFileSync(filepath, 'utf8')
|
||||
|
||||
const profile = await importProfile(path.basename(filepath), input)
|
||||
if (profile) {
|
||||
expect(dumpProfile(profile)).toMatchSnapshot()
|
||||
} else {
|
||||
fail('Failed to extract profile')
|
||||
return
|
||||
}
|
||||
|
||||
const exported = exportProfile(profile)
|
||||
const reimported = importSingleSpeedscopeProfile(exported)
|
||||
|
||||
expect(reimported.getName()).toEqual(profile.getName())
|
||||
expect(reimported.getTotalWeight()).toEqual(profile.getTotalWeight())
|
||||
expect(dumpProfile(reimported).stacks.join('\n')).toEqual(dumpProfile(profile).stacks.join('\n'))
|
||||
|
||||
const reexported = exportProfile(reimported)
|
||||
expect(exported).toEqual(reexported)
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,8 +1,12 @@
|
||||
import {FileFormat} from './file-format-spec'
|
||||
|
||||
export interface ValueFormatter {
|
||||
unit: FileFormat.ValueUnit
|
||||
format(v: number): string
|
||||
}
|
||||
|
||||
export class RawValueFormatter implements ValueFormatter {
|
||||
unit: FileFormat.ValueUnit = 'none'
|
||||
format(v: number) {
|
||||
return v.toLocaleString()
|
||||
}
|
||||
@@ -11,7 +15,7 @@ export class RawValueFormatter implements ValueFormatter {
|
||||
export class TimeFormatter implements ValueFormatter {
|
||||
private multiplier: number
|
||||
|
||||
constructor(unit: 'nanoseconds' | 'microseconds' | 'milliseconds' | 'seconds') {
|
||||
constructor(public unit: 'nanoseconds' | 'microseconds' | 'milliseconds' | 'seconds') {
|
||||
if (unit === 'nanoseconds') this.multiplier = 1e-9
|
||||
else if (unit === 'microseconds') this.multiplier = 1e-6
|
||||
else if (unit === 'milliseconds') this.multiplier = 1e-3
|
||||
@@ -30,6 +34,8 @@ export class TimeFormatter implements ValueFormatter {
|
||||
}
|
||||
|
||||
export class ByteFormatter implements ValueFormatter {
|
||||
unit: FileFormat.ValueUnit = 'bytes'
|
||||
|
||||
format(v: number) {
|
||||
if (v < 1024) return `${v.toFixed(0)} B`
|
||||
v /= 1024
|
||||
|
||||
Reference in New Issue
Block a user