-
Notifications
You must be signed in to change notification settings - Fork 2
/
manipfactor.etternaonline.js
285 lines (240 loc) · 11.6 KB
/
manipfactor.etternaonline.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
// ==UserScript==
// @name ManipFactor for EtternaOnline
// @namespace http://tampermonkey.net/
// @version 0.1.5
// @description Estimates the amount of manip from the replay data.
// @author U1wknUzeU6, OpakyL
// @match https://etternaonline.com/*
// @grant none
// @updateURL https://raw.githubusercontent.com/MaidOfFire/ManipFactorEtterna/main/manipfactor.etternaonline.js
// @downloadURL https://raw.githubusercontent.com/MaidOfFire/ManipFactorEtterna/main/manipfactor.etternaonline.js
// @homepageURL https://github.com/MaidOfFire/ManipFactorEtterna
// @supportURL https://github.com/MaidOfFire/ManipFactorEtterna/issues/new
// ==/UserScript==
(function() {
'use strict';
const scorePagePattern = /^https:\/\/etternaonline\.com\/users\/[^\/]+\/scores\/[^\/]+\/?$/;
let locationChangeTimeout;
// Helper function to calculate weighted mean
function weightedMean(values, weights) {
const weightedSum = values.reduce((sum, value, index) => sum + value * weights[index], 0);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
return weightedSum / weightSum;
}
// Function to calculate manipulation factor and Left hand MF, Right hand MF
function getManipFactor(replayData) {
const key0Data = [];
const key1Data = [];
const key2Data = [];
const key3Data = [];
replayData.forEach(([time, error, keyLabel]) => {
const timeInMs = time * 1000;
if (keyLabel === 0) {
key0Data.push({ time: timeInMs, error });
} else if (keyLabel === 1) {
key1Data.push({ time: timeInMs, error });
} else if (keyLabel === 2) {
key2Data.push({ time: timeInMs, error });
} else if (keyLabel === 3) {
key3Data.push({ time: timeInMs, error });
}
});
const k0to1Deviations = calculateDeviations(key0Data, key1Data);
const k1to0Deviations = calculateDeviations(key1Data, key0Data);
const k2to3Deviations = calculateDeviations(key2Data, key3Data);
const k3to2Deviations = calculateDeviations(key3Data, key2Data);
const mfk0to1 = k0to1Deviations.yValues.reduce((a, b) => a + b, 0) / k0to1Deviations.yValues.length;
const mfk1to0 = k1to0Deviations.yValues.reduce((a, b) => a + b, 0) / k1to0Deviations.yValues.length;
const mfk2to3 = k2to3Deviations.yValues.reduce((a, b) => a + b, 0) / k2to3Deviations.yValues.length;
const mfk3to2 = k3to2Deviations.yValues.reduce((a, b) => a + b, 0) / k3to2Deviations.yValues.length;
// LH-MF (Weighted average of 0→1 and 1→0)
const leftHandWeights = [
k0to1Deviations.yValues.length,
k1to0Deviations.yValues.length
];
const leftHandMF = weightedMean([mfk0to1, mfk1to0], leftHandWeights);
// RH-MF (Weighted average of 2→3 and 3→2)
const rightHandWeights = [
k2to3Deviations.yValues.length,
k3to2Deviations.yValues.length
];
const rightHandMF = weightedMean([mfk2to3, mfk3to2], rightHandWeights);
// Overall manip factor
const overallWeights = [
k0to1Deviations.yValues.length,
k1to0Deviations.yValues.length,
k2to3Deviations.yValues.length,
k3to2Deviations.yValues.length
];
const manipFactor = weightedMean([mfk0to1, mfk1to0, mfk2to3, mfk3to2], overallWeights);
return { manipFactor, leftHandMF, rightHandMF };
}
// Function to calculate ManipScore based on manip factor
function manipScoreFunction(manipFactor) {
if (manipFactor <= 0.1) {
return 1;
} else {
const C = 2.3;
const k = 10;
const b = -0.1;
return Math.exp(-k * Math.pow(manipFactor + b, C));
}
}
// Function to calculate deviations between keys
function calculateDeviations(keyAData, keyBData) {
const eps = 0.1;
const xValues = [];
const yValues = [];
const sortedKeyAData = keyAData.slice().sort((a, b) => a.time - b.time);
const sortedKeyBData = keyBData.slice().sort((a, b) => a.time - b.time);
const timesA = sortedKeyAData.map(({ time }) => time);
const timesB = sortedKeyBData.map(({ time }) => time);
const diffA = timesA.slice(1).map((time, index) => time - timesA[index]);
const diffB = timesB.slice(1).map((time, index) => time - timesB[index]);
const nonZeroDiffA = diffA.filter(diff => diff !== 0);
const nonZeroDiffB = diffB.filter(diff => diff !== 0);
const lowerPercentileA = percentile(nonZeroDiffA, 5);
const upperPercentileA = percentile(nonZeroDiffA, 95);
const lowerPercentileB = percentile(nonZeroDiffB, 5);
const upperPercentileB = percentile(nonZeroDiffB, 95);
const filteredDiffA = nonZeroDiffA.filter(diff => diff > lowerPercentileA - eps && diff < upperPercentileA + eps);
const filteredDiffB = nonZeroDiffB.filter(diff => diff > lowerPercentileB - eps && diff < upperPercentileB + eps);
const k0AvgInterval = filteredDiffA.reduce((sum, diff) => sum + diff, 0) / filteredDiffA.length;
const k1AvgInterval = filteredDiffB.reduce((sum, diff) => sum + diff, 0) / filteredDiffB.length;
let avgInterval = (k0AvgInterval + k1AvgInterval) / 2;
avgInterval /= 2;
sortedKeyAData.forEach(({ time: timeA, error: errorA }) => {
const lastKeyBItem = sortedKeyBData.filter(({ time }) => time < timeA - eps).pop();
if (lastKeyBItem) {
const { time: timeB, error: errorB } = lastKeyBItem;
const deviation = (errorB - errorA) / avgInterval;
const absDeviation = Math.abs(deviation);
if (absDeviation <= 1.5) {
xValues.push(timeA);
yValues.push(absDeviation);
}
}
});
return { xValues, yValues };
}
// Helper function to calculate percentiles
function percentile(arr, p) {
if (arr.length === 0) return 0;
arr.sort((a, b) => a - b);
const index = (p / 100) * (arr.length - 1);
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= arr.length) return arr[lower];
return arr[lower] * (1 - weight) + arr[upper] * weight;
}
// Convert manip factor to color (based on Lua logic)
function byMF(x) {
const hue = Math.max(0, 120 - (x * 300)); // hue from green to red
const saturation = 0.9;
const brightness = 0.9;
return `hsl(${hue}, ${saturation * 100}%, ${brightness * 100}%)`;
}
// Fetch replay data from the page
function fetchReplayData(callback) {
const replayData = window.$nuxt?.$children[1]?.$children[1]?.$children[0]?.replay;
if (replayData) {
callback(replayData);
} else {
console.warn("Replay data not found yet, retrying...");
setTimeout(() => fetchReplayData(callback), 500);
}
}
// Initialize and display manip score with hover details
const initializeManipScoreDisplay = () => {
fetchReplayData(replayData => {
const { manipFactor, leftHandMF, rightHandMF } = getManipFactor(replayData);
const manipScorePercent = (manipFactor * 100).toFixed(2); // convert to percentage
// Create new element to display ManipScore
const gradeOverallWrapper = document.querySelector(".grade-overall-wrapper");
if (gradeOverallWrapper) {
const manipDiv = document.createElement("div");
manipDiv.style.display = "flex";
manipDiv.style.flexDirection = "column";
manipDiv.style.alignItems = "flex-start"; // Align to left
const manipLabel = document.createElement("div");
manipLabel.className = "msd font-small-bold";
manipLabel.innerText = "MF"; // Label for Manipulation Factor
manipLabel.style.textAlign = "left"; // Align label to the left
manipDiv.appendChild(manipLabel);
const manipScoreElement = document.createElement("h6");
manipScoreElement.innerText = `${manipScorePercent}%`; // Display ManipScore in percentage
manipScoreElement.style.color = byMF(manipFactor); // Apply color based on factor
manipDiv.appendChild(manipScoreElement);
// Create tooltip for LH-MF and RH-MF values
const tooltip = document.createElement("div");
tooltip.style.position = "absolute";
tooltip.style.padding = "10px";
tooltip.style.background = "rgba(0, 0, 0, 0.8)";
tooltip.style.color = "#fff";
tooltip.style.borderRadius = "5px";
tooltip.style.display = "none";
tooltip.style.zIndex = "1000";
tooltip.innerHTML = `
<strong>Details:</strong><br>
Left Hand: ${(leftHandMF * 100).toFixed(2)}%<br>
Right Hand: ${(rightHandMF * 100).toFixed(2)}%<br>
`;
// Show tooltip on hover
manipDiv.addEventListener("mouseenter", function(e) {
tooltip.style.display = "block";
tooltip.style.left = `${e.pageX + 10}px`;
tooltip.style.top = `${e.pageY + 10}px`;
});
// Move tooltip with mouse
manipDiv.addEventListener("mousemove", function(e) {
tooltip.style.left = `${e.pageX + 10}px`;
tooltip.style.top = `${e.pageY + 10}px`;
});
// Hide tooltip on mouseleave
manipDiv.addEventListener("mouseleave", function() {
tooltip.style.display = "none";
});
// Append the new element and tooltip to the page
gradeOverallWrapper.appendChild(manipDiv);
document.body.appendChild(tooltip);
}
});
};
const initializeWrapper = () => {
clearTimeout(locationChangeTimeout);
// Set a debounce to prevent multiple callings
locationChangeTimeout = setTimeout(() => {
if (scorePagePattern.test(window.location.href)) {
// Check if on score page and display ManipScore
if (scorePagePattern.test(window.location.href)) {
initializeManipScoreDisplay();
}
}
}, 300);
}
// First init of script
initializeWrapper();
// Monkey-patch history methods to detect navigation
(function() {
const _pushState = history.pushState;
const _replaceState = history.replaceState;
history.pushState = function(state, title, url) {
const result = _pushState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return result;
};
history.replaceState = function(state, title, url) {
const result = _replaceState.apply(this, arguments);
window.dispatchEvent(new Event('locationchange'));
return result;
};
window.addEventListener('popstate', function() {
window.dispatchEvent(new Event('locationchange'));
});
})();
// Listen for custom 'locationchange' event
window.addEventListener('locationchange', function() {
initializeWrapper();
});
})();