- // ==UserScript==
- // @name Klavia Points Tracker + Theme Customizer
- // @version 2024-04.23
- // @namespace https://greatest.deepsurf.us/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);
- });
-
- })();