feat: #2 G-force circle dashboard + .tsr export/import
- g_force dashboard: acceleration vector on 2G ring, lat/long G readout - .tsr files export/import via API and UI - Composite dashboard reverted (G-force is standalone)
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||
<title>TurboSu - G力</title>
|
||||
<style>
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
|
||||
font-family:'SF Pro Display',-apple-system,system-ui,sans-serif}
|
||||
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
||||
#box{position:relative;overflow:hidden}
|
||||
#box.contain{max-width:100vw;max-height:100vh}
|
||||
#box.cover{min-width:100vw;min-height:100vh}
|
||||
#box.fill{width:100vw;height:100vh}
|
||||
#content{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#0a0a0f}
|
||||
canvas{width:100%;height:100%}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><div id="box" class="contain"><div id="content"><canvas id="g-canvas"></canvas></div></div></div>
|
||||
<script>
|
||||
const M={aspect_ratio:"1:1",render_mode:"contain"};
|
||||
(function(){
|
||||
const box=document.getElementById('box'),canvas=document.getElementById('g-canvas');
|
||||
const ctx=canvas.getContext('2d');
|
||||
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
|
||||
let ws,rt,ax=0,ay=0,az=0;
|
||||
|
||||
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
|
||||
|
||||
function apply(){
|
||||
const rt=pr(M.aspect_ratio||'1:1'),md=M.render_mode||'contain';
|
||||
if(!rt){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
|
||||
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
|
||||
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
|
||||
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
|
||||
box.style.width=cw+'px';box.style.height=ch+'px';draw()
|
||||
}
|
||||
|
||||
function sz(){const r=canvas.parentElement.getBoundingClientRect(),d=window.devicePixelRatio||1;canvas.width=r.width*d;canvas.height=r.height*d;canvas.style.width=r.width+'px';canvas.style.height=r.height+'px';ctx.setTransform(d,0,0,d,0,0)}
|
||||
|
||||
function draw(){
|
||||
sz();const W=canvas.width/devicePixelRatio,H=canvas.height/devicePixelRatio,dpr=window.devicePixelRatio||1;
|
||||
ctx.clearRect(0,0,W,H);
|
||||
const cx=W/2,cy=H/2,r=Math.min(W,H)*.38;
|
||||
const G=9.81,limit=2.0;
|
||||
|
||||
// outer ring
|
||||
ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.1)';ctx.lineWidth=2;ctx.stroke();
|
||||
// crosshairs
|
||||
ctx.beginPath();ctx.moveTo(cx-r,cy);ctx.lineTo(cx+r,cy);ctx.moveTo(cx,cy-r);ctx.lineTo(cx,cy+r);ctx.strokeStyle='rgba(255,255,255,.06)';ctx.lineWidth=1;ctx.stroke();
|
||||
// 1G ring
|
||||
ctx.beginPath();ctx.arc(cx,cy,r/limit,0,Math.PI*2);ctx.setLineDash([4,8]);ctx.strokeStyle='rgba(255,255,255,.08)';ctx.lineWidth=1;ctx.stroke();ctx.setLineDash([]);
|
||||
|
||||
// G dot
|
||||
const x=Math.max(-limit,Math.min(limit,ax/G)),y=Math.max(-limit,Math.min(limit,-ay/G));
|
||||
const dx=cx+(x/limit)*r,dy=cy+(y/limit)*r;
|
||||
ctx.beginPath();ctx.arc(dx,dy,Math.max(6,r/25),0,Math.PI*2);
|
||||
const i=Math.sqrt(x*x+y*y)/limit;
|
||||
const rc=Math.round(55+i*200),gc=Math.round(130-i*130),bc=Math.round(255-i*200);
|
||||
ctx.fillStyle=`rgb(${rc},${gc},${bc})`;ctx.fill();
|
||||
ctx.strokeStyle='rgba(255,255,255,.5)';ctx.lineWidth=1.5;ctx.stroke();
|
||||
|
||||
// trail
|
||||
ctx.beginPath();
|
||||
for(let t=0;t<8;t++){
|
||||
const tx=Math.max(-limit,Math.min(limit,(ax-t*.3)/G)),ty=Math.max(-limit,Math.min(limit,(-ay-t*.3)/G));
|
||||
const px=cx+(tx/limit)*r*.7,py=cy+(ty/limit)*r*.7;
|
||||
t===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
|
||||
}
|
||||
ctx.strokeStyle='rgba(255,255,255,.04)';ctx.lineWidth=1;ctx.stroke();
|
||||
|
||||
// numbers
|
||||
ctx.fillStyle='rgba(255,255,255,.25)';ctx.font=`${Math.max(14,W/40)|0}px sans-serif`;ctx.textAlign='center';
|
||||
ctx.fillText('Lat ' + (ax/G).toFixed(2) + 'G', cx, cy-r-20);
|
||||
ctx.fillText('Long ' + (-ay/G).toFixed(2) + 'G', cx, cy+r+30);
|
||||
ctx.fillText('L', cx-r-14, cy+4);ctx.fillText('R', cx+r+14, cy+4);
|
||||
ctx.fillText('B', cx, cy-r-4);ctx.fillText('F', cx, cy+r+18);
|
||||
}
|
||||
|
||||
function cn(){ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}
|
||||
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry'){ax=m.data.acceleration_x||0;ay=m.data.acceleration_y||0;az=m.data.acceleration_z||0;draw()}}catch(e){}}
|
||||
ws.onclose=()=>{rt=setTimeout(cn,2000)}}
|
||||
|
||||
apply();window.addEventListener('resize',()=>{apply()});cn()
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "g_force",
|
||||
"name": "G 力圆图",
|
||||
"category": "physics",
|
||||
"description": "加速度向量实时可视化,圆心偏移表示纵向/横向G力方向和大小",
|
||||
"author": "Yei.J. (AskaEth)",
|
||||
"version": "1.0.0",
|
||||
"supported_games": "all",
|
||||
"config": {
|
||||
"icon": "🎯",
|
||||
"aspect_ratio": "1:1",
|
||||
"render_mode": "contain"
|
||||
}
|
||||
}
|
||||
@@ -469,3 +469,23 @@ async def api_start_playback(data: dict):
|
||||
async def api_stop_playback():
|
||||
_get_player().stop()
|
||||
return {"ok": True, "playing": False}
|
||||
|
||||
|
||||
@router.get("/recording/{filename}/export")
|
||||
async def api_export_recording(filename: str):
|
||||
from fastapi.responses import FileResponse
|
||||
from server.recorder import RECORDINGS_DIR
|
||||
path = RECORDINGS_DIR / filename
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Recording not found")
|
||||
return FileResponse(path, media_type="application/octet-stream", filename=filename)
|
||||
|
||||
|
||||
@router.post("/recording/import")
|
||||
async def api_import_recording(file: UploadFile = File(...)):
|
||||
from server.recorder import RECORDINGS_DIR
|
||||
if not file.filename or not file.filename.endswith('.tsr'):
|
||||
raise HTTPException(400, "Only .tsr files are accepted")
|
||||
dest = RECORDINGS_DIR / file.filename
|
||||
dest.write_bytes(await file.read())
|
||||
return {"ok": True}
|
||||
|
||||
Reference in New Issue
Block a user