// ==UserScript==
// @name Klavia Points Tracker + Theme Customizer
// @version 2024-04.23
// @namespace https://greasyfork.org/users/1331131-tensorflow-dvorak
// @description Race stats + timeline UI, auto‑refresh & full Theme tab with color/image pickers, font family, import/export
// @author TensorFlow - Dvorak
// @match *://*.ntcomps.com/*
// @match *://*.klavia.io/*
// @match *://*.playklavia.com/*
// @run-at document-start
// @license MIT
// ==/UserScript==
(() => {
'use strict';
//
// 1) Inject race‑logger
//
(function injectLogger() {
const s = document.createElement('script');
s.textContent = String.raw`
(function() {
const STORAGE_KEY = 'klaviaRaceHistory';
const seen = new Set();
const liveWpm = new Map();
let localId = null;
function onWpm(m){
if(!localId && m.racerId) localId = m.racerId;
const a = liveWpm.get(m.racerId)||[];
a.push(m.wpm);
if(a.length>200) a.shift();
liveWpm.set(m.racerId,a);
}
const O = window.WebSocket;
window.WebSocket = new Proxy(O,{construct(t,a){
const w = new t(...a);
w.addEventListener('message',e=>{
let d; try{ d = JSON.parse(e.data); } catch{return;}
const idObj = d.identifier?JSON.parse(d.identifier):{};
const m = d.message;
if(m?.message==='update_racer_position' && typeof m.wpm==='number')
onWpm(m);
if(
idObj.channel==='RaceChannel' &&
m?.message==='update_race_results' &&
m.textCompleted &&
m.raceId &&
!seen.has(m.raceId)
){
seen.add(m.raceId);
const tl = {};
for(const[id,arr] of liveWpm.entries()) tl[id]=arr.slice();
const rec = {
raceId: m.raceId,
timestamp: new Date().toISOString(),
points: Math.round(
1 *
(100 + (m.wpm * 2.0)) *
(100 - ((100 - parseFloat(m.accuracy)) * 5)) /
100
),
wpm: m.wpm,
accuracy: parseFloat(m.accuracy),
raceSeconds: m.raceSeconds,
textSeconds: m.textSeconds,
boostBonus: m.boostBonus,
timelineByRacer: tl
};
const H = JSON.parse(localStorage.getItem(STORAGE_KEY)||'[]');
H.unshift(rec);
localStorage.setItem(STORAGE_KEY,JSON.stringify(H));
window.dispatchEvent(new CustomEvent('klavia:race-logged',{detail:rec}));
liveWpm.clear();
}
});
return w;
}});
})();
`;
document.documentElement.appendChild(s);
})();
//
// 2) Theme Manager
//
const THEME_KEY = 'klaviaTheme';
const defaults = {
bodyBgColor: '#000000',
bodyBgImage: '/assets/bg_season1-e6b567b6d451990d0a3376cd287cda90facb8980f979f027dc344c0aa2d743d9.png',
dashBgColor: '#060516',
dashBgImage: '',
textSize: 90,
gameWidth: 80,
dashHeight: 500,
typingTextColor: '#acaaff',
caretColor: '#00ffff',
fontFamily: 'monospace'
};
let theme = Object.assign({}, defaults, JSON.parse(localStorage.getItem(THEME_KEY)||'{}'));
function applyTheme(){
let st = document.getElementById('klavia-theme-style');
if(!st){
st = document.createElement('style');
st.id = 'klavia-theme-style';
document.head.appendChild(st);
}
st.textContent = `
/* BODY BACKGROUND */
body[data-bs-theme=dark]::before{
content:"";position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;
background:${theme.bodyBgImage?`url(${theme.bodyBgImage})`:`${theme.bodyBgColor}`} no-repeat center center!important;
background-size:cover!important;background-attachment:fixed!important;
opacity:0.2!important;pointer-events:none;
}
body { background:${theme.bodyBgColor}!important; }
/* TYPING CONTAINER */
#typing-text-container {
font-family:${theme.fontFamily}!important;
background:${theme.dashBgImage?`url(${theme.dashBgImage})`:`${theme.dashBgColor}`} no-repeat center center!important;
background-size:cover!important;background-attachment:fixed!important;
width:100%!important;max-width:100%!important;height:fit-content!important;
}
#typing-text {
font-size:${theme.textSize}px!important;
caret-color:${theme.caretColor}!important;
font-family:${theme.fontFamily}!important;
}
/* DASHBOARD */
#dashboard {
font-family:${theme.fontFamily}!important;
background:${theme.dashBgColor}!important;
width:100%!important;max-width:100%!important;
height:${theme.dashHeight}px!important;max-height:${theme.dashHeight}px!important;
}
#dashboard[data-bs-theme=dark] #typing-text {
color:${theme.typingTextColor}!important;
}
/* GAME CONTAINER & OTHERS */
#game-container {
width:100%!important;max-width:${theme.gameWidth}%!important;height:fit-content!important;
font-family:${theme.fontFamily}!important;
}
#canvas-container,#track,#content {
width:100%!important;max-width:100%!important;
font-family:${theme.fontFamily}!important;
}
`;
}
function saveTheme(){
localStorage.setItem(THEME_KEY,JSON.stringify(theme));
}
applyTheme();
//
// 3) UI Manager
//
const STORAGE_KEY = 'klaviaRaceHistory';
let historyData=[], activeTab='stats', uiVisible=false;
function createElem(tag,{attrs={},styles={},html=''}={}){
const el = document.createElement(tag);
Object.assign(el,attrs);
Object.assign(el.style,styles);
if(html) el.innerHTML = html;
return el;
}
function getColor(val,all){
const s=[...all].sort((a,b)=>a-b),
L=s[Math.floor(s.length*.33)],
H=s[Math.floor(s.length*.66)];
return val>=H?'#4CAF50':val>=L?'#FFC107':'#F44336';
}
function renderStatsUI(){
historyData = JSON.parse(localStorage.getItem(STORAGE_KEY)||'[]');
const ex = document.getElementById('klavia-stats');
if(ex) ex.remove();
const root = createElem('div',{attrs:{id:'klavia-stats'},styles:{
position:'fixed',top:'10px',right:'10px',
background:uiVisible?'#121212':'transparent',
color:'#e0e0e0',padding:uiVisible?'20px':'0',
borderRadius:'12px',zIndex:'9999',
maxWidth:'600px',maxHeight:'80vh',
overflowY:uiVisible?'auto':'visible',
boxShadow:uiVisible?'0 4px 20px rgba(0,0,0,0.3)':'',
fontFamily:'Segoe UI,sans-serif'
}});
document.body.append(root);
// toggle
root.append(createElem('button',{html:'DTR',attrs:{onclick:()=>{
uiVisible = !uiVisible; renderStatsUI();
}},styles:{
position:'absolute',top:'10px',right:'10px',
width:'40px',height:'40px',borderRadius:'50%',
background:'#ff4500',color:'#fff',border:'none',
cursor:'pointer',display:'flex',
justifyContent:'center',alignItems:'center'
}}));
if(!uiVisible) return;
// tabs
const tabs = createElem('div',{styles:{
display:'flex',gap:'10px',marginBottom:'16px', paddingRight:'2rem'
}});
[['stats','Stats'],['table','Table'],['analysis','Analysis'],['theme','Theme']].forEach(([k,l])=>{
tabs.append(createElem('button',{html:l,attrs:{onclick:()=>{
activeTab=k; renderStatsUI();
}},styles:{
padding:'6px 12px',
background:activeTab===k?'#1976d2':'#333',
color:'#fff',border:'none',borderRadius:'4px',cursor:'pointer'
}}));
});
root.append(tabs);
// content
const content = createElem('div',{attrs:{id:'klavia-stats-content'},styles:{
fontSize:'15px',lineHeight:'1.6',color:'#ccc'
}});
root.append(content);
// clear history
root.append(createElem('button',{html:'Clear History',attrs:{onclick:()=>{
localStorage.removeItem(STORAGE_KEY);
renderStatsUI();
}},styles:{
marginTop:'16px',padding:'8px 16px',
background:'#c62828',color:'#fff',
border:'none',borderRadius:'4px',cursor:'pointer'
}}));
// data arrays
const r = historyData.slice(),
vals = k => r.map(e=>e[k]),
avg = k => vals(k).reduce((a,b)=>a+b,0)/Math.max(r.length,1);
// --- Stats tab ---
if(activeTab==='stats'){
if(!r.length) content.innerHTML='<div style="text-align:center;color:#aaa">No data</div>';
else {
const L=r[0],
iv=r.slice(0,-1).map((e,i)=>(new Date(e.timestamp)-new Date(r[i+1].timestamp))/1000),
ms=iv.reduce((a,b)=>a+b,0)/iv.length,
rph=3600/ms, pph=rph*avg('points'),
est=`<br><div><strong style="color:#90caf9">Estimate</strong>: Races/hr: ${rph.toFixed(1)} Points/hr: ${pph.toFixed(0)}<br>
<small>(Avg ${ms.toFixed(1)}s)</small></div>`;
content.innerHTML=`
<div><strong style="color:#90caf9">Last Race</strong>:
<span style="color:${getColor(L.points,vals('points'))}">Points: ${L.points}</span> |
<span style="color:${getColor(L.wpm,vals('wpm'))}">WPM: ${L.wpm.toFixed(1)}</span> |
<span style="color:${getColor(L.accuracy,vals('accuracy'))}">Accuracy: ${L.accuracy.toFixed(2)}%</span>
</div><br>
<div><strong style="color:#90caf9">Average(${r.length})</strong>:
<span style="color:${getColor(avg('points'),vals('points'))}">Points: ${avg('points').toFixed(2)}</span> |
<span style="color:${getColor(avg('wpm'),vals('wpm'))}">WPM: ${avg('wpm').toFixed(1)}</span> |
<span style="color:${getColor(avg('accuracy'),vals('accuracy'))}">Accuracy: ${avg('accuracy').toFixed(2)}%</span>
</div>${r.length>1?est:''}`;
}
// --- Table tab ---
} else if(activeTab==='table'){
if(!r.length) content.innerHTML='<div style="text-align:center;color:#aaa">No data</div>';
else {
const rows=r.map((e,i)=>`
<tr style="background:${i%2?'#2c2c2c':'#1f1f1f'}">
<td style="padding:8px;color:#aaa">${i+1}</td>
<td style="padding:8px;color:${getColor(e.points,vals('points'))}">${e.points}</td>
<td style="padding:8px;color:${getColor(e.wpm,vals('wpm'))}">${e.wpm.toFixed(1)}</td>
<td style="padding:8px;color:${getColor(e.accuracy,vals('accuracy'))}">${e.accuracy.toFixed(2)}%</td>
</tr>`).join('');
content.innerHTML=`
<table style="width:100%;border-collapse:collapse">
<thead style="background:#333;color:#fff"><tr><th>#</th><th>Pts</th><th>WPM</th><th>Acc</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<style>#klavia-stats-content tr:hover td{background:#444!important;transition:background .2s}</style>`;
}
// --- Analysis tab
} else if (activeTab === "analysis") {
content.innerHTML = `
<h3 style="color:#90caf9;margin-bottom:8px;">Race Timeline</h3>
<div id="klavia-analysis-legend" style="margin-bottom:8px;"></div>
<select id="klavia-race-select" style="margin-bottom:8px;">
${r
.map(
(rec, i) => `
<option value="${i}">
${new Date(rec.timestamp).toLocaleString()} — ${rec.points} pts
</option>`
)
.join("")}
</select>
<canvas id="klavia-analysis-canvas" width="1000" height="300"
style="background:#111;border:1px solid #444;display:block;"></canvas>
`;
const sel = content.querySelector("#klavia-race-select");
const canvas = content.querySelector("#klavia-analysis-canvas");
const ctx = canvas.getContext("2d");
const legend = content.querySelector("#klavia-analysis-legend");
function drawMultiTimeline(tlr) {
const W = canvas.width,
H = canvas.height;
ctx.clearRect(0, 0, W, H);
// axes
ctx.strokeStyle = "#666";
ctx.beginPath();
ctx.moveTo(50, 10);
ctx.lineTo(50, H - 30);
ctx.lineTo(W - 10, H - 30);
ctx.stroke();
// labels
ctx.fillStyle = "#ccc";
ctx.font = "12px sans-serif";
ctx.fillText("WPM →", W - 60, H - 10);
ctx.save();
ctx.translate(10, H / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText("Time (s) →", 0, 0);
ctx.restore();
// scale
let maxTime = 0,
maxWpm = 0;
Object.values(tlr).forEach((arr) => {
maxTime = Math.max(maxTime, arr.length - 1);
maxWpm = Math.max(maxWpm, ...arr);
});
// draw each racer
legend.innerHTML = "";
const colors = {};
Object.entries(tlr).forEach(([id, arr]) => {
const col =
id === r[0].racerId
? "#00ffff"
: colors[id] ||
(colors[id] = `hsl(${Math.random() * 360},70%,60%)`);
ctx.strokeStyle = col;
ctx.beginPath();
arr.forEach((w, i) => {
const x = 50 + (i / maxTime) * (W - 60);
const y = H - 30 - (w / maxWpm) * (H - 40);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
// legend entry
const name = id === r[0].racerId ? "You" : "Racer " + id;
const span = document.createElement("span");
span.textContent = name;
span.style.color = col;
span.style.marginRight = "12px";
legend.append(span);
});
}
sel.addEventListener("change", () => {
const rec = historyData[parseInt(sel.value, 10)];
drawMultiTimeline(rec.timelineByRacer || {});
});
sel.selectedIndex = 0;
if (r[0]?.timelineByRacer) drawMultiTimeline(r[0].timelineByRacer);
} else {
content.innerHTML = `
<h3 style="color:#90caf9;margin-bottom:8px">Theme</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-family:inherit;">
<div><label>Body BG Color:<br><input type="color" id="th-bodyBgColor" value="${theme.bodyBgColor}"></label></div>
<div><label>Body BG Image URL:<br><input type="text" id="th-bodyBgImage" value="${theme.bodyBgImage}" placeholder="http://…"></label></div>
<div><label>Dash BG Color:<br><input type="color" id="th-dashBgColor" value="${theme.dashBgColor}"></label></div>
<div><label>Dash BG Image URL:<br><input type="text" id="th-dashBgImage" value="${theme.dashBgImage}" placeholder="http://…"></label></div>
<div><label>Typing Text Color:<br><input type="color" id="th-typingTextColor" value="${theme.typingTextColor}"></label></div>
<div><label>Caret Color:<br><input type="color" id="th-caretColor" value="${theme.caretColor}"></label></div>
<div><label>Font Family:<br>
<select id="th-fontFamily">
${['monospace','Arial','"Times New Roman"','"Courier New"','Verdana','Georgia','Tahoma','"Trebuchet MS"','"Comic Sans MS"']
.map(f=>`<option${f===theme.fontFamily?' selected':''}>${f}</option>`).join('')}
</select>
</label></div>
<div><label>Text Size:<br>
<input type="range" id="th-textSize" min="20" max="200" value="${theme.textSize}">
<span id="th-textSize-val">${theme.textSize}px</span>
</label></div>
<div><label>Game Width:<br>
<input type="range" id="th-gameWidth" min="20" max="100" value="${theme.gameWidth}">
<span id="th-gameWidth-val">${theme.gameWidth}%</span>
</label></div>
<div><label>Dash Height:<br>
<input type="range" id="th-dashHeight" min="200" max="2000" value="${theme.dashHeight}">
<span id="th-dashHeight-val">${theme.dashHeight}px</span>
</label></div>
</div>
<div style="margin-top:12px;text-align:center">
<button id="th-export">Export JSON</button>
<button id="th-import">Import JSON</button>
<button id="th-reset">Reset Defaults</button><br><br>
<textarea id="th-json" style="width:95%;height:5em;"></textarea>
</div>
`;
[['bodyBgColor','color'],['dashBgColor','color'],['typingTextColor','color'],['caretColor','color']].forEach(([k])=>{
content.querySelector(`#th-${k}`).oninput = e=>{
theme[k]=e.target.value; applyTheme(); saveTheme();
};
});
[['bodyBgImage','text'],['dashBgImage','text']].forEach(([k])=>{
content.querySelector(`#th-${k}`).onchange = e=>{
theme[k]=e.target.value; applyTheme(); saveTheme();
};
});
const ff = content.querySelector('#th-fontFamily');
ff.onchange = e=>{ theme.fontFamily=e.target.value; applyTheme(); saveTheme(); };
[['textSize','px'],['gameWidth','%'],['dashHeight','px']].forEach(([k,unit])=>{
const inp=content.querySelector(`#th-${k}`), lbl=content.querySelector(`#th-${k}-val`);
inp.oninput = e=>{
theme[k]=+e.target.value; applyTheme(); saveTheme();
lbl.textContent = e.target.value + unit;
};
});
// export/import/reset
content.querySelector('#th-export').onclick = ()=>{
content.querySelector('#th-json').value = JSON.stringify(theme,null,2);
};
content.querySelector('#th-import').onclick = ()=>{
try{
const obj = JSON.parse(content.querySelector('#th-json').value);
Object.assign(theme,obj);
saveTheme(); applyTheme(); renderStatsUI();
}catch{}
};
content.querySelector('#th-reset').onclick = ()=>{
theme = Object.assign({}, defaults);
saveTheme(); applyTheme(); renderStatsUI();
};
}
}
// 4) Auto‑refresh & init
window.addEventListener('klavia:race-logged', renderStatsUI);
document.addEventListener('DOMContentLoaded', ()=>{
renderStatsUI();
setInterval(()=>{ if(!document.getElementById('klavia-stats')) renderStatsUI(); },1000);
});
})();