├── .gitignore ├── .idea ├── artifacts │ ├── benchart_js_1_0_0_beta01.xml │ ├── benchart_js_1_0_0_rc01.xml │ ├── benchart_jvm_1_0_0_beta01.xml │ └── benchart_jvm_1_0_0_rc01.xml ├── benchart.iml ├── compiler.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml ├── misc.xml ├── modules.xml ├── modules │ ├── benchart.commonMain.iml │ ├── benchart.commonTest.iml │ ├── benchart.jsMain.iml │ ├── benchart.jsTest.iml │ ├── benchart.jvmMain.iml │ └── benchart.jvmTest.iml └── vcs.xml ├── .kotlin └── metadata │ └── kotlinTransformedMetadataLibraries │ └── org.jetbrains.kotlin-kotlin-stdlib-2.0.20-commonMain-WPEnbA.klib ├── README.md ├── build.gradle.kts ├── cover.jpeg ├── demo.gif ├── dist ├── benchart.js ├── benchart.js.LICENSE.txt ├── benchart.js.map ├── beta │ ├── benchart.js │ ├── benchart.js.LICENSE.txt │ ├── benchart.js.map │ └── index.html ├── icons │ ├── about.txt │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest └── index.html ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── kotlin-js-store └── yarn.lock ├── productionExecutable ├── benchart.js ├── benchart.js.LICENSE.txt ├── benchart.js.map ├── icons │ ├── about.txt │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest └── index.html ├── settings.gradle.kts └── src ├── commonMain └── kotlin │ ├── core │ ├── BenchmarkResult.kt │ ├── ChartsTransformers.kt │ └── TextNumberLine.kt │ └── model │ ├── Chart.kt │ ├── FormData.kt │ └── SavedBenchmark.kt ├── jsMain ├── kotlin │ ├── Utils.kt │ ├── chartjs │ │ ├── Chart_js.kt │ │ └── Type.kt │ ├── components │ │ ├── AutoFormUi.kt │ │ ├── AutoGroupToggle.kt │ │ ├── ChartUi.kt │ │ ├── EditableTitle.kt │ │ ├── Error.kt │ │ ├── FocusGroups.kt │ │ ├── Heading.kt │ │ ├── SavedBenchmarkNode.kt │ │ ├── SavedBenchmarksDropDown.kt │ │ ├── StandardDeviationUi.kt │ │ ├── Summary.kt │ │ ├── TestNameDetectionToggle.kt │ │ └── TestNames.kt │ ├── main.kt │ ├── page │ │ └── home │ │ │ ├── HomePage.kt │ │ │ ├── HomeViewModel.kt │ │ │ ├── ShareAwareModal.kt │ │ │ └── SharedModal.kt │ ├── repo │ │ ├── BenchmarkRepo.kt │ │ ├── FormRepo.kt │ │ ├── GoogleFormRepo.kt │ │ ├── GoogleSheetRepo.kt │ │ └── UserRepo.kt │ └── utils │ │ ├── DefaultValues.kt │ │ ├── JsonUtils.kt │ │ ├── Math.kt │ │ ├── RandomString.kt │ │ └── SummaryUtils.kt └── resources │ ├── icons │ ├── about.txt │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest │ └── index.html ├── jsTest └── kotlin │ ├── GoogleFormRepoTest.kt │ └── GoogleSheetRepoTest.kt ├── jvmMain └── kotlin │ ├── DemoDataGen.kt │ └── Test.kt └── jvmTest └── kotlin ├── BenchmarkParseTest.kt └── core └── TextNumberLineTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/kotlin,intellij,gradle 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,intellij,gradle 4 | 5 | ### Intellij ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/artifacts 37 | # .idea/compiler.xml 38 | # .idea/jarRepositories.xml 39 | # .idea/modules.xml 40 | # .idea/*.iml 41 | # .idea/modules 42 | # *.iml 43 | # *.ipr 44 | 45 | # CMake 46 | cmake-build-*/ 47 | 48 | # Mongo Explorer plugin 49 | .idea/**/mongoSettings.xml 50 | 51 | # File-based project format 52 | *.iws 53 | 54 | # IntelliJ 55 | out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Cursive Clojure plugin 64 | .idea/replstate.xml 65 | 66 | # Crashlytics plugin (for Android Studio and IntelliJ) 67 | com_crashlytics_export_strings.xml 68 | crashlytics.properties 69 | crashlytics-build.properties 70 | fabric.properties 71 | 72 | # Editor-based Rest Client 73 | .idea/httpRequests 74 | 75 | # Android studio 3.1+ serialized cache file 76 | .idea/caches/build_file_checksums.ser 77 | 78 | ### Intellij Patch ### 79 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 80 | 81 | # *.iml 82 | # modules.xml 83 | # .idea/misc.xml 84 | # *.ipr 85 | 86 | # Sonarlint plugin 87 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 88 | .idea/**/sonarlint/ 89 | 90 | # SonarQube Plugin 91 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 92 | .idea/**/sonarIssues.xml 93 | 94 | # Markdown Navigator plugin 95 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 96 | .idea/**/markdown-navigator.xml 97 | .idea/**/markdown-navigator-enh.xml 98 | .idea/**/markdown-navigator/ 99 | 100 | .DS_Store 101 | 102 | # Cache file creation bug 103 | # See https://youtrack.jetbrains.com/issue/JBR-2257 104 | .idea/$CACHE_FILE$ 105 | 106 | # CodeStream plugin 107 | # https://plugins.jetbrains.com/plugin/12206-codestream 108 | .idea/codestream.xml 109 | 110 | ### Kotlin ### 111 | # Compiled class file 112 | *.class 113 | 114 | # Log file 115 | *.log 116 | 117 | # BlueJ files 118 | *.ctxt 119 | 120 | # Mobile Tools for Java (J2ME) 121 | .mtj.tmp/ 122 | 123 | # Package Files # 124 | *.jar 125 | *.war 126 | *.nar 127 | *.ear 128 | *.zip 129 | *.tar.gz 130 | *.rar 131 | 132 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 133 | hs_err_pid* 134 | 135 | ### Gradle ### 136 | .gradle 137 | build/ 138 | 139 | # Ignore Gradle GUI config 140 | gradle-app.setting 141 | 142 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 143 | !gradle-wrapper.jar 144 | 145 | # Cache of project 146 | .gradletasknamecache 147 | 148 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 149 | # gradle/wrapper/gradle-wrapper.properties 150 | 151 | ### Gradle Patch ### 152 | **/build/ 153 | 154 | # End of https://www.toptal.com/developers/gitignore/api/kotlin,intellij,gradle 155 | build 156 | gpm.json 157 | 158 | 159 | -------------------------------------------------------------------------------- /.idea/artifacts/benchart_js_1_0_0_beta01.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | $PROJECT_DIR$/build/libs 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/artifacts/benchart_js_1_0_0_rc01.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | $PROJECT_DIR$/build/libs 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/artifacts/benchart_jvm_1_0_0_beta01.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | $PROJECT_DIR$/build/libs 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/artifacts/benchart_jvm_1_0_0_rc01.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | $PROJECT_DIR$/build/libs 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/benchart.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/modules/benchart.commonMain.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SOURCE_SET_HOLDER 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compose-compiler-plugin-embeddable/2.0.20/d3b5072df7943425b2ec5b5cfb323701cb5d8bd2/kotlin-compose-compiler-plugin-embeddable-2.0.20.jar 20 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin-embeddable/2.0.20/c48d1e6a5daf2345b982d68de4f259e0a1a8a449/kotlin-serialization-compiler-plugin-embeddable-2.0.20.jar 21 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-jvm/2.0.20/46b8d2d2028448f6596cc3ce0ad7b10b259ec236/kotlin-scripting-jvm-2.0.20.jar 22 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-common/2.0.20/af6097b8ac359457ec1cf9b34bd9b5313b52eb6/kotlin-scripting-common-2.0.20.jar 23 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/2.0.20/7388d355f7cceb002cd387ccb7ab3850e4e0a07f/kotlin-stdlib-2.0.20.jar 24 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar 25 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/2.0.20/4aea042b39014e0a924c2e1d4a21b6fff7e4d35/kotlin-script-runtime-2.0.20.jar 26 | 27 | 28 | 29 | 30 | plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaClasses=false 31 | plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false 32 | plugin:androidx.compose.compiler.plugins.kotlin:traceMarkersEnabled=true 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /.idea/modules/benchart.commonTest.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | benchart:commonMain 7 | SOURCE_SET_HOLDER 8 | 9 | jsNodeTest|:|js|js 10 | jsBrowserTest|:|js|js 11 | jvmTest|:|jvm|jvm 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compose-compiler-plugin-embeddable/2.0.20/d3b5072df7943425b2ec5b5cfb323701cb5d8bd2/kotlin-compose-compiler-plugin-embeddable-2.0.20.jar 26 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin-embeddable/2.0.20/c48d1e6a5daf2345b982d68de4f259e0a1a8a449/kotlin-serialization-compiler-plugin-embeddable-2.0.20.jar 27 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-jvm/2.0.20/46b8d2d2028448f6596cc3ce0ad7b10b259ec236/kotlin-scripting-jvm-2.0.20.jar 28 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-common/2.0.20/af6097b8ac359457ec1cf9b34bd9b5313b52eb6/kotlin-scripting-common-2.0.20.jar 29 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/2.0.20/7388d355f7cceb002cd387ccb7ab3850e4e0a07f/kotlin-stdlib-2.0.20.jar 30 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar 31 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/2.0.20/4aea042b39014e0a924c2e1d4a21b6fff7e4d35/kotlin-script-runtime-2.0.20.jar 32 | 33 | 34 | 35 | 36 | plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaClasses=false 37 | plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false 38 | plugin:androidx.compose.compiler.plugins.kotlin:traceMarkersEnabled=true 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /.idea/modules/benchart.jsMain.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | benchart:commonMain 7 | 8 | benchart.commonMain 9 | 10 | COMPILATION_AND_SOURCE_SET_HOLDER 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | jsMain:commonMain 32 | 33 | 34 | 35 | jsMain 36 | commonMain 37 | 38 | 39 | 40 | 41 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compose-compiler-plugin-embeddable/2.0.20/d3b5072df7943425b2ec5b5cfb323701cb5d8bd2/kotlin-compose-compiler-plugin-embeddable-2.0.20.jar 42 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin-embeddable/2.0.20/c48d1e6a5daf2345b982d68de4f259e0a1a8a449/kotlin-serialization-compiler-plugin-embeddable-2.0.20.jar 43 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-jvm/2.0.20/46b8d2d2028448f6596cc3ce0ad7b10b259ec236/kotlin-scripting-jvm-2.0.20.jar 44 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-common/2.0.20/af6097b8ac359457ec1cf9b34bd9b5313b52eb6/kotlin-scripting-common-2.0.20.jar 45 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/2.0.20/7388d355f7cceb002cd387ccb7ab3850e4e0a07f/kotlin-stdlib-2.0.20.jar 46 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar 47 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/2.0.20/4aea042b39014e0a924c2e1d4a21b6fff7e4d35/kotlin-script-runtime-2.0.20.jar 48 | 49 | 50 | 51 | 52 | plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaClasses=false 53 | plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false 54 | plugin:androidx.compose.compiler.plugins.kotlin:traceMarkersEnabled=true 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /.idea/modules/benchart.jsTest.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | benchart:commonTest 7 | 8 | benchart:jsMain 9 | benchart:commonMain 10 | 11 | 12 | benchart.commonTest 13 | 14 | COMPILATION_AND_SOURCE_SET_HOLDER 15 | 16 | jsNodeTest|:|js|js 17 | jsBrowserTest|:|js|js 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | jsTest:commonTest 40 | 41 | 42 | 43 | jsTest 44 | commonTest 45 | 46 | 47 | 48 | 49 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compose-compiler-plugin-embeddable/2.0.20/d3b5072df7943425b2ec5b5cfb323701cb5d8bd2/kotlin-compose-compiler-plugin-embeddable-2.0.20.jar 50 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin-embeddable/2.0.20/c48d1e6a5daf2345b982d68de4f259e0a1a8a449/kotlin-serialization-compiler-plugin-embeddable-2.0.20.jar 51 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-jvm/2.0.20/46b8d2d2028448f6596cc3ce0ad7b10b259ec236/kotlin-scripting-jvm-2.0.20.jar 52 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-common/2.0.20/af6097b8ac359457ec1cf9b34bd9b5313b52eb6/kotlin-scripting-common-2.0.20.jar 53 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/2.0.20/7388d355f7cceb002cd387ccb7ab3850e4e0a07f/kotlin-stdlib-2.0.20.jar 54 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar 55 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/2.0.20/4aea042b39014e0a924c2e1d4a21b6fff7e4d35/kotlin-script-runtime-2.0.20.jar 56 | 57 | 58 | 59 | 60 | plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaClasses=false 61 | plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false 62 | plugin:androidx.compose.compiler.plugins.kotlin:traceMarkersEnabled=true 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /.idea/modules/benchart.jvmMain.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | benchart:commonMain 7 | 8 | benchart.commonMain 9 | 10 | COMPILATION_AND_SOURCE_SET_HOLDER 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | jvmMain:commonMain 29 | 30 | 31 | 32 | jvmMain 33 | commonMain 34 | 35 | 36 | 37 | 38 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compose-compiler-plugin-embeddable/2.0.20/d3b5072df7943425b2ec5b5cfb323701cb5d8bd2/kotlin-compose-compiler-plugin-embeddable-2.0.20.jar 39 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin-embeddable/2.0.20/c48d1e6a5daf2345b982d68de4f259e0a1a8a449/kotlin-serialization-compiler-plugin-embeddable-2.0.20.jar 40 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-jvm/2.0.20/46b8d2d2028448f6596cc3ce0ad7b10b259ec236/kotlin-scripting-jvm-2.0.20.jar 41 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-common/2.0.20/af6097b8ac359457ec1cf9b34bd9b5313b52eb6/kotlin-scripting-common-2.0.20.jar 42 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/2.0.20/7388d355f7cceb002cd387ccb7ab3850e4e0a07f/kotlin-stdlib-2.0.20.jar 43 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar 44 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/2.0.20/4aea042b39014e0a924c2e1d4a21b6fff7e4d35/kotlin-script-runtime-2.0.20.jar 45 | 46 | 47 | 48 | 49 | plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaClasses=false 50 | plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false 51 | plugin:androidx.compose.compiler.plugins.kotlin:traceMarkersEnabled=true 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /.idea/modules/benchart.jvmTest.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | benchart:commonTest 7 | 8 | benchart:jvmMain 9 | benchart:commonMain 10 | 11 | 12 | benchart.commonTest 13 | 14 | COMPILATION_AND_SOURCE_SET_HOLDER 15 | 16 | jvmTest|:|jvm|jvm 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | jvmTest:commonTest 36 | 37 | 38 | 39 | jvmTest 40 | commonTest 41 | 42 | 43 | 44 | 45 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compose-compiler-plugin-embeddable/2.0.20/d3b5072df7943425b2ec5b5cfb323701cb5d8bd2/kotlin-compose-compiler-plugin-embeddable-2.0.20.jar 46 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-serialization-compiler-plugin-embeddable/2.0.20/c48d1e6a5daf2345b982d68de4f259e0a1a8a449/kotlin-serialization-compiler-plugin-embeddable-2.0.20.jar 47 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-jvm/2.0.20/46b8d2d2028448f6596cc3ce0ad7b10b259ec236/kotlin-scripting-jvm-2.0.20.jar 48 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-common/2.0.20/af6097b8ac359457ec1cf9b34bd9b5313b52eb6/kotlin-scripting-common-2.0.20.jar 49 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/2.0.20/7388d355f7cceb002cd387ccb7ab3850e4e0a07f/kotlin-stdlib-2.0.20.jar 50 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar 51 | $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/2.0.20/4aea042b39014e0a924c2e1d4a21b6fff7e4d35/kotlin-script-runtime-2.0.20.jar 52 | 53 | 54 | 55 | 56 | plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaClasses=false 57 | plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=false 58 | plugin:androidx.compose.compiler.plugins.kotlin:traceMarkersEnabled=true 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.0.20-commonMain-WPEnbA.klib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.0.20-commonMain-WPEnbA.klib -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](cover.jpeg) 2 | 3 | # 📊 benchart 4 | 5 | > A web tool to visualize and compare plain text data with Android Macrobenchmark data support 6 | 7 | ![](https://img.shields.io/github/deployments/theapache64/benchart/github-pages) 8 | 9 | Twitter: theapache64 10 | 11 | 12 | 13 | ![virat-kohli-ben-stokes](https://github.com/theapache64/benchart/assets/9678279/01381728-1ae2-4124-a4d5-7cc3dd7ba910) 14 | 15 | > Yeah, correct, [he](https://en.wikipedia.org/wiki/Virat_Kohli)'s saying "Benchart" 16 | 17 | ## ✨ Demo 18 | 19 | https://user-images.githubusercontent.com/9678279/204081186-36c7ce90-6c46-4ec6-9c36-dd16cdf25acf.mov 20 | 21 | ## 🐣 Getting Started 22 | 23 | Let's start with some sample log data to learn the basics. 24 | 25 | ![image](https://github.com/theapache64/benchart/assets/9678279/2cd4f30e-054a-4f1e-b0bf-1443319a50a4) 26 | 27 | ### 📄 Input 28 | 29 | - Benchart accepts input as plain text. 30 | - A block is a chunk of information that you can compare. 31 | - Blocks are separated by a blank line. 32 | - The first line of the block will be treated as the header line. 33 | - In the above example, `# before` and `# after`. 34 | - Remaining lines will be treated as input lines. 35 | - In an input line, the last number is treated as the value, and anything before that as the key. 36 | - For example, given the input line `myFirstFunction() = 90ms`, the key will be `myFirstFunction`, and the value will be `90`. 37 | - All special characters will be stripped out from all the lines (e.g., `(`, `)`, `=`, `#`). 38 | 39 | ### 📊 Visualization 40 | 41 | - The X-axis of the chart is for keys. 42 | - The Y-axis of the chart is for values. 43 | - Headers will be given as legend. 44 | 45 | ### 💻 Result 46 | 47 | - The result compares the blocks and shows 2 states: 48 | - `better`: a decrease in value. 49 | - `worse`: an increase in value. 50 | - Each result line shows the change in percentage as well as the change in value. 51 | 52 | ## 🔃 The Swap 53 | 54 | You can swap the blocks, and the results will also be swapped (see the above example to find the difference). 55 | 56 | 57 | 58 | ## 🧱 Multi-blocks 59 | 60 | You can have `n` number of blocks. The first word of the header will be considered a group. When you have multiple blocks starting with the same word, the result will be the average of those blocks. See the example below. 61 | 62 | ![image](https://github.com/theapache64/benchart/assets/9678279/f3877308-2d03-4152-b7e1-aec2e244a29c) 63 | 64 | ## 👥 Auto Group 65 | 66 | To color the same group with the same color on the chart, enable Auto Group. 67 | 68 | ![image](https://github.com/theapache64/benchart/assets/9678279/c33153ae-affc-4977-8c29-edb468054053) 69 | 70 | ## ➗ Auto Average 71 | 72 | In a block, if there are multiple lines with the same key, they will be averaged. 73 | 74 | ![image](https://github.com/theapache64/benchart/assets/9678279/0a6901d0-e042-4f31-893a-0570c6d837e2) 75 | 76 | ## 🎯 Focus Group 77 | 78 | You'll see a new element called Focus Group when auto average is performed. 79 | 80 | ![image](https://github.com/theapache64/benchart/assets/9678279/173591ea-018d-44a1-bc2b-8bd6d3dc69c8) 81 | 82 | Selecting a group from the Focus Group dropdown will show each value in the chart. 83 | 84 | ![image](https://github.com/theapache64/benchart/assets/9678279/86cb663c-31c4-433e-b11b-61f9a1ce9e7c) 85 | 86 | ## 🤖 Android Support 87 | 88 | You can paste your Macrobenchmark result data into the input box, and it'll draw custom charts for each metric. 89 | 90 | ![image](https://github.com/theapache64/benchart/assets/9678279/768babb9-7cff-4798-840e-46a13009734a) 91 | 92 | ## ⭐️ Star History 93 | 94 | [![Star History Chart](https://api.star-history.com/svg?repos=theapache64/benchart&type=Date)](https://star-history.com/#theapache64/benchart&Date) 95 | 96 | ## ✍️ Author 97 | 98 | 👤 **theapache64** 99 | 100 | * Twitter: @theapache64 101 | * Email: theapache64@gmail.com 102 | 103 | Feel free to ping me 😉 104 | 105 | ## 🤝 Contributing 106 | 107 | Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. 108 | 109 | 1. Open an issue first to discuss what you would like to change. 110 | 1. Fork the project. 111 | 1. Create your feature branch (`git checkout -b feature/amazing-feature`). 112 | 1. Commit your changes (`git commit -m 'Add some amazing feature'`). 113 | 1. Push to the branch (`git push origin feature/amazing-feature`). 114 | 1. Open a pull request. 115 | 116 | Please make sure to update tests as appropriate. 117 | 118 | ## ❤ Show Your Support 119 | 120 | Give a ⭐️ if this project helped you! 121 | 122 | 123 | Patron Link 124 | 125 | 126 | 127 | Buy Me A Coffee 128 | 129 | 130 | ## 📝 License 131 | ``` 132 | Copyright © 2024 - theapache64 133 | 134 | Licensed under the Apache License, Version 2.0 (the "License"); 135 | you may not use this file except in compliance with the License. 136 | You may obtain a copy of the License at 137 | 138 | http://www.apache.org/licenses/LICENSE-2.0 139 | 140 | Unless required by applicable law or agreed to in writing, software 141 | distributed under the License is distributed on an "AS IS" BASIS, 142 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 143 | See the License for the specific language governing permissions and 144 | limitations under the License. 145 | ``` 146 | 147 | _This README was generated by [readgen](https://github.com/theapache64/readgen)_ ❤ 148 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Add compose gradle plugin 2 | plugins { 3 | kotlin("multiplatform") version "2.0.20" 4 | id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" 5 | id("org.jetbrains.compose") version "1.7.0-alpha03" 6 | kotlin("plugin.serialization") version "2.0.20" 7 | } 8 | group = "com.theapache64.benchart" 9 | version = "1.0.0-rc01" 10 | 11 | // Add maven repositories 12 | repositories { 13 | mavenCentral() 14 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 15 | } 16 | 17 | kotlin { 18 | jvm() 19 | js(IR) { 20 | nodejs { 21 | testTask { 22 | useMocha { 23 | timeout = "9s" 24 | } 25 | } 26 | } 27 | browser { 28 | testTask { 29 | useKarma { 30 | useChromeHeadless() 31 | } 32 | } 33 | } 34 | binaries.executable() 35 | } 36 | sourceSets { 37 | val jsMain by getting { 38 | dependencies { 39 | implementation(compose.web.core) 40 | implementation(compose.runtime) 41 | implementation(npm("chart.js", "4.4.7", generateExternals = false)) 42 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") 43 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC.2") 44 | 45 | } 46 | } 47 | 48 | val jsTest by getting { 49 | dependencies { 50 | implementation("org.jetbrains.kotlin:kotlin-test-js") 51 | } 52 | } 53 | 54 | val jvmMain by getting { 55 | dependencies { 56 | implementation(compose.runtime) 57 | } 58 | } 59 | 60 | val jvmTest by getting { 61 | dependencies { 62 | implementation("junit:junit:4.13.2") 63 | } 64 | } 65 | } 66 | } 67 | // Workaround for https://youtrack.jetbrains.com/issue/KT-49124 68 | rootProject.extensions.configure { 69 | versions.webpackCli.version = "4.10.0" 70 | } 71 | -------------------------------------------------------------------------------- /cover.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/cover.jpeg -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/demo.gif -------------------------------------------------------------------------------- /dist/benchart.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * @kurkle/color v0.3.4 3 | * https://github.com/kurkle/color#readme 4 | * (c) 2024 Jukka Kurkela 5 | * Released under the MIT License 6 | */ 7 | 8 | /*! 9 | * Chart.js v4.4.7 10 | * https://www.chartjs.org 11 | * (c) 2024 Chart.js Contributors 12 | * Released under the MIT License 13 | */ 14 | -------------------------------------------------------------------------------- /dist/beta/benchart.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * @kurkle/color v0.2.1 3 | * https://github.com/kurkle/color#readme 4 | * (c) 2022 Jukka Kurkela 5 | * Released under the MIT License 6 | */ 7 | 8 | /*! 9 | * Chart.js v3.9.1 10 | * https://www.chartjs.org 11 | * (c) 2022 Chart.js Contributors 12 | * Released under the MIT License 13 | */ 14 | -------------------------------------------------------------------------------- /dist/beta/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 📊 benchart 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 28 | 29 | -------------------------------------------------------------------------------- /dist/icons/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following graphics from Twitter Twemoji: 2 | 3 | - Graphics Title: 1f4ca.svg 4 | - Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji) 5 | - Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/1f4ca.svg 6 | - Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/) 7 | -------------------------------------------------------------------------------- /dist/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/dist/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /dist/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/dist/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /dist/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/dist/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /dist/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/dist/icons/favicon-16x16.png -------------------------------------------------------------------------------- /dist/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/dist/icons/favicon-32x32.png -------------------------------------------------------------------------------- /dist/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/dist/icons/favicon.ico -------------------------------------------------------------------------------- /dist/icons/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 📊 benchart 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 35 | 36 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /productionExecutable/benchart.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * @kurkle/color v0.2.1 3 | * https://github.com/kurkle/color#readme 4 | * (c) 2022 Jukka Kurkela 5 | * Released under the MIT License 6 | */ 7 | 8 | /*! 9 | * Chart.js v3.9.1 10 | * https://www.chartjs.org 11 | * (c) 2022 Chart.js Contributors 12 | * Released under the MIT License 13 | */ 14 | -------------------------------------------------------------------------------- /productionExecutable/icons/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following graphics from Twitter Twemoji: 2 | 3 | - Graphics Title: 1f4ca.svg 4 | - Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji) 5 | - Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/1f4ca.svg 6 | - Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/) 7 | -------------------------------------------------------------------------------- /productionExecutable/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/productionExecutable/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /productionExecutable/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/productionExecutable/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /productionExecutable/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/productionExecutable/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /productionExecutable/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/productionExecutable/icons/favicon-16x16.png -------------------------------------------------------------------------------- /productionExecutable/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/productionExecutable/icons/favicon-32x32.png -------------------------------------------------------------------------------- /productionExecutable/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/productionExecutable/icons/favicon.ico -------------------------------------------------------------------------------- /productionExecutable/icons/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /productionExecutable/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 📊 benchart 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 33 | 34 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 5 | } 6 | } 7 | rootProject.name = "benchart" 8 | 9 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/core/BenchmarkResult.kt: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import model.FormData 4 | 5 | open class InvalidDataException(message: String?) : Throwable(message) 6 | class InvalidBenchmarkDataException(message: String?) : InvalidDataException(message) 7 | class InvalidGenericDataException(message: String?) : InvalidDataException(message) 8 | 9 | enum class MetricUnit(val singular: String, val plural: String) { 10 | View(" view", " views"), 11 | Ms(singular = "ms", plural = "ms"), 12 | Mah(singular = " Mah", plural = "Mah"), 13 | Kb(singular = "kb", plural = "kb"), 14 | Frame(" frame", " frames"), 15 | Percentage("%", "%") 16 | } 17 | 18 | data class BlockRow( 19 | val title: String, 20 | val fullData: Map> 21 | ) { 22 | val avgData: Map = fullData.mapValues { it.value.average().toFloat() } 23 | } 24 | 25 | data class SupportedMetrics( 26 | val key: String, 27 | val emoji: String, 28 | val title: String 29 | ) { 30 | /*Duration( 31 | emoji = "⏱", 32 | key = "frameDurationCpuMs", 33 | title = "Duration Summary" 34 | ), 35 | Overrun( 36 | emoji = "🏃🏻‍♂️", 37 | key = "frameOverrunMs", 38 | title = "Overrun Summary" 39 | ), 40 | InitialDisplay( 41 | emoji = "🌘", 42 | key = "timeToInitialDisplayMs", 43 | title = "Initial Display Summary" 44 | ), 45 | FullDisplay( 46 | emoji = "🌕", 47 | key = "timeToFullDisplayMs", 48 | title = "Full Display Summary" 49 | ), 50 | CreateViewCount( 51 | emoji = "🔢", 52 | key = "RV CreateViewCount", 53 | title = "Create View Count" 54 | ), 55 | CreateViewSum( 56 | emoji = "⏲", 57 | key = "RV CreateViewSumMs", 58 | title = "Create View Time Sum" 59 | ), 60 | BindViewCount( 61 | emoji = "🔄", 62 | key = "RV OnBindViewCount", 63 | title = "Bind View Count" 64 | ), 65 | BindViewSum( 66 | emoji = "⌛️", 67 | key = "RV OnBindViewSumMs", 68 | title = "Bind View Time Sum" 69 | ), 70 | FrameCount( 71 | emoji = "🖼", 72 | key = "frameCount", 73 | title = "Frame Count" 74 | ), 75 | JankPercent( 76 | emoji = "📊", 77 | key = "gfxFrameJankPercent", 78 | title = "Frame Jank Percentage" 79 | ), 80 | FrameTime50( 81 | emoji = "⚡️", 82 | key = "gfxFrameTime50thPercentileMs", 83 | title = "Frame Time 50th Percentile" 84 | ), 85 | FrameTime90( 86 | emoji = "🚀", 87 | key = "gfxFrameTime90thPercentileMs", 88 | title = "Frame Time 90th Percentile" 89 | ), 90 | FrameTime95( 91 | emoji = "🎯", 92 | key = "gfxFrameTime95thPercentileMs", 93 | title = "Frame Time 95th Percentile" 94 | ), 95 | FrameTime99( 96 | emoji = "⚠️", 97 | key = "gfxFrameTime99thPercentileMs", 98 | title = "Frame Time 99th Percentile" 99 | ), 100 | GfxFrameCount( 101 | emoji = "🎬", 102 | key = "gfxFrameTotalCount", 103 | title = "GFX Frame Total Count" 104 | ), 105 | MemoryHeap( 106 | emoji = "💾", 107 | key = "memoryHeapSizeMaxKb", 108 | title = "Memory Heap Size" 109 | ), 110 | OrderListPopulationCount( 111 | emoji = "📋", 112 | key = "order_list_populationCount", 113 | title = "Order List Population Count" 114 | ), 115 | OrderListPopulationSum( 116 | emoji = "📝", 117 | key = "order_list_populationSumMs", 118 | title = "Order List Population Time Sum" 119 | )*/ 120 | } 121 | 122 | enum class InputType { 123 | GENERIC, 124 | MACRO_BENCHMARK 125 | } 126 | 127 | data class ResultContainer( 128 | val inputType: InputType, 129 | val benchmarkResults: List, 130 | val focusGroups: Set 131 | ) 132 | 133 | data class BenchmarkResult( 134 | val title: String, 135 | val testName: String?, 136 | val blockRows: List 137 | ) { 138 | companion object { 139 | const val FOCUS_GROUP_ALL = "All" 140 | 141 | private val titleStripRegEx = "\\W+".toRegex() 142 | private val genericTitleStripRegEx = "\\W+".toRegex() 143 | private val testNameRegex = "[A-Z].*_[a-z].*".toRegex() 144 | 145 | fun parse(form: FormData, focusGroup: String): ResultContainer? { 146 | 147 | val blocks = form.data 148 | .split("\n").joinToString(separator = "\n") { it.trim() } 149 | .split("^\\s+".toRegex(RegexOption.MULTILINE)).map { it.trim() } 150 | .filter { it.isNotBlank() } 151 | 152 | println("parsing input...") 153 | if (blocks.isEmpty()) return null 154 | if (form.isGenericInput()) return parseGenericInput(blocks, focusGroup) 155 | 156 | println("parsing machine generated benchmark input...") 157 | val benchmarkResults = mutableListOf() 158 | 159 | for ((index, block) in blocks.withIndex()) { 160 | println("block: '$block'") 161 | val lines = block.split("\n").map { it.trim() } 162 | var title: String? = null 163 | var testName: String? = null 164 | val blockRows = mutableListOf() 165 | for (line in lines) { 166 | 167 | if (title == null && isHumanLine(line)) { 168 | title = line 169 | } 170 | 171 | if (form.isTestNameDetectionEnabled && isTestName(line)) { 172 | if (testName != null && blockRows.isNotEmpty()) { 173 | 174 | if (title == null) { 175 | title = "benchmark $index $testName" 176 | } 177 | 178 | // We already have an unsaved testData, so let's save it 179 | benchmarkResults.add( 180 | BenchmarkResult( 181 | title = title, 182 | testName = testName, 183 | blockRows = blockRows 184 | ) 185 | ) 186 | 187 | blockRows.clear() 188 | } 189 | 190 | testName = line 191 | } 192 | 193 | val metricName = findMetricKeyOrNull(line) 194 | println("QuickTag: BenchmarkResult:parse: metric name is $metricName") 195 | if (metricName != null) { 196 | val isMetricAlreadyAdded = blockRows.find { it.title == metricName } != null 197 | if (isMetricAlreadyAdded) { 198 | throw InvalidBenchmarkDataException("Two $metricName found in block ${index + 1}. Expected only one") 199 | } 200 | 201 | blockRows.add( 202 | BlockRow( 203 | title = metricName, 204 | fullData = parseValues(metricName, line).map { (key, value) -> 205 | key to listOf(value) 206 | }.toMap() 207 | ) 208 | ) 209 | } 210 | } 211 | 212 | if (title == null) { 213 | title = "benchmark $index" 214 | } 215 | 216 | title = parseTitle(title) 217 | 218 | if (blockRows.isNotEmpty()) { 219 | benchmarkResults.add( 220 | BenchmarkResult( 221 | title = title, 222 | testName = testName, 223 | blockRows = blockRows 224 | ) 225 | ) 226 | } 227 | } 228 | 229 | return ResultContainer(InputType.MACRO_BENCHMARK, benchmarkResults, setOf(FOCUS_GROUP_ALL)) 230 | } 231 | 232 | private fun parseGenericInput( 233 | blocks: List, 234 | focusGroup: String 235 | ): ResultContainer { 236 | val (focusGroups, benchmarkResults) = parseMultiLineGenericInput(blocks, focusGroup) 237 | return ResultContainer( 238 | InputType.GENERIC, 239 | benchmarkResults, 240 | focusGroups 241 | ) 242 | } 243 | 244 | private fun createChartTitle(blockRows: MutableList): String { 245 | return blockRows.joinToString(separator = " vs ") { it.title } 246 | } 247 | 248 | private fun parseMultiLineGenericInput( 249 | blocks: List, 250 | focusGroup: String 251 | ): Pair, List> { 252 | val benchmarkResults = mutableListOf() 253 | val blockRows = mutableListOf() 254 | val focusGroups = mutableSetOf(FOCUS_GROUP_ALL) 255 | for ((index, block) in blocks.withIndex()) { 256 | val lines = block.split("\n").map { it.trim() } 257 | var title: String? = null 258 | val valuesMap = mutableMapOf>() 259 | for ((lineIndex, line) in lines.withIndex()) { 260 | 261 | if (title == null && isHumanLine(line)) { 262 | title = line 263 | continue 264 | } 265 | 266 | if (line.shouldSkip()) { 267 | continue 268 | } 269 | 270 | val textNumberLine = TextNumberLine.parse(lineIndex, line) ?: continue 271 | val genericTitle = parseGenericTitle(textNumberLine.text) 272 | valuesMap.getOrPut(genericTitle) { mutableListOf() }.add(textNumberLine.number) 273 | } 274 | 275 | if (title == null) { 276 | title = "benchmark $index" 277 | } 278 | 279 | title = parseGenericTitle(title) 280 | 281 | blockRows.add( 282 | BlockRow( 283 | title = title, 284 | fullData = valuesMap 285 | ) 286 | ) 287 | } 288 | 289 | for (blockRow in blockRows) { 290 | for ((key, value) in blockRow.fullData) { 291 | if (value.size > 1) { 292 | focusGroups.add(key) 293 | } 294 | } 295 | } 296 | 297 | checkDataIntegrity(blockRows) 298 | 299 | val chartTitle = createChartTitle(blockRows) 300 | 301 | benchmarkResults.add( 302 | BenchmarkResult( 303 | title = chartTitle, 304 | testName = null, 305 | blockRows = blockRows 306 | ) 307 | ) 308 | 309 | return if (focusGroup == FOCUS_GROUP_ALL || focusGroup !in focusGroups) { 310 | Pair(focusGroups, benchmarkResults) 311 | } else { 312 | Pair(focusGroups, focus(benchmarkResults, focusGroup)) 313 | } 314 | } 315 | 316 | private fun focus(benchmarkResults: List, focusGroup: String): List { 317 | val newBenchmarkResult = mutableListOf() 318 | for (result in benchmarkResults) { 319 | val blockRows = mutableListOf() 320 | for (blockRow in result.blockRows) { 321 | blockRows.add( 322 | BlockRow( 323 | title = blockRow.title, 324 | fullData = blockRow.fullData[focusGroup]?.mapIndexed { index, value -> 325 | Pair(getPositionText(index + 1), listOf(value)) 326 | }?.toMap() ?: error("Invalid focus group '$focusGroup' for ${blockRow.title}") 327 | ) 328 | ) 329 | } 330 | newBenchmarkResult.add( 331 | BenchmarkResult( 332 | title = "$focusGroup - ${result.title}", 333 | testName = result.testName, 334 | blockRows = blockRows 335 | ) 336 | ) 337 | } 338 | return newBenchmarkResult 339 | } 340 | 341 | private fun getPositionText(index: Int): String { 342 | val suffix = when { 343 | index % 100 in 11..13 -> "th" 344 | index % 10 == 1 -> "st" 345 | index % 10 == 2 -> "nd" 346 | index % 10 == 3 -> "rd" 347 | else -> "th" 348 | } 349 | return "$index$suffix" 350 | } 351 | 352 | 353 | private fun checkDataIntegrity(blockRows: List) { 354 | if (blockRows.size >= 2) { 355 | val originalValueOrder = blockRows.first().avgData.keys.toList().sorted() 356 | for ((index, blockRow) in blockRows.withIndex()) { 357 | if (index == 0) { 358 | continue 359 | } 360 | val currentValueOrder = blockRow.avgData.keys.toList().sorted() 361 | if (originalValueOrder != currentValueOrder) { 362 | error("Missing ${originalValueOrder.minus(currentValueOrder.toSet())} in '${blockRow.title}' block") 363 | } 364 | } 365 | } 366 | 367 | val keyLengthMap = mutableMapOf() 368 | blockRows.forEach { blockRow -> 369 | blockRow.fullData.forEach { (key, values) -> 370 | if (keyLengthMap.containsKey(key) && keyLengthMap[key] != values.size) { 371 | error("Item count mismatch. For '$key', ${keyLengthMap[key]} rows expected, but found ${values.size} in '${blockRow.title}' block") 372 | } else { 373 | keyLengthMap[key] = values.size 374 | } 375 | } 376 | } 377 | } 378 | 379 | 380 | private fun isTestName(line: String): Boolean { 381 | return testNameRegex.matches(line) 382 | } 383 | 384 | private fun parseTitle(title: String): String { 385 | return title 386 | .replace(titleStripRegEx, " ") 387 | .replace("\\s{2,}".toRegex(), " ") 388 | .trim() 389 | } 390 | 391 | private fun parseGenericTitle(title: String): String { 392 | return title 393 | .replace(genericTitleStripRegEx, " ") 394 | .replace("\\s{2,}".toRegex(), " ") 395 | .trim() 396 | } 397 | 398 | private fun isHumanLine(line: String): Boolean { 399 | return !isMachineLine(line) 400 | } 401 | 402 | private fun isMachineLine(line: String): Boolean { 403 | return line.trim().contains("Traces: ") || line.matches(percentileRegex) || line.matches(minMaxMedianRegex) 404 | } 405 | 406 | private fun parseValues(key: String, data: String): Map { 407 | if (!data.startsWith(key)) { 408 | error("Invalid $key.Expected to start with '$key' but found '$data'") 409 | } 410 | 411 | val transformedList = data.replace(key, "") 412 | .replace("\\s+".toRegex(), " ") 413 | .split(", ") 414 | // remove commas in numbers 415 | .map { it.replace(",", "").trim().split(" ") } 416 | 417 | val valueMap = mutableMapOf() 418 | for (item in transformedList) { 419 | valueMap[item[0]] = item[1].toFloat() 420 | } 421 | return valueMap 422 | } 423 | 424 | 425 | private fun findMetricKeyOrNull(line: String): String? { 426 | when { 427 | line.matches(minMaxMedianRegex) -> { 428 | val minMaxMedianMatch = minMaxMedianRegex.matchEntire(line)!! 429 | val (metricName, _) = minMaxMedianMatch.destructured 430 | return metricName 431 | } 432 | 433 | line.matches(percentileRegex) -> { 434 | val percentileMatch = percentileRegex.matchEntire(line)!! 435 | val (metricName, _) = percentileMatch.destructured 436 | return metricName 437 | } 438 | 439 | else -> return null 440 | } 441 | } 442 | 443 | private fun String.shouldSkip(): Boolean { 444 | return this == "startup type is: cold" || this == "startup type is: warm" || this == "startup type is: hot" 445 | } 446 | } 447 | 448 | 449 | } 450 | 451 | val minMaxMedianRegex = "^(.+?)\\s+min\\s+(.+?),\\s+median\\s+(.+?),\\s+max\\s+(.+?)\$".toRegex() 452 | val percentileRegex = "^(.+?)\\s+P50\\s+(.+?),\\s+P90\\s+(.+?),\\s+P95\\s+(.+?),\\s+P99\\s+(.+)\$".toRegex() 453 | 454 | 455 | private fun FormData.isGenericInput(): Boolean { 456 | return this.data.lines().find { line -> line.matches(minMaxMedianRegex) || line.matches(percentileRegex) } == null 457 | } 458 | 459 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/core/ChartsTransformers.kt: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import model.Chart 4 | import model.ChartsBundle 5 | 6 | 7 | fun List.toCharts(): ChartsBundle { 8 | val chartNames = this 9 | .map { result -> 10 | result.blockRows.map { dataPoint -> 11 | dataPoint.title 12 | } 13 | } 14 | .flatten() 15 | .toSet() 16 | 17 | val charts = mutableListOf() 18 | for (chartName in chartNames) { 19 | // before1 -> {P50=40.5, P90=45.8, P95=60.4, P99=80.4} 20 | val dataSets = mutableMapOf>() 21 | for (item in this) { 22 | dataSets[item.title] = item.blockRows.find { it.title == chartName }?.avgData ?: emptyMap() 23 | } 24 | 25 | charts.add( 26 | Chart( 27 | emoji = getMetricEmoji(chartName), 28 | label = chartName, // frameDurationCpuMs, frameOverrunMs, etc 29 | dataSets = dataSets 30 | ) 31 | ) 32 | } 33 | 34 | val groupMap = parseGroupMap(this, isGeneric = false) 35 | return ChartsBundle( 36 | groupMap = groupMap, 37 | charts = charts 38 | ) 39 | } 40 | 41 | fun getMetricEmoji(chartName: String): String { 42 | // TODO: 43 | return "📊" 44 | } 45 | 46 | 47 | fun List.toGenericChart(): ChartsBundle { 48 | // Generic chart will be always 1 49 | val result = this.first() 50 | 51 | val chart = Chart( 52 | emoji = "📊", 53 | label = result.title, 54 | dataSets = mutableMapOf>().apply { 55 | for(blockRow in result.blockRows){ 56 | put(blockRow.title, blockRow.avgData) 57 | } 58 | }, 59 | bsClass = "col-lg-12" 60 | ) 61 | 62 | return ChartsBundle( 63 | groupMap = parseGroupMap(this, isGeneric = true), 64 | charts = listOf( 65 | chart 66 | ) 67 | ) 68 | } 69 | 70 | 71 | data class GroupMap( 72 | val autoGroupMap: Map, 73 | val wordColorMap: Map 74 | ) 75 | 76 | fun parseGroupMap( 77 | benchmarkResults: List, 78 | isGeneric : Boolean 79 | ): GroupMap { 80 | val autoGroupMap = mutableMapOf() 81 | val titles = if(isGeneric){ 82 | benchmarkResults.flatMap { it.blockRows.map { blockRow -> blockRow.title } } 83 | }else { 84 | benchmarkResults.map { it.title } 85 | } 86 | println("titles: $titles -> ${benchmarkResults.map { it.blockRows }}") 87 | val wordColorMap = mutableMapOf() 88 | // TODO: Add more colors 89 | val lineColors = mutableListOf( 90 | "rgba(255, 99, 132, 1)", 91 | "rgba(54, 162, 235, 1)", 92 | "rgba(255, 206, 86, 1)", 93 | "rgba(75, 192, 192, 1)", 94 | "rgba(153, 102, 255, 1)", 95 | "rgba(255, 159, 64, 1)", 96 | ) 97 | for (title in titles) { 98 | val firstWord = title.split(" ")[0] 99 | val color = wordColorMap.getOrPut(firstWord) { 100 | 101 | if (lineColors.isEmpty()) { 102 | lineColors.add("rgba(${randomRgb()}, ${randomRgb()}, ${randomRgb()}, 1)") 103 | } 104 | 105 | val newColor = lineColors.first() 106 | lineColors.remove(newColor) 107 | newColor 108 | } 109 | autoGroupMap[title] = color 110 | } 111 | return GroupMap( 112 | autoGroupMap = autoGroupMap, 113 | wordColorMap = wordColorMap 114 | ).also { 115 | println("groupMap: $it") 116 | } 117 | } 118 | 119 | private fun randomRgb() = (0..255).random() 120 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/core/TextNumberLine.kt: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | private val digitRegex = "\\d+(.\\d+)?".toRegex() 4 | 5 | data class TextNumberLine( 6 | val text: String, 7 | val number: Float 8 | ) { 9 | companion object { 10 | private val AVGIZER_REGEX = "\\(input count : .+\\)\$".toRegex() 11 | fun parse(index : Int, iLine: String): TextNumberLine? { 12 | // Quick support for https://theapache64.github.io/avgizer/ 13 | val match = AVGIZER_REGEX.find(iLine) 14 | val line = if (match != null){ 15 | iLine.replace(match.groupValues.first(), "") 16 | } else { 17 | iLine 18 | } 19 | 20 | val number = digitRegex.findAll(line) 21 | .lastOrNull() 22 | ?.groupValues 23 | ?.firstOrNull() 24 | ?: return null 25 | val numberIndex = line.lastIndexOf(number) 26 | val newLine = line.substring(0, numberIndex) 27 | return TextNumberLine(newLine, number.toFloat()) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/commonMain/kotlin/model/Chart.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import core.GroupMap 4 | 5 | data class ChartsBundle( 6 | val groupMap: GroupMap, 7 | val charts: List 8 | ) 9 | 10 | data class Chart( 11 | val emoji: String, 12 | val label: String, 13 | // eg format: (before1 -> map { p50 -> 20, p90 -> 30 }) 14 | val dataSets: Map>, 15 | val bsClass : String = "col-lg-6" 16 | ) 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/model/FormData.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | data class FormData( 4 | val data: String, 5 | val isTestNameDetectionEnabled : Boolean, 6 | val isAutoGroupEnabled : Boolean, 7 | val isLoading : Boolean, 8 | val loadingProgress : Int = 0 9 | ) 10 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/model/SavedBenchmark.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/Utils.kt: -------------------------------------------------------------------------------- 1 | inline fun jso(): T = js("({})") 2 | 3 | inline fun jso(builder: T.() -> Unit): T = jso().apply(builder) -------------------------------------------------------------------------------- /src/jsMain/kotlin/chartjs/Type.kt: -------------------------------------------------------------------------------- 1 | package chartjs 2 | 3 | interface Type { 4 | companion object { 5 | inline val line: Type get() = Type("line") 6 | inline val bar: Type get() = Type("bar") 7 | 8 | inline val horizontalBar: Type get() = Type("horizontalBar") 9 | inline val radar: Type get() = Type("radar") 10 | inline val doughnut: Type get() = Type("doughnut") 11 | inline val polarArea: Type get() = Type("polarArea") 12 | inline val bubble: Type get() = Type("bubble") 13 | inline val pie: Type get() = Type("pie") 14 | inline val scatter: Type get() = Type("scatter") 15 | } 16 | } 17 | 18 | inline fun Type(value: String) = value.unsafeCast() -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/AutoFormUi.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.key 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.runtime.setValue 10 | import kotlinx.coroutines.delay 11 | import model.FormData 12 | import org.jetbrains.compose.web.attributes.ButtonType 13 | import org.jetbrains.compose.web.attributes.placeholder 14 | import org.jetbrains.compose.web.attributes.rows 15 | import org.jetbrains.compose.web.attributes.type 16 | import org.jetbrains.compose.web.css.marginRight 17 | import org.jetbrains.compose.web.css.marginTop 18 | import org.jetbrains.compose.web.css.percent 19 | import org.jetbrains.compose.web.css.px 20 | import org.jetbrains.compose.web.css.width 21 | import org.jetbrains.compose.web.dom.Button 22 | import org.jetbrains.compose.web.dom.Div 23 | import org.jetbrains.compose.web.dom.Form 24 | import org.jetbrains.compose.web.dom.H3 25 | import org.jetbrains.compose.web.dom.Label 26 | import org.jetbrains.compose.web.dom.Text 27 | import org.jetbrains.compose.web.dom.TextArea 28 | 29 | private val ALL_LOADING_MESSAGES = listOf( 30 | "Loading...", 31 | "Loading magic... This won't take long!", 32 | "Almost there! Great things are worth the wait.", 33 | "We're putting on the final touches. Stay with us!", 34 | "Looks like your network is slow 🤔... Hang tight!", 35 | "If this takes too long, try spinning in your chair!", 36 | "This is taking longer than usual. In the meantime, do 3 push-ups. Remember, health is wealth!", 37 | "Patience level: Jedi Master... Almost there!", 38 | ) 39 | 40 | 41 | @Composable 42 | fun FormUi( 43 | form: FormData, 44 | shouldSelectUnsaved: Boolean, 45 | savedBenchmarks: List, 46 | onFormChanged: (form: FormData) -> Unit, 47 | onSaveClicked: (form: FormData) -> Unit, 48 | onShareClicked: (form: FormData) -> Unit, 49 | onSavedBenchmarkChanged: (key: String) -> Unit, 50 | onLoadBenchmarkClicked: (SavedBenchmarkNode) -> Unit, 51 | onDeleteBenchmarkClicked: (SavedBenchmarkNode) -> Unit, 52 | ) { 53 | 54 | 55 | LaunchedEffect(Unit) { 56 | onFormChanged(form) 57 | } 58 | 59 | H3 { 60 | Text("⌨️ Input") 61 | } 62 | 63 | Div { 64 | Form { 65 | 66 | key("inputForm") { 67 | 68 | SavedBenchmarksDropDown( 69 | shouldSelectUnsaved = shouldSelectUnsaved, 70 | savedBenchmarks = savedBenchmarks, 71 | onSavedBenchmarkChanged = onSavedBenchmarkChanged, 72 | onLoadBenchmarkClicked = onLoadBenchmarkClicked, 73 | onDeleteBenchmarkClicked = onDeleteBenchmarkClicked 74 | ) 75 | 76 | Div( 77 | attrs = { 78 | classes("form-group") 79 | } 80 | ) { 81 | 82 | Label( 83 | forId = "benchmark", 84 | attrs = { 85 | classes("form-label") 86 | } 87 | ) { 88 | Text("Benchmark :") 89 | } 90 | 91 | TextArea( 92 | value = form.data 93 | ) { 94 | id("benchmark") 95 | classes("form-control") 96 | placeholder(value = "Benchmark data") 97 | rows(20) 98 | onInput { textInput -> 99 | onFormChanged(form.copy(data = textInput.value)) 100 | } 101 | } 102 | } 103 | 104 | if (form.isLoading) { 105 | var progress by remember { mutableStateOf(20) } 106 | LaunchedEffect(Unit) { 107 | while (progress < 90) { 108 | delay(200) 109 | progress += 4 110 | } 111 | } 112 | 113 | var loadingMsg by remember { mutableStateOf("") } 114 | LaunchedEffect(Unit) { 115 | val loadingMessages = ALL_LOADING_MESSAGES.asReversed() 116 | .toMutableList() 117 | while (loadingMessages.isNotEmpty()) { 118 | loadingMsg = loadingMessages.removeAt(loadingMessages.lastIndex) 119 | delay(5000) 120 | } 121 | } 122 | 123 | Div( 124 | attrs = { 125 | classes("progress") 126 | style { 127 | marginTop(10.px) 128 | } 129 | } 130 | ) { 131 | Div( 132 | attrs = { 133 | classes("progress-bar", "progress-bar-striped", "progress-bar-animated", "bg-success") 134 | attr("role", "progressbar") 135 | attr("aria-valuenow", "$progress") 136 | attr("aria-valuemin", "0") 137 | attr("aria-valuemax", "100") 138 | style { 139 | width(progress.percent) 140 | } 141 | } 142 | ) { 143 | Text(loadingMsg) 144 | } 145 | } 146 | } 147 | 148 | Button( 149 | attrs = { 150 | classes("btn", "btn-dark", "float-end") 151 | style { 152 | marginTop(10.px) 153 | } 154 | if (form.data.isBlank()) { 155 | attr("disabled", "true") 156 | } 157 | onClick { 158 | onSaveClicked(form) 159 | } 160 | type(ButtonType.Button) 161 | } 162 | ) { 163 | Text("💾 SAVE") 164 | } 165 | 166 | Button( 167 | attrs = { 168 | classes("btn", "btn-dark", "float-end") 169 | style { 170 | marginTop(10.px) 171 | marginRight(10.px) 172 | } 173 | if (form.data.isBlank()) { 174 | attr("disabled", "true") 175 | } 176 | onClick { 177 | onShareClicked(form) 178 | } 179 | type(ButtonType.Button) 180 | } 181 | ) { 182 | Text("🔗 SHARE") 183 | } 184 | } 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/AutoGroupToggle.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.jetbrains.compose.web.attributes.ButtonType 5 | import org.jetbrains.compose.web.attributes.type 6 | import org.jetbrains.compose.web.css.marginLeft 7 | import org.jetbrains.compose.web.css.px 8 | import org.jetbrains.compose.web.dom.* 9 | 10 | @Composable 11 | fun AutoGroup( 12 | isEnabled: Boolean, 13 | onButtonClicked: () -> Unit 14 | ) { 15 | Div( 16 | attrs = { 17 | classes("form-group") 18 | style { 19 | marginLeft(10.px) 20 | } 21 | } 22 | ) { 23 | // 🖌 Color map 24 | 25 | Label( 26 | forId = "colorMap", 27 | attrs = { 28 | classes("form-label") 29 | } 30 | ) { 31 | Text("Auto Group:") 32 | } 33 | Br() 34 | Button( 35 | attrs = { 36 | id("colorMap") 37 | classes("btn", if (isEnabled) "btn-success" else "btn-secondary") 38 | onClick { 39 | onButtonClicked() 40 | } 41 | type(ButtonType.Button) 42 | } 43 | ) { 44 | Text(if (isEnabled) "ON" else "OFF") 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/ChartUi.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import Chart 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.DisposableEffect 6 | import chartjs.Type 7 | import core.GroupMap 8 | import jso 9 | import org.jetbrains.compose.web.css.height 10 | import org.jetbrains.compose.web.css.maxHeight 11 | import org.jetbrains.compose.web.css.maxWidth 12 | import org.jetbrains.compose.web.css.percent 13 | import org.jetbrains.compose.web.css.px 14 | import org.jetbrains.compose.web.css.width 15 | import org.jetbrains.compose.web.dom.Canvas 16 | import org.jetbrains.compose.web.dom.H3 17 | import org.jetbrains.compose.web.dom.Text 18 | 19 | @Composable 20 | fun ChartUi( 21 | isColorMapEnabled: Boolean, 22 | groupMap: GroupMap, 23 | chartModel: model.Chart, 24 | onDotClicked : (focusGroup : String) -> Unit 25 | ) { 26 | H3 { Text("${chartModel.emoji} ${chartModel.label}") } 27 | 28 | // Charts 29 | Canvas( 30 | attrs = { 31 | style { 32 | width(100.percent) 33 | maxWidth(100.percent) 34 | 35 | height(700.px) 36 | maxHeight(700.px) 37 | } 38 | } 39 | ) { 40 | DisposableEffect(chartModel, isColorMapEnabled) { 41 | val dataSets = mutableListOf() 42 | for ((legend, values) in chartModel.dataSets) { 43 | 44 | dataSets.add( 45 | jso { 46 | label = legend 47 | data = values.values.toTypedArray() 48 | borderColor = if (isColorMapEnabled) { 49 | groupMap.autoGroupMap[label] 50 | } else { 51 | arrayOf( 52 | "rgba(54, 162, 235, 1)", // Blue 53 | "rgba(255, 159, 64, 1)", // Orange 54 | "rgba(75, 192, 192, 1)", // Teal 55 | "rgba(153, 102, 255, 1)", // Purple 56 | "rgba(255, 205, 86, 1)", // Yellow 57 | "rgba(22, 160, 133, 1)", // Green 58 | "rgba(142, 68, 173, 1)", // Deep Purple 59 | "rgba(230, 126, 34, 1)", // Burnt Orange 60 | "rgba(52, 152, 219, 1)", // Light Blue 61 | "rgba(46, 204, 113, 1)", // Emerald 62 | "rgba(155, 89, 182, 1)", // Amethyst 63 | "rgba(241, 196, 15, 1)", // Sun Yellow 64 | "rgba(127, 140, 141, 1)", // Asphalt 65 | "rgba(26, 188, 156, 1)", // Turquoise 66 | "rgba(201, 203, 207, 1)", // Grey 67 | "rgba(211, 84, 0, 1)", // Pumpkin 68 | "rgba(41, 128, 185, 1)", // Ocean Blue 69 | "rgba(39, 174, 96, 1)" // Nephritis 70 | ) 71 | } 72 | borderWidth = 3 73 | } 74 | ) 75 | } 76 | val chart = Chart(scopeElement, jso { 77 | type = Type.line 78 | val chartLabels = chartModel.dataSets.values.flatMap { it.keys }.toSet().toTypedArray() 79 | this.data = jso { 80 | labels = chartLabels 81 | datasets = dataSets.toTypedArray() 82 | 83 | } 84 | this.options = jso { 85 | plugins = jso { 86 | title = jso { 87 | display = true 88 | } 89 | } 90 | scales = jso { 91 | y = jso { 92 | beginAtZero = true 93 | } 94 | } 95 | onClick = { event: dynamic, elements: Array -> 96 | if (elements.isNotEmpty()) { 97 | val element = elements[0] 98 | val datasetIndex = element.datasetIndex 99 | val index = element.index 100 | val focusGroup = chartLabels[index as Int] 101 | onDotClicked(focusGroup) 102 | } 103 | } 104 | } 105 | 106 | 107 | }) 108 | onDispose { 109 | chart.destroy() 110 | } 111 | } 112 | } 113 | 114 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/EditableTitle.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.jetbrains.compose.web.attributes.InputType 5 | import org.jetbrains.compose.web.attributes.placeholder 6 | import org.jetbrains.compose.web.css.fontSize 7 | import org.jetbrains.compose.web.css.px 8 | import org.jetbrains.compose.web.dom.Div 9 | import org.jetbrains.compose.web.dom.Input 10 | import org.jetbrains.compose.web.dom.Label 11 | import org.jetbrains.compose.web.dom.Text 12 | 13 | @Composable 14 | fun EditableTitle() { 15 | Div( 16 | attrs = { 17 | classes("row") 18 | } 19 | ) { 20 | Div( 21 | attrs = { 22 | classes("form-group") 23 | } 24 | ) { 25 | Label( 26 | forId = "customTitle", 27 | attrs = { 28 | classes("form-label") 29 | } 30 | ) { 31 | Text("Title :") 32 | } 33 | Input( 34 | type = InputType.Text, 35 | ) { 36 | id("customTitle") 37 | classes("form-control") 38 | placeholder(value = "Custom title goes here") 39 | style { 40 | fontSize(24.px) 41 | } 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/Error.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.jetbrains.compose.web.dom.Div 5 | import org.jetbrains.compose.web.dom.H4 6 | import org.jetbrains.compose.web.dom.Text 7 | 8 | @Composable 9 | fun ErrorUi(message: String) { 10 | Div(attrs = { 11 | classes("row") 12 | }) { 13 | Div(attrs = { 14 | classes("col-lg-12") 15 | }) { 16 | H4(attrs = { 17 | classes("text-center") 18 | }) { 19 | Text("❌ $message") 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/FocusGroups.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.jetbrains.compose.web.attributes.selected 5 | import org.jetbrains.compose.web.dom.* 6 | 7 | 8 | @Composable 9 | fun FocusGroups( 10 | focusGroups: List, 11 | currentFocusGroup: String?, 12 | onFocusGroupSelected: (focusGroup: String) -> Unit 13 | ){ 14 | if(focusGroups.isNotEmpty()){ 15 | Div( 16 | attrs = { 17 | classes("form-group") 18 | } 19 | ) { 20 | Label( 21 | forId = "focusGroups", 22 | attrs = { 23 | classes("form-label") 24 | } 25 | ) { 26 | Text("Focus Group :") 27 | } 28 | Select( 29 | attrs = { 30 | classes("form-select") 31 | id("focusGroups") 32 | onInput { 33 | it.value?.let { focusGroup -> 34 | onFocusGroupSelected(focusGroup) 35 | } 36 | } 37 | } 38 | ) { 39 | for (focusGroup in focusGroups) { 40 | Option( 41 | value = focusGroup, 42 | attrs = { 43 | if (focusGroup == currentFocusGroup) { 44 | selected() 45 | } 46 | } 47 | ) { 48 | Text(focusGroup) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/Heading.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.jetbrains.compose.web.css.marginBottom 5 | import org.jetbrains.compose.web.css.marginRight 6 | import org.jetbrains.compose.web.css.marginTop 7 | import org.jetbrains.compose.web.css.px 8 | import org.jetbrains.compose.web.css.width 9 | import org.jetbrains.compose.web.dom.Div 10 | import org.jetbrains.compose.web.dom.H1 11 | import org.jetbrains.compose.web.dom.Img 12 | import org.jetbrains.compose.web.dom.Text 13 | 14 | 15 | private const val version = "v25.03.16.0 (16 Mar 2025)" 16 | 17 | @Composable 18 | fun Heading() { 19 | Div(attrs = { 20 | classes("row") 21 | }) { 22 | Div(attrs = { 23 | classes("col-lg-12", "text-center") 24 | style { 25 | marginBottom(30.px) 26 | marginTop(30.px) 27 | } 28 | 29 | }) { 30 | H1( 31 | attrs = { 32 | attr("data-bs-toggle", "tooltip") 33 | attr("data-bs-placement", "top") 34 | attr("title", version) 35 | } 36 | ) { 37 | Img( 38 | src = "icons/apple-touch-icon.png", 39 | attrs = { 40 | style { 41 | width(36.px) 42 | marginRight(6.px) 43 | marginTop((-8).px) 44 | } 45 | } 46 | ) 47 | Text("BenChart") 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/SavedBenchmarkNode.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SavedBenchmarkNode( 7 | val key : String, 8 | val value : String 9 | ) 10 | 11 | @Serializable 12 | data class SavedBenchmarks( 13 | var items : List 14 | ) -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/SavedBenchmarksDropDown.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.* 4 | import org.jetbrains.compose.web.attributes.ButtonType 5 | import org.jetbrains.compose.web.attributes.disabled 6 | import org.jetbrains.compose.web.attributes.selected 7 | import org.jetbrains.compose.web.attributes.type 8 | import org.jetbrains.compose.web.css.marginRight 9 | import org.jetbrains.compose.web.css.px 10 | import org.jetbrains.compose.web.dom.* 11 | import kotlin.js.Date 12 | 13 | val KEY_UNSAVED_BENCHMARK = "unsavedBenchmark_${Date().getMilliseconds()}" 14 | 15 | @Composable 16 | fun SavedBenchmarksDropDown( 17 | shouldSelectUnsaved: Boolean, 18 | savedBenchmarks: List, 19 | onSavedBenchmarkChanged: (key: String) -> Unit, 20 | onLoadBenchmarkClicked: (SavedBenchmarkNode) -> Unit, 21 | onDeleteBenchmarkClicked: (SavedBenchmarkNode) -> Unit 22 | ) { 23 | 24 | if (savedBenchmarks.isEmpty()) { 25 | return 26 | } 27 | 28 | var selectedBenchmark by remember(savedBenchmarks) { mutableStateOf(savedBenchmarks.first()) } 29 | 30 | 31 | Label( 32 | forId = "savedBenchmarks", 33 | attrs = { 34 | classes("form-label") 35 | } 36 | ) { 37 | Text("Load Benchmark :") 38 | } 39 | 40 | Div( 41 | attrs = { 42 | classes("form-group") 43 | } 44 | ) { 45 | Div( 46 | attrs = { 47 | classes("row") 48 | } 49 | ) { 50 | 51 | Div( 52 | attrs = { 53 | classes("col") 54 | } 55 | ) { 56 | Select( 57 | attrs = { 58 | classes("form-select") 59 | id("savedBenchmarks") 60 | onChange { 61 | it.value?.let { benchmarkKey -> 62 | onSavedBenchmarkChanged(benchmarkKey) 63 | selectedBenchmark = 64 | savedBenchmarks.find { benchmark -> benchmark.key == benchmarkKey }!! 65 | } 66 | } 67 | } 68 | ) { 69 | for (savedBenchmark in savedBenchmarks) { 70 | Option( 71 | value = savedBenchmark.key, 72 | attrs = { 73 | if (savedBenchmark.key == selectedBenchmark.key && !shouldSelectUnsaved) { 74 | selected() 75 | } 76 | } 77 | ) { 78 | Text(savedBenchmark.key) 79 | } 80 | } 81 | 82 | Option( 83 | value = KEY_UNSAVED_BENCHMARK, 84 | attrs = { 85 | if (shouldSelectUnsaved) { 86 | selected() 87 | } 88 | } 89 | ) { 90 | Text("Unsaved benchmark") 91 | } 92 | } 93 | } 94 | 95 | Div( 96 | attrs = { 97 | classes("col") 98 | } 99 | ) { 100 | Button( 101 | attrs = { 102 | classes("btn", "btn-primary") 103 | style { 104 | marginRight(10.px) 105 | } 106 | onClick { 107 | onLoadBenchmarkClicked(selectedBenchmark) 108 | } 109 | type(ButtonType.Button) 110 | 111 | if (shouldSelectUnsaved) { 112 | disabled() 113 | } 114 | } 115 | ) { 116 | Text("LOAD") 117 | } 118 | 119 | Button( 120 | attrs = { 121 | classes("btn", "btn-danger") 122 | onClick { 123 | onDeleteBenchmarkClicked(selectedBenchmark) 124 | } 125 | type(ButtonType.Button) 126 | 127 | if (shouldSelectUnsaved) { 128 | disabled() 129 | } 130 | } 131 | ) { 132 | Text("DELETE") 133 | } 134 | } 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/StandardDeviationUi.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.key 5 | import org.jetbrains.compose.web.attributes.href 6 | import org.jetbrains.compose.web.css.CSSColorValue 7 | import org.jetbrains.compose.web.css.Color 8 | import org.jetbrains.compose.web.css.color 9 | import org.jetbrains.compose.web.css.textAlign 10 | import org.jetbrains.compose.web.dom.A 11 | import org.jetbrains.compose.web.dom.Table 12 | import org.jetbrains.compose.web.dom.Tbody 13 | import org.jetbrains.compose.web.dom.Td 14 | import org.jetbrains.compose.web.dom.Text 15 | import org.jetbrains.compose.web.dom.Th 16 | import org.jetbrains.compose.web.dom.Thead 17 | import org.jetbrains.compose.web.dom.Tr 18 | 19 | data class SDNode( 20 | val name: String, 21 | val population: List, 22 | val standardDeviation: Float, 23 | val errorMargin: Map, 24 | val min : Float, 25 | val median :Float, 26 | val max: Float, 27 | val percentiles : Map 28 | ) 29 | 30 | 31 | @Composable 32 | fun StandardDeviationUi( 33 | groupName: String, 34 | sdNodes: List 35 | ) { 36 | Table( 37 | attrs = { 38 | attr("border", "1") 39 | classes("table", "table-bordered") 40 | } 41 | ) { 42 | Thead { 43 | Tr { 44 | Th( 45 | attrs = { 46 | attr("rowspan", "2") 47 | } 48 | ) { 49 | Text(groupName) 50 | } 51 | Th( 52 | attrs = { 53 | attr("rowspan", "2") 54 | } 55 | ) { 56 | Text("Std. Deviation") 57 | } 58 | Th( 59 | attrs = { 60 | attr("colspan", "${sdNodes.firstOrNull()?.errorMargin?.size ?: 0}") 61 | style { 62 | textAlign("center") 63 | } 64 | } 65 | ) { 66 | Text("Error Margin") 67 | } 68 | } 69 | Tr { 70 | sdNodes.firstOrNull()?.errorMargin?.keys?.forEach { emKey -> 71 | key(emKey) { 72 | Th { Text(emKey) } 73 | } 74 | } 75 | } 76 | } 77 | Tbody { 78 | for (sdNode in sdNodes) { 79 | key(sdNode.toString()) { 80 | Tr { 81 | Td { Text(sdNode.name) } 82 | Td( 83 | attrs = { 84 | title("${sdNode.population}") 85 | } 86 | ) { 87 | A( 88 | attrs = { 89 | href( 90 | "https://www.calculator.net/standard-deviation-calculator.html?numberinputs=${ 91 | sdNode.population.joinToString( 92 | separator = "," 93 | ) 94 | }&ctype=p&x=Calculate" 95 | ) 96 | style { 97 | color(Color.black) 98 | } 99 | } 100 | ) { 101 | Text(sdNode.standardDeviation.toString()) 102 | } 103 | } 104 | 105 | sdNode.errorMargin.values.forEach { margin -> 106 | Td { Text("$margin%") } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | @Composable 116 | fun Stats( 117 | groupName: String, 118 | sdNodes: List 119 | ) { 120 | Table( 121 | attrs = { 122 | attr("border", "1") 123 | classes("table", "table-bordered") 124 | } 125 | ) { 126 | Thead { 127 | Tr { 128 | Th( 129 | attrs = { 130 | attr("rowspan", "2") 131 | } 132 | ) { 133 | Text(groupName) 134 | } 135 | Th( 136 | attrs = { 137 | attr("rowspan", "2") 138 | } 139 | ) { 140 | Text("Min") 141 | } 142 | Th( 143 | attrs = { 144 | attr("rowspan", "2") 145 | } 146 | ) { 147 | Text("Median") 148 | } 149 | 150 | Th( 151 | attrs = { 152 | attr("rowspan", "2") 153 | } 154 | ) { 155 | Text("Max") 156 | } 157 | Th( 158 | attrs = { 159 | attr("colspan", "${sdNodes.firstOrNull()?.percentiles?.size ?: 0}") 160 | style { 161 | textAlign("center") 162 | } 163 | } 164 | ) { 165 | Text("Percentiles") 166 | } 167 | } 168 | Tr { 169 | sdNodes.firstOrNull()?.percentiles?.keys?.forEach { emKey -> 170 | key(emKey) { 171 | Th { Text(emKey) } 172 | } 173 | } 174 | } 175 | } 176 | Tbody { 177 | for (sdNode in sdNodes) { 178 | key(sdNode.toString()) { 179 | Tr { 180 | Td { Text(sdNode.name) } 181 | Td( 182 | attrs = { 183 | title("${sdNode.population.sorted()}") 184 | } 185 | ) { 186 | Text(sdNode.min.toString()) 187 | } 188 | 189 | Td( 190 | attrs = { 191 | title("${sdNode.population}") 192 | } 193 | ) { 194 | Text(sdNode.median.toString()) 195 | } 196 | 197 | Td( 198 | attrs = { 199 | title("${sdNode.population.sortedDescending()}") 200 | } 201 | ) { 202 | Text(sdNode.max.toString()) 203 | } 204 | 205 | 206 | sdNode.percentiles.values.forEach { percentile -> 207 | Td { Text("$percentile") } 208 | } 209 | } 210 | } 211 | } 212 | } 213 | } 214 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/Summary.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.key 5 | import core.BenchmarkResult.Companion.FOCUS_GROUP_ALL 6 | import core.MetricUnit 7 | import kotlinx.browser.document 8 | import org.jetbrains.compose.web.attributes.AttrsScope 9 | import org.jetbrains.compose.web.attributes.ButtonType 10 | import org.jetbrains.compose.web.attributes.selected 11 | import org.jetbrains.compose.web.attributes.type 12 | import org.jetbrains.compose.web.css.fontSize 13 | import org.jetbrains.compose.web.css.fontWeight 14 | import org.jetbrains.compose.web.css.px 15 | import org.jetbrains.compose.web.dom.AttrBuilderContext 16 | import org.jetbrains.compose.web.dom.Br 17 | import org.jetbrains.compose.web.dom.Button 18 | import org.jetbrains.compose.web.dom.ContentBuilder 19 | import org.jetbrains.compose.web.dom.Div 20 | import org.jetbrains.compose.web.dom.ElementBuilder 21 | import org.jetbrains.compose.web.dom.H3 22 | import org.jetbrains.compose.web.dom.Li 23 | import org.jetbrains.compose.web.dom.Option 24 | import org.jetbrains.compose.web.dom.P 25 | import org.jetbrains.compose.web.dom.Select 26 | import org.jetbrains.compose.web.dom.Small 27 | import org.jetbrains.compose.web.dom.Span 28 | import org.jetbrains.compose.web.dom.TagElement 29 | import org.jetbrains.compose.web.dom.Text 30 | import org.jetbrains.compose.web.dom.Ul 31 | import org.w3c.dom.Element 32 | import org.w3c.dom.HTMLElement 33 | import org.w3c.dom.HTMLSpanElement 34 | import kotlin.math.absoluteValue 35 | 36 | // P50 : After performed 25% better (-30ms) 37 | class SummaryNode( 38 | val isGeneric: Boolean, 39 | val emoji: String, 40 | val segment: String, 41 | val label: String, 42 | val percentage: Float, 43 | val stateWord: String, 44 | val diff: Float, 45 | val diffSymbol: String, 46 | val after: Float, 47 | val before: Float, 48 | val badgeClass: String, 49 | val unit: MetricUnit?, 50 | ) 51 | 52 | data class Summary( 53 | val title: String, 54 | val nodes: List 55 | ) 56 | 57 | @Composable 58 | fun SummaryContainer( 59 | selector: @Composable () -> Unit, 60 | oldSummaries: List, 61 | newSummaries: List, 62 | oldAvgOfCount: Int, 63 | newAvgOfCount: Int, 64 | currentFocusedGroup: String 65 | ) { 66 | 67 | selector() 68 | for ((index, summaries) in listOf(oldSummaries to oldAvgOfCount, newSummaries to newAvgOfCount).withIndex()) { 69 | key("summaries-$index") { 70 | if (summaries.first.isNotEmpty()) { 71 | Br() 72 | 73 | for (summary in summaries.first) { 74 | key(summary.title + index) { 75 | SummaryUi(summary.title, summaries.second, summary.nodes, currentFocusedGroup) 76 | Br() 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | @Composable 85 | fun SummarySelector( 86 | bestButtonLabel: String, 87 | worstButtonLabel: String, 88 | onBestClicked: () -> Unit, 89 | onWorstClicked: () -> Unit, 90 | blockNames: List, 91 | selectedBlockNameOne: String?, 92 | selectedBlockNameTwo: String?, 93 | onBlockOneSelected: (String) -> Unit, 94 | onBlockTwoSelected: (String) -> Unit, 95 | ) { 96 | 97 | Div( 98 | attrs = { 99 | classes("row", "mb-3") 100 | } 101 | ) { 102 | 103 | Div( 104 | attrs = { 105 | classes("col-auto") 106 | } 107 | ) { 108 | // Best 109 | Button( 110 | attrs = { 111 | classes("btn", "btn-outline-dark", "btn-sm") 112 | onClick { 113 | onBestClicked() 114 | } 115 | type(ButtonType.Button) 116 | } 117 | ) { 118 | Text(bestButtonLabel) 119 | } 120 | 121 | } 122 | Div( 123 | attrs = { 124 | classes("col-auto") 125 | } 126 | ) { 127 | // Best 128 | Button( 129 | attrs = { 130 | classes("btn", "btn-outline-dark", "btn-sm") 131 | onClick { 132 | onWorstClicked() 133 | } 134 | type(ButtonType.Button) 135 | } 136 | ) { 137 | Text(worstButtonLabel) 138 | } 139 | } 140 | 141 | } 142 | 143 | Div( 144 | attrs = { 145 | classes("row") 146 | } 147 | ) { 148 | repeat(2) { index -> 149 | key("block-selector-$index") { 150 | Div( 151 | attrs = { 152 | classes("col") 153 | } 154 | ) { 155 | Select( 156 | attrs = { 157 | classes("form-select") 158 | onInput { 159 | it.value?.let { newBlockName -> 160 | if (index == 0) { 161 | // first block name 162 | onBlockOneSelected(newBlockName) 163 | } else { 164 | // second block name 165 | onBlockTwoSelected(newBlockName) 166 | } 167 | } 168 | } 169 | } 170 | ) { 171 | for (blockName in blockNames) { 172 | Option( 173 | value = blockName, 174 | attrs = { 175 | val selectedBlockName = 176 | if (index == 0) selectedBlockNameOne else selectedBlockNameTwo 177 | if (blockName == selectedBlockName) { 178 | selected() 179 | } 180 | } 181 | ) { 182 | Text(blockName) 183 | } 184 | } 185 | } 186 | } 187 | 188 | if (index == 0) { 189 | Div( 190 | attrs = { 191 | classes("col-auto") 192 | } 193 | ) { 194 | P { 195 | Strong { 196 | Text("vs") 197 | } 198 | } 199 | } 200 | 201 | } 202 | } 203 | } 204 | 205 | } 206 | 207 | 208 | } 209 | 210 | private open class ElementBuilderImplementation(private val tagName: String) : 211 | ElementBuilder { 212 | private val el: Element by lazy { document.createElement(tagName) } 213 | 214 | @Suppress("UNCHECKED_CAST") 215 | override fun create(): TElement = el.cloneNode() as TElement 216 | } 217 | 218 | private val Strong: ElementBuilder = ElementBuilderImplementation("strong") 219 | 220 | @Composable 221 | fun Strong( 222 | attrs: AttrBuilderContext? = null, 223 | content: ContentBuilder? = null 224 | ) = TagElement(elementBuilder = Strong, applyAttrs = attrs, content = content) 225 | 226 | @Composable 227 | fun SummaryUi(title: String, avgOfCount: Int, summary: List, currentFocusGroup: String) { 228 | Div( 229 | attrs = { 230 | classes("row") 231 | } 232 | ) { 233 | H3 { 234 | Text(title) 235 | if (avgOfCount >= 1) { 236 | Small( 237 | attrs = { 238 | classes("text-muted") 239 | style { 240 | fontSize(18.px) 241 | } 242 | } 243 | ) { 244 | if (avgOfCount == 1) { 245 | if (currentFocusGroup != FOCUS_GROUP_ALL) { 246 | Text(" (focused on '$currentFocusGroup')") 247 | } 248 | } else { 249 | Text(" (average of $avgOfCount)") 250 | } 251 | } 252 | } 253 | } 254 | Ul { 255 | summary.forEach { node -> 256 | Li { 257 | Text("${node.emoji} ") 258 | BoldText( 259 | text = node.segment, 260 | style = { 261 | classes("text-capitalize") 262 | } 263 | ) 264 | Text(" : ") 265 | BoldText(node.label) 266 | Text(if (node.isGeneric) " looks " else " performed ") 267 | if (node.diff != 0f) { 268 | BoldText("${node.percentage}% ") 269 | } 270 | val postfix = node.getPostfix(node.diff) 271 | val beforePostfix = node.getPostfix(node.before) 272 | val afterPostfix = node.getPostfix(node.after) 273 | 274 | Span( 275 | attrs = { 276 | classes("badge", "bg-${node.badgeClass}") 277 | 278 | attr("data-bs-toggle", "tooltip") 279 | attr("data-bs-placement", "top") 280 | 281 | attr("title", if(node.diff ==0f) "both ${node.before}$beforePostfix" else "${node.before}$beforePostfix to ${node.after}$afterPostfix") 282 | } 283 | ) { 284 | Text(node.stateWord) 285 | } 286 | Text(" (${node.diffSymbol}${node.diff}$postfix)") 287 | } 288 | } 289 | } 290 | } 291 | } 292 | 293 | fun SummaryNode.getPostfix(num: Float): String { 294 | return unit?.let { unit -> 295 | if (num.absoluteValue > 1) unit.plural else unit.singular 296 | } ?: "" 297 | } 298 | fun String.predictTitle(): String { 299 | var name = this 300 | if (name.endsWith("Mah", ignoreCase = false)) { 301 | name = name.replace("Mah", "") 302 | } 303 | if (name.endsWith("Ms", ignoreCase = false)) { 304 | name = name.replace("Ms", "") 305 | } 306 | return name.map { 307 | if (it.isUpperCase()) { 308 | " $it" 309 | } else { 310 | it.toString() 311 | } 312 | }.joinToString(separator = "").replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } 313 | } 314 | 315 | @Composable 316 | private fun BoldText( 317 | text: String, 318 | style: (AttrsScope.() -> Unit)? = null 319 | ) { 320 | Span( 321 | attrs = { 322 | style?.invoke(this) 323 | style { 324 | fontWeight("bold") 325 | } 326 | } 327 | ) { 328 | Text(text) 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/TestNameDetectionToggle.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.jetbrains.compose.web.attributes.ButtonType 5 | import org.jetbrains.compose.web.attributes.type 6 | import org.jetbrains.compose.web.css.marginLeft 7 | import org.jetbrains.compose.web.css.px 8 | import org.jetbrains.compose.web.dom.* 9 | 10 | @Composable 11 | fun TestNameDetectionToggle( 12 | isEnabled: Boolean, 13 | onButtonClicked: () -> Unit 14 | ) { 15 | Div( 16 | attrs = { 17 | classes("form-group") 18 | style { 19 | marginLeft(10.px) 20 | } 21 | } 22 | ) { 23 | // 🖌 Color map 24 | 25 | Label( 26 | forId = "testNameDetection", 27 | attrs = { 28 | classes("form-label") 29 | } 30 | ) { 31 | Text("Test Name Detection:") 32 | } 33 | Br() 34 | Button( 35 | attrs = { 36 | id("testNameDetection") 37 | classes("btn", if (isEnabled) "btn-success" else "btn-secondary") 38 | onClick { 39 | onButtonClicked() 40 | } 41 | type(ButtonType.Button) 42 | } 43 | ) { 44 | Text(if (isEnabled) "ON" else "OFF") 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/components/TestNames.kt: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.jetbrains.compose.web.attributes.selected 5 | import org.jetbrains.compose.web.dom.* 6 | 7 | 8 | @Composable 9 | fun TestNames( 10 | testNames: List, 11 | currentTestName: String? = null, 12 | onTestNameSelected: (option: String) -> Unit 13 | ){ 14 | if(testNames.isNotEmpty()){ 15 | Div( 16 | attrs = { 17 | classes("form-group") 18 | } 19 | ) { 20 | Label( 21 | forId = "testNames", 22 | attrs = { 23 | classes("form-label") 24 | } 25 | ) { 26 | Text("Test Name :") 27 | } 28 | Select( 29 | attrs = { 30 | classes("form-select") 31 | id("testNames") 32 | onInput { 33 | it.value?.let { newTestName -> 34 | onTestNameSelected(newTestName) 35 | } 36 | } 37 | } 38 | ) { 39 | for (testName in testNames) { 40 | Option( 41 | value = testName, 42 | attrs = { 43 | if (testName == currentTestName) { 44 | selected() 45 | } 46 | } 47 | ) { 48 | Text(testName) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.web.renderComposable 2 | import page.home.HomePageUi 3 | 4 | const val IS_INJECT_DUMMY = true 5 | 6 | fun main() { 7 | 8 | initChartSettings() 9 | renderComposable(rootElementId = "root") { 10 | HomePageUi() 11 | } 12 | } 13 | 14 | private fun initChartSettings() { 15 | Chart.register( 16 | ArcElement, 17 | LineElement, 18 | BarElement, 19 | PointElement, 20 | BarController, 21 | BubbleController, 22 | DoughnutController, 23 | LineController, 24 | PieController, 25 | PolarAreaController, 26 | RadarController, 27 | ScatterController, 28 | CategoryScale, 29 | LinearScale, 30 | LogarithmicScale, 31 | RadialLinearScale, 32 | TimeScale, 33 | TimeSeriesScale, 34 | Decimation, 35 | Filler, 36 | Legend, 37 | Title, 38 | Tooltip, 39 | SubTitle 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/page/home/HomePage.kt: -------------------------------------------------------------------------------- 1 | package page.home 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import components.AutoGroup 6 | import components.ChartUi 7 | import components.EditableTitle 8 | import components.ErrorUi 9 | import components.FocusGroups 10 | import components.FormUi 11 | import components.Heading 12 | import components.StandardDeviationUi 13 | import components.Stats 14 | import components.SummaryContainer 15 | import components.SummarySelector 16 | import components.TestNameDetectionToggle 17 | import components.TestNames 18 | import core.InputType 19 | import org.jetbrains.compose.web.css.paddingBottom 20 | import org.jetbrains.compose.web.css.paddingLeft 21 | import org.jetbrains.compose.web.css.paddingRight 22 | import org.jetbrains.compose.web.css.px 23 | import org.jetbrains.compose.web.dom.Br 24 | import org.jetbrains.compose.web.dom.Div 25 | import org.jetbrains.compose.web.dom.Form 26 | import org.jetbrains.compose.web.dom.H3 27 | import org.jetbrains.compose.web.dom.Text 28 | import repo.BenchmarkRepoImpl 29 | import repo.FormRepoImpl 30 | import repo.GoogleFormRepoImpl 31 | import repo.GoogleSheetRepoImpl 32 | import repo.UserRepoImpl 33 | 34 | @Composable 35 | fun HomePageUi( 36 | viewModel: HomeViewModel = remember { 37 | HomeViewModel( 38 | BenchmarkRepoImpl(), 39 | FormRepoImpl(), 40 | GoogleFormRepoImpl(), 41 | GoogleSheetRepoImpl(), 42 | UserRepoImpl() 43 | ) 44 | } 45 | ) { 46 | Div( 47 | attrs = { 48 | classes("container-fluid") 49 | } 50 | ) { 51 | 52 | // Heading 53 | Heading() 54 | 55 | // Error 56 | if (viewModel.errorMsg.isNotBlank()) { 57 | ErrorUi(viewModel.errorMsg) 58 | } 59 | 60 | 61 | // Main 62 | Div(attrs = { 63 | classes("row") 64 | style { 65 | paddingLeft(40.px) 66 | paddingRight(40.px) 67 | paddingBottom(40.px) 68 | } 69 | }) { 70 | Div(attrs = { 71 | classes("col-lg-4") 72 | }) { 73 | FormUi( 74 | form = viewModel.form, 75 | shouldSelectUnsaved = viewModel.shouldSelectUnsaved, 76 | onFormChanged = viewModel::onFormChanged, 77 | onSaveClicked = viewModel::onSaveClicked, 78 | savedBenchmarks = viewModel.savedBenchmarks, 79 | onSavedBenchmarkChanged = viewModel::onSavedBenchmarkChanged, 80 | onLoadBenchmarkClicked = viewModel::onLoadBenchmarkClicked, 81 | onDeleteBenchmarkClicked = viewModel::onDeleteBenchmarkClicked, 82 | onShareClicked = viewModel::onShareClicked 83 | ) 84 | 85 | Br() 86 | Br() 87 | 88 | SummaryContainer( 89 | selector = { 90 | println("block size ${viewModel.blockNames.size}") 91 | if (viewModel.blockNames.size > 2) { 92 | SummarySelector( 93 | bestButtonLabel = "BEST (-${viewModel.bestAggSummary?.sumOfGreen}${viewModel.unit})", 94 | worstButtonLabel = "WORST (+${viewModel.worstAggSummary?.sumOfRed}${viewModel.unit})", 95 | onBestClicked = viewModel::onBestClicked, 96 | onWorstClicked = viewModel::onWorstClicked, 97 | blockNames = viewModel.blockNames, 98 | selectedBlockNameOne = viewModel.selectedBlockNameOne, 99 | selectedBlockNameTwo = viewModel.selectedBlockNameTwo, 100 | onBlockOneSelected = viewModel::onBlockNameOneChanged, 101 | onBlockTwoSelected = viewModel::onBlockNameTwoChanged 102 | ) 103 | } 104 | }, 105 | newSummaries = viewModel.summaries, 106 | oldSummaries = viewModel.oldSummaries, 107 | newAvgOfCount = viewModel.avgOfCount, 108 | oldAvgOfCount = viewModel.oldAvgOfCount, 109 | currentFocusedGroup = viewModel.currentFocusedGroup 110 | ) 111 | } 112 | 113 | viewModel.chartsBundle?.charts?.takeIf { it.isNotEmpty() }?.let { fullChartsList -> 114 | val mainCharts = viewModel.chartsBundle ?: error("TSH") 115 | Div( 116 | attrs = { 117 | classes("col-lg-8") 118 | } 119 | ) { 120 | 121 | if (viewModel.isEditableTitleEnabled) { 122 | EditableTitle() 123 | } else { 124 | H3( 125 | attrs = { 126 | onDoubleClick { 127 | viewModel.onTitleDoubleClicked() 128 | } 129 | } 130 | ) { 131 | Text("🖥 Output") 132 | } 133 | } 134 | 135 | // 🧪 ToolBar 136 | Div( 137 | attrs = { 138 | classes("row") 139 | } 140 | ) { 141 | Form { 142 | Div( 143 | attrs = { 144 | classes("row") 145 | } 146 | ) { 147 | 148 | if (viewModel.isAutoGroupButtonVisible) { 149 | Div( 150 | attrs = { 151 | classes("col-md-2") 152 | } 153 | ) { 154 | AutoGroup( 155 | isEnabled = viewModel.form.isAutoGroupEnabled, 156 | onButtonClicked = viewModel::onToggleAutoGroupClicked 157 | ) 158 | } 159 | } 160 | 161 | if (viewModel.focusGroups.size > 1) { 162 | Div( 163 | attrs = { 164 | classes("col-md-4") 165 | } 166 | ) { 167 | FocusGroups( 168 | focusGroups = viewModel.focusGroups, 169 | currentFocusGroup = viewModel.currentFocusedGroup, 170 | onFocusGroupSelected = { focusGroup -> 171 | viewModel.onFocusGroupSelected(focusGroup) 172 | } 173 | ) 174 | } 175 | } 176 | 177 | if (viewModel.inputType == InputType.MACRO_BENCHMARK) { 178 | Div( 179 | attrs = { 180 | classes("col-md-2") 181 | } 182 | ) { 183 | TestNameDetectionToggle( 184 | isEnabled = viewModel.form.isTestNameDetectionEnabled, 185 | onButtonClicked = viewModel::onToggleTestNameDetectionClicked 186 | ) 187 | } 188 | } 189 | 190 | if (viewModel.testNames.isNotEmpty()) { 191 | Div( 192 | attrs = { 193 | classes("col-md-4") 194 | } 195 | ) { 196 | TestNames( 197 | testNames = viewModel.testNames, 198 | onTestNameSelected = { newTestName -> 199 | viewModel.onTestNameSelected(newTestName) 200 | } 201 | ) 202 | } 203 | 204 | } 205 | 206 | } 207 | } 208 | } 209 | 210 | Br() 211 | val chunkedCharts = remember(fullChartsList) { fullChartsList.chunked(2) } 212 | 213 | 214 | // 📊 Charts 215 | for (charts in chunkedCharts) { 216 | Div( 217 | attrs = { 218 | classes("row") 219 | } 220 | ) { 221 | for (chart in charts) { 222 | // 📊 duration chart 223 | Div(attrs = { 224 | classes(chart.bsClass) 225 | }) { 226 | ChartUi( 227 | isColorMapEnabled = viewModel.form.isAutoGroupEnabled, 228 | groupMap = mainCharts.groupMap, 229 | chartModel = chart, 230 | onDotClicked = viewModel::onDotClicked, 231 | ) 232 | } 233 | } 234 | } 235 | } 236 | 237 | 238 | Br() 239 | 240 | // Summary 241 | if(viewModel.sdNodes.isNotEmpty()){ 242 | Div( 243 | attrs = { 244 | classes("row") 245 | } 246 | ) { 247 | Div( 248 | attrs = { 249 | classes("col-md-6") 250 | } 251 | ) { 252 | H3 { 253 | Text("📈 Standard Deviation: ") 254 | } 255 | 256 | StandardDeviationUi(viewModel.currentFocusedGroup, viewModel.sdNodes) 257 | } 258 | 259 | Div( 260 | attrs = { 261 | classes("col-md-6") 262 | } 263 | ) { 264 | H3 { 265 | Text("📈 Statistical Summary: ") 266 | } 267 | 268 | Stats(viewModel.currentFocusedGroup, viewModel.sdNodes) 269 | } 270 | } 271 | 272 | 273 | } 274 | } 275 | } 276 | 277 | 278 | } 279 | } 280 | 281 | ShareAwareModal( 282 | onShareClicked = { 283 | viewModel.onAwarePublicShare() 284 | } 285 | ) 286 | 287 | SharedModal( 288 | shareUrl = viewModel.sharedUrl, 289 | onCopyToClipboardClicked = { sharedUrl -> 290 | viewModel.onCopyToClipboardClicked(sharedUrl) 291 | } 292 | ) 293 | } 294 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/page/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package page.home 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateListOf 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import components.KEY_UNSAVED_BENCHMARK 9 | import components.SDNode 10 | import components.SavedBenchmarkNode 11 | import components.Summary 12 | import core.BenchmarkResult 13 | import core.BenchmarkResult.Companion.FOCUS_GROUP_ALL 14 | import core.InputType 15 | import core.toCharts 16 | import core.toGenericChart 17 | import kotlinx.browser.window 18 | import model.ChartsBundle 19 | import model.FormData 20 | import org.w3c.dom.events.KeyboardEvent 21 | import repo.BenchmarkRepo 22 | import repo.FormRepo 23 | import repo.GoogleFormRepo 24 | import repo.GoogleSheetRepo 25 | import repo.UserRepo 26 | import utils.DefaultValues 27 | import utils.RandomString 28 | import utils.SummaryUtils 29 | import utils.calculateErrorMargins 30 | import kotlin.js.Date 31 | import kotlin.math.min 32 | 33 | external fun setTimeout(handler: dynamic, timeout: Int): Int 34 | external fun clearTimeout(timeoutId: Int) 35 | 36 | data class ConfidenceIntervals( 37 | val mean: Float, 38 | // Absolute margins 39 | val marginOf68p3: Float, 40 | val marginOf90: Float, 41 | val marginOf95: Float, 42 | val marginOf99: Float, 43 | // Percentage margins 44 | val percentageMarginOf68p3: Float, 45 | val percentageMarginOf90: Float, 46 | val percentageMarginOf95: Float, 47 | val percentageMarginOf99: Float, 48 | val sampleSize: Int, 49 | val standardDeviation: Float 50 | ) 51 | 52 | 53 | @Stable 54 | class HomeViewModel( 55 | private val benchmarkRepo: BenchmarkRepo, 56 | private val formRepo: FormRepo, 57 | private val googleFormRepo: GoogleFormRepo, 58 | private val googleSheetRepo: GoogleSheetRepo, 59 | private val userRepo: UserRepo 60 | ) { 61 | 62 | companion object { 63 | private const val ERROR_GENERIC = "Something went wrong!" 64 | 65 | // keys 66 | const val RETRY_COUNT = 3 67 | } 68 | 69 | 70 | var savedBenchmarks by mutableStateOf>(emptyList()) 71 | private set 72 | 73 | // States 74 | private var currentTestName: String? = null 75 | 76 | var testNames = mutableStateListOf() 77 | private set 78 | 79 | 80 | var currentFocusedGroup by mutableStateOf(FOCUS_GROUP_ALL) 81 | private set 82 | 83 | var focusGroups = mutableStateListOf() 84 | private set 85 | 86 | var chartsBundle by mutableStateOf(null) 87 | private set 88 | 89 | var errorMsg by mutableStateOf("") 90 | private set 91 | 92 | var isEditableTitleEnabled by mutableStateOf(false) 93 | private set 94 | 95 | var shouldSelectUnsaved by mutableStateOf(false) 96 | private set 97 | 98 | var selectedBlockNameOne by mutableStateOf(null) 99 | private set 100 | 101 | var selectedBlockNameTwo by mutableStateOf(null) 102 | private set 103 | 104 | var blockNames = mutableStateListOf() 105 | private set 106 | 107 | var sdNodes = mutableStateListOf() 108 | private set 109 | 110 | var oldAvgOfCount by mutableStateOf(-1) 111 | private set 112 | 113 | var avgOfCount by mutableStateOf(-1) 114 | private set 115 | 116 | var isAutoGroupButtonVisible by mutableStateOf(false) 117 | private set 118 | 119 | var oldSummaries = mutableStateListOf() 120 | private set 121 | 122 | var summaries = mutableStateListOf() 123 | private set 124 | 125 | var inputType by mutableStateOf(null) 126 | private set 127 | 128 | var unit by mutableStateOf("") 129 | private set 130 | 131 | var bestAggSummary by mutableStateOf(null) 132 | private set 133 | 134 | var worstAggSummary by mutableStateOf(null) 135 | private set 136 | 137 | var sharedUrl by mutableStateOf(null) 138 | private set 139 | 140 | var form by mutableStateOf( 141 | FormData( 142 | data = "", 143 | isTestNameDetectionEnabled = false, 144 | isAutoGroupEnabled = false, 145 | isLoading = true 146 | ) 147 | ) 148 | private set 149 | 150 | init { 151 | refreshBenchmarks() 152 | 153 | // set key press listener on window 154 | window.addEventListener("keydown", { 155 | val event = it.unsafeCast() 156 | if (event.key == "Escape") { 157 | onFocusGroupSelected(FOCUS_GROUP_ALL) 158 | } 159 | }) 160 | 161 | // Reading shareKey 162 | val currentUrl = window.location.href 163 | val shareKey = if (currentUrl.contains("#")) { 164 | currentUrl.substring(currentUrl.lastIndexOf("#") + 1).trim() 165 | } else { 166 | null 167 | } 168 | println("QuickTag: HomeViewModel:: shareKey: '$shareKey'") 169 | if (!shareKey.isNullOrBlank()) { 170 | // Load input for the shareKey 171 | googleSheetRepo.getSharedInput( 172 | shareKey = shareKey, 173 | onSharedInput = { sharedInput -> 174 | form = form.copy(data = sharedInput, isLoading = false) 175 | onFormChanged(form) 176 | sharedUrl = window.location.href 177 | }, 178 | onFailed = { message -> 179 | window.alert(message) 180 | loadDefaultForm() 181 | } 182 | ) 183 | } else { 184 | loadDefaultForm() 185 | } 186 | } 187 | 188 | private fun loadDefaultForm() { 189 | form = (formRepo.getFormData() ?: form.copy(data = DefaultValues.form)).copy(isLoading = false) 190 | } 191 | 192 | private fun refreshBenchmarks() { 193 | savedBenchmarks = benchmarkRepo.getSavedBenchmarks() 194 | } 195 | 196 | // Normal fields 197 | private val fullBenchmarkResults = mutableListOf() 198 | 199 | 200 | var timeoutId: Int? = null 201 | fun debounce(func: () -> Unit, delay: Int) { 202 | timeoutId?.let { clearTimeout(it) } 203 | timeoutId = setTimeout({ 204 | func() 205 | }, delay) 206 | } 207 | 208 | fun onFormChanged(unfilteredForm: FormData, shouldSelectUnsaved: Boolean = true) { 209 | val oldFormData = form.data 210 | 211 | // filtering android log 212 | form = unfilteredForm.copy(data = filterOutAndroidJunkLog(unfilteredForm.data)) 213 | 214 | // check if input changes 215 | if (oldFormData != form.data) { 216 | console.log("input has changed...") 217 | sharedUrl = null 218 | } 219 | 220 | formRepo.storeFormData(form) 221 | 222 | debounce( 223 | func = { 224 | 225 | this.shouldSelectUnsaved = shouldSelectUnsaved 226 | try { 227 | // clearing old data 228 | fullBenchmarkResults.clear() 229 | testNames.clear() 230 | focusGroups.clear() 231 | blockNames.clear() 232 | sdNodes.clear() 233 | 234 | // refill 235 | val (inputType, benchmarkResults, focusGroups) = BenchmarkResult.parse(form, currentFocusedGroup) 236 | ?: run { 237 | println("failed to parse form") 238 | reset() 239 | errorMsg = "" 240 | return@debounce 241 | } 242 | this.inputType = inputType 243 | fullBenchmarkResults.addAll(benchmarkResults) 244 | this.focusGroups.addAll(focusGroups) 245 | 246 | 247 | if (!focusGroups.contains(currentFocusedGroup)) { 248 | currentFocusedGroup = FOCUS_GROUP_ALL 249 | } 250 | 251 | if (currentFocusedGroup == FOCUS_GROUP_ALL) { 252 | oldAvgOfCount = -1 253 | } else if (oldAvgOfCount == -1) { 254 | oldAvgOfCount = avgOfCount 255 | } 256 | 257 | avgOfCount = benchmarkResults 258 | .flatMap { 259 | it.blockRows.map { blockRow -> 260 | blockRow.fullData.map { fullData -> 261 | fullData.value.size 262 | } 263 | } 264 | }.flatten().takeIf { it.isNotEmpty() }?.min() ?: -1 265 | 266 | 267 | when (inputType) { 268 | InputType.GENERIC -> { 269 | val newCharts = fullBenchmarkResults.toGenericChart() 270 | chartsBundle = newCharts 271 | onChartsBundleUpdated(newCharts) 272 | unit = "" 273 | } 274 | 275 | InputType.MACRO_BENCHMARK -> { 276 | 277 | testNames.addAll(fullBenchmarkResults.mapNotNull { it.testName }.toSet()) 278 | 279 | val currentTestName = testNames.find { it == currentTestName } ?: testNames.firstOrNull() 280 | val filteredBenchmarkResult = if (currentTestName != null) { 281 | fullBenchmarkResults.filter { it.testName == currentTestName } 282 | } else { 283 | fullBenchmarkResults 284 | } 285 | val newCharts = filteredBenchmarkResult.toCharts() 286 | chartsBundle = newCharts 287 | onChartsBundleUpdated(newCharts) 288 | unit = "ms" 289 | } 290 | } 291 | 292 | if (currentFocusedGroup != FOCUS_GROUP_ALL) { 293 | fullBenchmarkResults 294 | .flatMap { it.blockRows } 295 | .forEach { blockRow -> 296 | console.log("Block row is ", blockRow) 297 | val population = blockRow.avgData.values 298 | val confidenceIntervals = population.calculateErrorMargins() 299 | sdNodes.add( 300 | SDNode( 301 | name = blockRow.title, 302 | population = population.toList(), 303 | standardDeviation = confidenceIntervals.standardDeviation.formatTwoDecimals(), 304 | errorMargin = mapOf( 305 | "68.3%" to confidenceIntervals.percentageMarginOf68p3.formatTwoDecimals(), 306 | "90%" to confidenceIntervals.percentageMarginOf90.formatTwoDecimals(), 307 | "95%" to confidenceIntervals.percentageMarginOf95.formatTwoDecimals(), 308 | "99%" to confidenceIntervals.percentageMarginOf99.formatTwoDecimals(), 309 | ), 310 | min = population.minOrNull() ?: 0f, 311 | median = population.average().toFloat().formatTwoDecimals(), 312 | max = population.maxOrNull() ?: 0f, 313 | percentiles = mapOf( 314 | "50%" to population.sorted()[min(0.50 * population.size, population.size - 1f.toDouble()).toInt()].formatTwoDecimals(), 315 | "90%" to population.sorted()[min(0.90 * population.size, population.size - 1f.toDouble()).toInt()].formatTwoDecimals(), 316 | "99%" to population.sorted()[min(0.99 * population.size, population.size - 1f.toDouble()).toInt()].formatTwoDecimals() 317 | ) 318 | ) 319 | ) 320 | } 321 | } 322 | 323 | 324 | val autoGroupMapSize = chartsBundle?.groupMap?.autoGroupMap?.size ?: 0 325 | val wordColorMapSize = chartsBundle?.groupMap?.wordColorMap?.size ?: 0 326 | isAutoGroupButtonVisible = autoGroupMapSize != wordColorMapSize 327 | errorMsg = "" 328 | } catch (e: Throwable) { 329 | e.printStackTrace() 330 | errorMsg = e.message ?: ERROR_GENERIC 331 | reset() 332 | } 333 | }, 334 | 300 335 | ) 336 | } 337 | 338 | 339 | private fun Float.formatTwoDecimals(): Float { 340 | return asDynamic().toFixed(2).toString().toFloat() 341 | } 342 | 343 | 344 | // timestamp eg : 2024-06-29 11:30:46.641 345 | val fullTimestampRegex = "\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}".toRegex() 346 | 347 | // compact timestamp eg: 11:30:46.865 348 | val compactTimestampRegex = "\\d{2}:\\d{2}:\\d{2}\\.\\d{3}".toRegex() 349 | 350 | val logLevelRegex = "^(I|D|E|W|V) ".toRegex() 351 | 352 | /** 353 | * this is a custom logic to filter out android junk logs (personal) 354 | */ 355 | private fun filterOutAndroidJunkLog(data: String): String { 356 | return data.split("\n") 357 | .filterNot { line -> 358 | // line removal 359 | line.contains("PROCESS ENDED", ignoreCase = false) || 360 | line.contains("PROCESS STARTED", ignoreCase = false) 361 | }.joinToString(separator = "\n") { 362 | // line manipulation 363 | var line = it.replace(fullTimestampRegex, "").trimStart() 364 | line = line.replace(compactTimestampRegex, "").trimStart() 365 | if (line.startsWith("System.out ")) { 366 | line = line.replace("System.out ", "").trimStart() 367 | } 368 | line = line.replace(logLevelRegex, "").trimStart() 369 | line = when { 370 | line.contains("startup type is: cold") -> { 371 | "startup type is: cold" 372 | } 373 | 374 | line.contains("startup type is: warm") -> { 375 | "startup type is: warm" 376 | } 377 | 378 | line.contains("startup type is: hot") -> { 379 | "startup type is: hot" 380 | } 381 | 382 | else -> { 383 | line 384 | } 385 | }.trimStart() 386 | line 387 | } 388 | } 389 | 390 | private fun reset() { 391 | selectedBlockNameOne = null 392 | selectedBlockNameTwo = null 393 | blockNames.clear() 394 | chartsBundle = null 395 | summaries.clear() 396 | oldSummaries.clear() 397 | bestAggSummary = null 398 | worstAggSummary = null 399 | avgOfCount = -1 400 | oldAvgOfCount = -1 401 | sdNodes.clear() 402 | updateSummary() 403 | } 404 | 405 | private fun calcAggSummary() { 406 | val isGeneric = inputType == InputType.GENERIC 407 | val newAggSums = mutableListOf() 408 | for (blockNameOuter in blockNames) { 409 | for (blockNameInner in blockNames) { 410 | if (blockNameOuter == blockNameInner) { 411 | continue 412 | } 413 | chartsBundle?.charts?.mapNotNull { chart -> 414 | SummaryUtils.getSummaryOrThrow( 415 | currentFocusedGroup = currentFocusedGroup, 416 | isGeneric = isGeneric, 417 | chart = chart, 418 | selectedBlockNameOne = blockNameOuter, 419 | selectedBlockNameTwo = blockNameInner 420 | ) 421 | }?.let { summaries -> 422 | var greenSum = 0 423 | var redSum = 0 424 | for (summary in summaries) { 425 | for (node in summary.nodes) { 426 | when { 427 | node.diff > 0 -> { 428 | // bad 429 | redSum += node.diff.toInt() 430 | } 431 | 432 | node.diff < 0 -> { 433 | // green 434 | greenSum -= node.diff.toInt() 435 | } 436 | } 437 | } 438 | } 439 | newAggSums.add(AggSummary(blockNameOuter, blockNameInner, sumOfGreen = greenSum, sumOfRed = redSum)) 440 | } 441 | } 442 | } 443 | 444 | bestAggSummary = newAggSums.maxByOrNull { it.sumOfGreen } 445 | worstAggSummary = newAggSums.maxByOrNull { it.sumOfRed } 446 | } 447 | 448 | private fun onChartsBundleUpdated(chartsBundle: ChartsBundle) { 449 | blockNames.clear() 450 | val blockNames = chartsBundle.groupMap.wordColorMap.keys.toList() 451 | this.blockNames.addAll(blockNames) 452 | if (blockNames.size >= 2) { 453 | selectedBlockNameOne = blockNames[0] 454 | selectedBlockNameTwo = blockNames[1] 455 | } else { 456 | selectedBlockNameOne = null 457 | selectedBlockNameTwo = null 458 | } 459 | updateSummary() 460 | } 461 | 462 | private fun updateSummary() { 463 | if (currentFocusedGroup != FOCUS_GROUP_ALL && oldSummaries.isEmpty()) { 464 | // preserving previous summary because user is now focusing ona particular group 465 | oldSummaries.addAll(summaries) 466 | println("QuickTag: HomeViewModel:updateSummary: preserving ${summaries.size} summary nodes (old $oldAvgOfCount) ") 467 | } 468 | 469 | if (currentFocusedGroup == FOCUS_GROUP_ALL && oldSummaries.isNotEmpty()) { 470 | println("QuickTag: HomeViewModel:updateSummary: clearing ${oldSummaries.size} nodes") 471 | // user is not focused on a particular metric, hence two summaries are not needed. 472 | // the old summaries can now be cleared 473 | oldSummaries.clear() 474 | oldAvgOfCount = -1 475 | } 476 | 477 | // Calculating duration summary 478 | summaries.clear() 479 | 480 | val isGeneric = inputType == InputType.GENERIC 481 | val allSummaries = chartsBundle?.charts?.mapNotNull { chart -> 482 | SummaryUtils.getSummaryOrThrow( 483 | currentFocusedGroup = currentFocusedGroup, 484 | isGeneric = isGeneric, 485 | chart = chart, 486 | selectedBlockNameOne = selectedBlockNameOne, 487 | selectedBlockNameTwo = selectedBlockNameTwo 488 | ) 489 | } 490 | summaries.addAll(allSummaries ?: emptyList()) 491 | calcAggSummary() 492 | } 493 | 494 | fun onTestNameSelected(newTestName: String) { 495 | try { 496 | currentTestName = newTestName 497 | val filteredBenchmarkResult = if (currentTestName != null) { 498 | fullBenchmarkResults.filter { it.testName == currentTestName } 499 | } else { 500 | fullBenchmarkResults 501 | } 502 | val newCharts = filteredBenchmarkResult.toCharts() 503 | chartsBundle = newCharts 504 | updateSummary() 505 | errorMsg = "" 506 | } catch (e: Throwable) { 507 | summaries.clear() 508 | e.printStackTrace() 509 | errorMsg = e.message ?: ERROR_GENERIC 510 | } 511 | } 512 | 513 | fun onFocusGroupSelected(focusGroup: String) { 514 | currentFocusedGroup = focusGroup 515 | onFormChanged(form) 516 | } 517 | 518 | fun onTitleDoubleClicked() { 519 | isEditableTitleEnabled = true 520 | } 521 | 522 | fun onToggleAutoGroupClicked() { 523 | onFormChanged(form.copy(isAutoGroupEnabled = !form.isAutoGroupEnabled)) 524 | } 525 | 526 | fun onToggleTestNameDetectionClicked() { 527 | onFormChanged(form.copy(isTestNameDetectionEnabled = !form.isTestNameDetectionEnabled)) 528 | } 529 | 530 | fun onSaveClicked(formData: FormData) { 531 | val bName = window.prompt("Name: ") 532 | if (bName.isNullOrBlank()) { 533 | return 534 | } 535 | 536 | val isExist = savedBenchmarks.find { it.key == bName } != null 537 | if (isExist) { 538 | window.alert("Bruhh.. $bName exists! Try something else") 539 | return 540 | } 541 | 542 | // Appending new benchmark 543 | val newList = savedBenchmarks.toMutableList().apply { 544 | add( 545 | index = 0, 546 | element = SavedBenchmarkNode( 547 | key = bName, value = formData.data 548 | ) 549 | ) 550 | } 551 | benchmarkRepo.saveBenchmarks(newList) 552 | shouldSelectUnsaved = false 553 | refreshBenchmarks() 554 | } 555 | 556 | fun onShareClicked(formData: FormData) { 557 | if (sharedUrl != null) { 558 | // show the modal again 559 | showSharedModal() 560 | return 561 | } 562 | 563 | val startTime = Date().getTime() 564 | val isAwareDataPublic = userRepo.isAwareShareIsPublic() 565 | println("QuickTag: HomeViewModel:onShareClicked: isAwareDataPublic $isAwareDataPublic") 566 | if (isAwareDataPublic) { 567 | form = form.copy(isLoading = true) 568 | debounce( 569 | func = { 570 | // We need to split the input into chunk of 30,000 character 571 | val chunks = formData.data.chunked(30000) 572 | // since we're using the millis as Random see 10 should be enough 🤔 573 | val shareKey = 574 | "${RandomString.getRandomString(10)}_${Date().getTime()}_${RandomString.getRandomString(10)}" 575 | 576 | // Submit the Google form to insert the data to google sheet 577 | for ((index, chunk) in chunks.withIndex()) { 578 | try { 579 | googleFormRepo.insert( 580 | shareKey, 581 | index, 582 | chunk 583 | ) 584 | } catch (e: Throwable) { 585 | e.printStackTrace() 586 | // ignoring 587 | } 588 | } 589 | 590 | // show a success message to user that the URL has been copied to the clipboard 591 | println("QuickTag: HomeViewModel:onShareClicked: Huhhaaa!!! shareKey: $shareKey. Checking data integrity...") 592 | 593 | // using shareKey and chunkSize to verify the upload 594 | retriedCount = 0; 595 | window.setTimeout({ 596 | confirmChunkSize(shareKey, chunks, startTime) 597 | },1500) 598 | 599 | }, 600 | delay = 500 601 | ) 602 | } else { 603 | js("var myModal = new bootstrap.Modal(document.getElementById('shareAwareModal'), {});myModal.show();") 604 | } 605 | } 606 | 607 | fun showSharedModal() { 608 | js("var myModal = new bootstrap.Modal(document.getElementById('sharedModal'), {});myModal.show();") 609 | } 610 | 611 | private var retriedCount = 0 612 | private fun confirmChunkSize( 613 | shareKey: String, 614 | chunks: List, 615 | startTime: Double, 616 | ) { 617 | retriedCount++ 618 | googleSheetRepo.getChunkSize( 619 | shareKey = shareKey, 620 | onChunkSize = { remoteChunkSize -> 621 | println("QuickTag: HomeViewModel:confirmChunkSize: remote chunk size is $remoteChunkSize (expected ${chunks.size})") 622 | if (remoteChunkSize == chunks.size) { 623 | // Data integrity ✅ 624 | println("QuickTag: HomeViewModel:onShareClicked: SHARE SUCCESS!") 625 | println("QuickTag: HomeViewModel:onShareClicked: time took : ${Date().getTime() - startTime}ms") 626 | form = form.copy(isLoading = false) 627 | /*window.prompt( 628 | message = "Ready to share, copy below URL", 629 | default = 630 | )*/ 631 | sharedUrl = "${window.location.origin}/benchart/#$shareKey" 632 | showSharedModal() 633 | } else { 634 | if (retriedCount >= RETRY_COUNT) { 635 | form = form.copy(isLoading = false) 636 | window.alert("Share failed. Expected ${chunks.size} chunk(s) but found $remoteChunkSize") 637 | } else { 638 | retryGetChunkSize(shareKey, chunks, startTime) 639 | } 640 | } 641 | }, 642 | onFailed = { reason -> 643 | println("QuickTag: HomeViewModel:confirmChunkSize: failed: $reason : retried: $retriedCount/ $RETRY_COUNT") 644 | if (retriedCount >= RETRY_COUNT) { 645 | form = form.copy(isLoading = false) 646 | window.alert("Share failed : $reason") 647 | } else { 648 | retryGetChunkSize(shareKey, chunks, startTime) 649 | } 650 | } 651 | ) 652 | } 653 | 654 | private fun retryGetChunkSize( 655 | shareKey: String, 656 | chunks: List, 657 | startTime: Double 658 | ) { 659 | setTimeout( 660 | { 661 | confirmChunkSize(shareKey, chunks, startTime) 662 | }, 663 | 2000 664 | ) 665 | } 666 | 667 | fun onLoadBenchmarkClicked(savedBenchmarkNode: SavedBenchmarkNode) { 668 | val newForm = form.copy(data = savedBenchmarkNode.value) 669 | onFormChanged(newForm, shouldSelectUnsaved = false) 670 | } 671 | 672 | fun onDeleteBenchmarkClicked(deletedBenchmarkNode: SavedBenchmarkNode) { 673 | val isYes = window.confirm( 674 | "Do you want to delete `${deletedBenchmarkNode.key}` ?" 675 | ) 676 | 677 | if (isYes) { 678 | benchmarkRepo.delete(deletedBenchmarkNode) 679 | shouldSelectUnsaved = true 680 | refreshBenchmarks() 681 | } 682 | } 683 | 684 | fun onSavedBenchmarkChanged(key: String) { 685 | shouldSelectUnsaved = key == KEY_UNSAVED_BENCHMARK 686 | if (shouldSelectUnsaved) { 687 | val newForm = formRepo.getFormData() ?: form 688 | onFormChanged(newForm, shouldSelectUnsaved = false) 689 | } 690 | } 691 | 692 | fun onBlockNameOneChanged(newBlockName: String) { 693 | selectedBlockNameOne = newBlockName 694 | updateSummary() 695 | } 696 | 697 | fun onBlockNameTwoChanged(newBlockName: String) { 698 | selectedBlockNameTwo = newBlockName 699 | updateSummary() 700 | } 701 | 702 | fun onBestClicked() { 703 | selectedBlockNameOne = bestAggSummary?.blockOneName 704 | selectedBlockNameTwo = bestAggSummary?.blockTwoName 705 | updateSummary() 706 | } 707 | 708 | fun onWorstClicked() { 709 | selectedBlockNameOne = worstAggSummary?.blockOneName 710 | selectedBlockNameTwo = worstAggSummary?.blockTwoName 711 | updateSummary() 712 | } 713 | 714 | fun onDotClicked(focusGroup: String) { 715 | if (focusGroups.contains(focusGroup)) { 716 | onFocusGroupSelected(focusGroup) 717 | } 718 | } 719 | 720 | 721 | fun onAwarePublicShare() { 722 | userRepo.setAwareShareIsPublic(isAware = true) 723 | onShareClicked(form) 724 | } 725 | 726 | fun onCopyToClipboardClicked(sharedUrl: String?) { 727 | if (sharedUrl != null) { 728 | window.navigator.clipboard.writeText(sharedUrl) 729 | .then( 730 | onFulfilled = { 731 | console.log("Copied to clipboard") 732 | }, 733 | onRejected = { 734 | window.alert("Failed to copy to clipboard : ${it.message}") 735 | } 736 | ) 737 | } else { 738 | window.alert("Failed to copy to clipboard. data is null") 739 | } 740 | } 741 | 742 | } 743 | 744 | data class AggSummary( 745 | val blockOneName: String, 746 | val blockTwoName: String, 747 | val sumOfGreen: Int, 748 | val sumOfRed: Int 749 | ) 750 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/page/home/ShareAwareModal.kt: -------------------------------------------------------------------------------- 1 | package page.home 2 | 3 | import androidx.compose.runtime.Composable 4 | import kotlinx.browser.window 5 | import org.jetbrains.compose.web.attributes.ButtonType 6 | import org.jetbrains.compose.web.attributes.type 7 | import org.jetbrains.compose.web.css.marginTop 8 | import org.jetbrains.compose.web.css.px 9 | import org.jetbrains.compose.web.dom.Button 10 | import org.jetbrains.compose.web.dom.Div 11 | import org.jetbrains.compose.web.dom.H4 12 | import org.jetbrains.compose.web.dom.P 13 | import org.jetbrains.compose.web.dom.Text 14 | 15 | @Composable 16 | fun ShareAwareModal( 17 | onShareClicked : () -> Unit 18 | ){ 19 | Div( 20 | attrs = { 21 | id("shareAwareModal") 22 | classes("modal", "fade") 23 | } 24 | ) { 25 | Div( 26 | attrs = { 27 | classes("modal-dialog", "modal-lg") 28 | } 29 | ) { 30 | Div( 31 | attrs = { 32 | classes("modal-content") 33 | } 34 | ) { 35 | Div( 36 | attrs = { 37 | classes("modal-header") 38 | } 39 | ) { 40 | H4( 41 | attrs = { 42 | classes("modal-title") 43 | } 44 | ) { 45 | Text("Share") 46 | } 47 | } 48 | 49 | Div( 50 | attrs = { 51 | classes("modal-body") 52 | } 53 | ) { 54 | P { 55 | Text(""" 56 | Ahh..it looks like you're using the 'Share' feature for the first time. 57 | Please be aware that the data you share will be visible to everyone. 58 | Make sure your input doesn't contain any sensitive data. 59 | 60 | If you need private share, please vote for the feature below :) 61 | """.trimIndent()) 62 | } 63 | } 64 | 65 | Div( 66 | attrs = { 67 | classes("modal-footer") 68 | } 69 | ) { 70 | 71 | Button( 72 | attrs = { 73 | classes("btn", "btn-dark") 74 | style { 75 | marginTop(10.px) 76 | } 77 | 78 | onClick { 79 | window.open("https://forms.gle/KtPAA5LMeE8sak5h9", target = "_blank") 80 | } 81 | type(ButtonType.Button) 82 | } 83 | ) { 84 | Text("Vote for Private Share") 85 | } 86 | 87 | Button( 88 | attrs = { 89 | classes("btn", "btn-danger") 90 | attr("data-bs-dismiss", "modal") 91 | style { 92 | marginTop(10.px) 93 | } 94 | type(ButtonType.Button) 95 | } 96 | ) { 97 | Text("Cancel Share") 98 | } 99 | 100 | Button( 101 | attrs = { 102 | classes("btn", "btn-success") 103 | attr("data-bs-dismiss", "modal") 104 | style { 105 | marginTop(10.px) 106 | } 107 | 108 | onClick { 109 | onShareClicked() 110 | } 111 | type(ButtonType.Button) 112 | } 113 | ) { 114 | Text("Understood, Share!") 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/page/home/SharedModal.kt: -------------------------------------------------------------------------------- 1 | package page.home 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import components.Strong 6 | import org.jetbrains.compose.web.attributes.ButtonType 7 | import org.jetbrains.compose.web.attributes.type 8 | import org.jetbrains.compose.web.css.marginTop 9 | import org.jetbrains.compose.web.css.px 10 | import org.jetbrains.compose.web.dom.Button 11 | import org.jetbrains.compose.web.dom.Div 12 | import org.jetbrains.compose.web.dom.H4 13 | import org.jetbrains.compose.web.dom.Text 14 | 15 | @Composable 16 | fun SharedModal( 17 | shareUrl : String?, 18 | onCopyToClipboardClicked : (shareUrl : String?) -> Unit 19 | ){ 20 | Div( 21 | attrs = { 22 | id("sharedModal") 23 | classes("modal", "fade") 24 | } 25 | ) { 26 | Div( 27 | attrs = { 28 | classes("modal-dialog", "modal-lg") 29 | } 30 | ) { 31 | Div( 32 | attrs = { 33 | classes("modal-content") 34 | } 35 | ) { 36 | Div( 37 | attrs = { 38 | classes("modal-header") 39 | } 40 | ) { 41 | H4( 42 | attrs = { 43 | classes("modal-title") 44 | } 45 | ) { 46 | Text("🚀 Share URL Ready!") 47 | } 48 | } 49 | 50 | Div( 51 | attrs = { 52 | classes("modal-body") 53 | } 54 | ) { 55 | Div( 56 | attrs = { 57 | classes("alert","alert-success") 58 | } 59 | ) { 60 | Strong { 61 | if(shareUrl!=null){ 62 | Text(shareUrl) 63 | } 64 | } 65 | } 66 | } 67 | 68 | Div( 69 | attrs = { 70 | classes("modal-footer") 71 | } 72 | ) { 73 | 74 | Button( 75 | attrs = { 76 | classes("btn", "btn-success") 77 | attr("data-bs-dismiss", "modal") 78 | style { 79 | marginTop(10.px) 80 | } 81 | 82 | onClick { 83 | onCopyToClipboardClicked(shareUrl) 84 | } 85 | type(ButtonType.Button) 86 | } 87 | ) { 88 | Text("Copy to clipboard") 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/repo/BenchmarkRepo.kt: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import components.SavedBenchmarkNode 4 | import components.SavedBenchmarks 5 | import kotlinx.browser.window 6 | import kotlinx.serialization.decodeFromString 7 | import kotlinx.serialization.encodeToString 8 | import utils.JsonUtils 9 | 10 | interface BenchmarkRepo { 11 | fun getSavedBenchmarks(): List 12 | fun saveBenchmarks(newList: List) 13 | fun delete(deletedBenchmarkNode: SavedBenchmarkNode) 14 | } 15 | 16 | class BenchmarkRepoImpl : BenchmarkRepo { 17 | 18 | companion object { 19 | private const val KEY_SAVED_BENCHMARKS = "savedBenchmarks" 20 | } 21 | 22 | 23 | override fun getSavedBenchmarks(): List { 24 | val savedBenchmarksString = window.localStorage.getItem(KEY_SAVED_BENCHMARKS) 25 | val savedBenchmark = if (savedBenchmarksString == null) { 26 | // Creating first saved benchmark 27 | SavedBenchmarks(items = listOf()) 28 | } else { 29 | println("JSON is '$savedBenchmarksString'") 30 | try { 31 | JsonUtils.json.decodeFromString(savedBenchmarksString) 32 | }catch (e: Exception){ 33 | e.printStackTrace() 34 | saveBenchmarks(listOf()) // reset 35 | SavedBenchmarks(items = listOf()) 36 | } 37 | } 38 | 39 | return savedBenchmark.items.toList() 40 | } 41 | 42 | override fun saveBenchmarks(newList: List) { 43 | val savedBenchmarks = JsonUtils.json.encodeToString(SavedBenchmarks(newList)) 44 | window.localStorage.setItem(KEY_SAVED_BENCHMARKS, savedBenchmarks) 45 | } 46 | 47 | override fun delete(deletedBenchmarkNode: SavedBenchmarkNode) { 48 | // Appending new benchmark 49 | val newList = getSavedBenchmarks().toMutableList().apply { 50 | removeAll { it.key == deletedBenchmarkNode.key } 51 | } 52 | saveBenchmarks(newList) 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/repo/FormRepo.kt: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import kotlinx.browser.window 4 | import model.FormData 5 | 6 | interface FormRepo { 7 | fun storeFormData(newForm: FormData) 8 | fun getFormData(): FormData? 9 | } 10 | 11 | class FormRepoImpl : FormRepo { 12 | companion object { 13 | private const val KEY_AUTO_FORM_INPUT = "auto_form_input" 14 | private const val KEY_IS_TEST_NAME_DETECTION_ENABLED = "is_test_name_detection_enabled" 15 | private const val KEY_IS_AUTO_GROUP_ENABLED = "is_auto_group_enabled" 16 | } 17 | 18 | override fun storeFormData(newForm: FormData) { 19 | window.localStorage.apply { 20 | setItem(KEY_AUTO_FORM_INPUT, newForm.data) 21 | setItem(KEY_IS_TEST_NAME_DETECTION_ENABLED, newForm.isTestNameDetectionEnabled.toString()) 22 | setItem(KEY_IS_AUTO_GROUP_ENABLED, newForm.isAutoGroupEnabled.toString()) 23 | } 24 | } 25 | 26 | override fun getFormData(): FormData? { 27 | val localStorage = window.localStorage 28 | val data = localStorage.getItem(KEY_AUTO_FORM_INPUT) ?: return null 29 | val isTestNameDetectionEnabled = localStorage.getItem(KEY_IS_TEST_NAME_DETECTION_ENABLED).toBoolean() 30 | val isAutoGroupEnabled = localStorage.getItem(KEY_IS_AUTO_GROUP_ENABLED).toBoolean() 31 | return FormData(data, isTestNameDetectionEnabled, isAutoGroupEnabled, isLoading = true) // true because its not reached UI yet 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/repo/GoogleFormRepo.kt: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import org.w3c.xhr.XMLHttpRequest 4 | 5 | interface GoogleFormRepo { 6 | fun insert( 7 | shareKey : String, 8 | chunkIndex : Int, 9 | inputChunk : String 10 | ) 11 | } 12 | 13 | class GoogleFormRepoImpl : GoogleFormRepo { 14 | 15 | companion object { 16 | private const val FORM_SUBMISSION_URL = 17 | "https://docs.google.com/forms/d/e/1FAIpQLSfYy0ZnzlSot_3SpJ7GVK9umEpf3Dqzz1pQ7jyLUVd7jO2qCQ/formResponse" 18 | } 19 | 20 | override fun insert(shareKey: String, chunkIndex: Int, inputChunk: String) { 21 | val data = "entry.1218983684=$shareKey&entry.1886726465=$chunkIndex&entry.1340578003=$inputChunk"; 22 | val xhr = XMLHttpRequest() 23 | xhr.open("POST", FORM_SUBMISSION_URL, async = false) 24 | xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 25 | xhr.send(data) 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/repo/GoogleSheetRepo.kt: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import org.w3c.xhr.XMLHttpRequest 4 | 5 | interface GoogleSheetRepo { 6 | fun getChunkSize( 7 | shareKey: String, 8 | onChunkSize: (chunkSize: Int) -> Unit, 9 | onFailed: (reason: String) -> Unit 10 | ) 11 | 12 | fun getSharedInput( 13 | shareKey: String, 14 | onSharedInput: (input: String) -> Unit, 15 | onFailed: (reason: String) -> Unit 16 | ) 17 | } 18 | 19 | class GoogleSheetRepoImpl : GoogleSheetRepo { 20 | companion object { 21 | private const val BASE_URL = 22 | "https://docs.google.com/spreadsheets/d/1U1bKMHN0hlpZ1CVke3TB3-Xc20ZJwZxlMWYXpMcII-k/gviz/tq?tqx=out:csv&sheet=Sheet1" 23 | } 24 | 25 | override fun getChunkSize( 26 | shareKey: String, 27 | onChunkSize: (chunkSize: Int) -> Unit, 28 | onFailed: (reason: String) -> Unit 29 | ) { 30 | try { 31 | val chunkCountUrl = "$BASE_URL&tq=SELECT COUNT(C) WHERE B = '$shareKey'" 32 | val xhr = XMLHttpRequest() 33 | xhr.open("GET", chunkCountUrl) 34 | xhr.onreadystatechange = { _ -> 35 | println("QuickTag: GoogleSheetRepoImpl:getChunkCount: readyState: ${xhr.readyState}, status = ${xhr.status}") 36 | if (xhr.readyState == 4.toShort()) { 37 | if (xhr.status == 200.toShort()) { 38 | val responseLines = xhr.responseText.split("\n") 39 | if (responseLines.size == 2) { 40 | // chunk exist 41 | val chunkSize = responseLines[1].replace("\"", "").toInt() 42 | println("QuickTag: GoogleSheetRepoImpl:getChunkSize: chunk size is '$chunkSize'") 43 | onChunkSize(chunkSize) 44 | } else { 45 | // share doesn't exist 46 | onFailed("No chunk exist for shareKey '$shareKey'") 47 | } 48 | } else { 49 | onFailed("Share request failed") 50 | } 51 | } 52 | } 53 | xhr.send() 54 | } catch (e: Throwable) { 55 | e.printStackTrace() 56 | onFailed(e.message ?: "Something wrong") 57 | } 58 | } 59 | 60 | override fun getSharedInput( 61 | shareKey: String, 62 | onSharedInput: (input: String) -> Unit, 63 | onFailed: (reason: String) -> Unit 64 | ) { 65 | try { 66 | val chunkCountUrl = "$BASE_URL&tq=SELECT C,D WHERE B = '$shareKey' ORDER BY C" 67 | val xhr = XMLHttpRequest() 68 | xhr.open("GET", chunkCountUrl) 69 | xhr.onreadystatechange = { _ -> 70 | println("QuickTag: GoogleSheetRepoImpl:getChunkCount: readyState: ${xhr.readyState}, status = ${xhr.status}") 71 | if (xhr.readyState == 4.toShort()) { 72 | if (xhr.status == 200.toShort()) { 73 | val responseLines = xhr.responseText 74 | val firstLineBreakIndex = responseLines.indexOf('\n') 75 | if (firstLineBreakIndex != -1) { 76 | val sharedInput = responseLines 77 | .substring(firstLineBreakIndex+1, responseLines.length - 1) 78 | .replace("\"\\n\"(?:\\d+)\",\"".toRegex(),"") 79 | .substring(5) 80 | onSharedInput(sharedInput) 81 | } else { 82 | onFailed("Invalid shareKey '$shareKey'") 83 | } 84 | } else { 85 | onFailed("Share request failed") 86 | } 87 | } 88 | } 89 | xhr.send() 90 | } catch (e: Throwable) { 91 | onFailed(e.message ?: "Something wrong") 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/repo/UserRepo.kt: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import kotlinx.browser.window 4 | 5 | interface UserRepo { 6 | fun isAwareShareIsPublic() : Boolean 7 | fun setAwareShareIsPublic(isAware : Boolean) 8 | } 9 | 10 | class UserRepoImpl : UserRepo { 11 | companion object{ 12 | private const val KEY_IS_AWARE_SHARE_IS_PUBLIC = "is_aware_share_is_public" 13 | } 14 | override fun isAwareShareIsPublic(): Boolean { 15 | return window.localStorage.getItem(KEY_IS_AWARE_SHARE_IS_PUBLIC)?.toBoolean() ?: false 16 | } 17 | 18 | override fun setAwareShareIsPublic(isAware: Boolean) { 19 | window.localStorage.setItem(KEY_IS_AWARE_SHARE_IS_PUBLIC, isAware.toString()) 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/DefaultValues.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | object DefaultValues { 4 | val form = """ 5 | - Before 1 6 | # first line will be treated as title of the block 7 | special chars will be stripped from the title 8 | HomeScrollBenchmark_scrollTest 9 | frameDurationCpuMs P50 40.5, P90 45.8, P95 60.4, P99 80.4 10 | frameOverrunMs P50 -5.9, P90 7.0, P95 20.1, P99 64.4 11 | Traces: Iteration 0 1 2 3 4 12 | 13 | ## Before 2 14 | # line breaks are used to separate the block 15 | HomeScrollBenchmark_scrollTest 16 | frameDurationCpuMs P50 45.5, P90 43.8, P95 58.4, P99 78.4 17 | frameOverrunMs P50 -6.5, P90 5.4, P95 15.0, P99 60.3 18 | Traces: Iteration 0 1 2 3 4 19 | 20 | After 1 21 | you can include whatever text you want anywhere you want 22 | HomeScrollBenchmark_scrollTest 23 | frameDurationCpuMs P50 13.6, P90 21.8, P95 27.5, P99 49.4 24 | the order doesn't matter 25 | frameOverrunMs P50 -6.2, P90 7.3, P95 19.5, P99 61.7 26 | Traces: Iteration 0 1 2 3 4 27 | 28 | > After 2 29 | HomeScrollBenchmark_scrollTest 30 | frameDurationCpuMs P50 13.8, P90 21.9, P95 27.3, P99 53.4 31 | see.. am some random text 32 | frameOverrunMs P50 -5.7, P90 7.4, P95 22.4, P99 63.2 33 | Traces: Iteration 0 1 2 3 4 34 | """.trimIndent() 35 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/JsonUtils.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import kotlinx.serialization.json.Json 4 | 5 | object JsonUtils { 6 | val json = Json { 7 | ignoreUnknownKeys = true 8 | } 9 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/Math.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import page.home.ConfidenceIntervals 4 | import kotlin.math.sqrt 5 | 6 | 7 | 8 | fun Collection.calculateErrorMargins(): ConfidenceIntervals { 9 | if (this.isEmpty()) { 10 | return ConfidenceIntervals( 11 | mean = 0f, 12 | marginOf68p3 = 0f, 13 | marginOf90 = 0f, 14 | marginOf95 = 0f, 15 | marginOf99 = 0f, 16 | percentageMarginOf68p3 = 0f, 17 | percentageMarginOf90 = 0f, 18 | percentageMarginOf95 = 0f, 19 | percentageMarginOf99 = 0f, 20 | sampleSize = 0, 21 | standardDeviation = 0f 22 | ) 23 | } 24 | 25 | val mean = this.average().toFloat() 26 | val sampleSize = this.size 27 | val stdDev = this.populationStandardDeviation() 28 | 29 | val standardError = stdDev / sqrt(sampleSize.toFloat()) 30 | 31 | // Calculate absolute margins of error for different confidence levels 32 | val margin68p3 = standardError // 68.3% confidence 33 | val margin90 = standardError * 1.645f // 90% confidence 34 | val margin95 = standardError * 1.96f // 95% confidence 35 | val margin99 = standardError * 2.576f // 99% confidence 36 | 37 | // Calculate percentage margins relative to mean 38 | // Avoid division by zero if mean is 0 39 | val percentMargin68p3 = if (mean != 0f) (margin68p3 / mean) * 100f else 0f 40 | val percentMargin90 = if (mean != 0f) (margin90 / mean) * 100f else 0f 41 | val percentMargin95 = if (mean != 0f) (margin95 / mean) * 100f else 0f 42 | val percentMargin99 = if (mean != 0f) (margin99 / mean) * 100f else 0f 43 | 44 | return ConfidenceIntervals( 45 | mean = mean, 46 | marginOf68p3 = margin68p3, 47 | marginOf90 = margin90, 48 | marginOf95 = margin95, 49 | marginOf99 = margin99, 50 | percentageMarginOf68p3 = percentMargin68p3, 51 | percentageMarginOf90 = percentMargin90, 52 | percentageMarginOf95 = percentMargin95, 53 | percentageMarginOf99 = percentMargin99, 54 | sampleSize = sampleSize, 55 | standardDeviation = stdDev 56 | ) 57 | } 58 | 59 | private fun Collection.populationStandardDeviation(): Float { 60 | if (this.isEmpty()) return 0f 61 | 62 | val mean = this.average() 63 | val sumSquaredDiffs = this.sumOf { 64 | val diff = it - mean 65 | (diff * diff).toDouble() 66 | } 67 | val variance = sumSquaredDiffs / this.size 68 | return sqrt(variance).toFloat() 69 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/RandomString.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | 4 | import kotlin.js.Date 5 | import kotlin.random.Random 6 | 7 | /** 8 | * Created by theapache64 on 9/4/16. 9 | * and reused in 2024 :P 10 | */ 11 | object RandomString { 12 | private const val RANDOM_ENGINE = "0123456789AaBbCcDdEeFfGgHhIiJjKkLkMmNnOoPpQqRrSsTtUuVvWwXxYyZz" 13 | 14 | fun getRandomString(length: Int): String { 15 | val random = Random(Date().getTime().toInt() + (0..99999999999999999).random()) 16 | val apiKeyBuilder = StringBuilder() 17 | for (i in 0 until length) { 18 | apiKeyBuilder.append(RANDOM_ENGINE[random.nextInt(RANDOM_ENGINE.length)]) 19 | } 20 | return apiKeyBuilder.toString() 21 | } 22 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/SummaryUtils.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import components.Summary 4 | import components.SummaryNode 5 | import core.BenchmarkResult.Companion.FOCUS_GROUP_ALL 6 | import core.MetricUnit 7 | import core.getMetricEmoji 8 | import model.Chart 9 | import kotlin.math.absoluteValue 10 | 11 | object SummaryUtils { 12 | 13 | private fun getMetricTitle(label: String): String { 14 | return label 15 | } 16 | 17 | private val highIsGoodMetricRegex = arrayOf( 18 | "frameCount", 19 | "gfxFrameTotalCount", 20 | ).joinToString(separator = "|", prefix = "(", postfix = ")").toRegex() 21 | 22 | fun getSummaryOrThrow( 23 | currentFocusedGroup: String, 24 | isGeneric: Boolean, 25 | chart: Chart, 26 | selectedBlockNameOne: String?, 27 | selectedBlockNameTwo: String?, 28 | ): Summary? { 29 | if (selectedBlockNameOne == null || selectedBlockNameTwo == null) { 30 | println("blank block name detected. skipping summary") 31 | return null 32 | } 33 | 34 | val combinedMap = mutableMapOf>() 35 | val words = listOf(selectedBlockNameOne, selectedBlockNameTwo) 36 | println("words : $words") 37 | for (word in words) { 38 | 39 | combinedMap[word] = 40 | chart.dataSets.filterKeys { it.startsWith(word) }.values.map { it.values.toFloatArray() } 41 | .let { arrays -> 42 | // Sum 43 | val newArray = mutableListOf().apply { 44 | repeat(chart.dataSets.values.first().size) { 45 | add(0f) 46 | } 47 | } 48 | for (array in arrays) { 49 | for (i in newArray.indices) { 50 | newArray[i] = newArray[i] + array[i] 51 | } 52 | } 53 | // Average 54 | for (i in newArray.indices) { 55 | newArray[i] = newArray[i] / arrays.size 56 | } 57 | newArray 58 | } 59 | } 60 | println("combinedMap : ${combinedMap.map { it.value.toList() }}") 61 | 62 | val summaryNodes = mutableListOf() 63 | val segments = chart.dataSets.values.first().keys.toList() 64 | println("segments: $segments") 65 | 66 | val title = if (isGeneric) { 67 | if (currentFocusedGroup == FOCUS_GROUP_ALL) { 68 | "📊 $selectedBlockNameOne vs $selectedBlockNameTwo" 69 | } else { 70 | "📊 ${chart.label}" 71 | } 72 | } else { 73 | "${getMetricEmoji(chart.label)} ${getMetricTitle(chart.label)}" 74 | } 75 | 76 | repeat(segments.size) { index -> 77 | val segment = segments[index] 78 | val after = combinedMap[words[1]]?.get(index) ?: 0f 79 | val before = combinedMap[words[0]]?.get(index) ?: 0f 80 | println("before : '$before' -> after: '$after'") 81 | val diff = "${(after - before).asDynamic().toFixed(2)}".toFloat() 82 | var percDiff = if (before == 0f) { 83 | after * 100 84 | } else { 85 | (((before - after) / before) * 100) 86 | } 87 | percDiff = "${percDiff.asDynamic().toFixed(2)}".toFloat().absoluteValue as Float 88 | 89 | val isHighGoodMetric = highIsGoodMetricRegex.containsMatchIn(title) 90 | 91 | val resultWord = if (diff == 0f) { 92 | "equally" 93 | } else if (isHighGoodMetric == (diff > 0)) { 94 | "better" 95 | } else { 96 | "worse" 97 | } 98 | val symbol = if (diff > 0) "+" else "" 99 | val emoji = if (diff > 0 == isHighGoodMetric) "✅" else "❌" 100 | val badgeClass = when { 101 | diff == 0f -> "secondary" 102 | diff > 0 != isHighGoodMetric -> "danger" 103 | else -> "success" 104 | } 105 | summaryNodes.add( 106 | SummaryNode( 107 | isGeneric = isGeneric, 108 | emoji = emoji, 109 | segment = segment, 110 | label = words[1], 111 | percentage = percDiff, 112 | stateWord = resultWord, 113 | diff = diff, 114 | diffSymbol = symbol, 115 | after = "${after.asDynamic().toFixed(2)}".toFloat(), 116 | before = "${before.asDynamic().toFixed(2)}".toFloat(), 117 | unit = getMetricUnit(title), 118 | badgeClass = badgeClass, 119 | ) 120 | ) 121 | } 122 | 123 | 124 | return Summary(title = title, summaryNodes) 125 | } 126 | 127 | private fun getMetricUnit(title: String): MetricUnit? { 128 | return when { 129 | title.endsWith("Ms") -> MetricUnit.Ms 130 | title.endsWith("Mah") -> MetricUnit.Mah 131 | title.endsWith("Kb") -> MetricUnit.Kb 132 | title.endsWith("ViewCount") -> MetricUnit.View 133 | title.endsWith("Percent") -> MetricUnit.Percentage 134 | title.contains("frame", ignoreCase = true) && title.contains("count", ignoreCase = true) -> MetricUnit.Frame 135 | else -> null 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /src/jsMain/resources/icons/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following graphics from Twitter Twemoji: 2 | 3 | - Graphics Title: 1f4ca.svg 4 | - Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji) 5 | - Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/1f4ca.svg 6 | - Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/) 7 | -------------------------------------------------------------------------------- /src/jsMain/resources/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/src/jsMain/resources/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/jsMain/resources/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/src/jsMain/resources/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/jsMain/resources/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/src/jsMain/resources/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /src/jsMain/resources/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/src/jsMain/resources/icons/favicon-16x16.png -------------------------------------------------------------------------------- /src/jsMain/resources/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/src/jsMain/resources/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/jsMain/resources/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theapache64/benchart/52236285b95d3e8195f5e0b52a143a83439cf5d9/src/jsMain/resources/icons/favicon.ico -------------------------------------------------------------------------------- /src/jsMain/resources/icons/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /src/jsMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | BenChart 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 35 | 36 | -------------------------------------------------------------------------------- /src/jsTest/kotlin/GoogleFormRepoTest.kt: -------------------------------------------------------------------------------- 1 | import org.w3c.xhr.XMLHttpRequest 2 | import repo.GoogleFormRepoImpl 3 | import kotlin.test.Test 4 | 5 | class GoogleFormRepoTest { 6 | 7 | 8 | private val googleFormRepo = GoogleFormRepoImpl() 9 | 10 | @Test 11 | fun writeDataToSheet() { 12 | googleFormRepo.insert( 13 | shareKey = "myShareKey", 14 | chunkIndex = 0, 15 | inputChunk = "iamTheInput" 16 | ) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/jsTest/kotlin/GoogleSheetRepoTest.kt: -------------------------------------------------------------------------------- 1 | import org.w3c.xhr.XMLHttpRequest 2 | import repo.GoogleFormRepoImpl 3 | import kotlin.test.Test 4 | 5 | class GoogleSheetRepoTest { 6 | 7 | @Test 8 | fun readDataFromSheet(){ 9 | val shareKey = "iamShareKey" 10 | val xhr = XMLHttpRequest() 11 | xhr.open("GET", getReadUrl(shareKey), async = false) 12 | // read data 13 | xhr.onload = { 14 | println("QuickTag: AppTest:readDataFromSheet: ${xhr.responseText}") 15 | } 16 | xhr.send() 17 | } 18 | 19 | private fun getReadUrl(shareKey : String): String { 20 | return "https://docs.google.com/spreadsheets/d/1U1bKMHN0hlpZ1CVke3TB3-Xc20ZJwZxlMWYXpMcII-k/gviz/tq?tqx=out:csv&sheet=Sheet1&tq=SELECT%20C,D%20WHERE%20B%20=%20'$shareKey'" 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/DemoDataGen.kt: -------------------------------------------------------------------------------- 1 | import java.util.* 2 | 3 | fun main() { 4 | startupTimeGen() 5 | } 6 | 7 | fun startupTimeGen() { 8 | val randomEng = 500..5000 9 | println("How many?: ") 10 | val count = Scanner(System.`in`).nextInt() 11 | repeat(count) { 12 | println( 13 | """ 14 | timeToFullDisplayMs min ${randomEng.random()}, median ${randomEng.random()}, max ${randomEng.random()} 15 | timeToInitialDisplayMs min ${randomEng.random()}, median ${randomEng.random()}, max ${randomEng.random()} 16 | """.trimIndent() 17 | ) 18 | println("-------------------------") 19 | } 20 | } 21 | 22 | fun frameTimeGen() { 23 | val randomEng = 0..100 24 | println("How many?: ") 25 | val count = Scanner(System.`in`).nextInt() 26 | repeat(count) { 27 | println( 28 | """ 29 | frameDurationCpuMs P50 ${randomEng.random()}, P90 ${randomEng.random()}, P95 ${randomEng.random()}, P99 ${randomEng.random()} 30 | frameOverrunMs P50 ${randomEng.random()}, P90 ${randomEng.random()}, P95 ${randomEng.random()}, P99 ${randomEng.random()} 31 | """.trimIndent() 32 | ) 33 | println("-------------------------") 34 | } 35 | } -------------------------------------------------------------------------------- /src/jvmMain/kotlin/Test.kt: -------------------------------------------------------------------------------- 1 | fun main() { 2 | val x = arrayOf( 3 | "B" 4 | ) 5 | x[0] = "AA" 6 | println(x.toList()) 7 | } -------------------------------------------------------------------------------- /src/jvmTest/kotlin/core/TextNumberLineTest.kt: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | 7 | class TextNumberLineTest { 8 | @Test 9 | fun simple() { 10 | TextNumberLine.parse(0, "a = 10").also { 11 | assertEquals(TextNumberLine("a = ", 10f), it) 12 | } 13 | } 14 | 15 | @Test 16 | fun withUnit() { 17 | TextNumberLine.parse(0,"a = 10 rs").also { 18 | assertEquals(TextNumberLine("a = ", 10f), it) 19 | } 20 | } 21 | 22 | @Test 23 | fun numeric() { 24 | TextNumberLine.parse(0,"2019 = 20").also { 25 | assertEquals(TextNumberLine("2019 = ", 20f), it) 26 | } 27 | } 28 | 29 | } --------------------------------------------------------------------------------