diff --git a/MotionMark/developer.html b/MotionMark/developer.html index f095355..8b2d919 100644 --- a/MotionMark/developer.html +++ b/MotionMark/developer.html @@ -59,11 +59,51 @@

version

Suites:

-
- Drop results here - - + +
+
+ + +
+ + +
+
Drop results here
+
+ +
+
+ + +
+
+ +
+

Baseline (A)

+
Drop Baseline A here
+ +
No file loaded
+
+ + +
+

Comparison (B)

+
Drop Comparison B here
+ +
No file loaded
+
+
+ +
+ + +
+
+ + + + +
diff --git a/MotionMark/resources/debug-runner/debug-runner.js b/MotionMark/resources/debug-runner/debug-runner.js index 049f92a..01b4b8d 100644 --- a/MotionMark/resources/debug-runner/debug-runner.js +++ b/MotionMark/resources/debug-runner/debug-runner.js @@ -528,6 +528,117 @@ window.suitesManager = new class SuitesManager { } } +class ComparisonResultsTable extends ResultsTable { + constructor(element, headers, scoreCalculatorA, scoreCalculatorB) + { + super(element, headers); + this.scoreCalculatorA = scoreCalculatorA; + this.scoreCalculatorB = scoreCalculatorB; + } + + _addTest(testName, testResultA, testResultB, optionsA, optionsB, testDataA, testDataB) + { + const row = Utilities.createElement("tr", {}, this.tbody); + + this._flattenedHeaders.forEach(function (header) { + var className = ""; + if (header.className) { + if (typeof header.className == "function") + className = header.className(testResultA, testResultB, optionsA, optionsB); + else + className = header.className; + } + + const td = Utilities.createElement("td", { class: className }, row); + + if (header.text == Strings.text.testName) { + td.textContent = testName; + } else if (header.title === Strings.text.graph) { + var button = Utilities.createElement("button", { class: "small-button compare-button" }, td); + button.textContent = "Compare Graph…"; + button.testName = testName; + button.testResultA = testResultA; + button.testResultB = testResultB; + button.testDataA = testDataA; + button.testDataB = testDataB; + + button.addEventListener("click", function(e) { + const target = e.currentTarget; + benchmarkController.showComparisonGraph( + target.testName, + target.testResultA, + target.testResultB, + target.testDataA, + target.testDataB + ); + }); + } else if (typeof header.text == "string") { + var data = testResultB ? testResultB[header.text] : undefined; + if (typeof data == "number") + data = data.toFixed(2); + td.textContent = data !== undefined ? data : "N/A"; + } else if (typeof header.text == "function") { + const rendered = header.text(testResultA, testResultB, optionsA, optionsB); + if (rendered instanceof HTMLElement || rendered instanceof DocumentFragment) { + td.appendChild(rendered); + } else { + td.textContent = rendered !== undefined ? rendered : "N/A"; + } + } + }, this); + } + + _addIteration(iterationResultA, iterationResultB, iterationDataA, iterationDataB, optionsA, optionsB) + { + const testsResultsA = iterationResultA ? iterationResultA[Strings.json.results.tests] : {}; + const testsResultsB = iterationResultB ? iterationResultB[Strings.json.results.tests] : {}; + + for (const suiteName in testsResultsB) { + this._addEmptyRow(); + const suiteResultB = testsResultsB[suiteName]; + const suiteDataB = iterationDataB[suiteName]; + + const suiteResultA = testsResultsA[suiteName] || {}; + const suiteDataA = (iterationDataA && iterationDataA[suiteName]) || {}; + + for (let testName in suiteResultB) { + const testResultB = suiteResultB[testName]; + const testDataB = suiteDataB[testName]; + + const testResultA = suiteResultA[testName]; + const testDataA = suiteDataA[testName]; + + this._addTest(testName, testResultA, testResultB, optionsA, optionsB, testDataA, testDataB); + } + } + } + + showIterations() + { + this.clear(); + this._addHeader(); + this._addBody(); + + const iterationsResultsA = this.scoreCalculatorA.results; + const iterationsResultsB = this.scoreCalculatorB.results; + + iterationsResultsB.forEach(function(iterationResultB, index) { + const iterationResultA = iterationsResultsA[index] || {}; + const iterationDataA = this.scoreCalculatorA.data[index]; + const iterationDataB = this.scoreCalculatorB.data[index]; + + this._addIteration( + iterationResultA, + iterationResultB, + iterationDataA, + iterationDataB, + this.scoreCalculatorA.options, + this.scoreCalculatorB.options + ); + }, this); + } +} + class DebugBenchmarkController extends BenchmarkController { async initialize() { @@ -551,6 +662,10 @@ class DebugBenchmarkController extends BenchmarkController { suitesManager.updateEditsElementsState(); this.#setupDropTarget(); + this.#setupCompareDropTargets(); + this.baselineFileData = null; + this.comparisonFileData = null; + this.isComparisonMode = false; this.frameRateDetectionComplete = false; this.updateStartButtonState(); @@ -562,6 +677,7 @@ class DebugBenchmarkController extends BenchmarkController { #setupDropTarget() { var dropTarget = document.getElementById("drop-target"); + if (!dropTarget) return; function stopEvent(e) { e.stopPropagation(); e.preventDefault(); @@ -580,9 +696,9 @@ class DebugBenchmarkController extends BenchmarkController { dropTarget.addEventListener("drop", (e) => { stopEvent(e); + dropTarget.classList.remove("drag-over"); if (!e.dataTransfer.files.length) { - dropTarget.classList.remove("drag-over"); return; } @@ -591,10 +707,109 @@ class DebugBenchmarkController extends BenchmarkController { }, false); } + #setupCompareDropTargets() + { + const dropTargetA = document.getElementById("drop-target-a"); + const dropTargetB = document.getElementById("drop-target-b"); + + if (!dropTargetA || !dropTargetB) return; + + const stopEvent = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + // Add global window protection to prevent accidental outer drops from navigating the tab + if (!window.comparisonGlobalDragWired) { + window.addEventListener("dragover", stopEvent, false); + window.addEventListener("drop", stopEvent, false); + window.comparisonGlobalDragWired = true; + } + + // Target A with Drag Counter to completely avoid children visual flickers + let dragCounterA = 0; + dropTargetA.addEventListener("dragenter", (e) => { + stopEvent(e); + dragCounterA++; + dropTargetA.classList.add("drag-over"); + }, false); + + dropTargetA.addEventListener("dragover", stopEvent, false); + + dropTargetA.addEventListener("dragleave", (e) => { + stopEvent(e); + dragCounterA--; + if (dragCounterA === 0) { + dropTargetA.classList.remove("drag-over"); + } + }, false); + + dropTargetA.addEventListener("drop", (e) => { + stopEvent(e); + dragCounterA = 0; + dropTargetA.classList.remove("drag-over"); + if (e.dataTransfer.files.length) { + const innerDZ = dropTargetA.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Processing…'; + this.handleResultsFileA(e.dataTransfer.files[0]); + } + }, false); + + // Target B with Drag Counter + let dragCounterB = 0; + dropTargetB.addEventListener("dragenter", (e) => { + stopEvent(e); + dragCounterB++; + dropTargetB.classList.add("drag-over"); + }, false); + + dropTargetB.addEventListener("dragover", stopEvent, false); + + dropTargetB.addEventListener("dragleave", (e) => { + stopEvent(e); + dragCounterB--; + if (dragCounterB === 0) { + dropTargetB.classList.remove("drag-over"); + } + }, false); + + dropTargetB.addEventListener("drop", (e) => { + stopEvent(e); + dragCounterB = 0; + dropTargetB.classList.remove("drag-over"); + if (e.dataTransfer.files.length) { + const innerDZ = dropTargetB.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Processing…'; + this.handleResultsFileB(e.dataTransfer.files[0]); + } + }, false); + } + + switchPanelMode(mode) { + document.querySelectorAll('.mode-tab').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('.control-panel').forEach(panel => panel.classList.remove('active')); + + if (mode === 'run') { + document.getElementById('btn-mode-run').classList.add('active'); + document.getElementById('panel-run').classList.add('active'); + } else { + document.getElementById('btn-mode-compare').classList.add('active'); + document.getElementById('panel-compare').classList.add('active'); + } + } + loadResults() { document.getElementById("load-results-input").click(); } + loadResultsA() { + document.getElementById("load-results-input-a").click(); + } + + loadResultsB() { + document.getElementById("load-results-input-b").click(); + } + handleResultsFile(fileOrInput) { const file = fileOrInput instanceof File ? fileOrInput : fileOrInput.files[0]; if (!file) @@ -609,12 +824,279 @@ class DebugBenchmarkController extends BenchmarkController { RunData.resultsDataFromSingleRunData(data); this.ensureRunnerClient([], {}); this.runnerClient.scoreCalculator = new ScoreCalculator(results); + this.isComparisonMode = false; + + // Re-show profile selector in results and graph views + const resultsScoreProfileSelector = document.getElementById("results-score-profile"); + if (resultsScoreProfileSelector) + resultsScoreProfileSelector.style.display = "inline-block"; + const graphScoreProfileSelector = document.getElementById("graph-score-profile"); + if (graphScoreProfileSelector) + graphScoreProfileSelector.parentNode.style.display = "block"; + + const dropTarget = document.getElementById("drop-target"); + if (dropTarget) dropTarget.textContent = 'Drop results here'; + this.showResults(); }; reader.readAsText(file); document.title = "File: " + reader.filename; } + handleResultsFileA(fileOrInput) { + const file = fileOrInput instanceof File ? fileOrInput : fileOrInput.files[0]; + if (!file) + return; + + const reader = new FileReader(); + reader.filename = file.name; + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result); + this.baselineFileData = data; + + const indicator = document.getElementById("file-indicator-a"); + indicator.textContent = file.name; + indicator.classList.add("loaded"); + + const dropTarget = document.getElementById("drop-target-a"); + if (dropTarget) { + const innerDZ = dropTarget.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Baseline A Loaded'; + } + + this.updateCompareButtonState(); + } catch (err) { + alert("Failed to parse JSON file A: " + err.message); + const dropTarget = document.getElementById("drop-target-a"); + if (dropTarget) { + const innerDZ = dropTarget.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Drop Baseline A here'; + } + } + }; + reader.readAsText(file); + } + + handleResultsFileB(fileOrInput) { + const file = fileOrInput instanceof File ? fileOrInput : fileOrInput.files[0]; + if (!file) + return; + + const reader = new FileReader(); + reader.filename = file.name; + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result); + this.comparisonFileData = data; + + const indicator = document.getElementById("file-indicator-b"); + indicator.textContent = file.name; + indicator.classList.add("loaded"); + + const dropTarget = document.getElementById("drop-target-b"); + if (dropTarget) { + const innerDZ = dropTarget.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Comparison B Loaded'; + } + + this.updateCompareButtonState(); + } catch (err) { + alert("Failed to parse JSON file B: " + err.message); + const dropTarget = document.getElementById("drop-target-b"); + if (dropTarget) { + const innerDZ = dropTarget.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Drop Comparison B here'; + } + } + }; + reader.readAsText(file); + } + + updateCompareButtonState() + { + const btn = document.getElementById("compare-btn"); + if (btn) { + if (this.baselineFileData && this.comparisonFileData) { + btn.classList.remove("disabled"); + btn.disabled = false; + } else { + btn.classList.add("disabled"); + btn.disabled = true; + } + } + } + + triggerComparison() + { + if (!this.baselineFileData || !this.comparisonFileData) + return; + + this.isComparisonMode = true; + + // Construct baseline ScoreCalculator (Run A) + const resultsA = (this.baselineFileData['debugOutput'] instanceof Array) ? + RunData.resultsDataFromBenchmarkRunnerData(this.baselineFileData['debugOutput']) : + RunData.resultsDataFromSingleRunData(this.baselineFileData); + + this.comparisonCalculatorA = new ScoreCalculator(resultsA); + + // Construct comparison ScoreCalculator (Run B) + const resultsB = (this.comparisonFileData['debugOutput'] instanceof Array) ? + RunData.resultsDataFromBenchmarkRunnerData(this.comparisonFileData['debugOutput']) : + RunData.resultsDataFromSingleRunData(this.comparisonFileData); + + this.comparisonCalculatorB = new ScoreCalculator(resultsB); + + // Ensure runner client has correct configuration (fall back to Run B parameters) + this.ensureRunnerClient([], {}); + this.runnerClient.scoreCalculator = this.comparisonCalculatorB; + + // Reset file drops displays securely + const dropA = document.getElementById("drop-target-a"); + if (dropA) { + const innerDZ = dropA.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Drop Baseline A here'; + } + const dropB = document.getElementById("drop-target-b"); + if (dropB) { + const innerDZ = dropB.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Drop Comparison B here'; + } + + // Show comparison results tables and score badge! + this.showComparisonResults(); + } + + clearComparison() + { + this.baselineFileData = null; + this.comparisonFileData = null; + + const indicatorA = document.getElementById("file-indicator-a"); + if (indicatorA) { + indicatorA.textContent = "No file loaded"; + indicatorA.classList.remove("loaded"); + } + + const indicatorB = document.getElementById("file-indicator-b"); + if (indicatorB) { + indicatorB.textContent = "No file loaded"; + indicatorB.classList.remove("loaded"); + } + + const dropA = document.getElementById("drop-target-a"); + if (dropA) { + const innerDZ = dropA.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Drop Baseline A here'; + } + const dropB = document.getElementById("drop-target-b"); + if (dropB) { + const innerDZ = dropB.querySelector(".drop-zone"); + if (innerDZ) innerDZ.textContent = 'Drop Comparison B here'; + } + + this.updateCompareButtonState(); + } + + showComparisonResults() + { + if (!this.addedKeyEvent) { + document.addEventListener("keypress", this.handleKeyPress, false); + this.addedKeyEvent = true; + } + + const calcA = this.comparisonCalculatorA; + const calcB = this.comparisonCalculatorB; + + const scoreA = calcA.score; + const scoreB = calcB.score; + const diff = scoreB - scoreA; + const percent = (diff / scoreA) * 100; + const percentStr = (percent >= 0 ? "+" : "") + percent.toFixed(2) + "%"; + const badgeClass = percent >= 0 ? "comp-badge-better" : "comp-badge-worse"; + + // Version comparison string + const versionStr = `Baseline A (v${calcA.version}) vs Comparison B (v${calcB.version})`; + sectionsManager.setSectionVersion("results", versionStr); + + // Customize score display for comparison (XSS-safe DOM builder) + const scoreElement = document.querySelector("#results .score"); + if (scoreElement) { + scoreElement.replaceChildren(); + const badge = document.createElement("span"); + badge.className = `comparison-badge ${badgeClass}`; + badge.textContent = percentStr; + scoreElement.appendChild(badge); + } + + // Customize confidence/metric displays (XSS-safe DOM builder) + const confidenceElement = document.querySelector("#results .confidence"); + if (confidenceElement) { + confidenceElement.replaceChildren(); + + const rowDiv = document.createElement("div"); + rowDiv.className = "comparison-details-row"; + + const createSpan = (labelText, scoreVal) => { + const span = document.createElement("span"); + span.textContent = labelText + ": "; + const strong = document.createElement("strong"); + strong.textContent = scoreVal; + span.appendChild(strong); + return span; + }; + + const createSeparator = () => { + const sep = document.createElement("span"); + sep.className = "separator"; + sep.textContent = "|"; + return sep; + }; + + rowDiv.appendChild(createSpan("Baseline (A)", scoreA.toFixed(2))); + rowDiv.appendChild(createSeparator()); + rowDiv.appendChild(createSpan("Comparison (B)", scoreB.toFixed(2))); + rowDiv.appendChild(createSeparator()); + rowDiv.appendChild(createSpan("Change", (diff >= 0 ? "+" : "") + diff.toFixed(2))); + + confidenceElement.appendChild(rowDiv); + } + + // Hide score profile in comparison view + const resultsScoreProfileSelector = document.getElementById("results-score-profile"); + if (resultsScoreProfileSelector) + resultsScoreProfileSelector.style.display = "none"; + + // Populate comparison tables! + const compHeaderTable = new ComparisonResultsTable( + document.getElementById("results-header"), + Headers.testName, + calcA, + calcB + ); + compHeaderTable.showIterations(); + + const compScoreTable = new ComparisonResultsTable( + document.getElementById("results-score"), + Headers.comparisonScore, + calcA, + calcB + ); + compScoreTable.showIterations(); + + const compDataTable = new ComparisonResultsTable( + document.getElementById("results-data"), + Headers.comparisonDetails, + calcA, + calcB + ); + compDataTable.showIterations(); + + sectionsManager.showSection("results", true); + document.title = "Comparison: A vs B"; + } + frameRateDeterminationComplete(targetFrameRate) { let frameRateLabelContent = Strings.text.usingFrameRate.replace("%s", targetFrameRate); @@ -624,9 +1106,14 @@ class DebugBenchmarkController extends BenchmarkController { targetFrameRate = 60; } - document.getElementById("frame-rate-detection").textContent = frameRateLabelContent; - document.getElementById("system-frame-rate").value = targetFrameRate; - document.getElementById("frame-rate").value = targetFrameRate; + const frD = document.getElementById("frame-rate-detection"); + if (frD) frD.textContent = frameRateLabelContent; + + const sysFr = document.getElementById("system-frame-rate"); + if (sysFr) sysFr.value = targetFrameRate; + + const fr = document.getElementById("frame-rate"); + if (fr) fr.value = targetFrameRate; this.frameRateDetectionComplete = true; this.updateStartButtonState(); @@ -635,6 +1122,7 @@ class DebugBenchmarkController extends BenchmarkController { updateStartButtonState() { var startButton = document.getElementById("start-button"); + if (!startButton) return; if ("isInLandscapeOrientation" in this && !this.isInLandscapeOrientation) { startButton.disabled = true; return; @@ -690,6 +1178,11 @@ class DebugBenchmarkController extends BenchmarkController { showResults() { + if (this.isComparisonMode) { + this.showComparisonResults(); + return; + } + if (!this.addedKeyEvent) { document.addEventListener("keypress", this.handleKeyPress, false); this.addedKeyEvent = true; @@ -736,17 +1229,66 @@ class DebugBenchmarkController extends BenchmarkController { this._currentGraphParams = { testName: testName, testResult: testResult, - testData: testData + testData: testData, + isComparison: false }; sectionsManager.setSectionHeader("test-graph", testName); sectionsManager.showSection("test-graph", true); + this.graphController.isComparisonMode = false; + this.graphController.restoreOriginalNav(); + + const graphScoreProfileSelector = document.getElementById("graph-score-profile"); + if (graphScoreProfileSelector) + graphScoreProfileSelector.parentNode.style.display = "block"; + this.graphController.updateGraphData(testResult, testData, this.runnerClient.scoreCalculator.options); } + showComparisonGraph(testName, testResultA, testResultB, testDataA, testDataB) + { + this._currentGraphParams = { + testName: testName, + testResultA: testResultA, + testResultB: testResultB, + testDataA: testDataA, + testDataB: testDataB, + isComparison: true + }; + + sectionsManager.setSectionHeader("test-graph", "Compare: " + testName); + sectionsManager.showSection("test-graph", true); + + // Hide score profile selection in comparison graph mode + const graphScoreProfileSelector = document.getElementById("graph-score-profile"); + if (graphScoreProfileSelector) + graphScoreProfileSelector.parentNode.style.display = "none"; + + this.graphController.updateComparisonGraphData( + testName, + testResultA, + testResultB, + testDataA, + testDataB, + this.comparisonCalculatorA.options, + this.comparisonCalculatorB.options + ); + } + reloadCurrentGraph() { if (!this._currentGraphParams) return; + if (this._currentGraphParams.isComparison) { + this.showComparisonGraph( + this._currentGraphParams.testName, + this._currentGraphParams.testResultA, + this._currentGraphParams.testResultB, + this._currentGraphParams.testDataA, + this._currentGraphParams.testDataB + ); + return; + } + const scoreCalculator = this.runnerClient.scoreCalculator; const testData = this._currentGraphParams.testData; const testName = this._currentGraphParams.testName; @@ -773,3 +1315,4 @@ class DebugBenchmarkController extends BenchmarkController { window.benchmarkControllerClass = DebugBenchmarkController; window.benchmarkRunnerClientClass = DebugBenchmarkRunnerClient; window.sectionsManagerClass = DebugSectionsManager; + diff --git a/MotionMark/resources/debug-runner/graph.js b/MotionMark/resources/debug-runner/graph.js index b4ca40f..c5b68a1 100644 --- a/MotionMark/resources/debug-runner/graph.js +++ b/MotionMark/resources/debug-runner/graph.js @@ -633,6 +633,14 @@ class GraphController { _showOrHideNodes(isShown, selector) { + if (this.isComparisonMode) { + if (selector === "#complexity") selector = "[id^=complexity-]"; + else if (selector === "#rawFPS") selector = "[id^=rawFPS-]"; + else if (selector === "#filteredFPS") selector = "[id^=filteredFPS-]"; + else if (selector === "#mutFPS") selector = "[id^=mutFPS-]"; + else if (selector === "#regressions") selector = "[id^=regressions]"; + } + var nodeList = document.querySelectorAll(selector); if (isShown) { for (var i = 0; i < nodeList.length; ++i) @@ -666,6 +674,11 @@ class GraphController { onGraphTypeChanged() { + if (this.isComparisonMode) { + this.onComparisonGraphTypeChanged(); + return; + } + var form = document.forms["graph-type"].elements; var testResult = document.getElementById("test-graph-data")._testResult; var isTimeSelected = form["graph-type"].value == "time"; @@ -696,7 +709,8 @@ class GraphController { mean = mean.join(""); } else { var complexityRegression = testResult[Strings.json.complexity]; - document.getElementById("complexity-regression-aggregate-raw").textContent = complexityRegression.complexity.toFixed(2) + ", ±" + complexityRegression.stdev.toFixed(2) + "ms"; + const agg = document.getElementById("complexity-regression-aggregate-raw"); + if (agg) agg.textContent = complexityRegression.complexity.toFixed(2) + ", ±" + complexityRegression.stdev.toFixed(2) + "ms"; var bootstrap = complexityRegression[Strings.json.bootstrap]; if (bootstrap) { score = bootstrap.median.toFixed(2); @@ -712,4 +726,854 @@ class GraphController { sectionsManager.setSectionScore("test-graph", score, mean, this._targetFrameRate); } + + setupComparisonNav() { + const nav = document.querySelector("#test-graph nav"); + if (!nav || nav.classList.contains("comparison-nav-setup")) + return; + + this._originalNavHTML = nav.innerHTML; + + const targetClasses = ["time", "complexity", "rawFPS", "filteredFPS", "mutFPS"]; + + targetClasses.forEach(className => { + const originalSpan = document.querySelector(`#test-graph nav .${className}`); + if (!originalSpan) return; + + originalSpan.style.display = "none"; + + const parent = originalSpan.parentNode; + + const spanA = document.createElement("span"); + spanA.className = `${className}-a comp-teal`; + spanA.style.fontWeight = "bold"; + + const separator = document.createElement("span"); + separator.className = `${className}-sep`; + separator.textContent = className === "time" ? " / " : " vs "; + separator.style.opacity = "0.5"; + + const spanB = document.createElement("span"); + spanB.className = `${className}-b comp-orange`; + spanB.style.fontWeight = "bold"; + + parent.appendChild(spanA); + parent.appendChild(separator); + parent.appendChild(spanB); + }); + + const originalAgg = document.getElementById("complexity-regression-aggregate-raw"); + if (originalAgg) { + originalAgg.style.display = "none"; + + const parent = originalAgg.parentNode; + + const divContainer = document.createElement("div"); + divContainer.id = "complexity-regression-aggregate-raw-comp"; + divContainer.className = "comp-nav-legend-col"; + + const divA = document.createElement("div"); + divA.className = "comp-teal"; + divA.style.fontWeight = "bold"; + divA.textContent = "A: "; + const spanA = document.createElement("span"); + spanA.id = "complexity-regression-aggregate-raw-a"; + divA.appendChild(spanA); + + const divB = document.createElement("div"); + divB.className = "comp-orange"; + divB.style.fontWeight = "bold"; + divB.textContent = "B: "; + const spanB = document.createElement("span"); + spanB.id = "complexity-regression-aggregate-raw-b"; + divB.appendChild(spanB); + + divContainer.appendChild(divA); + divContainer.appendChild(divB); + parent.appendChild(divContainer); + } + + nav.classList.add("comparison-nav-setup"); + } + + restoreOriginalNav() { + const nav = document.querySelector("#test-graph nav"); + if (nav && nav.classList.contains("comparison-nav-setup")) { + const targetClasses = ["time", "complexity", "rawFPS", "filteredFPS", "mutFPS"]; + + targetClasses.forEach(className => { + const originalSpan = document.querySelector(`#test-graph nav .${className}`); + if (originalSpan) { + originalSpan.style.display = "inline"; + } + + const spanA = document.querySelector(`#test-graph nav .${className}-a`); + if (spanA) spanA.parentNode.removeChild(spanA); + + const sep = document.querySelector(`#test-graph nav .${className}-sep`); + if (sep) sep.parentNode.removeChild(sep); + + const spanB = document.querySelector(`#test-graph nav .${className}-b`); + if (spanB) spanB.parentNode.removeChild(spanB); + }); + + const originalAgg = document.getElementById("complexity-regression-aggregate-raw"); + if (originalAgg) { + originalAgg.style.display = "inline"; + } + + const divContainer = document.getElementById("complexity-regression-aggregate-raw-comp"); + if (divContainer) { + divContainer.parentNode.removeChild(divContainer); + } + + nav.classList.remove("comparison-nav-setup"); + } + } + + updateComparisonGraphData(testName, testResultA, testResultB, testDataA, testDataB, optionsA, optionsB) + { + this.isComparisonMode = true; + this._comparisonGraphParams = { + testName: testName, + testResultA: testResultA, + testResultB: testResultB, + testDataA: testDataA, + testDataB: testDataB, + optionsA: optionsA, + optionsB: optionsB + }; + + var element = document.getElementById("test-graph-data"); + if (element) { + element.innerHTML = ""; + element._testResult = testResultB; + element._options = optionsB; + } + + var margins = new Insets(30, 30, 50, 40); + var size = GeometryHelpers.elementClientSize(element); + + var samplesWithPropertiesA = null; + if (testDataA) { + samplesWithPropertiesA = {}; + [Strings.json.controller, Strings.json.complexity].forEach(function(seriesName) { + var series = testDataA[Strings.json.samples][seriesName]; + samplesWithPropertiesA[seriesName] = series.toArray(); + }); + } + + var samplesWithPropertiesB = null; + if (testDataB) { + samplesWithPropertiesB = {}; + [Strings.json.controller, Strings.json.complexity].forEach(function(seriesName) { + var series = testDataB[Strings.json.samples][seriesName]; + samplesWithPropertiesB[seriesName] = series.toArray(); + }); + } + + this._targetFrameRate = optionsB ? optionsB["frame-rate"] : 60; + + this.setupComparisonNav(); + + // Create comparative overlaid time graph + this._createComparisonTimeGraph( + testResultA, + testResultB, + samplesWithPropertiesA ? samplesWithPropertiesA[Strings.json.controller] : null, + samplesWithPropertiesB ? samplesWithPropertiesB[Strings.json.controller] : null, + testDataA ? testDataA[Strings.json.marks] : null, + testDataB ? testDataB[Strings.json.marks] : null, + testDataA ? testDataA[Strings.json.controller] : null, + testDataB ? testDataB[Strings.json.controller] : null, + optionsA, + optionsB, + margins, + size + ); + this.onTimeGraphOptionsChanged(); + + // Create comparative overlaid complexity graph + this._showOrHideNodes(true, "form[name=graph-type]"); + document.forms["graph-type"].elements["type"] = "complexity"; + + this._createComparisonComplexityGraph( + testResultA, + testResultB, + testDataA ? testDataA[Strings.json.controller] : null, + testDataB ? testDataB[Strings.json.controller] : null, + samplesWithPropertiesA, + samplesWithPropertiesB, + optionsA, + optionsB, + margins, + size + ); + this.onComplexityGraphOptionsChanged(); + + this.onGraphTypeChanged(); + } + + onComparisonGraphTypeChanged() + { + var form = document.forms["graph-type"].elements; + var isTimeSelected = form["graph-type"].value == "time"; + + this._showOrHideNodes(isTimeSelected, "#time-graph"); + this._showOrHideNodes(isTimeSelected, "form[name=time-graph-options]"); + this._showOrHideNodes(!isTimeSelected, "#complexity-graph"); + this._showOrHideNodes(!isTimeSelected, "form[name=complexity-graph-options]"); + + const testResultA = this._comparisonGraphParams.testResultA; + const testResultB = this._comparisonGraphParams.testResultB; + + let scoreA = 0, scoreB = 0; + let labelA = "", labelB = ""; + + if (isTimeSelected) { + if (testResultA) { + scoreA = testResultA[Strings.json.score]; + labelA = `A: ${scoreA.toFixed(2)}`; + } + if (testResultB) { + scoreB = testResultB[Strings.json.score]; + labelB = `B: ${scoreB.toFixed(2)}`; + } + } else { + // Complexity graph aggregates raw data displays + if (testResultA) { + const complexityRegressionA = testResultA[Strings.json.complexity]; + const aggA = document.getElementById("complexity-regression-aggregate-raw-a"); + if (aggA) aggA.textContent = complexityRegressionA.complexity.toFixed(2) + ", ±" + complexityRegressionA.stdev.toFixed(2) + "ms"; + + const bootstrapA = complexityRegressionA[Strings.json.bootstrap]; + if (bootstrapA) { + scoreA = bootstrapA.median; + labelA = `A: ${scoreA.toFixed(2)} [${bootstrapA.confidenceLow.toFixed(1)} - ${bootstrapA.confidenceHigh.toFixed(1)}]`; + } else { + scoreA = testResultA[Strings.json.score]; + labelA = `A: ${scoreA.toFixed(2)}`; + } + } + if (testResultB) { + const complexityRegressionB = testResultB[Strings.json.complexity]; + const aggB = document.getElementById("complexity-regression-aggregate-raw-b"); + if (aggB) aggB.textContent = complexityRegressionB.complexity.toFixed(2) + ", ±" + complexityRegressionB.stdev.toFixed(2) + "ms"; + + const bootstrapB = complexityRegressionB[Strings.json.bootstrap]; + if (bootstrapB) { + scoreB = bootstrapB.median; + labelB = `B: ${scoreB.toFixed(2)} [${bootstrapB.confidenceLow.toFixed(1)} - ${bootstrapB.confidenceHigh.toFixed(1)}]`; + } else { + scoreB = testResultB[Strings.json.score]; + labelB = `B: ${scoreB.toFixed(2)}`; + } + } + } + + let changeStr = ""; + if (scoreA && scoreB) { + const diff = scoreB - scoreA; + const pct = (diff / scoreA) * 100; + changeStr = `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`; + } + + const scoreElement = document.querySelector("#test-graph .score"); + if (scoreElement) { + scoreElement.replaceChildren(); + if (changeStr) { + const pctVal = parseFloat(changeStr); + const className = pctVal >= 0 ? "comp-badge-better inline-badge" : "comp-badge-worse inline-badge"; + const badge = document.createElement("span"); + badge.className = className; + badge.textContent = changeStr; + scoreElement.appendChild(badge); + } + } + + const confidenceElement = document.querySelector("#test-graph .confidence"); + if (confidenceElement) { + confidenceElement.replaceChildren(); + + const createTextSpan = (text, className) => { + const span = document.createElement("span"); + span.className = className; + span.textContent = text; + return span; + }; + + const sep = document.createElement("span"); + sep.className = "separator"; + sep.textContent = " | "; + + if (labelA) confidenceElement.appendChild(createTextSpan(labelA, "comp-teal")); + confidenceElement.appendChild(sep); + if (labelB) confidenceElement.appendChild(createTextSpan(labelB, "comp-orange")); + } + } + + _createComparisonTimeGraph(resultA, resultB, samplesA, samplesB, marksA, marksB, regressionsA, regressionsB, optionsA, optionsB, margins, size) + { + const axisWidth = size.width - margins.left - margins.right; + const axisHeight = size.height - margins.top - margins.bottom; + + var svg = d3.select("#test-graph-data").append("svg") + .attr("id", "time-graph") + .attr("width", size.width) + .attr("height", size.height) + .append("g") + .attr("transform", "translate(" + margins.left + "," + margins.top + ")"); + + const timeMin = Math.min( + samplesA ? d3.min(samplesA, s => s.time) : 0, + samplesB ? d3.min(samplesB, s => s.time) : 0, + 0 + ); + const timeMax = Math.max( + samplesA ? d3.max(samplesA, s => s.time) : 0, + samplesB ? d3.max(samplesB, s => s.time) : 0 + ); + + var x = d3.scale.linear() + .range([0, axisWidth]) + .domain([timeMin, timeMax]); + + let complexityMax = 0; + if (samplesA) { + complexityMax = Math.max(complexityMax, d3.max(samplesA, s => s.time > 0 ? s.complexity : 0)); + } + if (samplesB) { + complexityMax = Math.max(complexityMax, d3.max(samplesB, s => s.time > 0 ? s.complexity : 0)); + } + complexityMax *= 1.2; + + const graphTop = 10; + var yLeft = d3.scale.linear() + .range([axisHeight, graphTop]) + .domain([0, complexityMax]); + + const targetFPS_A = optionsA ? optionsA["frame-rate"] : 60; + const targetFPS_B = optionsB ? optionsB["frame-rate"] : 60; + + const minFrameRate = Math.min(this._minFrameRate(optionsA), this._minFrameRate(optionsB)); + const maxFrameRate = Math.max(this._maxFrameRate(optionsA), this._maxFrameRate(optionsB)); + + const yRightMin = msPerSecond / minFrameRate; + const yRightMax = msPerSecond / maxFrameRate; + + var yRight = d3.scale.linear() + .range([axisHeight, graphTop]) + .domain([yRightMin, yRightMax]); + + var xAxis = d3.svg.axis() + .scale(x) + .orient("bottom") + .tickFormat(function(d) { return (d / msPerSecond).toFixed(0); }); + var yAxisLeft = d3.svg.axis() + .scale(yLeft) + .orient("left"); + + var yAxisRight = d3.svg.axis() + .scale(yRight) + .tickValues(this._tickValuesForFrameRate(this._targetFrameRate, minFrameRate, maxFrameRate)) + .tickFormat(function(d) { return (msPerSecond / d).toFixed(0); }) + .orient("right"); + + svg.append("g") + .attr("class", "x axis") + .attr("fill", "rgb(235, 235, 235)") + .attr("transform", "translate(0," + axisHeight + ")") + .call(xAxis) + .append("text") + .attr("class", "label") + .attr("x", axisWidth) + .attr("y", -6) + .attr("fill", "rgb(235, 235, 235)") + .style("text-anchor", "end") + .text("time"); + + svg.append("g") + .attr("class", "yLeft axis") + .attr("fill", "#7ADD49") + .call(yAxisLeft) + .append("text") + .attr("class", "label") + .attr("transform", "rotate(-90)") + .attr("y", 6) + .attr("fill", "#7ADD49") + .attr("dy", ".71em") + .style("text-anchor", "end") + .text(Strings.text.complexity); + + svg.append("g") + .attr("class", "yRight axis") + .attr("fill", "#FA4925") + .attr("transform", "translate(" + axisWidth + ", 0)") + .call(yAxisRight) + .append("text") + .attr("class", "label") + .attr("x", 9) + .attr("y", -20) + .attr("fill", "#FA4925") + .attr("dy", ".71em") + .style("text-anchor", "start") + .text(Strings.text.frameRate); + + var yMin = yRight(yAxisRight.scale().domain()[0]); + var yMax = yRight(yAxisRight.scale().domain()[1]); + const drawMarks = marksB || marksA; + if (drawMarks) { + for (var markName in drawMarks) { + var mark = drawMarks[markName]; + var xLocation = x(mark.time); + + var markerGroup = svg.append("g") + .attr("class", "marker") + .attr("transform", "translate(" + xLocation + ", 0)"); + markerGroup.append("text") + .attr("transform", "translate(10, " + (yMin - 10) + ") rotate(-90)") + .style("text-anchor", "start") + .text(markName); + markerGroup.append("line") + .attr("x1", 0) + .attr("x2", 0) + .attr("y1", yMin) + .attr("y2", yMax); + } + } + + if (resultA && Strings.json.controller in resultA) { + var compA = resultA[Strings.json.controller]; + var regA = svg.append("g").attr("class", "complexity mean comp-run-a"); + this._addRegressionLine(regA, x, yLeft, [[samplesA[0].time, compA.average], [samplesA[samplesA.length - 1].time, compA.average]], compA.stdev); + } + if (resultB && Strings.json.controller in resultB) { + var compB = resultB[Strings.json.controller]; + var regB = svg.append("g").attr("class", "complexity mean comp-run-b"); + this._addRegressionLine(regB, x, yLeft, [[samplesB[0].time, compB.average], [samplesB[samplesB.length - 1].time, compB.average]], compB.stdev); + } + + if (resultA && Strings.json.frameLength in resultA) { + var fpsA = resultA[Strings.json.frameLength]; + var regFpsA = svg.append("g").attr("class", "fps mean comp-run-a"); + this._addRegressionLine(regFpsA, x, yRight, [[samplesA[0].time, msPerSecond / fpsA.average], [samplesA[samplesA.length - 1].time, msPerSecond / fpsA.average]], fpsA.stdev); + } + if (resultB && Strings.json.frameLength in resultB) { + var fpsB = resultB[Strings.json.frameLength]; + var regFpsB = svg.append("g").attr("class", "fps mean comp-run-b"); + this._addRegressionLine(regFpsB, x, yRight, [[samplesB[0].time, msPerSecond / fpsB.average], [samplesB[samplesB.length - 1].time, msPerSecond / fpsB.average]], fpsB.stdev); + } + + if (optionsA && optionsA["controller"] == "adaptive") { + svg.append("line") + .attr("x1", x(0)) + .attr("x2", axisWidth) + .attr("y1", yRight(msPerSecond / targetFPS_A)) + .attr("y2", yRight(msPerSecond / targetFPS_A)) + .attr("class", "target-fps marker comp-run-a"); + } + if (optionsB && optionsB["controller"] == "adaptive") { + svg.append("line") + .attr("x1", x(0)) + .attr("x2", axisWidth) + .attr("y1", yRight(msPerSecond / targetFPS_B)) + .attr("y2", yRight(msPerSecond / targetFPS_B)) + .attr("class", "target-fps marker comp-run-b"); + } + + var cursorGroup = svg.append("g").attr("class", "cursor"); + cursorGroup.append("line") + .attr("x1", 0) + .attr("x2", 0) + .attr("y1", yMin) + .attr("y2", yMin); + + function addDataset(runId, runClass, samples, animData, mutData, filteredData) { + function addCurve(name, data, yCoordinateCallback, pointRadius, omitLine) { + const compoundId = `${name}-${runId}`; + var svgGroup = svg.append("g") + .attr("id", compoundId) + .attr("class", runClass); + if (!omitLine) { + svgGroup.append("path") + .datum(data) + .attr("d", d3.svg.line() + .x(function(d) { return x(d.time); }) + .y(yCoordinateCallback)); + } + svgGroup.selectAll("circle") + .data(data) + .enter() + .append("circle") + .attr("cx", function(d) { return x(d.time); }) + .attr("cy", yCoordinateCallback) + .attr("r", pointRadius); + + cursorGroup.append("circle") + .attr("class", `${name}-${runId} ${runClass}`) + .attr("r", pointRadius + 2); + } + + addCurve("complexity", samples, d => yLeft(d.complexity), 2); + addCurve("rawFPS", animData, d => yRight(d.frameLength), 1); + addCurve("mutFPS", mutData, d => yRight(d.frameLength), 1); + addCurve("filteredFPS", filteredData, d => yRight(d.smoothedFrameLength), 2); + } + + if (samplesA) { + const animA = samplesA.filter(s => s['frameType'] == Strings.json.animationFrameType); + const mutA = samplesA.filter(s => s['frameType'] == Strings.json.mutationFrameType); + const filtA = animA.filter(s => "smoothedFrameLength" in s); + addDataset("a", "comp-run-a-complexity comp-run-a-rawfps comp-run-a-filteredfps", samplesA, animA, mutA, filtA); + } + + if (samplesB) { + const animB = samplesB.filter(s => s['frameType'] == Strings.json.animationFrameType); + const mutB = samplesB.filter(s => s['frameType'] == Strings.json.mutationFrameType); + const filtB = animB.filter(s => "smoothedFrameLength" in s); + addDataset("b", "comp-run-b-complexity comp-run-b-rawfps comp-run-b-filteredfps", samplesB, animB, mutB, filtB); + } + + var regressionGroup = svg.append("g").attr("id", "regressions"); + + function drawRegressions(regressionsList, runClass) { + if (!regressionsList) return; + regressionsList.forEach(function (regression) { + var regSub = regressionGroup.append("g").attr("class", runClass); + if (!isNaN(regression.segment1[0][1]) && !isNaN(regression.segment1[1][1])) { + regSub.append("line") + .attr("x1", x(regression.segment1[0][0])) + .attr("x2", x(regression.segment1[1][0])) + .attr("y1", yRight(regression.segment1[0][1])) + .attr("y2", yRight(regression.segment1[1][1])); + } + if (!isNaN(regression.segment2[0][1]) && !isNaN(regression.segment2[1][1])) { + regSub.append("line") + .attr("x1", x(regression.segment2[0][0])) + .attr("x2", x(regression.segment2[1][0])) + .attr("y1", yRight(regression.segment2[0][1])) + .attr("y2", yRight(regression.segment2[1][1])); + } + regSub.append("circle") + .attr("cx", x(regression.segment2[0][0])) + .attr("cy", yRight(regression.segment2[0][1])) + .attr("r", 3); + regSub.append("line") + .attr("class", "association") + .attr("stroke-dasharray", "5, 3") + .attr("x1", x(regression.segment2[0][0])) + .attr("x2", x(regression.segment2[0][0])) + .attr("y1", yRight(regression.segment2[0][1])) + .attr("y2", yLeft(regression.complexity)); + regSub.append("circle") + .attr("cx", x(regression.segment1[1][0])) + .attr("cy", yLeft(regression.complexity)) + .attr("r", 5); + }); + } + + drawRegressions(regressionsA, "comp-run-a"); + drawRegressions(regressionsB, "comp-run-b"); + + var area = svg.append("rect") + .attr("fill", "transparent") + .attr("x", 0) + .attr("y", 0) + .attr("width", axisWidth) + .attr("height", axisHeight); + + var timeBisect = d3.bisector(function(d) { return d.time; }).right; + var statsToHighlight = ["complexity", "rawFPS", "filteredFPS", "mutFPS"]; + + area.on("mouseover", function() { + document.querySelector("#time-graph .cursor").classList.remove("hidden"); + document.querySelector("#test-graph nav").classList.remove("hide-data"); + }).on("mouseout", function() { + document.querySelector("#time-graph .cursor").classList.add("hidden"); + document.querySelector("#test-graph nav").classList.add("hide-data"); + }).on("mousemove", function() { + var form = document.forms["time-graph-options"].elements; + var mx_domain = x.invert(d3.mouse(this)[0]); + + var indexA = samplesA ? Math.min(timeBisect(samplesA, mx_domain), samplesA.length - 1) : null; + var indexB = samplesB ? Math.min(timeBisect(samplesB, mx_domain), samplesB.length - 1) : null; + + var dataA = indexA !== null ? samplesA[indexA] : null; + var dataB = indexB !== null ? samplesB[indexB] : null; + + if (dataA) { + document.querySelector("#test-graph nav .time-a").textContent = (dataA.time / msPerSecond).toFixed(3) + "s (" + indexA + ")"; + } + if (dataB) { + document.querySelector("#test-graph nav .time-b").textContent = (dataB.time / msPerSecond).toFixed(3) + "s (" + indexB + ")"; + } + + var cursor_x = x(mx_domain); + var ys = [yRight(yAxisRight.scale().domain()[0]), yRight(yAxisRight.scale().domain()[1])]; + + statsToHighlight.forEach(function(name) { + var elementA = document.querySelector("#test-graph nav ." + name + "-a"); + var elementB = document.querySelector("#test-graph nav ." + name + "-b"); + + function fillStats(data, element, targetCircleClass, isA) { + if (!data) { + element.textContent = ""; + document.querySelector("#time-graph .cursor ." + targetCircleClass).classList.add("hidden"); + return; + } + + var content = ""; + var data_y = null; + switch (name) { + case "complexity": + content = data.complexity; + data_y = yLeft(data.complexity); + break; + case "rawFPS": + if (data.frameType == Strings.json.animationFrameType) { + content = (msPerSecond / data.frameLength).toFixed(1); + data_y = yRight(data.frameLength); + } + break; + case "filteredFPS": + if ("smoothedFrameLength" in data) { + content = (msPerSecond / data.smoothedFrameLength).toFixed(1); + data_y = yRight(data.smoothedFrameLength); + } + break; + case "mutFPS": + if (data.frameType == Strings.json.mutationFrameType) { + content = (msPerSecond / data.frameLength).toFixed(1); + data_y = yRight(data.frameLength); + } + break; + } + + element.textContent = content; + + if (form[name].checked && data_y !== null) { + ys.push(data_y); + cursorGroup.select("." + targetCircleClass) + .attr("cx", x(data.time)) + .attr("cy", data_y); + document.querySelector("#time-graph .cursor ." + targetCircleClass).classList.remove("hidden"); + } else { + document.querySelector("#time-graph .cursor ." + targetCircleClass).classList.add("hidden"); + } + } + + fillStats(dataA, elementA, name + "-a", true); + fillStats(dataB, elementB, name + "-b", false); + }); + + cursorGroup.select("line") + .attr("x1", cursor_x) + .attr("x2", cursor_x) + .attr("y1", Math.min.apply(null, ys)) + .attr("y2", Math.max.apply(null, ys)); + }); + } + + _createComparisonComplexityGraph(resultA, resultB, regressionsA, regressionsB, samplesA, samplesB, optionsA, optionsB, margins, size) + { + var svg = d3.select("#test-graph-data").append("svg") + .attr("id", "complexity-graph") + .attr("class", "hidden") + .attr("width", size.width) + .attr("height", size.height) + .append("g") + .attr("transform", "translate(" + margins.left + "," + margins.top + ")"); + + var timeSamplesA = samplesA ? samplesA[Strings.json.controller] : null; + var timeSamplesB = samplesB ? samplesB[Strings.json.controller] : null; + + let xMin = 100000, xMax = 0; + + function updateMinMax(samples, regressions) { + if (!samples) return; + if (regressions) { + regressions.forEach(function(regression) { + for (var i = regression.startIndex; i <= regression.endIndex; ++i) { + if (samples[i]) { + xMin = Math.min(xMin, samples[i].complexity); + xMax = Math.max(xMax, samples[i].complexity); + } + } + }); + } else { + xMin = Math.min(xMin, d3.min(samples, s => s.complexity)); + xMax = Math.max(xMax, d3.max(samples, s => s.complexity)); + } + } + + updateMinMax(timeSamplesA, regressionsA); + updateMinMax(timeSamplesB, regressionsB); + + const axisWidth = size.width - margins.left - margins.right; + const axisHeight = size.height - margins.top - margins.bottom; + + const minFrameRate = Math.min(this._minFrameRate(optionsA), this._minFrameRate(optionsB)); + const maxFrameRate = Math.max(this._maxFrameRate(optionsA), this._maxFrameRate(optionsB)); + + const yMin = msPerSecond / minFrameRate; + const yMax = msPerSecond / maxFrameRate; + + var xScale = d3.scale.linear() + .range([0, axisWidth]) + .domain([xMin, xMax]); + var yScale = d3.scale.linear() + .range([axisHeight, 0]) + .domain([yMin, yMax]); + + var xAxis = d3.svg.axis() + .scale(xScale) + .orient("bottom"); + var yAxis = d3.svg.axis() + .scale(yScale) + .tickValues(this._tickValuesForFrameRate(optionsB ? optionsB["frame-rate"] : 60, minFrameRate, maxFrameRate)) + .tickFormat(function(d) { return (msPerSecond / d).toFixed(0); }) + .orient("left"); + + svg.append("g") + .attr("class", "x axis") + .attr("transform", "translate(0," + axisHeight + ")") + .call(xAxis); + + svg.append("g") + .attr("class", "y axis") + .call(yAxis); + + if (resultA && Strings.json.controller in resultA) { + var meanA = svg.append("g").attr("class", "mean complexity comp-run-a"); + var resA = resultA[Strings.json.controller]; + this._addRegressionLine(meanA, xScale, yScale, [[resA.average, yMin], [resA.average, yMax]], resA.stdev, true); + } + if (resultB && Strings.json.controller in resultB) { + var meanB = svg.append("g").attr("class", "mean complexity comp-run-b"); + var resB = resultB[Strings.json.controller]; + this._addRegressionLine(meanB, xScale, yScale, [[resB.average, yMin], [resB.average, yMax]], resB.stdev, true); + } + + if (resultA && resultA[Strings.json.complexity]) { + this._addRegression(resultA[Strings.json.complexity], svg.append("g").attr("class", "regression raw comp-run-a"), xScale, yScale); + } + if (resultB && resultB[Strings.json.complexity]) { + this._addRegression(resultB[Strings.json.complexity], svg.append("g").attr("class", "regression raw comp-run-b"), xScale, yScale); + } + + function drawBootstrap(result, runClass) { + if (!result) return; + var bootstrapResult = result[Strings.json.complexity][Strings.json.bootstrap]; + if (bootstrapResult) { + var histogram = d3.layout.histogram().bins(xScale.ticks(100))(bootstrapResult.data); + var yBootstrapScale = d3.scale.linear() + .range([axisHeight/2, 0]) + .domain([0, d3.max(histogram, function(d) { return d.y; })]); + + let group = svg.append("g").attr("class", `bootstrap ${runClass}`); + var bar = group.selectAll(".bar") + .data(histogram) + .enter().append("g") + .attr("class", "bar") + .attr("transform", function(d) { return "translate(" + xScale(d.x) + "," + yBootstrapScale(d.y) + ")"; }); + bar.append("rect") + .attr("x", 1) + .attr("y", axisHeight/2) + .attr("width", xScale(histogram[1].x) - xScale(histogram[0].x) - 1) + .attr("height", function(d) { return axisHeight/2 - yBootstrapScale(d.y); }); + + group = group.append("g").attr("class", "median"); + this._addRegressionLine(group, xScale, yScale, [[bootstrapResult.median, yMin], [bootstrapResult.median, yMax]], [bootstrapResult.confidenceLow, bootstrapResult.confidenceHigh], true); + + const targetFrameRate = optionsB ? optionsB["frame-rate"] : 60; + group.append("circle") + .attr("cx", xScale(bootstrapResult.median)) + .attr("cy", yScale(msPerSecond / targetFrameRate)) + .attr("r", 5); + } + } + + drawBootstrap.call(this, resultA, "comp-run-a"); + drawBootstrap.call(this, resultB, "comp-run-b"); + + function drawRawSeries(samples, runClass) { + if (!samples) return; + let group = svg.append("g") + .attr("class", `series raw ${runClass}`) + .selectAll("line") + .data(samples[Strings.json.complexity]) + .enter(); + + group.append("line") + .attr("x1", d => xScale(d.complexity) - 3) + .attr("x2", d => xScale(d.complexity) + 3) + .attr("y1", d => yScale(d.frameLength) - 3) + .attr("y2", d => yScale(d.frameLength) + 3) + .attr("class", d => d.frameType === "m" ? 'mutation' : 'animation'); + group.append("line") + .attr("x1", d => xScale(d.complexity) - 3) + .attr("x2", d => xScale(d.complexity) + 3) + .attr("y1", d => yScale(d.frameLength) + 3) + .attr("y2", d => yScale(d.frameLength) - 3) + .attr("class", d => d.frameType === "m" ? 'mutation' : 'animation'); + } + + drawRawSeries(samplesA, "comp-run-a"); + drawRawSeries(samplesB, "comp-run-b"); + + var cursorGroup = svg.append("g").attr("class", "cursor hidden"); + cursorGroup.append("line") + .attr("class", "x") + .attr("x1", 0) + .attr("x2", 0) + .attr("y1", yScale(yAxis.scale().domain()[0]) + 10) + .attr("y2", yScale(yAxis.scale().domain()[1])); + cursorGroup.append("line") + .attr("class", "y") + .attr("x1", xScale(xAxis.scale().domain()[0]) - 10) + .attr("x2", xScale(xAxis.scale().domain()[1])) + .attr("y1", 0) + .attr("y2", 0); + cursorGroup.append("text") + .attr("class", "label x") + .attr("x", 0) + .attr("y", yScale(yAxis.scale().domain()[0]) + 15) + .attr("baseline-shift", "-100%") + .attr("text-anchor", "middle"); + cursorGroup.append("text") + .attr("class", "label y") + .attr("x", xScale(xAxis.scale().domain()[0]) - 15) + .attr("y", 0) + .attr("baseline-shift", "-30%") + .attr("text-anchor", "end"); + + var area = svg.append("rect") + .attr("fill", "transparent") + .attr("x", 0) + .attr("y", 0) + .attr("width", size.width) + .attr("height", axisHeight); + + area.on("mouseover", function() { + document.querySelector("#complexity-graph .cursor").classList.remove("hidden"); + }).on("mouseout", function() { + document.querySelector("#complexity-graph .cursor").classList.add("hidden"); + }).on("mousemove", function() { + var location = d3.mouse(this); + var location_domain = [xScale.invert(location[0]), yScale.invert(location[1])]; + cursorGroup.select("line.x") + .attr("x1", location[0]) + .attr("x2", location[0]); + cursorGroup.select("text.x") + .attr("x", location[0]) + .text(location_domain[0].toFixed(1)); + cursorGroup.select("line.y") + .attr("y1", location[1]) + .attr("y2", location[1]); + cursorGroup.select("text.y") + .attr("y", location[1]) + .text((msPerSecond / location_domain[1]).toFixed(1)); + }); + } } + diff --git a/MotionMark/resources/debug-runner/motionmark.css b/MotionMark/resources/debug-runner/motionmark.css index fa83391..167062b 100644 --- a/MotionMark/resources/debug-runner/motionmark.css +++ b/MotionMark/resources/debug-runner/motionmark.css @@ -832,3 +832,436 @@ body.showing-test-graph header, body.showing-test-graph nav { #complexity-graph .bootstrap .median polygon { fill: hsla(240, 56%, 66%, .4); } + +/* -------------------------------------------------------------------------- */ +/* Comparison & Panel Controls */ +/* -------------------------------------------------------------------------- */ + +.control-panel-container { + background: rgba(45, 45, 45, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 1.2em; + margin: 2.5em 0; + max-width: 580px; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2); +} + +.mode-toggles { + display: flex; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 1.2em; + gap: 0.5em; +} + +.mode-tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.6) !important; + font-size: 1.05em; + padding: 0.6em 1.2em; + border-radius: 0; + cursor: pointer; + font-weight: 500; + transition: all 0.25s ease; + display: inline-block !important; + min-width: initial !important; +} + +.mode-tab:hover { + color: white !important; + background: transparent; +} + +.mode-tab.active { + color: white !important; + border-bottom-color: hsl(20, 95%, 52%) !important; + background: transparent; +} + +.control-panel { + display: none; + animation: fadeIn 0.3s ease; +} + +.control-panel.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.drop-zone { + border: 2px dashed rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1.5em; + text-align: center; + color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.02); + transition: all 0.2s ease; + margin-bottom: 1em; +} + +.drop-zone.drag-over { + border-color: hsl(20, 95%, 52%); + background: rgba(250, 73, 37, 0.08); + color: white; +} + +.compare-zones-row { + display: flex; + gap: 1.2em; + margin-bottom: 1.5em; +} + +.compare-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 1em; +} + +.compare-col h4 { + margin: 0 0 0.8em 0; + font-size: 1.05em; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); +} + +.compare-drop { + width: 100%; + box-sizing: border-box; + padding: 1.2em 0.5em; + font-size: 0.9em; + margin-bottom: 0.8em; +} + +.panel-btn { + border: 1.5px solid white !important; + color: white !important; + background: transparent !important; + padding: 0.5em 1.5em !important; + font-size: 1.1em !important; + border-radius: 6px !important; + cursor: pointer !important; + transition: all 0.2s !important; + display: block !important; +} + +.panel-btn:hover { + background: rgba(255, 255, 255, 0.1) !important; +} + +.panel-btn-small { + border: 1px solid rgba(255, 255, 255, 0.4) !important; + color: rgba(255, 255, 255, 0.9) !important; + background: transparent !important; + padding: 0.4em 1em !important; + font-size: 0.85em !important; + border-radius: 4px !important; + cursor: pointer !important; + transition: all 0.2s !important; + margin-bottom: 0.6em !important; + display: block !important; +} + +.panel-btn-small:hover { + background: rgba(255, 255, 255, 0.08) !important; +} + +.loaded-file-indicator { + font-size: 0.78em; + color: rgba(255, 255, 255, 0.4); + text-align: center; + word-break: break-all; + max-width: 100%; +} + +.loaded-file-indicator.loaded { + color: hsl(122, 60%, 65%); + font-weight: 500; +} + +.compare-actions { + display: flex; + justify-content: center; + align-items: center; + gap: 1.5em; +} + +.compare-run-btn { + border: none !important; + background: linear-gradient(135deg, hsl(20, 95%, 52%) 0%, hsl(10, 96%, 56%) 100%) !important; + color: white !important; + padding: 0.6em 2.2em !important; + font-size: 1.25em !important; + font-weight: bold !important; + border-radius: 8px !important; + cursor: pointer !important; + box-shadow: 0 4px 15px rgba(250, 73, 37, 0.3) !important; + transition: all 0.25s ease !important; + display: block !important; +} + +.compare-run-btn:hover:not(.disabled) { + box-shadow: 0 6px 20px rgba(250, 73, 37, 0.5) !important; + transform: translateY(-1px) !important; +} + +.compare-run-btn.disabled, .compare-run-btn:disabled { + background: rgba(255, 255, 255, 0.08) !important; + color: rgba(255, 255, 255, 0.2) !important; + box-shadow: none !important; + cursor: not-allowed !important; + transform: none !important; +} + +.panel-btn-text { + background: transparent !important; + border: none !important; + color: rgba(255, 255, 255, 0.5) !important; + font-size: 1em !important; + cursor: pointer !important; + text-decoration: underline !important; + padding: 0.2em !important; + display: inline-block !important; + min-width: initial !important; +} + +.panel-btn-text:hover { + color: white !important; +} + +/* Overall Comparison Results Styling */ +.comparison-badge { + padding: 0.15em 0.45em; + border-radius: 8px; + font-size: 1.1em; + font-weight: bold; + display: inline-block; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.comp-badge-better { + background: hsla(122, 55%, 42%, 0.2) !important; + border: 1.5px solid hsl(122, 55%, 45%) !important; + color: hsl(122, 60%, 65%) !important; +} + +.comp-badge-worse { + background: hsla(11, 72%, 45%, 0.2) !important; + border: 1.5px solid hsl(11, 72%, 48%) !important; + color: hsl(11, 75%, 65%) !important; +} + +.comparison-details-row { + display: flex; + justify-content: center; + align-items: center; + gap: 1.5em; + font-size: 0.95em; + color: rgba(255, 255, 255, 0.85); +} + +.comparison-details-row .separator { + color: rgba(255, 255, 255, 0.15); +} + +/* Delta cells in result tables */ +.diff-better { + color: hsl(122, 60%, 65%) !important; + font-weight: bold; +} + +.diff-worse { + color: hsl(11, 75%, 65%) !important; + font-weight: bold; +} + +.font-bold { + font-weight: bold; +} + +.sub-stdev { + font-size: 0.82em; + color: rgba(255, 255, 255, 0.38); + margin-left: 0.25em; +} + +.inline-badge { + font-size: 0.7em !important; + vertical-align: middle; + margin-left: 0.6em; +} + +.comp-nav-legend, .comp-nav-values { + float: right; + font-weight: bold; +} + +.comp-teal { + color: hsl(180, 70%, 48%) !important; +} + +.comp-orange { + color: hsl(15, 90%, 58%) !important; +} + +.comp-nav-legend-col { + display: flex; + flex-direction: column; + align-items: flex-end; + float: right; + margin-top: -0.8em; +} + +/* Dynamic Graph Comparison Overlays HSL Rules */ + +/* Baseline (A) HSL Teal */ +.comp-run-a-complexity path { + stroke: hsla(180, 70%, 48%, 0.7) !important; + stroke-width: 2px !important; + stroke-dasharray: 4, 4 !important; +} +.comp-run-a-complexity circle { + fill: hsl(180, 70%, 48%) !important; +} +.comp-run-a-rawfps path { + stroke: hsla(200, 70%, 50%, 0.4) !important; + stroke-width: 1px !important; +} +.comp-run-a-rawfps circle { + fill: hsl(200, 70%, 50%) !important; +} +.comp-run-a-filteredfps path { + stroke: hsla(190, 80%, 48%, 0.8) !important; + stroke-width: 2px !important; +} +.comp-run-a-filteredfps circle { + fill: hsl(190, 80%, 48%) !important; +} +.mean.comp-run-a line { + stroke: hsla(180, 70%, 45%, 0.8) !important; + stroke-width: 2px !important; +} +.mean.comp-run-a polygon { + fill: hsla(180, 70%, 45%, 0.05) !important; +} + +/* Comparison (B) HSL Coral Orange */ +.comp-run-b-complexity path { + stroke: hsla(15, 90%, 58%, 0.85) !important; + stroke-width: 2.5px !important; +} +.comp-run-b-complexity circle { + fill: hsl(15, 90%, 58%) !important; +} +.comp-run-b-rawfps path { + stroke: hsla(35, 90%, 50%, 0.45) !important; + stroke-width: 1px !important; +} +.comp-run-b-rawfps circle { + fill: hsl(35, 90%, 50%) !important; +} +.comp-run-b-filteredfps path { + stroke: hsla(20, 95%, 54%, 0.85) !important; + stroke-width: 2.5px !important; +} +.comp-run-b-filteredfps circle { + fill: hsl(20, 95%, 54%) !important; +} +.mean.comp-run-b line { + stroke: hsla(15, 90%, 50%, 0.8) !important; + stroke-width: 2px !important; +} +.mean.comp-run-b polygon { + fill: hsla(15, 90%, 50%, 0.05) !important; +} + +/* Complexity Graph Comparisons */ +#complexity-graph .regression.comp-run-a line { + stroke: hsla(180, 70%, 45%, 0.85) !important; + stroke-width: 2px !important; +} +#complexity-graph .regression.comp-run-a polygon { + fill: hsla(180, 70%, 45%, 0.15) !important; +} +#complexity-graph .regression.comp-run-b line { + stroke: hsla(15, 90%, 58%, 0.85) !important; + stroke-width: 2.5px !important; +} +#complexity-graph .regression.comp-run-b polygon { + fill: hsla(15, 90%, 58%, 0.2) !important; +} + +#complexity-graph .bootstrap.comp-run-a .bar { + fill: hsla(180, 70%, 45%, 0.25) !important; +} +#complexity-graph .bootstrap.comp-run-a .median line { + stroke: hsla(180, 70%, 40%, 0.8) !important; + stroke-width: 2px !important; +} +#complexity-graph .bootstrap.comp-run-a .median circle { + fill: hsla(180, 70%, 40%) !important; +} +#complexity-graph .bootstrap.comp-run-b .bar { + fill: hsla(15, 90%, 58%, 0.25) !important; +} +#complexity-graph .bootstrap.comp-run-b .median line { + stroke: hsla(15, 90%, 50%, 0.8) !important; + stroke-width: 2.5px !important; +} +#complexity-graph .bootstrap.comp-run-b .median circle { + fill: hsla(15, 90%, 50%) !important; +} + +#complexity-graph .series.raw.comp-run-a line { + stroke: hsla(180, 70%, 48%, 0.25) !important; +} +#complexity-graph .series.raw.comp-run-b line { + stroke: hsla(15, 90%, 58%, 0.3) !important; +} +#complexity-graph .comp-run-a-rawfps circle { + fill: hsl(180, 70%, 45%) !important; +} +#complexity-graph .comp-run-b-rawfps circle { + fill: hsl(15, 90%, 58%) !important; +} + +.target-fps.comp-run-a { + stroke: hsla(180, 70%, 45%, 0.45) !important; +} +.target-fps.comp-run-b { + stroke: hsla(15, 90%, 58%, 0.5) !important; +} +#time-graph .cursor .comp-teal { + fill: hsl(180, 70%, 48%) !important; +} +#time-graph .cursor .comp-orange { + fill: hsl(15, 90%, 58%) !important; +} +#results .score-profile { + margin-bottom: 0.5em; +} +#results .confidence { + text-indent: 0 !important; +} + +.compare-col.drag-over { + background: rgba(250, 73, 37, 0.08) !important; + border-color: hsl(20, 95%, 52%) !important; + border-style: solid !important; +} + +.compare-col.drag-over .drop-zone { + border-color: hsl(20, 95%, 52%) !important; + color: white !important; +} + diff --git a/MotionMark/resources/debug-runner/tests.js b/MotionMark/resources/debug-runner/tests.js index f48d0b1..302c51b 100644 --- a/MotionMark/resources/debug-runner/tests.js +++ b/MotionMark/resources/debug-runner/tests.js @@ -456,3 +456,153 @@ Suites.push(new Suite("Basic canvas path suite", } ] )); + +Utilities.extendObject(Headers, { + comparisonScore: [ + { + title: "Baseline A", + text: function(testResultA, testResultB) { + if (!testResultA) return "N/A"; + return testResultA[Strings.json.score].toFixed(2); + }, + className: "right pad-left pad-right" + }, + { + title: "Comp B", + text: function(testResultA, testResultB) { + if (!testResultB) return "N/A"; + return testResultB[Strings.json.score].toFixed(2); + }, + className: "right pad-left pad-right" + }, + { + title: "Change %", + text: function(testResultA, testResultB) { + if (!testResultA || !testResultB) return "N/A"; + const scoreA = testResultA[Strings.json.score]; + const scoreB = testResultB[Strings.json.score]; + const diff = scoreB - scoreA; + const percent = (diff / scoreA) * 100; + const percentStr = (percent >= 0 ? "+" : "") + percent.toFixed(2) + "%"; + const className = percent >= 0 ? "diff-better" : "diff-worse"; + + const span = document.createElement("span"); + span.className = className; + span.textContent = `${percentStr} (${(diff >= 0 ? "+" : "") + diff.toFixed(1)})`; + return span; + }, + className: "right pad-left pad-right font-bold" + } + ], + comparisonDetails: [ + { + title: Strings.text.graph + }, + { + title: "Complexity Baseline (A)", + text: function(testResultA, testResultB, optionsA, optionsB) { + if (!testResultA) return "N/A"; + const complexityVal = testResultA[Strings.json.complexity]; + if (!complexityVal) return "N/A"; + + const bootstrap = complexityVal[Strings.json.bootstrap]; + if (bootstrap) { + const fragment = document.createDocumentFragment(); + const medianText = document.createTextNode(bootstrap.median.toFixed(1) + " "); + const subSpan = document.createElement("span"); + subSpan.className = "sub-stdev"; + subSpan.textContent = `[${bootstrap.confidenceLow.toFixed(1)} - ${bootstrap.confidenceHigh.toFixed(1)}]`; + fragment.appendChild(medianText); + fragment.appendChild(subSpan); + return fragment; + } + + const controllerData = testResultA[Strings.json.controller]; + if (controllerData) { + const fragment = document.createDocumentFragment(); + const avgText = document.createTextNode(controllerData[Strings.json.measurements.average].toFixed(1) + " "); + const subSpan = document.createElement("span"); + subSpan.className = "sub-stdev"; + subSpan.textContent = `±${controllerData[Strings.json.measurements.percent].toFixed(1)}%`; + fragment.appendChild(avgText); + fragment.appendChild(subSpan); + return fragment; + } + return "N/A"; + }, + className: "center pad-left pad-right" + }, + { + title: "Complexity Comp (B)", + text: function(testResultA, testResultB, optionsA, optionsB) { + if (!testResultB) return "N/A"; + const complexityVal = testResultB[Strings.json.complexity]; + if (!complexityVal) return "N/A"; + + const bootstrap = complexityVal[Strings.json.bootstrap]; + if (bootstrap) { + const fragment = document.createDocumentFragment(); + const medianText = document.createTextNode(bootstrap.median.toFixed(1) + " "); + const subSpan = document.createElement("span"); + subSpan.className = "sub-stdev"; + subSpan.textContent = `[${bootstrap.confidenceLow.toFixed(1)} - ${bootstrap.confidenceHigh.toFixed(1)}]`; + fragment.appendChild(medianText); + fragment.appendChild(subSpan); + return fragment; + } + + const controllerData = testResultB[Strings.json.controller]; + if (controllerData) { + const fragment = document.createDocumentFragment(); + const avgText = document.createTextNode(controllerData[Strings.json.measurements.average].toFixed(1) + " "); + const subSpan = document.createElement("span"); + subSpan.className = "sub-stdev"; + subSpan.textContent = `±${controllerData[Strings.json.measurements.percent].toFixed(1)}%`; + fragment.appendChild(avgText); + fragment.appendChild(subSpan); + return fragment; + } + return "N/A"; + }, + className: "center pad-left pad-right" + }, + { + title: "FPS Baseline (A)", + text: function(testResultA, testResultB, optionsA, optionsB) { + if (!testResultA) return "N/A"; + const frameLength = testResultA[Strings.json.frameLength]; + if (frameLength) { + const fragment = document.createDocumentFragment(); + const avgText = document.createTextNode(frameLength[Strings.json.measurements.average].toFixed(1) + " "); + const subSpan = document.createElement("span"); + subSpan.className = "sub-stdev"; + subSpan.textContent = `±${frameLength[Strings.json.measurements.percent].toFixed(1)}%`; + fragment.appendChild(avgText); + fragment.appendChild(subSpan); + return fragment; + } + return "N/A"; + }, + className: "center pad-left pad-right" + }, + { + title: "FPS Comp (B)", + text: function(testResultA, testResultB, optionsA, optionsB) { + if (!testResultB) return "N/A"; + const frameLength = testResultB[Strings.json.frameLength]; + if (frameLength) { + const fragment = document.createDocumentFragment(); + const avgText = document.createTextNode(frameLength[Strings.json.measurements.average].toFixed(1) + " "); + const subSpan = document.createElement("span"); + subSpan.className = "sub-stdev"; + subSpan.textContent = `±${frameLength[Strings.json.measurements.percent].toFixed(1)}%`; + fragment.appendChild(avgText); + fragment.appendChild(subSpan); + return fragment; + } + return "N/A"; + }, + className: "center pad-left pad-right" + } + ] +});