pm-mobile: beat-aligned pads + per-lane subdivision rhythm icons

- Pads now group into per-beat cells (one flex cell per beat), so beats line up
  in columns across lanes regardless of each lane's subdivision; the downbeat
  pad in each cell is full-height and the sub-beat pads are shorter/smaller.
- Each lane label shows a small engraved rhythm figure for its subdivision,
  drawn as SVG (notehead + stem + beams + tuplet number): quarter, beamed
  eighths, triplet (3), sixteenths (double beam), sextuplet (6), etc.

Engine untouched; conformance passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-07 12:18:48 -05:00
parent 4b53f917f4
commit b98c37ff68

View file

@ -104,12 +104,18 @@
#trackpanel .tp-msg{ color:#5fd08a; margin-left:auto; }
.lane{ display:flex; align-items:center; gap:8px; }
.lane.off{ opacity:.5; }
.lmeta{ flex:0 0 auto; width:30%; max-width:130px; min-width:64px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left;
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:6px 8px;
.lmeta{ flex:0 0 auto; width:34%; max-width:150px; min-width:92px; display:flex; align-items:center; gap:5px; text-align:left;
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:5px 8px;
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
.lmeta .lg{ color:var(--muted); }
.pads{ flex:1 1 auto; display:flex; gap:3px; overflow-x:auto; padding-bottom:2px; min-width:0; }
.pad{ flex:1 0 14px; min-width:14px; height:clamp(20px,3.6vmin,28px); border-radius:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
.lmeta .ln-name{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lmeta .lg{ flex:0 0 auto; color:var(--muted); }
.lmeta .rhythm{ flex:0 0 auto; color:var(--txt); display:block; }
/* beats line up in columns across lanes; sub-beats sit inside the beat cell, smaller */
.pads{ flex:1 1 auto; display:flex; gap:7px; overflow-x:auto; padding-bottom:2px; min-width:0; align-items:center; }
.beatcell{ flex:1 1 0; min-width:0; display:flex; gap:2px; align-items:center; }
.pad{ flex:1 1 0; min-width:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
.pad.beat{ height:clamp(20px,3.8vmin,28px); border-radius:5px; }
.pad.sub{ height:clamp(11px,2.3vmin,16px); border-radius:3px; }
.pad.gs{ border-color:var(--amber); }
.pad.on{ background:var(--cyan); }
.pad.acc{ background:var(--amber); }
@ -443,25 +449,43 @@ function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
let laneSig=null, editLaneIdx=0;
function laneSignature(){ return meters.map(m=>m.sound+":"+m.groupsStr+"/"+m.stepsPerBeat+(m.swing?"s":"")+(m.enabled?"":"!")+(m.poly?"~":"")).join("|"); }
function lvlClass(l){ return l===2?"acc":l===3?"ghost":l===1?"on":""; }
function padClass(m,k){ const spb=m.stepsPerBeat, isBeat=(k%spb===0), gs=isBeat&&m.groupStarts.has(k/spb), lvl=m.beatsOn[k]|0;
return "pad "+(isBeat?"beat":"sub")+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }
// Small engraved rhythm figure for a beat's subdivision (1=quarter, 2=8ths, 3=triplet,
// 4=16ths, 5/6/7=tuplets). Drawn as SVG so triplets/sextuplets render crisply.
function rhythmSVG(spb){
spb=Math.max(1,spb|0);
const n=spb, beams = n===1?0 : (n<=3?1:2), tup = (n===3||n>=5)?n:null;
const LEFT=3, GAP = n<=2?8 : n<=4?6.5 : 5.5, baseY=15, topY=6;
const W=Math.round(LEFT*2 + (n-1)*GAP + 4); let g="", first=0, last=0;
for(let i=0;i<n;i++){ const cx=LEFT+i*GAP, sx=cx+2.0; if(i===0) first=sx; last=sx;
g+='<ellipse cx="'+cx.toFixed(1)+'" cy="'+baseY+'" rx="2.4" ry="1.8" transform="rotate(-20 '+cx.toFixed(1)+' '+baseY+')"/>';
g+='<rect x="'+(sx-0.45).toFixed(2)+'" y="'+topY+'" width="0.9" height="'+(baseY-topY-0.5).toFixed(1)+'"/>'; }
if(beams>=1) g+='<rect x="'+(first-0.45).toFixed(2)+'" y="'+topY+'" width="'+(last-first+0.9).toFixed(2)+'" height="1.7"/>';
if(beams>=2) g+='<rect x="'+(first-0.45).toFixed(2)+'" y="'+(topY+2.6)+'" width="'+(last-first+0.9).toFixed(2)+'" height="1.7"/>';
if(tup) g+='<text x="'+(W/2).toFixed(1)+'" y="3.6" font-size="6" text-anchor="middle" font-style="italic" stroke="none">'+tup+'</text>';
return '<svg class="rhythm" viewBox="0 0 '+W+' 18" width="'+W+'" height="16" fill="currentColor" aria-hidden="true">'+g+'</svg>';
}
function buildLanes(){
const box=$("lanes"); box.innerHTML="";
meters.forEach((m,i)=>{
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off");
const meta=document.createElement("button"); meta.className="lmeta";
meta.innerHTML=esc(m.sound)+" <span class='lg'>"+esc(m.groupsStr)+(m.stepsPerBeat>1?"/"+m.stepsPerBeat:"")+(m.poly?"~":"")+"</span>";
meta.innerHTML="<span class='ln-name'>"+esc(m.sound)+"</span>"+rhythmSVG(m.stepsPerBeat)+"<span class='lg'>"+esc(m.groupsStr)+(m.swing?" sw":"")+(m.poly?"~":"")+"</span>";
meta.onclick=()=>openLaneSheet(i);
const pads=document.createElement("div"); pads.className="pads";
const steps=m.beatsPerBar*m.stepsPerBeat; m._padEls=[]; m._lastPad=-1;
for(let k=0;k<steps;k++){ const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); pads.appendChild(p); m._padEls.push(p); }
const spb=m.stepsPerBeat; m._padEls=new Array(m.beatsPerBar*spb); m._lastPad=-1;
for(let b=0;b<m.beatsPerBar;b++){ const cell=document.createElement("div"); cell.className="beatcell";
for(let s=0;s<spb;s++){ const k=b*spb+s; const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); cell.appendChild(p); m._padEls[k]=p; }
pads.appendChild(cell); }
lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane);
});
const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add);
renderPadLevels();
}
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return; const spb=m.stepsPerBeat;
m._padEls.forEach((p,k)=>{ const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k]|0; p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }); }); }
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; const spb=m.stepsPerBeat; const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k];
p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); saveState(); }
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return;
m._padEls.forEach((p,k)=>{ if(p) p.className=padClass(m,k); }); }); }
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; p.className=padClass(m,k); saveState(); }
function renderPadPlayheads(){ meters.forEach(m=>{ if(!m._padEls) return; const cur=state.running?m.currentStep:-1;
if(m._lastPad!==cur){ if(m._lastPad>=0&&m._padEls[m._lastPad]) m._padEls[m._lastPad].classList.remove("cur"); if(cur>=0&&m._padEls[cur]) m._padEls[cur].classList.add("cur"); m._lastPad=cur; } }); }
function rebuildLane(i,cfg){