fix: rewrite composite RPM gauge with Canvas, segmented ticks for accurate ╭ shape

This commit is contained in:
2026-07-25 16:51:23 +08:00
parent 67ac9a57fe
commit 59623d7cf5
+74 -94
View File
@@ -15,21 +15,17 @@ html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
#box.fill{width:100vw;height:100vh}
#content{position:relative;width:100%;height:100%;background:#0a0a0f}
/* RPM arc */
#rpm-area{position:absolute;left:3%;top:6%;width:60%;height:78%}
#rpm-svg{width:100%;height:100%}
#rpm-area{position:absolute;left:2%;top:4%;width:62%;height:82%}
#rpm-canvas{width:100%;height:100%}
/* Speed */
#speed-area{position:absolute;left:3%;bottom:8%;width:60%;text-align:center}
#speed-area{position:absolute;left:2%;bottom:6%;width:62%;text-align:center}
#speed-val{font-size:min(8vw,76px);font-weight:200;line-height:1;letter-spacing:-.02em}
#speed-unit{font-size:min(2vw,16px);color:rgba(255,255,255,.2);letter-spacing:.2em;margin-top:2px}
/* Gear */
#gear-area{position:absolute;right:6%;top:50%;transform:translateY(-55%);text-align:center}
#gear-box{width:min(16vw,140px);height:min(16vw,140px);border-radius:50%;border:2px solid rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center}
#gear-box{width:min(16vw,140px);height:min(16vw,140px);border-radius:50%;border:1px solid rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center}
#gear-box span{font-size:min(8vw,72px);font-weight:200;transition:.1s}
/* Pedals */
#pedals{position:absolute;right:4%;top:5%;width:18%}
.pedal{margin-bottom:12px}
.pedal .pl{font-size:min(1.3vw,10px);color:rgba(255,255,255,.2);margin-bottom:4px;text-transform:uppercase;letter-spacing:.1em}
@@ -37,7 +33,6 @@ html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
.pedal .pf{height:100%;transition:width .06s linear}
#tf{background:#fff}#bf{background:rgba(255,255,255,.4)}
/* Fuel / Temp */
#status{position:absolute;right:4%;bottom:6%;width:18%}
.st{margin-bottom:10px}
.st .sl{font-size:min(1.2vw,10px);color:rgba(255,255,255,.2);margin-bottom:3px;display:flex;justify-content:space-between}
@@ -48,7 +43,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<div id="rpm-area"><svg id="rpm-svg" viewBox="0 0 640 480" preserveAspectRatio="xMidYMid meet"></svg></div>
<div id="rpm-area"><canvas id="rpm-canvas"></canvas></div>
<div id="speed-area"><div id="speed-val">0</div><div id="speed-unit">KM/H</div></div>
<div id="gear-area"><div id="gear-box"><span id="gear-val">1</span></div></div>
<div id="pedals">
@@ -61,110 +56,95 @@ html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
</div>
</div></div></div>
<script>
const M={aspect_ratio:"16:9",render_mode:"contain"};
let GAME_ID='';
const M={aspect_ratio:"16:9",render_mode:"contain"};let GAME_ID='';
(function(){
const box=document.getElementById('box'),svg=document.getElementById('rpm-svg'),
const box=document.getElementById('box'),canvas=document.getElementById('rpm-canvas'),
sV=document.getElementById('speed-val'),sU=document.getElementById('speed-unit'),
gV=document.getElementById('gear-val'),gBox=document.getElementById('gear-box'),
tF=document.getElementById('tf'),bF=document.getElementById('bf'),
fF=document.getElementById('ff'),fV=document.getElementById('fv'),
wF=document.getElementById('wf'),wV=document.getElementById('wv');
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
let ws,rt,lR=-1,lG=-999;
const ctx=canvas.getContext('2d');
let ws,rt,lR=-1,lG=-999,ldP=-1;
const CX=300,CY=420,R=300,SA=180,EA=270;
const N=66, Na=Math.round(N*.55), Nl=N-Na;
const Ca=200, Cb=390, Cr=320, As=180, Ae=270; // arc: center(200,390), r=320, from 180°→270°
function d2r(d){return d*Math.PI/180}
function drawRpm(cur,max,gear){
max=max||8000;cur=cur||0;
const sx=CX+R*Math.cos(d2r(SA)),sy=CY+R*Math.sin(d2r(SA)); // arc start
const ex=CX+R*Math.cos(d2r(EA)),ey=CY+R*Math.sin(d2r(EA)); // arc end -> top of arc
const lx=620,ly=ey; // straight line end (right side, same Y as arc top)
// arc length (90°) = 2πR * 90/360
const arcLen=Math.PI*R*90/360;
const lineLen=lx-ex;
const totalLen=arcLen+lineLen;
let h='';
// background: arc + line
h+=`<path d="M${sx},${sy} A${R},${R} 0 0 0 ${ex},${ey} L${lx},${ly}" fill="none" stroke="rgba(255,255,255,.04)" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>`;
// scale marks on arc
for(let r=0;r<=max;r+=1000){
const pct=r/max;
const ang=SA+(EA-SA)*pct;
if(ang<=EA){
const a=d2r(ang);
const x1=CX+(R-18)*Math.cos(a),y1=CY+(R-18)*Math.sin(a);
const x2=CX+(R+18)*Math.cos(a),y2=CY+(R+18)*Math.sin(a);
const c=r>=max*0.85?'rgba(255,255,255,.12)':(r%2000===0?'rgba(255,255,255,.15)':'rgba(255,255,255,.05)');
h+=`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${c}" stroke-width="${r%2000===0?2:1}"/>`;
if(r%2000===0){
h+=`<text x="${CX+(R-34)*Math.cos(a)}" y="${CY+(R-34)*Math.sin(a)+4}" text-anchor="middle" font-size="10" fill="rgba(255,255,255,.2)">${r/1000}</text>`;
}
}
}
// progress
if(cur>0){
const pct=Math.min(cur/max,1);
if(pct<=arcLen/totalLen){
// progress within arc
const ap=pct*totalLen/arcLen;
const ang=SA+(EA-SA)*ap;
const px=CX+R*Math.cos(d2r(ang)),py=CY+R*Math.sin(d2r(ang));
h+=`<path d="M${sx},${sy} A${R},${R} 0 0 0 ${px},${py}" fill="none" stroke="#fff" stroke-width="22" stroke-linecap="round"/>`;
}else{
// full arc + partial line
const lp=(pct*totalLen-arcLen)/lineLen;
const px=ex+lp*(lx-ex);
h+=`<path d="M${sx},${sy} A${R},${R} 0 0 0 ${ex},${ey} L${px},${ly}" fill="none" stroke="#fff" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>`;
}
}
h+=`<text x="${CX}" y="${CY-24}" text-anchor="middle" font-size="22" font-weight="200" fill="#fff">${Math.round(cur).toLocaleString()}</text>`;
h+=`<text x="${CX}" y="${CY+4}" text-anchor="middle" font-size="10" fill="rgba(255,255,255,.15)">&times;1000 rpm</text>`;
svg.innerHTML=h;
}
function parseRatio(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];return w/h}return null}
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 ratio=parseRatio(M.aspect_ratio||'16:9'),mode=M.render_mode||'contain';
if(!ratio){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
const rt=pr(M.aspect_ratio||'16:9'),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(mode==='cover'){cw=vw;ch=vw/ratio;box.className='cover'}
else{box.className='contain';cw=vr>ratio?vh*ratio:vw;ch=vr>ratio?vh:vw/ratio}
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'
}
function connect(){
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==='connected')GAME_ID=m.selected_game_id||'';
if(m.type==='telemetry')update(m.data)
}catch(e){}}
ws.onclose=()=>{rt=setTimeout(connect,2000)}
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 update(d){
const rpm=d.rpm||0,mr=d.max_rpm||8000,g=d.gear||0,sp=d.speed_kmh||0;
if(Math.abs(rpm-lR)>30||g!==lG){lR=rpm;lG=g;drawRpm(rpm,mr,g)}
sV.textContent=Math.round(sp);
const gv=g===0?'N':g===11?'R':g===21?'P':String(g);
gV.textContent=gv;
tF.style.width=(d.throttle*100).toFixed(0)+'%';
bF.style.width=(d.brake*100).toFixed(0)+'%';
function tp(i){
const W=canvas.width/devicePixelRatio,H=canvas.height/devicePixelRatio;
const sx=Ca/640*W,sy=Cb/480*H,sr=Cr/640*W;
if(i<Na){
const p=i/(Na-1),a=d2r(As+(Ae-As)*p);
return{x:sx+sr*Math.cos(a),y:sy+sr*Math.sin(a),r:a-Math.PI/2};
}else{
const ae=d2r(Ae),ex=sx+sr*Math.cos(ae),ey=sy+sr*Math.sin(ae);
const p=(i-Na+1)/(Nl-1),x=ex+p*(W*0.93-ex);
return{x,y:ey,r:0};
}
}
function draw(cur,max){
max=max||8000;cur=cur||0;sz();
const W=canvas.width/devicePixelRatio,H=canvas.height/devicePixelRatio,dpr=window.devicePixelRatio||1;
ctx.clearRect(0,0,W,H);
const tw=Math.max(12,W/45),th=Math.max(3,H/130);
// bg ticks
for(let i=0;i<N;i++){const p=tp(i);ctx.save();ctx.translate(p.x,p.y);ctx.rotate(p.r);ctx.fillStyle='rgba(255,255,255,.04)';ctx.fillRect(-tw/2,-th/2,tw,th);ctx.restore()}
// progress
const pc=Math.min(cur/max,1),on=Math.round(pc*N);
for(let i=0;i<on;i++){const p=tp(i);ctx.save();ctx.translate(p.x,p.y);ctx.rotate(p.r);ctx.fillStyle=cur>=max*.85?'rgba(255,50,35,.95)':'rgba(255,255,255,.85)';ctx.fillRect(-tw/2,-th/2,tw,th);ctx.restore()}
// scale nums
ctx.fillStyle='rgba(255,255,255,.15)';ctx.font=`${Math.max(9,W/55)|0}px sans-serif`;ctx.textAlign='center';
const sx=Ca/640*W,sy=Cb/480*H,sr=Cr/640*W;
for(let r=0;r<=max;r+=2000){const p=r/max,a=d2r(As+(Ae-As)*p);ctx.fillText(r/1000,sx+(sr-36)*Math.cos(a),sy+(sr-36)*Math.sin(a)+3)}
// center text
ctx.fillStyle='#fff';ctx.font=`${Math.max(20,W/28)|0}px sans-serif`;ctx.textAlign='center';
ctx.fillText(Math.round(cur).toLocaleString(),sx,sy-10);
ctx.fillStyle='rgba(255,255,255,.1)';ctx.font=`${Math.max(9,W/55)|0}px sans-serif`;
ctx.fillText('\u00d71000 rpm',sx,sy+8);
// redline
ctx.strokeStyle='rgba(255,40,30,.1)';ctx.lineWidth=1;ctx.setLineDash([3,6]);ctx.beginPath();
const ra=d2r(As+(Ae-As)*.85);
ctx.moveTo(sx,sy);ctx.lineTo(sx+sr*Math.cos(ra),sy+sr*Math.sin(ra));ctx.stroke();ctx.setLineDash([])
}
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==='connected')GAME_ID=m.selected_game_id||'';if(m.type==='telemetry')up(m.data)}catch(e){}}
ws.onclose=()=>{rt=setTimeout(cn,2000)}}
function up(d){const rpm=d.rpm||0,mr=d.max_rpm||8000,g=d.gear||0,sp=d.speed_kmh||0;
if(Math.abs(rpm-lR)>25||g!==lG){lR=rpm;lG=g;draw(rpm,mr)}
sV.textContent=Math.round(sp);gV.textContent=g===0?'N':g===11?'R':g===21?'P':String(g);
tF.style.width=(d.throttle*100).toFixed(0)+'%';bF.style.width=(d.brake*100).toFixed(0)+'%';
const fp=Math.round((d.fuel||1)*100);fF.style.width=fp+'%';fV.textContent=fp+'%';
const et=d.engine_temp||0;
if(et>0){const ep=Math.min(et/120,1)*100;wF.style.width=ep+'%';wV.textContent=Math.round(et)+'\u00B0C'}
}
const et=d.engine_temp||0;if(et>0){const ep=Math.min(et/120,1)*100;wF.style.width=ep+'%';wV.textContent=Math.round(et)+'\u00B0C'}}
drawRpm(0,8000,1);apply();window.addEventListener('resize',apply);connect()
draw(0,8000);apply();window.addEventListener('resize',()=>{apply();draw(lR>0?lR:0,8000)});cn()
})();
</script>
</body>