Klavia Points Tracker + Theme Customizer

Race stats + timeline UI, auto‑refresh & full Theme tab with color/image pickers, font family, import/export

  1. // ==UserScript==
  2. // @name Klavia Points Tracker + Theme Customizer
  3. // @version 2024-04.23
  4. // @namespace https://greatest.deepsurf.us/users/1331131-tensorflow-dvorak
  5. // @description Race stats + timeline UI, auto‑refresh & full Theme tab with color/image pickers, font family, import/export
  6. // @author TensorFlow - Dvorak
  7. // @match *://*.ntcomps.com/*
  8. // @match *://*.klavia.io/*
  9. // @match *://*.playklavia.com/*
  10. // @run-at document-start
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (() => {
  15. 'use strict';
  16.  
  17. //
  18. // 1) Inject race‑logger
  19. //
  20. (function injectLogger() {
  21. const s = document.createElement('script');
  22. s.textContent = String.raw`
  23. (function() {
  24. const STORAGE_KEY = 'klaviaRaceHistory';
  25. const seen = new Set();
  26. const liveWpm = new Map();
  27. let localId = null;
  28. function onWpm(m){
  29. if(!localId && m.racerId) localId = m.racerId;
  30. const a = liveWpm.get(m.racerId)||[];
  31. a.push(m.wpm);
  32. if(a.length>200) a.shift();
  33. liveWpm.set(m.racerId,a);
  34. }
  35. const O = window.WebSocket;
  36. window.WebSocket = new Proxy(O,{construct(t,a){
  37. const w = new t(...a);
  38. w.addEventListener('message',e=>{
  39. let d; try{ d = JSON.parse(e.data); } catch{return;}
  40. const idObj = d.identifier?JSON.parse(d.identifier):{};
  41. const m = d.message;
  42. if(m?.message==='update_racer_position' && typeof m.wpm==='number')
  43. onWpm(m);
  44. if(
  45. idObj.channel==='RaceChannel' &&
  46. m?.message==='update_race_results' &&
  47. m.textCompleted &&
  48. m.raceId &&
  49. !seen.has(m.raceId)
  50. ){
  51. seen.add(m.raceId);
  52. const tl = {};
  53. for(const[id,arr] of liveWpm.entries()) tl[id]=arr.slice();
  54. const rec = {
  55. raceId: m.raceId,
  56. timestamp: new Date().toISOString(),
  57. points: Math.round(
  58. 1 *
  59. (100 + (m.wpm * 2.0)) *
  60. (100 - ((100 - parseFloat(m.accuracy)) * 5)) /
  61. 100
  62. ),
  63. wpm: m.wpm,
  64. accuracy: parseFloat(m.accuracy),
  65. raceSeconds: m.raceSeconds,
  66. textSeconds: m.textSeconds,
  67. boostBonus: m.boostBonus,
  68. timelineByRacer: tl
  69. };
  70. const H = JSON.parse(localStorage.getItem(STORAGE_KEY)||'[]');
  71. H.unshift(rec);
  72. localStorage.setItem(STORAGE_KEY,JSON.stringify(H));
  73. window.dispatchEvent(new CustomEvent('klavia:race-logged',{detail:rec}));
  74. liveWpm.clear();
  75. }
  76. });
  77. return w;
  78. }});
  79. })();
  80. `;
  81. document.documentElement.appendChild(s);
  82. })();
  83.  
  84. //
  85. // 2) Theme Manager
  86. //
  87. const THEME_KEY = 'klaviaTheme';
  88. const defaults = {
  89. bodyBgColor: '#000000',
  90. bodyBgImage: '/assets/bg_season1-e6b567b6d451990d0a3376cd287cda90facb8980f979f027dc344c0aa2d743d9.png',
  91. dashBgColor: '#060516',
  92. dashBgImage: '',
  93. textSize: 90,
  94. gameWidth: 80,
  95. dashHeight: 500,
  96. typingTextColor: '#acaaff',
  97. caretColor: '#00ffff',
  98. fontFamily: 'monospace'
  99. };
  100. let theme = Object.assign({}, defaults, JSON.parse(localStorage.getItem(THEME_KEY)||'{}'));
  101.  
  102. function applyTheme(){
  103. let st = document.getElementById('klavia-theme-style');
  104. if(!st){
  105. st = document.createElement('style');
  106. st.id = 'klavia-theme-style';
  107. document.head.appendChild(st);
  108. }
  109. st.textContent = `
  110. /* BODY BACKGROUND */
  111. body[data-bs-theme=dark]::before{
  112. content:"";position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;
  113. background:${theme.bodyBgImage?`url(${theme.bodyBgImage})`:`${theme.bodyBgColor}`} no-repeat center center!important;
  114. background-size:cover!important;background-attachment:fixed!important;
  115. opacity:0.2!important;pointer-events:none;
  116. }
  117. body { background:${theme.bodyBgColor}!important; }
  118.  
  119. /* TYPING CONTAINER */
  120. #typing-text-container {
  121. font-family:${theme.fontFamily}!important;
  122. background:${theme.dashBgImage?`url(${theme.dashBgImage})`:`${theme.dashBgColor}`} no-repeat center center!important;
  123. background-size:cover!important;background-attachment:fixed!important;
  124. width:100%!important;max-width:100%!important;height:fit-content!important;
  125. }
  126. #typing-text {
  127. font-size:${theme.textSize}px!important;
  128. caret-color:${theme.caretColor}!important;
  129. font-family:${theme.fontFamily}!important;
  130. }
  131.  
  132. /* DASHBOARD */
  133. #dashboard {
  134. font-family:${theme.fontFamily}!important;
  135. background:${theme.dashBgColor}!important;
  136. width:100%!important;max-width:100%!important;
  137. height:${theme.dashHeight}px!important;max-height:${theme.dashHeight}px!important;
  138. }
  139. #dashboard[data-bs-theme=dark] #typing-text {
  140. color:${theme.typingTextColor}!important;
  141. }
  142.  
  143. /* GAME CONTAINER & OTHERS */
  144. #game-container {
  145. width:100%!important;max-width:${theme.gameWidth}%!important;height:fit-content!important;
  146. font-family:${theme.fontFamily}!important;
  147. }
  148. #canvas-container,#track,#content {
  149. width:100%!important;max-width:100%!important;
  150. font-family:${theme.fontFamily}!important;
  151. }
  152. `;
  153. }
  154. function saveTheme(){
  155. localStorage.setItem(THEME_KEY,JSON.stringify(theme));
  156. }
  157. applyTheme();
  158.  
  159. //
  160. // 3) UI Manager
  161. //
  162. const STORAGE_KEY = 'klaviaRaceHistory';
  163. let historyData=[], activeTab='stats', uiVisible=false;
  164.  
  165. function createElem(tag,{attrs={},styles={},html=''}={}){
  166. const el = document.createElement(tag);
  167. Object.assign(el,attrs);
  168. Object.assign(el.style,styles);
  169. if(html) el.innerHTML = html;
  170. return el;
  171. }
  172. function getColor(val,all){
  173. const s=[...all].sort((a,b)=>a-b),
  174. L=s[Math.floor(s.length*.33)],
  175. H=s[Math.floor(s.length*.66)];
  176. return val>=H?'#4CAF50':val>=L?'#FFC107':'#F44336';
  177. }
  178.  
  179. function renderStatsUI(){
  180. historyData = JSON.parse(localStorage.getItem(STORAGE_KEY)||'[]');
  181. const ex = document.getElementById('klavia-stats');
  182. if(ex) ex.remove();
  183.  
  184. const root = createElem('div',{attrs:{id:'klavia-stats'},styles:{
  185. position:'fixed',top:'10px',right:'10px',
  186. background:uiVisible?'#121212':'transparent',
  187. color:'#e0e0e0',padding:uiVisible?'20px':'0',
  188. borderRadius:'12px',zIndex:'9999',
  189. maxWidth:'600px',maxHeight:'80vh',
  190. overflowY:uiVisible?'auto':'visible',
  191. boxShadow:uiVisible?'0 4px 20px rgba(0,0,0,0.3)':'',
  192. fontFamily:'Segoe UI,sans-serif'
  193. }});
  194. document.body.append(root);
  195.  
  196. // toggle
  197. root.append(createElem('button',{html:'DTR',attrs:{onclick:()=>{
  198. uiVisible = !uiVisible; renderStatsUI();
  199. }},styles:{
  200. position:'absolute',top:'10px',right:'10px',
  201. width:'40px',height:'40px',borderRadius:'50%',
  202. background:'#ff4500',color:'#fff',border:'none',
  203. cursor:'pointer',display:'flex',
  204. justifyContent:'center',alignItems:'center'
  205. }}));
  206. if(!uiVisible) return;
  207.  
  208. // tabs
  209. const tabs = createElem('div',{styles:{
  210. display:'flex',gap:'10px',marginBottom:'16px', paddingRight:'2rem'
  211. }});
  212. [['stats','Stats'],['table','Table'],['analysis','Analysis'],['theme','Theme']].forEach(([k,l])=>{
  213. tabs.append(createElem('button',{html:l,attrs:{onclick:()=>{
  214. activeTab=k; renderStatsUI();
  215. }},styles:{
  216. padding:'6px 12px',
  217. background:activeTab===k?'#1976d2':'#333',
  218. color:'#fff',border:'none',borderRadius:'4px',cursor:'pointer'
  219. }}));
  220. });
  221. root.append(tabs);
  222.  
  223. // content
  224. const content = createElem('div',{attrs:{id:'klavia-stats-content'},styles:{
  225. fontSize:'15px',lineHeight:'1.6',color:'#ccc'
  226. }});
  227. root.append(content);
  228.  
  229. // clear history
  230. root.append(createElem('button',{html:'Clear History',attrs:{onclick:()=>{
  231. localStorage.removeItem(STORAGE_KEY);
  232. renderStatsUI();
  233. }},styles:{
  234. marginTop:'16px',padding:'8px 16px',
  235. background:'#c62828',color:'#fff',
  236. border:'none',borderRadius:'4px',cursor:'pointer'
  237. }}));
  238.  
  239. // data arrays
  240. const r = historyData.slice(),
  241. vals = k => r.map(e=>e[k]),
  242. avg = k => vals(k).reduce((a,b)=>a+b,0)/Math.max(r.length,1);
  243.  
  244. // --- Stats tab ---
  245. if(activeTab==='stats'){
  246. if(!r.length) content.innerHTML='<div style="text-align:center;color:#aaa">No data</div>';
  247. else {
  248. const L=r[0],
  249. iv=r.slice(0,-1).map((e,i)=>(new Date(e.timestamp)-new Date(r[i+1].timestamp))/1000),
  250. ms=iv.reduce((a,b)=>a+b,0)/iv.length,
  251. rph=3600/ms, pph=rph*avg('points'),
  252. est=`<br><div><strong style="color:#90caf9">Estimate</strong>: Races/hr: ${rph.toFixed(1)} Points/hr: ${pph.toFixed(0)}<br>
  253. <small>(Avg ${ms.toFixed(1)}s)</small></div>`;
  254. content.innerHTML=`
  255. <div><strong style="color:#90caf9">Last Race</strong>:
  256. <span style="color:${getColor(L.points,vals('points'))}">Points: ${L.points}</span> |
  257. <span style="color:${getColor(L.wpm,vals('wpm'))}">WPM: ${L.wpm.toFixed(1)}</span> |
  258. <span style="color:${getColor(L.accuracy,vals('accuracy'))}">Accuracy: ${L.accuracy.toFixed(2)}%</span>
  259. </div><br>
  260. <div><strong style="color:#90caf9">Average(${r.length})</strong>:
  261. <span style="color:${getColor(avg('points'),vals('points'))}">Points: ${avg('points').toFixed(2)}</span> |
  262. <span style="color:${getColor(avg('wpm'),vals('wpm'))}">WPM: ${avg('wpm').toFixed(1)}</span> |
  263. <span style="color:${getColor(avg('accuracy'),vals('accuracy'))}">Accuracy: ${avg('accuracy').toFixed(2)}%</span>
  264. </div>${r.length>1?est:''}`;
  265. }
  266.  
  267. // --- Table tab ---
  268. } else if(activeTab==='table'){
  269. if(!r.length) content.innerHTML='<div style="text-align:center;color:#aaa">No data</div>';
  270. else {
  271. const rows=r.map((e,i)=>`
  272. <tr style="background:${i%2?'#2c2c2c':'#1f1f1f'}">
  273. <td style="padding:8px;color:#aaa">${i+1}</td>
  274. <td style="padding:8px;color:${getColor(e.points,vals('points'))}">${e.points}</td>
  275. <td style="padding:8px;color:${getColor(e.wpm,vals('wpm'))}">${e.wpm.toFixed(1)}</td>
  276. <td style="padding:8px;color:${getColor(e.accuracy,vals('accuracy'))}">${e.accuracy.toFixed(2)}%</td>
  277. </tr>`).join('');
  278. content.innerHTML=`
  279. <table style="width:100%;border-collapse:collapse">
  280. <thead style="background:#333;color:#fff"><tr><th>#</th><th>Pts</th><th>WPM</th><th>Acc</th></tr></thead>
  281. <tbody>${rows}</tbody>
  282. </table>
  283. <style>#klavia-stats-content tr:hover td{background:#444!important;transition:background .2s}</style>`;
  284. }
  285. // --- Analysis tab
  286. } else if (activeTab === "analysis") {
  287. content.innerHTML = `
  288. <h3 style="color:#90caf9;margin-bottom:8px;">Race Timeline</h3>
  289. <div id="klavia-analysis-legend" style="margin-bottom:8px;"></div>
  290. <select id="klavia-race-select" style="margin-bottom:8px;">
  291. ${r
  292. .map(
  293. (rec, i) => `
  294. <option value="${i}">
  295. ${new Date(rec.timestamp).toLocaleString()} ${rec.points} pts
  296. </option>`
  297. )
  298. .join("")}
  299. </select>
  300. <canvas id="klavia-analysis-canvas" width="1000" height="300"
  301. style="background:#111;border:1px solid #444;display:block;"></canvas>
  302. `;
  303. const sel = content.querySelector("#klavia-race-select");
  304. const canvas = content.querySelector("#klavia-analysis-canvas");
  305. const ctx = canvas.getContext("2d");
  306. const legend = content.querySelector("#klavia-analysis-legend");
  307.  
  308. function drawMultiTimeline(tlr) {
  309. const W = canvas.width,
  310. H = canvas.height;
  311. ctx.clearRect(0, 0, W, H);
  312. // axes
  313. ctx.strokeStyle = "#666";
  314. ctx.beginPath();
  315. ctx.moveTo(50, 10);
  316. ctx.lineTo(50, H - 30);
  317. ctx.lineTo(W - 10, H - 30);
  318. ctx.stroke();
  319. // labels
  320. ctx.fillStyle = "#ccc";
  321. ctx.font = "12px sans-serif";
  322. ctx.fillText("WPM →", W - 60, H - 10);
  323. ctx.save();
  324. ctx.translate(10, H / 2);
  325. ctx.rotate(-Math.PI / 2);
  326. ctx.fillText("Time (s) →", 0, 0);
  327. ctx.restore();
  328.  
  329. // scale
  330. let maxTime = 0,
  331. maxWpm = 0;
  332. Object.values(tlr).forEach((arr) => {
  333. maxTime = Math.max(maxTime, arr.length - 1);
  334. maxWpm = Math.max(maxWpm, ...arr);
  335. });
  336.  
  337. // draw each racer
  338. legend.innerHTML = "";
  339. const colors = {};
  340. Object.entries(tlr).forEach(([id, arr]) => {
  341. const col =
  342. id === r[0].racerId
  343. ? "#00ffff"
  344. : colors[id] ||
  345. (colors[id] = `hsl(${Math.random() * 360},70%,60%)`);
  346. ctx.strokeStyle = col;
  347. ctx.beginPath();
  348. arr.forEach((w, i) => {
  349. const x = 50 + (i / maxTime) * (W - 60);
  350. const y = H - 30 - (w / maxWpm) * (H - 40);
  351. i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  352. });
  353. ctx.stroke();
  354. // legend entry
  355. const name = id === r[0].racerId ? "You" : "Racer " + id;
  356. const span = document.createElement("span");
  357. span.textContent = name;
  358. span.style.color = col;
  359. span.style.marginRight = "12px";
  360. legend.append(span);
  361. });
  362. }
  363.  
  364. sel.addEventListener("change", () => {
  365. const rec = historyData[parseInt(sel.value, 10)];
  366. drawMultiTimeline(rec.timelineByRacer || {});
  367. });
  368. sel.selectedIndex = 0;
  369. if (r[0]?.timelineByRacer) drawMultiTimeline(r[0].timelineByRacer);
  370. } else {
  371. content.innerHTML = `
  372. <h3 style="color:#90caf9;margin-bottom:8px">Theme</h3>
  373. <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-family:inherit;">
  374. <div><label>Body BG Color:<br><input type="color" id="th-bodyBgColor" value="${theme.bodyBgColor}"></label></div>
  375. <div><label>Body BG Image URL:<br><input type="text" id="th-bodyBgImage" value="${theme.bodyBgImage}" placeholder="http://…"></label></div>
  376. <div><label>Dash BG Color:<br><input type="color" id="th-dashBgColor" value="${theme.dashBgColor}"></label></div>
  377. <div><label>Dash BG Image URL:<br><input type="text" id="th-dashBgImage" value="${theme.dashBgImage}" placeholder="http://…"></label></div>
  378. <div><label>Typing Text Color:<br><input type="color" id="th-typingTextColor" value="${theme.typingTextColor}"></label></div>
  379. <div><label>Caret Color:<br><input type="color" id="th-caretColor" value="${theme.caretColor}"></label></div>
  380. <div><label>Font Family:<br>
  381. <select id="th-fontFamily">
  382. ${['monospace','Arial','"Times New Roman"','"Courier New"','Verdana','Georgia','Tahoma','"Trebuchet MS"','"Comic Sans MS"']
  383. .map(f=>`<option${f===theme.fontFamily?' selected':''}>${f}</option>`).join('')}
  384. </select>
  385. </label></div>
  386. <div><label>Text Size:<br>
  387. <input type="range" id="th-textSize" min="20" max="200" value="${theme.textSize}">
  388. <span id="th-textSize-val">${theme.textSize}px</span>
  389. </label></div>
  390. <div><label>Game Width:<br>
  391. <input type="range" id="th-gameWidth" min="20" max="100" value="${theme.gameWidth}">
  392. <span id="th-gameWidth-val">${theme.gameWidth}%</span>
  393. </label></div>
  394. <div><label>Dash Height:<br>
  395. <input type="range" id="th-dashHeight" min="200" max="2000" value="${theme.dashHeight}">
  396. <span id="th-dashHeight-val">${theme.dashHeight}px</span>
  397. </label></div>
  398. </div>
  399. <div style="margin-top:12px;text-align:center">
  400. <button id="th-export">Export JSON</button>
  401. <button id="th-import">Import JSON</button>
  402. <button id="th-reset">Reset Defaults</button><br><br>
  403. <textarea id="th-json" style="width:95%;height:5em;"></textarea>
  404. </div>
  405. `;
  406. [['bodyBgColor','color'],['dashBgColor','color'],['typingTextColor','color'],['caretColor','color']].forEach(([k])=>{
  407. content.querySelector(`#th-${k}`).oninput = e=>{
  408. theme[k]=e.target.value; applyTheme(); saveTheme();
  409. };
  410. });
  411. [['bodyBgImage','text'],['dashBgImage','text']].forEach(([k])=>{
  412. content.querySelector(`#th-${k}`).onchange = e=>{
  413. theme[k]=e.target.value; applyTheme(); saveTheme();
  414. };
  415. });
  416. const ff = content.querySelector('#th-fontFamily');
  417. ff.onchange = e=>{ theme.fontFamily=e.target.value; applyTheme(); saveTheme(); };
  418.  
  419. [['textSize','px'],['gameWidth','%'],['dashHeight','px']].forEach(([k,unit])=>{
  420. const inp=content.querySelector(`#th-${k}`), lbl=content.querySelector(`#th-${k}-val`);
  421. inp.oninput = e=>{
  422. theme[k]=+e.target.value; applyTheme(); saveTheme();
  423. lbl.textContent = e.target.value + unit;
  424. };
  425. });
  426. // export/import/reset
  427. content.querySelector('#th-export').onclick = ()=>{
  428. content.querySelector('#th-json').value = JSON.stringify(theme,null,2);
  429. };
  430. content.querySelector('#th-import').onclick = ()=>{
  431. try{
  432. const obj = JSON.parse(content.querySelector('#th-json').value);
  433. Object.assign(theme,obj);
  434. saveTheme(); applyTheme(); renderStatsUI();
  435. }catch{}
  436. };
  437. content.querySelector('#th-reset').onclick = ()=>{
  438. theme = Object.assign({}, defaults);
  439. saveTheme(); applyTheme(); renderStatsUI();
  440. };
  441. }
  442. }
  443.  
  444. // 4) Auto‑refresh & init
  445. window.addEventListener('klavia:race-logged', renderStatsUI);
  446. document.addEventListener('DOMContentLoaded', ()=>{
  447. renderStatsUI();
  448. setInterval(()=>{ if(!document.getElementById('klavia-stats')) renderStatsUI(); },1000);
  449. });
  450.  
  451. })();