├── public ├── CNAME ├── favicon.ico ├── robots.txt ├── images │ └── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-144x144.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x150.png │ │ ├── mstile-310x310.png │ │ ├── mstile-70x70.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ └── safari-pinned-tab.svg ├── sitemap.xml ├── browserconfig.xml ├── manifest.webmanifest └── favicon.svg ├── src ├── main.ts ├── log-2-axis.ts ├── app.ts └── chart-builder.ts ├── docs └── images │ ├── chart.png │ └── bmc-48.svg ├── .gitignore ├── package.json ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ └── build-and-deploy.yml ├── README.md └── index.html /public/CNAME: -------------------------------------------------------------------------------- 1 | chartbenchmark.net -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { App } from "./app"; 2 | 3 | new App(); -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | 4 | Sitemap: https://chartbenchmark.net/sitemap.xml -------------------------------------------------------------------------------- /docs/images/chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/docs/images/chart.png -------------------------------------------------------------------------------- /public/images/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/icons/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/mstile-144x144.png -------------------------------------------------------------------------------- /public/images/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/images/icons/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/mstile-310x150.png -------------------------------------------------------------------------------- /public/images/icons/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/mstile-310x310.png -------------------------------------------------------------------------------- /public/images/icons/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/mstile-70x70.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/images/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yv989c/ChartsForBenchmarkDotNet/HEAD/public/images/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://chartbenchmark.net/ 5 | 6 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #333333 7 | 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "charts-for-benchmarkdotnet", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "typescript": "^5.0.2", 13 | "vite": "^4.3.2" 14 | }, 15 | "dependencies": { 16 | "chart.js": "^3.9.1", 17 | "lz-string": "^1.5.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { 4 | "src": "/images/icons/android-chrome-192x192.png", 5 | "sizes": "192x192", 6 | "type": "image/png" 7 | }, 8 | { 9 | "src": "/images/icons/android-chrome-512x512.png", 10 | "sizes": "512x512", 11 | "type": "image/png" 12 | } 13 | ], 14 | "theme_color": "#272b30", 15 | "background_color": "#272b30" 16 | } -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Carlos Villegas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/build-and-deploy.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['main'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: 'pages' 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Set up Node 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18 37 | cache: 'npm' 38 | - name: Install dependencies 39 | run: npm install 40 | - name: Build 41 | run: npm run build 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v3 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v1 46 | with: 47 | # Upload dist repository 48 | path: './dist' 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v1 52 | -------------------------------------------------------------------------------- /public/images/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 19 | 23 | 27 | 30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/log-2-axis.ts: -------------------------------------------------------------------------------- 1 | import { Scale, LinearScale } from "chart.js"; 2 | 3 | /** 4 | * https://www.chartjs.org/docs/master/samples/advanced/derived-axis-type.html#log2-axis-implementation 5 | */ 6 | export class Log2Axis extends Scale { 7 | static id = 'log2'; 8 | static defaults = {}; 9 | 10 | _startValue: number = 0; 11 | _valueRange: number = 0; 12 | 13 | constructor(cfg: any) { 14 | super(cfg); 15 | } 16 | 17 | parse(raw: unknown, index: number) { 18 | const value = LinearScale.prototype.parse.apply(this, [raw, index]); 19 | return isFinite(value) && value > 0 ? value : null; 20 | } 21 | 22 | determineDataLimits() { 23 | const { min, max } = this.getMinMax(true); 24 | this.min = (isFinite(min) ? Math.max(0, min) : null); 25 | this.max = (isFinite(max) ? Math.max(0, max) : null); 26 | } 27 | 28 | buildTicks() { 29 | const ticks = []; 30 | 31 | let power = Math.floor(Math.log2(this.min || 1)); 32 | let maxPower = Math.ceil(Math.log2(this.max || 2)); 33 | while (power <= maxPower) { 34 | ticks.push({ value: Math.pow(2, power) }); 35 | power += 1; 36 | } 37 | 38 | this.min = ticks[0].value; 39 | this.max = ticks[ticks.length - 1].value; 40 | return ticks; 41 | } 42 | 43 | configure() { 44 | const start = this.min; 45 | 46 | super.configure(); 47 | 48 | this._startValue = Math.log2(start); 49 | this._valueRange = Math.log2(this.max) - Math.log2(start); 50 | } 51 | 52 | getPixelForValue(value: number) { 53 | if (value === undefined || value === 0) { 54 | value = this.min; 55 | } 56 | 57 | return this.getPixelForDecimal(value === this.min ? 0 58 | : (Math.log2(value) - this._startValue) / this._valueRange); 59 | } 60 | 61 | getValueForPixel(pixel: number) { 62 | const decimal = this.getDecimalForPixel(pixel); 63 | return Math.pow(2, this._startValue + decimal * this._valueRange); 64 | } 65 | } 66 | 67 | Log2Axis.id = "log2"; 68 | Log2Axis.defaults = {}; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Charts for BenchmarkDotNet 2 | 3 | This [web app][ChartsForBenchmarkDotNet] allows you to create a visual representation of your [BenchmarkDotNet] console results. You can conveniently copy the generated chart to your clipboard, save it as a PNG image, or share it through a URL. 4 | 5 | It currently understands standard results like the one shown below. Any columns between `Method`/`Runtime` and `Mean` are considered categories in the chart across the x-axis. These columns are typically created via properties decorated with the [`Params`](https://benchmarkdotnet.org/articles/features/parameterization.html) attribute in your benchmark. 6 | 7 | **Console Results** 8 | ```ini 9 | BenchmarkDotNet=v0.13.2, OS=Windows 11 (10.0.22621.674) 10 | AMD Ryzen 9 6900HS Creator Edition, 1 CPU, 16 logical and 8 physical cores 11 | .NET SDK=6.0.401 12 | [Host] : .NET 6.0.9 (6.0.922.41905), X64 RyuJIT AVX2 13 | Job-MLMWYV : .NET 6.0.9 (6.0.922.41905), X64 RyuJIT AVX2 14 | 15 | Runtime=.NET 6.0 Server=True InvocationCount=32 16 | IterationCount=10 RunStrategy=Monitoring WarmupCount=1 17 | 18 | | Method | NumberOfValues | Mean | Error | StdDev | 19 | |---------------- |--------------- |------------:|----------:|----------:| 20 | | Int32ValuesXml | 32 | 1,031.3 us | 112.23 us | 74.23 us | 21 | | Int32ValuesJson | 32 | 683.1 us | 20.65 us | 13.66 us | 22 | | Int32ValuesXml | 64 | 1,289.4 us | 169.78 us | 112.30 us | 23 | | Int32ValuesJson | 64 | 710.6 us | 33.60 us | 22.23 us | 24 | | Int32ValuesXml | 128 | 1,717.3 us | 130.11 us | 86.06 us | 25 | | Int32ValuesJson | 128 | 705.6 us | 43.78 us | 28.96 us | 26 | | Int32ValuesXml | 256 | 2,619.3 us | 404.60 us | 267.62 us | 27 | | Int32ValuesJson | 256 | 780.0 us | 28.38 us | 18.77 us | 28 | | Int32ValuesXml | 512 | 4,712.5 us | 137.08 us | 90.67 us | 29 | | Int32ValuesJson | 512 | 919.8 us | 144.47 us | 95.56 us | 30 | | Int32ValuesXml | 1024 | 8,012.5 us | 214.29 us | 141.74 us | 31 | | Int32ValuesJson | 1024 | 1,266.1 us | 120.41 us | 79.65 us | 32 | | Int32ValuesXml | 2048 | 14,420.2 us | 261.61 us | 173.04 us | 33 | | Int32ValuesJson | 2048 | 1,673.8 us | 93.54 us | 61.87 us | 34 | | Int32ValuesXml | 4096 | 27,582.6 us | 249.89 us | 165.29 us | 35 | | Int32ValuesJson | 4096 | 2,732.6 us | 259.02 us | 171.33 us | 36 | ``` 37 | 38 | **Generated Chart** 39 | ![Generated Chart](/docs/images/chart.png) 40 | 41 | ## How do I use it? 42 | Just paste your [BenchmarkDotNet] console results into [the app][ChartsForBenchmarkDotNet]. The chart will be automatically generated. A few controls are provided above the chart, allowing you to customize the size, metric to display, y-axis scale, and theme. 43 | 44 | > 💡 You can also paste the content of the `.md` file created after running your benchmarks. It's typically stored in the following project directory: `bin\Release\[Runtime]\BenchmarkDotNet.Artifacts\results` 45 | 46 | ## Your Support is Appreciated! 47 | If you feel that this solution has provided you some value, please consider [buying me a ☕][BuyMeACoffee]. 48 | 49 | [![Buy me a coffee][BuyMeACoffeeButton]][BuyMeACoffee] 50 | 51 | Your ⭐ on [this repository][Repository] also helps! Thanks! 🖖🙂 52 | 53 | 54 | [ChartsForBenchmarkDotNet]: https://chartbenchmark.net/ 55 | [BenchmarkDotNet]: https://benchmarkdotnet.org/ 56 | [Repository]: https://github.com/yv989c/ChartsForBenchmarkDotNet 57 | [BuyMeACoffee]: https://www.buymeacoffee.com/yv989c 58 | [BuyMeACoffeeButton]: /docs/images/bmc-48.svg 59 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Legend, SubTitle, Tooltip, LinearScale, LogarithmicScale, BarController, BarElement, CategoryScale, Chart } from "chart.js"; 2 | import { Log2Axis } from "./log-2-axis"; 3 | import { ChartBuilder, Theme, ScaleType, DisplayMode } from "./chart-builder"; 4 | import LZString from "lz-string"; 5 | 6 | interface ISharedData { 7 | v?: number, 8 | settings: { 9 | display: string, 10 | scale: string, 11 | theme: string 12 | }, 13 | results: string 14 | } 15 | 16 | export class App { 17 | private static readonly DefaultFileName = 'chart.png'; 18 | 19 | _chartWrapper: HTMLElement; 20 | _chartCanvas: HTMLCanvasElement; 21 | _builder: ChartBuilder; 22 | _resultsInput: HTMLInputElement; 23 | _displayRadioContainer: HTMLElement; 24 | _scaleRadioContainer: HTMLElement; 25 | _themeRadioContainer: HTMLElement; 26 | 27 | constructor() { 28 | Chart.register(Legend, SubTitle, Tooltip, LinearScale, LogarithmicScale, CategoryScale, BarController, BarElement, Log2Axis); 29 | Chart.defaults.font.family = 'system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"'; 30 | 31 | this._chartWrapper = document.getElementById('chartWrapper')!; 32 | this._chartCanvas = document.getElementById('chartCanvas') as HTMLCanvasElement; 33 | this._builder = new ChartBuilder(this._chartCanvas); 34 | 35 | this._resultsInput = document.getElementById('resultsInput') as HTMLInputElement; 36 | this._displayRadioContainer = document.getElementById('displayRadioContainer') as HTMLElement; 37 | this._scaleRadioContainer = document.getElementById('scaleRadioContainer') as HTMLElement; 38 | this._themeRadioContainer = document.getElementById('themeRadioContainer') as HTMLElement; 39 | 40 | this.bindSizeControls(this._chartWrapper); 41 | 42 | this._scaleRadioContainer.addEventListener('input', e => { 43 | this._builder.scaleType = (e.target as HTMLInputElement).value as ScaleType; 44 | }); 45 | 46 | this._displayRadioContainer.addEventListener('input', e => { 47 | this._builder.displayMode = (e.target as HTMLInputElement).value as DisplayMode; 48 | }); 49 | 50 | this._themeRadioContainer.addEventListener('input', e => { 51 | this._builder.theme = (e.target as HTMLInputElement).value as Theme; 52 | this.refreshChartContainer(); 53 | }); 54 | 55 | this.bindCopyToClipboardButton(); 56 | 57 | document.getElementById('downloadButton')!.addEventListener('click', () => { 58 | var link = document.createElement('a'); 59 | link.download = App.DefaultFileName; 60 | link.href = this._chartCanvas.toDataURL(); 61 | link.click(); 62 | }); 63 | 64 | document.getElementById('shareButtonContainer')!.addEventListener('click', async (event) => { 65 | event.preventDefault(); 66 | 67 | const target = event.target as HTMLAnchorElement; 68 | switch (target.dataset.action) { 69 | case 'shareAsUrl': 70 | await this.shareAsUrl(); 71 | break; 72 | case 'shareAsImage': 73 | await this.shareAsImage(); 74 | break; 75 | } 76 | }); 77 | 78 | document.addEventListener('readystatechange', () => { 79 | if (document.readyState === 'complete') { 80 | // Fixes MBC Widget Google Pay bug. 81 | const widgetIFrame = document.querySelector('iframe[title*="Buy Me a Coffee"]'); 82 | if (widgetIFrame !== null) { 83 | widgetIFrame.setAttribute('allow', 'payment'); 84 | } 85 | } 86 | }); 87 | 88 | this.bindResultsInput(this._builder); 89 | } 90 | 91 | private bindCopyToClipboardButton() { 92 | const copyToClipboardButton = document.getElementById('copyToClipboardButton')!; 93 | const span = copyToClipboardButton.querySelector('span')!; 94 | const defaultText = span.innerText; 95 | const feedbackText = copyToClipboardButton.dataset.feedbackText!; 96 | 97 | let setTimeoutId = 0; 98 | 99 | copyToClipboardButton.addEventListener('click', () => { 100 | clearTimeout(setTimeoutId); 101 | this._chartCanvas.toBlob(async blob => { 102 | const item = new ClipboardItem({ 'image/png': blob! }); 103 | await navigator.clipboard.write([item]); 104 | span.innerText = feedbackText; 105 | setTimeoutId = setTimeout(() => { 106 | span.innerText = defaultText; 107 | }, 1000); 108 | }); 109 | }); 110 | } 111 | 112 | private bindResultsInput(builder: ChartBuilder) { 113 | const resultsInput = this._resultsInput; 114 | 115 | resultsInput.addEventListener('input', () => { 116 | loadBenchmarkResult(); 117 | }); 118 | 119 | if (this.tryRestoreFromSharedUrl()) { 120 | loadBenchmarkResult(); 121 | return; 122 | } 123 | 124 | resultsInput.addEventListener('click', () => { 125 | resultsInput.value = ''; 126 | loadBenchmarkResult(); 127 | }, { once: true }); 128 | 129 | loadBenchmarkResult(); 130 | 131 | function loadBenchmarkResult() { 132 | builder.loadBenchmarkResult(resultsInput.value); 133 | } 134 | } 135 | 136 | private bindSizeControls(chartWrapper: HTMLElement) { 137 | const widthRangeInput = document.getElementById('widthRangeInput') as HTMLInputElement; 138 | const heightRangeInput = document.getElementById('heightRangeInput') as HTMLInputElement; 139 | 140 | if (document.body.clientWidth < 992) { 141 | widthRangeInput.value = '1'; 142 | } 143 | 144 | updateWidth(); 145 | updateHeight(); 146 | 147 | widthRangeInput.addEventListener('input', updateWidth); 148 | heightRangeInput.addEventListener('input', updateHeight); 149 | 150 | function updateWidth() { 151 | const value = `${parseFloat(widthRangeInput.value) * 100}%`; 152 | widthRangeInput.title = value; 153 | chartWrapper.style.width = value; 154 | } 155 | 156 | function updateHeight() { 157 | const value = parseFloat(heightRangeInput.value); 158 | heightRangeInput.title = `${value * 100}%`; 159 | chartWrapper.style.height = `${value * 1600}px`; 160 | } 161 | } 162 | 163 | private refreshChartContainer() { 164 | switch (this._builder.theme) { 165 | case Theme.Dark: 166 | this._chartWrapper.classList.remove('bg-light'); 167 | break; 168 | case Theme.Light: 169 | this._chartWrapper.classList.add('bg-light'); 170 | break; 171 | } 172 | } 173 | 174 | private getValue(container: HTMLElement) { 175 | return (container.querySelector('input:checked')).value; 176 | } 177 | 178 | private setValue(container: HTMLElement, value: string) { 179 | return (container.querySelector(`input[value="${value}"]`)).checked = true; 180 | } 181 | 182 | private async share(data: any) { 183 | const canShare = 184 | typeof (navigator.canShare) === 'function' && 185 | navigator.canShare(data); 186 | 187 | if (canShare) { 188 | try { 189 | await navigator.share(data); 190 | return true; 191 | } catch (error) { 192 | if (error instanceof Error && error.name === 'AbortError') { 193 | return true; 194 | } 195 | } 196 | } 197 | 198 | return false; 199 | } 200 | 201 | private showUnsupportedMessage() { 202 | alert('This feature is not supported by your browser. 😞'); 203 | } 204 | 205 | private async shareAsUrl() { 206 | const data: ISharedData = { 207 | v: 1, 208 | settings: { 209 | display: this.getValue(this._displayRadioContainer), 210 | scale: this.getValue(this._scaleRadioContainer), 211 | theme: this.getValue(this._themeRadioContainer) 212 | }, 213 | results: LZString.compressToBase64(this._resultsInput.value) 214 | }; 215 | 216 | const serializedData = encodeURIComponent(JSON.stringify(data)); 217 | const url = `${window.location.origin}#shared=${serializedData}`; 218 | 219 | try { 220 | if (!await this.share({ url: url })) { 221 | await navigator.clipboard.writeText(url); 222 | alert('A shareable URL was copied to your clipboard!') 223 | } 224 | } catch (error) { 225 | console.error(error); 226 | prompt('Copy the following URL for sharing:', url); 227 | } 228 | } 229 | 230 | private async shareAsImage() { 231 | this._chartCanvas.toBlob(async blob => { 232 | try { 233 | const file = new File([blob], App.DefaultFileName, { type: 'image/png' }); 234 | const data = { files: [file] }; 235 | if (!await this.share(data)) { 236 | this.showUnsupportedMessage(); 237 | } 238 | } catch (error) { 239 | console.error(error); 240 | this.showUnsupportedMessage(); 241 | } 242 | }); 243 | } 244 | 245 | private tryRestoreFromSharedUrl() { 246 | try { 247 | if (window.location.hash.length < 2) { 248 | return; 249 | } 250 | 251 | const urlParams = new URLSearchParams(window.location.hash.substring(1)); 252 | const sharedEncodedData = urlParams.get('shared'); 253 | 254 | if (sharedEncodedData) { 255 | const sharedData = JSON.parse(sharedEncodedData); 256 | 257 | this._resultsInput.value = sharedData.v === 1 ? LZString.decompressFromBase64(sharedData.results) : sharedData.results; 258 | this.setValue(this._displayRadioContainer, sharedData.settings.display); 259 | this.setValue(this._scaleRadioContainer, sharedData.settings.scale); 260 | this.setValue(this._themeRadioContainer, sharedData.settings.theme); 261 | 262 | this._builder.displayMode = this.getValue(this._displayRadioContainer); 263 | this._builder.scaleType = this.getValue(this._scaleRadioContainer); 264 | this._builder.theme = this.getValue(this._themeRadioContainer); 265 | this.refreshChartContainer(); 266 | 267 | return true; 268 | } 269 | } catch (error) { 270 | console.error(error); 271 | alert('Error while restoring the data from the URL.'); 272 | } 273 | 274 | return false; 275 | } 276 | } 277 | 278 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Charts for BenchmarkDotNet 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 57 | 58 | 61 | 62 | 63 | 64 | 65 | 66 |
67 |
68 |
69 | 83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |

Paste your console results below:

91 | 118 |

119 | (This information does not leave your browser.) 120 |

121 |
122 |
123 |
124 |
125 |
126 | 127 |
128 |
129 | Width 130 | 133 |
134 |
135 | Height 136 | 139 |
140 |
141 |
142 |
143 | 144 |
145 |
146 | 148 | 151 |
152 |
153 | 155 | 158 |
159 |
160 | 162 | 165 |
166 |
167 |
168 |
169 | 170 |
171 |
172 | 174 | 177 |
178 |
179 | 181 | 184 |
185 |
186 | 188 | 191 |
192 |
193 |
194 |
195 | 196 |
197 |
198 | 200 | 203 |
204 |
205 | 207 | 210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 | 218 |
219 |
220 |
221 | 228 | 235 |
236 | 244 | 248 |
249 |
250 |
251 |
252 |
253 | 266 |
267 | 268 | 272 | 273 | 275 | 276 | 277 | 278 | -------------------------------------------------------------------------------- /src/chart-builder.ts: -------------------------------------------------------------------------------- 1 | import { Chart } from "chart.js"; 2 | import { color } from "chart.js/helpers"; 3 | 4 | export class ChartBuilder { 5 | private readonly _chart: any; 6 | /* 7 | https://coolors.co/palette/f94144-f3722c-f8961e-f9c74f-90be6d-43aa8b-4d908e-577590 8 | https://coolors.co/palette/ff595e-ff924c-ffca3a-c5ca30-8ac926-36949d-1982c4-4267ac-565aa0-6a4c93 9 | https://coolors.co/palette/264653-287271-2a9d8f-8ab17d-babb74-e9c46a-efb366-f4a261-ee8959-e76f51 10 | https://coolors.co/palette/264653-287271-2a9d8f-8ab17d-e9c46a-f4a261-ee8959-e76f51 11 | */ 12 | private readonly _colors = ['#F94144', '#F3722C', '#F8961E', '#F9C74F', '#90BE6D', '#43AA8B', '#4D908E', '#577590']; 13 | 14 | private _benchmarkResultRows: IBenchmarkResultRow[] = []; 15 | private _theme = Theme.Dark; 16 | private _yAxisTextColor = '#606060'; 17 | 18 | private _y2AxisBaseColor = '#A600FF'; 19 | private _y2AxisGridLineColor = this._y2AxisBaseColor; 20 | private _y2AxisTextColor = this._y2AxisBaseColor; 21 | private _y2AxisBarColor = color(this._y2AxisBaseColor).clearer(0.3).hexString(); 22 | 23 | private _xAxisTextColor = '#606060'; 24 | private _gridLineColor = ''; 25 | private _creditTextColor = ''; 26 | private _scaleType = ScaleType.Log2; 27 | 28 | private _displayMode = DisplayMode.All; 29 | 30 | private _hasAllocationData = false; 31 | 32 | get theme() { 33 | return this._theme; 34 | } 35 | set theme(value) { 36 | switch (value) { 37 | case Theme.Dark: 38 | this._creditTextColor = '#66666675'; 39 | this._gridLineColor = '#66666638'; 40 | this._y2AxisGridLineColor = color(this._y2AxisBaseColor).clearer(0.5).hexString(); 41 | break; 42 | case Theme.Light: 43 | this._creditTextColor = '#00000040'; 44 | this._gridLineColor = '#0000001a'; 45 | this._y2AxisGridLineColor = color(this._y2AxisBaseColor).clearer(0.75).hexString(); 46 | break; 47 | default: 48 | break; 49 | } 50 | this._theme = value; 51 | this.render(); 52 | } 53 | 54 | get scaleType() { 55 | return this._scaleType; 56 | } 57 | set scaleType(value) { 58 | this._scaleType = value; 59 | this.render(); 60 | } 61 | 62 | get displayMode() { 63 | return this._displayMode; 64 | } 65 | set displayMode(value) { 66 | this._displayMode = value; 67 | this.render(); 68 | } 69 | 70 | get hasAllocationData() { 71 | return this._hasAllocationData && (this.displayMode === DisplayMode.All || this.displayMode === DisplayMode.Allocation); 72 | } 73 | 74 | private get chartPlugins() { 75 | return this._chart.config.options.plugins; 76 | } 77 | 78 | private get chartScales() { 79 | return this._chart.config.options.scales; 80 | } 81 | 82 | constructor(canvas: HTMLCanvasElement) { 83 | this._chart = getChart(); 84 | this.theme = this.theme; 85 | const that = this; 86 | 87 | function getChart() { 88 | const numberFormat = new Intl.NumberFormat('en-US', { style: 'decimal' }); 89 | 90 | const config = { 91 | type: 'bar', 92 | options: { 93 | maintainAspectRatio: false, 94 | responsive: true, 95 | plugins: { 96 | subtitle: { 97 | display: true, 98 | text: 'Made with chartbenchmark.net', 99 | position: 'right', 100 | align: 'center', 101 | fullSize: false, 102 | font: { 103 | size: 11, 104 | family: 'SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace' 105 | } 106 | }, 107 | legend: { 108 | labels: { 109 | usePointStyle: true, 110 | filter: (legend: any, data: any) => { 111 | const display = 112 | (that.displayMode === DisplayMode.Allocation && that.hasAllocationData) || 113 | data.datasets[legend.datasetIndex].isDuration === true; 114 | 115 | return display; 116 | } 117 | }, 118 | onClick: (_: any, legendItem: any, legend: any) => { 119 | const index = legendItem.datasetIndex; 120 | const ci = legend.chart; 121 | const hideAllocation = that.displayMode === DisplayMode.All && that.hasAllocationData; 122 | 123 | if (ci.isDatasetVisible(index)) { 124 | if (hideAllocation) { 125 | ci.hide(index + 1); 126 | } 127 | ci.hide(index); 128 | legendItem.hidden = true; 129 | } else { 130 | if (hideAllocation) { 131 | ci.show(index + 1); 132 | } 133 | ci.show(index); 134 | legendItem.hidden = false; 135 | } 136 | } 137 | }, 138 | tooltip: { 139 | callbacks: { 140 | label: (context: any) => { 141 | const dataset = context.dataset as IDataset; 142 | 143 | let label = dataset.label || ''; 144 | 145 | if (dataset.isDuration) { 146 | label += ' - Duration: '; 147 | } 148 | else if (dataset.isAllocation) { 149 | label += ' - Memory Allocation: '; 150 | } 151 | 152 | if (context.parsed.y !== null) { 153 | label += `${numberFormat.format(context.parsed.y)} ${dataset.unitInfo.short}`; 154 | } 155 | 156 | return label; 157 | } 158 | } 159 | } 160 | }, 161 | scales: { 162 | y: { 163 | title: { 164 | display: true 165 | }, 166 | display: 'auto' 167 | }, 168 | y2: { 169 | title: { 170 | display: true 171 | }, 172 | position: 'right', 173 | display: 'auto' 174 | }, 175 | x: { 176 | title: { 177 | display: true 178 | }, 179 | display: 'auto' 180 | } 181 | } 182 | } 183 | }; 184 | 185 | return new Chart(canvas.getContext('2d'), config); 186 | } 187 | } 188 | 189 | loadBenchmarkResult(text: string) { 190 | this._benchmarkResultRows = text 191 | .split('\n') 192 | .map(i => i.trim()) 193 | .filter(i => i.length > 2 && i.startsWith('|') && i.endsWith('|')) 194 | .filter((_, index) => index !== 1) 195 | .map(i => ({ 196 | text: i, 197 | columns: i 198 | .split('|') 199 | .slice(1, -1) 200 | .map(i => i.replace(/\*/g, '').trim()) 201 | })) 202 | .filter(i => i.columns.some(c => c.length > 0)); 203 | 204 | this.render(); 205 | } 206 | 207 | private getBenchmarkResult() { 208 | const rows = this._benchmarkResultRows; 209 | 210 | if (rows.length === 0) { 211 | return null; 212 | } 213 | 214 | const headerRow = rows[0]; 215 | const methodIndex = headerRow.columns.indexOf('Method'); 216 | const meanIndex = headerRow.columns.lastIndexOf('Mean'); 217 | 218 | if (methodIndex < 0 || meanIndex < 0) { 219 | return null; 220 | } 221 | 222 | const runtimeIndex = headerRow.columns.indexOf('Runtime'); 223 | 224 | const categoryIndexStart = Math.max(methodIndex, runtimeIndex) + 1; 225 | const categoryIndexEnd = meanIndex - 1; 226 | 227 | const allocatedIndex = headerRow.columns.indexOf('Allocated', meanIndex); 228 | 229 | const methods = new Map(); 230 | const orderByMethod = new Map(); 231 | 232 | for (const row of rows) { 233 | if (row === headerRow) { 234 | continue; 235 | } 236 | 237 | let category = ''; 238 | 239 | for (let i = categoryIndexStart; i <= categoryIndexEnd; i++) { 240 | const separator = category.length > 0 ? ', ' : ''; 241 | category += `${separator}${row.columns[i]}`; 242 | } 243 | 244 | const runtimeName = runtimeIndex >= 0 ? row.columns[runtimeIndex] : null; 245 | 246 | const methodNamePrefix = row.columns[methodIndex]; 247 | const methodName = methodNamePrefix + (runtimeName !== null ? ` (${runtimeName})` : ''); 248 | let methodInfo = methods.get(methodName); 249 | 250 | if (typeof (methodInfo) === 'undefined') { 251 | let methodOrder = orderByMethod.get(methodNamePrefix); 252 | 253 | if (typeof (methodOrder) === 'undefined') { 254 | methodOrder = orderByMethod.size; 255 | orderByMethod.set(methodNamePrefix, methodOrder); 256 | } 257 | 258 | methodInfo = { 259 | name: methodName, 260 | order: methodOrder, 261 | results: [] 262 | }; 263 | 264 | methods.set(methodName, methodInfo); 265 | } 266 | 267 | const durationMean = getMeasure(row.columns[meanIndex]); 268 | 269 | const result: IMethodResult = { 270 | category: category, 271 | duration: durationMean, 272 | allocation: allocatedIndex >= 0 ? getMeasure(row.columns[allocatedIndex]) : null 273 | }; 274 | 275 | methodInfo.results.push(result); 276 | } 277 | 278 | const methodsArray = getMethods(); 279 | 280 | return { 281 | categories: getCategories(), 282 | categoriesTitle: getCategoriesTitle(), 283 | methods: methodsArray, 284 | durationUnit: inferDurationUnit(), 285 | allocationUnit: inferAllocationUnit() 286 | }; 287 | 288 | function getCategories() { 289 | const categories = new Set(); 290 | for (const method of methods) { 291 | for (const result of method[1].results) { 292 | categories.add(result.category); 293 | } 294 | } 295 | return [...categories]; 296 | } 297 | 298 | function getCategoriesTitle() { 299 | let title = ''; 300 | for (let i = categoryIndexStart; i <= categoryIndexEnd; i++) { 301 | const separator = title.length > 0 ? ', ' : ''; 302 | title += `${separator}${headerRow.columns[i]}`; 303 | } 304 | return title; 305 | } 306 | 307 | function getMethods() { 308 | return [...methods] 309 | .map(i => i[1]) 310 | .sort((a, b) => a.order - b.order); 311 | } 312 | 313 | function inferDurationUnit() { 314 | const info: IUnitInfo = { 315 | short: '', 316 | long: '' 317 | }; 318 | 319 | for (const method of methods) { 320 | for (const result of method[1].results) { 321 | info.short = result.duration.unit; 322 | 323 | switch (result.duration.unit) { 324 | case 'us': 325 | case 'μs': 326 | info.long = 'Microseconds'; 327 | break; 328 | case 'ms': 329 | info.long = 'Milliseconds'; 330 | break; 331 | case 's': 332 | info.long = 'Seconds'; 333 | break; 334 | default: 335 | info.long = result.duration.unit; 336 | break; 337 | } 338 | 339 | return info; 340 | } 341 | } 342 | 343 | return info; 344 | } 345 | 346 | function inferAllocationUnit() { 347 | const info: IUnitInfo = { 348 | short: '', 349 | long: '' 350 | }; 351 | 352 | for (const method of methods) { 353 | for (const result of method[1].results) { 354 | if (result.allocation === null) { 355 | continue; 356 | } 357 | 358 | info.short = result.allocation.unit; 359 | 360 | switch (result.allocation.unit) { 361 | case 'B': 362 | info.long = 'Bytes'; 363 | break; 364 | case 'KB': 365 | info.long = 'Kilobytes'; 366 | break; 367 | case 'MB': 368 | info.long = 'Megabytes'; 369 | break; 370 | case 'GB': 371 | info.long = 'Gigabytes'; 372 | break; 373 | case 'TB': 374 | info.long = 'Terabytes'; 375 | break; 376 | case 'PB': 377 | info.long = 'Petabytes'; 378 | break; 379 | default: 380 | info.long = result.allocation.unit; 381 | break; 382 | } 383 | 384 | return info; 385 | } 386 | } 387 | 388 | return info; 389 | } 390 | 391 | function getMeasure(formattedNumber: string): IMeasure { 392 | const unitSeparatorIndex = formattedNumber.indexOf(' '); 393 | const unit = unitSeparatorIndex >= 0 ? formattedNumber.substring(unitSeparatorIndex).trim() : ''; 394 | const value = parseFloat(formattedNumber.replace(/[^0-9.]/g, '')); 395 | return { 396 | value, 397 | unit 398 | } 399 | } 400 | } 401 | 402 | private render() { 403 | const yAxe = this.chartScales.y; 404 | const y2Axe = this.chartScales.y2; 405 | const xAxe = this.chartScales.x; 406 | const chartData = this._chart.data; 407 | const benchmarkResult = this.getBenchmarkResult(); 408 | 409 | yAxe.title.text = ''; 410 | y2Axe.title.text = ''; 411 | xAxe.title.text = ''; 412 | chartData.labels.length = 0; 413 | chartData.datasets.length = 0; 414 | this._hasAllocationData = false; 415 | 416 | if (benchmarkResult === null) { 417 | this._chart.update(); 418 | return; 419 | } 420 | 421 | this._hasAllocationData = benchmarkResult.methods 422 | .some(m => m.results.some(r => r.allocation !== null)); 423 | 424 | this.chartPlugins.subtitle.color = this._creditTextColor; 425 | 426 | yAxe.title.text = `Duration (${benchmarkResult.durationUnit.long})`; 427 | yAxe.title.color = this._yAxisTextColor; 428 | yAxe.grid.color = this._gridLineColor; 429 | 430 | switch (this.scaleType) { 431 | case ScaleType.Log10: 432 | yAxe.type = 'logarithmic'; 433 | break; 434 | case ScaleType.Log2: 435 | yAxe.type = 'log2'; 436 | break; 437 | default: 438 | yAxe.type = 'linear'; 439 | break; 440 | } 441 | 442 | y2Axe.title.text = `Memory Allocation (${benchmarkResult.allocationUnit.long})`; 443 | y2Axe.title.color = this._y2AxisTextColor; 444 | y2Axe.ticks.color = this._y2AxisTextColor; 445 | y2Axe.grid.color = this._y2AxisGridLineColor; 446 | y2Axe.type = yAxe.type; 447 | 448 | xAxe.title.text = benchmarkResult.categoriesTitle; 449 | xAxe.title.color = this._xAxisTextColor; 450 | xAxe.grid.color = this._gridLineColor; 451 | 452 | if (this.displayMode === DisplayMode.Allocation) { 453 | if (!this.hasAllocationData) { 454 | this._chart.update(); 455 | return; 456 | } 457 | 458 | yAxe.title.text = y2Axe.title.text; 459 | } 460 | 461 | chartData.labels = benchmarkResult.categories; 462 | 463 | const indexByCategory = new Map(); 464 | for (let index = 0; index < benchmarkResult.categories.length; index++) { 465 | indexByCategory.set(benchmarkResult.categories[index], index); 466 | } 467 | 468 | const colors = this._colors; 469 | let colorIndex = 0; 470 | 471 | const datasets = []; 472 | 473 | for (const methodInfo of benchmarkResult.methods) { 474 | const color = getNextColor(); 475 | 476 | if (this.displayMode === DisplayMode.All || this.displayMode === DisplayMode.Duration) { 477 | const durationDataset = { 478 | isDuration: true, 479 | label: methodInfo.name, 480 | data: getData(methodInfo.results, r => r.duration.value), 481 | backgroundColor: color, 482 | stack: methodInfo.name, 483 | yAxisID: 'y', 484 | unitInfo: benchmarkResult.durationUnit, 485 | order: 2, 486 | barPercentage: 0.9, 487 | pointStyle: 'rect' 488 | }; 489 | 490 | datasets.push(durationDataset); 491 | } 492 | 493 | if (this.hasAllocationData && (this.displayMode === DisplayMode.All || this.displayMode === DisplayMode.Allocation)) { 494 | const allocationDataset = { 495 | isAllocation: true, 496 | label: methodInfo.name, 497 | data: getData(methodInfo.results, r => r.allocation === null ? 0 : r.allocation.value), 498 | backgroundColor: this._y2AxisBarColor, 499 | stack: methodInfo.name, 500 | yAxisID: 'y2', 501 | unitInfo: benchmarkResult.allocationUnit, 502 | order: 1, 503 | barPercentage: 0.2, 504 | pointStyle: 'rect' 505 | }; 506 | 507 | if (this.displayMode === DisplayMode.Allocation) { 508 | allocationDataset.backgroundColor = color; 509 | allocationDataset.yAxisID = 'y'; 510 | allocationDataset.barPercentage = 0.9; 511 | } 512 | 513 | datasets.push(allocationDataset); 514 | } 515 | } 516 | 517 | chartData.datasets = datasets; 518 | 519 | function getData(results: IMethodResult[], getResultCallback: (r: IMethodResult) => number) { 520 | const data: number[] = new Array(indexByCategory.size); 521 | for (const result of results) { 522 | const index = indexByCategory.get(result.category); 523 | if (index !== undefined) { 524 | data[index] = getResultCallback(result); 525 | } 526 | } 527 | return data; 528 | } 529 | 530 | function getNextColor() { 531 | if ((colorIndex ^ colors.length) === 0) { 532 | colorIndex = 0; 533 | } 534 | return colors[colorIndex++]; 535 | } 536 | 537 | this._chart.update(); 538 | } 539 | } 540 | 541 | interface IBenchmarkResultRow { 542 | text: string, 543 | columns: string[] 544 | } 545 | 546 | interface IMeasure { 547 | value: number, 548 | unit: string 549 | } 550 | 551 | interface IMethodResult { 552 | category: string, 553 | duration: IMeasure, 554 | allocation: IMeasure | null 555 | } 556 | 557 | interface IMethodInfo { 558 | name: string; 559 | order: number; 560 | results: IMethodResult[]; 561 | } 562 | 563 | interface IUnitInfo { 564 | short: string, 565 | long: string 566 | } 567 | 568 | interface IDataset { 569 | label: string, 570 | isDuration?: boolean, 571 | isAllocation?: boolean, 572 | unitInfo: IUnitInfo 573 | } 574 | 575 | export enum Theme { 576 | Dark = 'Dark', 577 | Light = 'Light' 578 | } 579 | 580 | export enum ScaleType { 581 | Linear = 'Linear', 582 | Log10 = 'Log10', 583 | Log2 = 'Log2' 584 | } 585 | 586 | export enum DisplayMode { 587 | All = 'All', 588 | Duration = 'Duration', 589 | Allocation = 'Allocation' 590 | } -------------------------------------------------------------------------------- /docs/images/bmc-48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | --------------------------------------------------------------------------------