Skip to content

Instantly share code, notes, and snippets.

@raphi011
Created February 8, 2026 09:39
Show Gist options
  • Select an option

  • Save raphi011/f455b24f9afc8a260c02e1cc0639fc5f to your computer and use it in GitHub Desktop.

Select an option

Save raphi011/f455b24f9afc8a260c02e1cc0639fc5f to your computer and use it in GitHub Desktop.
Interactive Scale Navigation with Guitar Fretboard
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scale Navigation System</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@300;400;500&family=Outfit:wght@200;300;400;500;600;700&display=swap');
:root { --bg:#0a0a0f; --bgs:#12121a; --text:#e8e6e1; --dim:#7a7872; --acc:#d4a054; --accg:#d4a05440; --rb:#2a2a3a; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'Outfit',sans-serif; min-height:100vh; display:flex; flex-direction:column; align-items:center; overflow-x:hidden; }
.grain { position:fixed; inset:0; pointer-events:none; z-index:1000; opacity:0.03; background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); background-repeat:repeat; }
header { text-align:center; padding:2.5rem 1rem 1rem; position:relative; z-index:1; }
header h1 { font-family:'DM Serif Display',serif; font-size:clamp(1.8rem,4vw,2.8rem); font-weight:400; letter-spacing:0.02em; margin-bottom:0.3rem; }
header h1 span { color:var(--acc); }
header p { font-family:'JetBrains Mono',monospace; font-size:0.7rem; font-weight:300; letter-spacing:0.15em; text-transform:uppercase; color:var(--dim); }
.main { display:flex; flex-direction:column; align-items:center; gap:1.5rem; padding:1rem; width:100%; max-width:900px; position:relative; z-index:1; }
.cw { position:relative; width:min(88vw,680px); height:min(88vw,680px); touch-action:none; }
canvas { width:100%; height:100%; cursor:grab; touch-action:none; }
canvas.grabbing { cursor:grabbing; }
.ctrls { display:flex; gap:0.6rem; flex-wrap:wrap; justify-content:center; align-items:center; }
.ctrls label { font-family:'JetBrains Mono',monospace; font-size:0.65rem; letter-spacing:0.1em; text-transform:uppercase; color:var(--dim); margin-right:0.3rem; }
.ctrls select { font-family:'JetBrains Mono',monospace; font-size:0.7rem; padding:0.5rem 0.8rem; border:1px solid var(--rb); border-radius:6px; background:var(--bgs); color:var(--text); cursor:pointer; appearance:none; background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%237a7872' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 0.6rem center; padding-right:2rem; transition:all 0.2s; }
.ctrls select:hover { border-color:var(--acc); color:var(--acc); }
.ctrls button { font-family:'JetBrains Mono',monospace; font-size:0.65rem; letter-spacing:0.1em; text-transform:uppercase; padding:0.5rem 1rem; border:1px solid var(--rb); border-radius:6px; background:var(--bgs); color:var(--dim); cursor:pointer; transition:all 0.2s; }
.ctrls button:hover { border-color:var(--acc); color:var(--text); }
.ctrls button.active { border-color:var(--acc); color:var(--acc); background:#d4a05410; }
.ipanel { width:100%; max-width:620px; min-height:110px; background:var(--bgs); border:1px solid var(--rb); border-radius:12px; padding:1.5rem 2rem; display:flex; flex-direction:column; align-items:center; gap:0.6rem; transition:all 0.4s cubic-bezier(0.22,1,0.36,1); }
.ipanel.active { border-color:var(--acc); box-shadow:0 0 40px var(--accg); }
.ititle { font-family:'DM Serif Display',serif; font-size:1.6rem; color:var(--acc); opacity:0; transform:translateY(8px); transition:all 0.4s cubic-bezier(0.22,1,0.36,1); }
.ititle.v { opacity:1; transform:translateY(0); }
.inotes { font-family:'JetBrains Mono',monospace; font-size:0.85rem; color:#e8c060; letter-spacing:0.08em; opacity:0; transition:opacity 0.3s 0.08s; }
.inotes.v { opacity:1; }
.iint { font-family:'JetBrains Mono',monospace; font-size:0.75rem; color:var(--dim); letter-spacing:0.05em; opacity:0; transition:opacity 0.3s 0.12s; }
.iint.v { opacity:1; }
.idesc { font-family:'Outfit',sans-serif; font-weight:300; font-size:0.8rem; color:var(--dim); text-align:center; opacity:0; transition:opacity 0.3s 0.16s; max-width:500px; line-height:1.5; }
.idesc.v { opacity:1; }
.empty { font-family:'Outfit',sans-serif; font-weight:300; font-size:0.9rem; color:var(--dim); text-align:center; padding:0.5rem 0; }
.hint { font-family:'JetBrains Mono',monospace; font-size:0.6rem; letter-spacing:0.1em; text-transform:uppercase; color:var(--dim); opacity:0.5; text-align:center; }
.legend { display:flex; flex-wrap:wrap; gap:1rem; justify-content:center; padding:0.5rem 0 2rem; }
.litem { display:flex; align-items:center; gap:0.4rem; font-family:'JetBrains Mono',monospace; font-size:0.6rem; letter-spacing:0.1em; text-transform:uppercase; color:var(--dim); }
.ldot { width:10px; height:10px; border-radius:50%; border:1px solid rgba(255,255,255,0.1); }
/* Fretboard */
.fb-section { width:100%; max-width:880px; padding:0 1rem 2rem; position:relative; z-index:1; }
.fb-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.8rem; flex-wrap:wrap; gap:0.5rem; }
.fb-title { font-family:'DM Serif Display',serif; font-size:1.2rem; color:var(--text); }
.fb-title span { color:var(--acc); }
.fb-views { display:flex; gap:0.4rem; }
.fb-views button { font-family:'JetBrains Mono',monospace; font-size:0.6rem; letter-spacing:0.08em; text-transform:uppercase; padding:0.4rem 0.8rem; border:1px solid var(--rb); border-radius:5px; background:var(--bgs); color:var(--dim); cursor:pointer; transition:all 0.2s; }
.fb-views button:hover { border-color:var(--acc); color:var(--text); }
.fb-views button.active { border-color:var(--acc); color:var(--acc); background:#d4a05410; }
.fb-wrap { width:100%; overflow-x:scroll; border:1px solid var(--rb); border-radius:10px; background:#1a1e28; -webkit-overflow-scrolling:touch; touch-action:pan-x pan-y; }
.fb-wrap::-webkit-scrollbar { height:6px; }
.fb-wrap::-webkit-scrollbar-track { background:#12141a; border-radius:3px; }
.fb-wrap::-webkit-scrollbar-thumb { background:#3a3e4a; border-radius:3px; }
.fb-wrap::-webkit-scrollbar-thumb:hover { background:#5a5e6a; }
.fb-empty { text-align:center; padding:2rem; font-family:'Outfit',sans-serif; font-weight:300; font-size:0.85rem; color:var(--dim); }
canvas.fb { display:block; min-width:800px; height:auto; touch-action:pan-x; pointer-events:none; }
.fb-legend { display:flex; gap:1rem; justify-content:center; margin-top:0.6rem; flex-wrap:wrap; }
.fb-legend .litem .ldot { width:12px; height:12px; }
@media(max-width:500px) { header{padding:1.5rem 1rem 0.5rem;} .ipanel{padding:1rem 1.2rem;min-height:90px;} .ititle{font-size:1.3rem;} .fb-title{font-size:1rem;} }
</style>
</head>
<body>
<div class="grain"></div>
<header>
<h1>Scale <span>Navigation</span> System</h1>
<p>Interactive Music Theory Explorer</p>
</header>
<div class="main">
<div class="ctrls">
<label>Root</label>
<select id="rs">
<option value="0">C</option><option value="1">C♯ / D♭</option><option value="2">D</option>
<option value="3">D♯ / E♭</option><option value="4">E</option><option value="5">F</option>
<option value="6">F♯ / G♭</option><option value="7">G</option><option value="8">G♯ / A♭</option>
<option value="9">A</option><option value="10">A♯ / B♭</option><option value="11">B</option>
</select>
<button id="bs" class="active">Sound: On</button>
</div>
<div class="cw"><canvas id="cv"></canvas></div>
<div class="hint">Drag to rotate · Click to select</div>
<div class="ipanel" id="ip">
<div class="empty" id="em">Click any segment to explore</div>
<div class="ititle" id="it"></div>
<div class="inotes" id="in"></div>
<div class="iint" id="ii"></div>
<div class="idesc" id="id"></div>
</div>
<div class="legend">
<div class="litem"><div class="ldot" style="background:#e0b050"></div>Notes</div>
<div class="litem"><div class="ldot" style="background:#3a8aaa"></div>Triads</div>
<div class="litem"><div class="ldot" style="background:#265a78"></div>7th Chords</div>
<div class="litem"><div class="ldot" style="background:#162a42"></div>Modes</div>
</div>
</div>
<div class="fb-section" id="fbs">
<div class="fb-header">
<div class="fb-title">Guitar <span>Fretboard</span></div>
<div class="fb-views">
<button id="fb-notes" class="active">Notes</button>
<button id="fb-intervals">Intervals</button>
</div>
</div>
<div class="fb-wrap">
<div class="fb-empty" id="fb-empty">Select a chord, scale, or mode above to see it on the fretboard</div>
<canvas id="fbc" class="fb" style="display:none"></canvas>
</div>
<div class="fb-legend">
<div class="litem"><div class="ldot" style="background:#d4a054"></div>Root</div>
<div class="litem"><div class="ldot" style="background:#6aabcc"></div>Chord tone</div>
<div class="litem"><div class="ldot" style="background:#555a68"></div>Scale tone</div>
</div>
</div>
<script>
// === DATA ===
const AN=['C','C♯','D','D♯','E','F','F♯','G','G♯','A','A♯','B'];
const FN=['C','D♭','D','E♭','E','F','G♭','G','A♭','A','B♭','B'];
const MS=[0,2,4,5,7,9,11];
const DQ=['maj','min','min','maj','maj','min','dim'];
const D7=['maj7','min7','min7','maj7','dom7','min7','half dim'];
const MN=['Ionian','Dorian','Phrygian','Lydian','Mixolydian','Aeolian','Locrian'];
const UF=new Set([5,10,3,8,1,6]);
const TF={'maj':'1·3·5','min':'1·♭3·5','dim':'1·♭3·♭5'};
const SF={'maj7':'1·3·5·7','min7':'1·♭3·5·♭7','dom7':'1·3·5·♭7','half dim':'1·♭3·♭5·♭7'};
const MF={'Ionian':'1·2·3·4·5·6·7','Dorian':'1·2·♭3·4·5·6·♭7','Phrygian':'1·♭2·♭3·4·5·♭6·♭7','Lydian':'1·2·3·♯4·5·6·7','Mixolydian':'1·2·3·4·5·6·♭7','Aeolian':'1·2·♭3·4·5·♭6·♭7','Locrian':'1·♭2·♭3·4·♭5·♭6·♭7'};
const MD={'Ionian':'The standard major scale — bright, happy, resolved.','Dorian':'Minor with a bright 6th — jazzy, soulful, sophisticated.','Phrygian':'Dark and exotic — Spanish/flamenco flavor.','Lydian':'Dreamy and floating — the raised 4th creates wonder.','Mixolydian':'Major with a flat 7th — bluesy, rock, dominant feel.','Aeolian':'The natural minor scale — melancholic, introspective.','Locrian':'Unstable and dissonant — rarely used as a tonal center.'};
const FR={0:261.63,1:277.18,2:293.66,3:311.13,4:329.63,5:349.23,6:369.99,7:392,8:415.3,9:440,10:466.16,11:493.88};
function nn(s,r){return UF.has(r)?FN[((s%12)+12)%12]:AN[((s%12)+12)%12];}
function sn(r){return MS.map(i=>nn((r+i)%12,r));}
function tn(r,d){const s=sn(r);return[0,2,4].map(o=>s[(d+o)%7]);}
function svn(r,d){const s=sn(r);return[0,2,4,6].map(o=>s[(d+o)%7]);}
function mn(r,d){const s=sn(r);return Array.from({length:7},(_,i)=>s[(d+i)%7]);}
// === STATE ===
let R=0,sel=null,hov=null,sndOn=true,Z=0,aT={};
let rot=0,tRot=0,drag=false,dS=0,dL=0,dV=0,dM=false,dT=0;
// === AUDIO ===
let ac=null;
let activeNodes=[]; // track all playing oscillators+gains so we can stop them
function ea(){if(!ac)ac=new(window.AudioContext||window.webkitAudioContext)();if(ac.state==='suspended')ac.resume();}
function stopAll(){
const now=ac?ac.currentTime:0;
activeNodes.forEach(({o,g})=>{
try{g.gain.cancelScheduledValues(now);g.gain.setValueAtTime(g.gain.value,now);g.gain.linearRampToValueAtTime(0,now+0.05);o.stop(now+0.06);}catch(e){}
});
activeNodes=[];
}
function mt(f,s,d,v=0.12){
// Electric guitar: layered oscillators + waveshaper distortion + pluck envelope
const master=ac.createGain();
master.gain.setValueAtTime(v,s);
master.connect(ac.destination);
// Distortion waveshaper
const dist=ac.createWaveShaper();
const curve=new Float32Array(256);
for(let i=0;i<256;i++){const x=i*2/255-1;curve[i]=Math.tanh(2.5*x);}
dist.curve=curve;
dist.oversample='4x';
// Cabinet sim: lowpass + slight resonance peak
const cab=ac.createBiquadFilter();
cab.type='lowpass';cab.frequency.setValueAtTime(3500,s);cab.Q.setValueAtTime(1.5,s);
// Presence peak
const pres=ac.createBiquadFilter();
pres.type='peaking';pres.frequency.setValueAtTime(2200,s);pres.gain.setValueAtTime(4,s);pres.Q.setValueAtTime(2,s);
// Low cut to remove mud
const hipass=ac.createBiquadFilter();
hipass.type='highpass';hipass.frequency.setValueAtTime(80,s);
// Chain: oscillators -> distortion -> presence -> cabinet -> hipass -> master
dist.connect(pres);pres.connect(cab);cab.connect(hipass);hipass.connect(master);
// Osc 1: sawtooth (main body)
const o1=ac.createOscillator();
const g1=ac.createGain();
o1.type='sawtooth';
o1.frequency.setValueAtTime(f,s);
g1.gain.setValueAtTime(0,s);
g1.gain.linearRampToValueAtTime(0.35,s+0.005);
g1.gain.exponentialRampToValueAtTime(0.15,s+0.08);
g1.gain.exponentialRampToValueAtTime(0.001,s+d);
o1.connect(g1);g1.connect(dist);
o1.start(s);o1.stop(s+d+0.05);
// Osc 2: square, slightly detuned (thickness)
const o2=ac.createOscillator();
const g2=ac.createGain();
o2.type='square';
o2.frequency.setValueAtTime(f*1.002,s);
g2.gain.setValueAtTime(0,s);
g2.gain.linearRampToValueAtTime(0.15,s+0.005);
g2.gain.exponentialRampToValueAtTime(0.06,s+0.08);
g2.gain.exponentialRampToValueAtTime(0.001,s+d);
o2.connect(g2);g2.connect(dist);
o2.start(s);o2.stop(s+d+0.05);
// Osc 3: sawtooth one octave up, quieter (bite)
const o3=ac.createOscillator();
const g3=ac.createGain();
o3.type='sawtooth';
o3.frequency.setValueAtTime(f*2.001,s);
g3.gain.setValueAtTime(0,s);
g3.gain.linearRampToValueAtTime(0.08,s+0.003);
g3.gain.exponentialRampToValueAtTime(0.001,s+d*0.6);
o3.connect(g3);g3.connect(dist);
o3.start(s);o3.stop(s+d+0.05);
activeNodes.push(
{o:o1,g:master},{o:o2,g:master},{o:o3,g:master}
);
o1.onended=()=>{activeNodes=activeNodes.filter(n=>n.o!==o1&&n.o!==o2&&n.o!==o3);};
}
// Sort semitones ascending from root, ensuring each note is higher than the previous
function sortAscending(semis){
if(semis.length===0) return [];
const result=[semis[0]];
for(let i=1;i<semis.length;i++){
let s=semis[i];
while(s<=result[result.length-1]) s+=12;
result.push(s);
}
return result;
}
// Convert semitone (possibly >11) to frequency
function freqOf(s){return FR[s%12]*Math.pow(2,Math.floor(s/12));}
function pN(s){if(!sndOn)return;ea();stopAll();mt(FR[s%12],ac.currentTime,1.2,0.15);}
function pC(ss){
if(!sndOn)return;ea();stopAll();
const asc=sortAscending(ss);
const now=ac.currentTime;
const arpDelay=0.33; // time between each arpeggiated note
const arpDur=0.65; // duration of each arp note
const chordStart=now+asc.length*arpDelay+0.3;
const chordDur=1.6;
// Arpeggiate: play each note in sequence
asc.forEach((s,i)=>mt(freqOf(s),now+i*arpDelay,arpDur,0.1));
// Then play all notes together as a chord
asc.forEach(s=>mt(freqOf(s),chordStart,chordDur,0.08));
}
function pS(ss){
if(!sndOn)return;ea();stopAll();
const asc=sortAscending(ss);
const now=ac.currentTime;
asc.forEach((s,i)=>mt(freqOf(s),now+i*0.15,0.5,0.12));
}
function gS(r,d,t){const dr=(r+MS[d])%12;if(t==='n')return[dr];if(t==='t'){const q=DQ[d];return(q==='maj'?[0,4,7]:q==='min'?[0,3,7]:[0,3,6]).map(i=>(dr+i)%12);}if(t==='s'){const q=D7[d];return(q==='maj7'?[0,4,7,11]:q==='min7'?[0,3,7,10]:q==='dom7'?[0,4,7,10]:[0,3,6,10]).map(i=>(dr+i)%12);}return MS.map((_,i)=>(r+MS[(d+i)%7])%12);}
// === CANVAS ===
const cv=document.getElementById('cv');
const c=cv.getContext('2d');
function resize(){const w=cv.parentElement.getBoundingClientRect();Z=Math.min(w.width,w.height);const d=window.devicePixelRatio||1;cv.width=Z*d;cv.height=Z*d;cv.style.width=Z+'px';cv.style.height=Z+'px';c.setTransform(d,0,0,d,0,0);}
window.addEventListener('resize',resize);resize();
const SG=7,SL=(2*Math.PI)/SG,BS=-Math.PI/2-SL/2;
function sa(){return BS+rot;}
function rr(){const r=Z/2;return{mode:{i:r*.72,o:r*.96},seventh:{i:r*.52,o:r*.72},triad:{i:r*.35,o:r*.52},note:{i:r*.22,o:r*.35},ctr:r*.22};}
function pIS(x,y,ir,or,idx){const cx=Z/2,dx=x-cx,dy=y-cx,d=Math.hypot(dx,dy);if(d<ir||d>or)return false;const nm=a=>((a%(2*Math.PI))+(2*Math.PI))%(2*Math.PI);let a=nm(Math.atan2(dy,dx)),a1=nm(sa()+idx*SL),a2=nm(a1+SL);return a1<a2?(a>=a1&&a<a2):(a>=a1||a<a2);}
function pIC(x,y){return Math.hypot(x-Z/2,y-Z/2)<rr().ctr;}
// === CURVED TEXT ===
function drawC(text,radius,a1,a2,font,color){
const cx=Z/2;
const mid=(a1+a2)/2;
c.font=font;
c.fillStyle=color;
c.textAlign='center';
c.textBaseline='middle';
// Measure characters
const chars=[...text];
const widths=chars.map(ch=>c.measureText(ch).width);
let totalW=widths.reduce((a,b)=>a+b,0);
// Auto-shrink if needed
const avail=(a2-a1)*radius*0.85;
if(totalW>avail&&avail>0){
const ns=Math.max(4,parseFloat(font)*(avail/totalW));
c.font=font.replace(/[\d.]+px/,ns+'px');
for(let i=0;i<chars.length;i++) widths[i]=c.measureText(chars[i]).width;
totalW=widths.reduce((a,b)=>a+b,0);
}
// Total angular span of the text at this radius
const totalAng=totalW/radius;
// Start angle: center the text on the midpoint of the segment
let ang=mid-totalAng/2;
for(let i=0;i<chars.length;i++){
const charAng=widths[i]/radius;
ang+=charAng/2;
// Position on the arc
const x=cx+radius*Math.cos(ang);
const y=cx+radius*Math.sin(ang);
c.save();
c.translate(x,y);
// Rotate so the character is tangent to the arc
// ang points from center to char position; +π/2 makes text perpendicular (tangent)
c.rotate(ang+Math.PI/2);
c.fillText(chars[i],0,0);
c.restore();
ang+=charAng/2;
}
}
// === COLORS ===
function rgba(h,a){return`rgba(${parseInt(h.slice(1,3),16)},${parseInt(h.slice(3,5),16)},${parseInt(h.slice(5,7),16)},${a})`;}
function lerp(a,b,t){const p=h=>[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];const ca=p(a),cb=p(b);return`rgb(${Math.round(ca[0]+(cb[0]-ca[0])*t)},${Math.round(ca[1]+(cb[1]-ca[1])*t)},${Math.round(ca[2]+(cb[2]-ca[2])*t)})`;}
const RC={
mode:{f:'#142638',fh:'#1e3650',fs:'#1a3a55',s:'#1e3a52',t:'#a8c4d8',td:'#607888'},
seventh:{f:'#1a3a55',fh:'#244a68',fs:'#265a78',s:'#265a78',t:'#b0d0e4',td:'#6a8898'},
triad:{f:'#2a6888',fh:'#3480a0',fs:'#3a8aaa',s:'#3a7a98',t:'#d0e8f4',td:'#80a8c0'},
note:{f:'#c89428',fh:'#d8a838',fs:'#e0b040',s:'#a87820',t:'#3a2a0a',td:'#5a4010'},
};
// === DRAW SEGMENT ===
function dSeg(ring,idx,lines){
const r=rr()[ring],cx=Z/2,a1=sa()+idx*SL,a2=a1+SL,co=RC[ring];
const iS=sel&&sel.r===ring&&sel.i===idx,iH=hov&&hov.r===ring&&hov.i===idx,iR=sel&&sel.i===idx&&sel.r!==ring;
const k=ring+idx;if(!aT[k])aT[k]=0;aT[k]+=((iS||iH||iR?1:0)-aT[k])*0.15;const t=aT[k];
c.beginPath();c.arc(cx,cx,r.o,a1,a2);c.arc(cx,cx,r.i,a2,a1,true);c.closePath();
let fl=co.f;if(iS)fl=co.fs;else if(iH)fl=co.fh;else if(iR&&t>0.05)fl=lerp(co.f,co.fs,t*0.6);
c.fillStyle=fl;c.fill();
c.strokeStyle=iS?'#d4a054':(iR?rgba('#d4a054',0.3*t):co.s);c.lineWidth=iS?2:0.8;c.stroke();
if(ring==='seventh'||ring==='mode'){c.save();c.setLineDash([3,4]);c.beginPath();c.arc(cx,cx,r.i,a1,a2);c.strokeStyle=rgba(co.s,0.4);c.lineWidth=0.6;c.stroke();c.restore();}
if((iS||iR)&&t>0.1){c.save();c.shadowColor='#d4a054';c.shadowBlur=12*t;c.beginPath();c.arc(cx,cx,r.o,a1,a2);c.arc(cx,cx,r.i,a2,a1,true);c.closePath();c.strokeStyle=rgba('#d4a054',0.2*t);c.lineWidth=1.5;c.stroke();c.restore();}
const h=r.o-r.i;
if(lines.length===1){drawC(lines[0].t,r.i+h*0.5,a1,a2,lines[0].f,lines[0].c);}
else if(lines.length===2){drawC(lines[0].t,r.i+h*0.65,a1,a2,lines[0].f,lines[0].c);drawC(lines[1].t,r.i+h*0.32,a1,a2,lines[1].f,lines[1].c);}
else if(lines.length>=3){drawC(lines[0].t,r.i+h*0.78,a1,a2,lines[0].f,lines[0].c);drawC(lines[1].t,r.i+h*0.50,a1,a2,lines[1].f,lines[1].c);drawC(lines[2].t,r.i+h*0.25,a1,a2,lines[2].f,lines[2].c);}
}
function dCtr(){
const rs=rr(),cx=Z/2,r=rs.ctr,iS=sel&&sel.r==='center';
const g=c.createRadialGradient(cx,cx,0,cx,cx,r);
g.addColorStop(0,iS?'#faf0d0':'#f0e8c8');g.addColorStop(1,iS?'#e8d8a0':'#d8c888');
c.beginPath();c.arc(cx,cx,r,0,Math.PI*2);c.fillStyle=g;c.fill();
c.strokeStyle=iS?'#d4a054':'#b0982a';c.lineWidth=iS?2:1;c.stroke();
c.save();c.setLineDash([4,4]);c.beginPath();c.arc(cx,cx,r*0.82,0,Math.PI*2);c.strokeStyle='#c0a84088';c.lineWidth=1;c.stroke();c.restore();
const s=sn(R),ts=Math.max(8,Z*0.018),ns=Math.max(6,Z*0.013),is=Math.max(5,Z*0.011);
c.fillStyle='#3a3020';c.font=`600 ${ts}px 'Outfit',sans-serif`;c.textAlign='center';c.textBaseline='middle';
c.fillText(`${s[0]} MAJOR SCALE`,cx,cx-Z*0.03);
c.fillStyle='#6a5a30';c.font=`400 ${ns}px 'JetBrains Mono',monospace`;c.fillText(s.join(' · '),cx,cx+Z*0.003);
c.fillStyle='#8a7a50';c.font=`300 ${is}px 'JetBrains Mono',monospace`;c.fillText('1 · 2 · 3 · 4 · 5 · 6 · 7',cx,cx+Z*0.032);
}
function draw(){
c.clearRect(0,0,Z,Z);
const s=sn(R);
if(sel){const cx=Z/2,g=c.createRadialGradient(cx,cx,0,cx,cx,Z/2);g.addColorStop(0,rgba('#d4a054',0.04));g.addColorStop(1,'transparent');c.fillStyle=g;c.fillRect(0,0,Z,Z);}
const tf=w=>`500 ${Math.max(8,Z*w)}px 'Outfit',sans-serif`;
const nf=w=>`400 ${Math.max(5,Z*w)}px 'JetBrains Mono',monospace`;
const xf=w=>`300 ${Math.max(4,Z*w)}px 'JetBrains Mono',monospace`;
for(let i=0;i<7;i++){const m=mn(R,i),iA=sel?.r==='mode'&&sel?.i===i,iH=hov?.r==='mode'&&hov?.i===i,tc=(iA||iH)?'#fff':RC.mode.t;
dSeg('mode',i,[{t:`${s[i]} ${MN[i]}`,f:tf(0.019),c:tc},{t:m.join(' · '),f:nf(0.012),c:(iA||iH)?'#e8c060':'#c8a838'},{t:MF[MN[i]],f:xf(0.01),c:(iA||iH)?'#aaa':RC.mode.td}]);}
for(let i=0;i<7;i++){const sv=svn(R,i),q=D7[i],iA=sel?.r==='seventh'&&sel?.i===i,iH=hov?.r==='seventh'&&hov?.i===i,tc=(iA||iH)?'#fff':RC.seventh.t;
dSeg('seventh',i,[{t:`${s[i]} ${q}`,f:tf(0.019),c:tc},{t:sv.join(' · '),f:nf(0.012),c:(iA||iH)?'#e8c060':'#c8a838'},{t:SF[q],f:xf(0.01),c:(iA||iH)?'#aaa':RC.seventh.td}]);}
for(let i=0;i<7;i++){const t2=tn(R,i),q=DQ[i],iA=sel?.r==='triad'&&sel?.i===i,iH=hov?.r==='triad'&&hov?.i===i,tc=(iA||iH)?'#fff':RC.triad.t;
dSeg('triad',i,[{t:`${s[i]} ${q}`,f:tf(0.021),c:tc},{t:t2.join(' · '),f:nf(0.013),c:(iA||iH)?'#e8c060':'#c8a838'},{t:TF[q],f:xf(0.011),c:(iA||iH)?'#aaa':RC.triad.td}]);}
for(let i=0;i<7;i++){const iA=sel?.r==='note'&&sel?.i===i,iH=hov?.r==='note'&&hov?.i===i;
dSeg('note',i,[{t:s[i],f:`600 ${Math.max(13,Z*0.034)}px 'Outfit',sans-serif`,c:(iA||iH)?'#1a0a00':RC.note.t}]);}
dCtr();
}
// === DRAG TO ROTATE ===
function gA(e){const r=cv.getBoundingClientRect();return Math.atan2(e.clientY-r.top-r.height/2,e.clientX-r.left-r.width/2);}
function gAT(t){const r=cv.getBoundingClientRect();return Math.atan2(t.clientY-r.top-r.height/2,t.clientX-r.left-r.width/2);}
cv.addEventListener('mousedown',e=>{drag=true;dM=false;dT=Date.now();dS=gA(e);dL=dS;dV=0;cv.classList.add('grabbing');});
window.addEventListener('mousemove',e=>{
if(drag){let d=gA(e)-dL;if(d>Math.PI)d-=2*Math.PI;if(d<-Math.PI)d+=2*Math.PI;if(Math.abs(d)>0.005)dM=true;dV=d;rot+=d;tRot=rot;dL=gA(e);}
else{const r=cv.getBoundingClientRect();const x=(e.clientX-r.left)/r.width*Z,y=(e.clientY-r.top)/r.height*Z;hov=gSA(x,y);}
});
function snapRot(rawTarget){
// Snap to nearest multiple of SL (one segment = 2π/7)
const steps=Math.round(rawTarget/SL);
return steps*SL;
}
window.addEventListener('mouseup',e=>{
if(drag){drag=false;cv.classList.remove('grabbing');
const raw=rot+dV*8;tRot=snapRot(raw);
if(!dM&&(Date.now()-dT)<300){const r=cv.getBoundingClientRect();hS((e.clientX-r.left)/r.width*Z,(e.clientY-r.top)/r.height*Z);}}
});
cv.addEventListener('mouseleave',()=>{if(!drag)hov=null;});
cv.addEventListener('touchstart',e=>{e.preventDefault();const t=e.touches[0];drag=true;dM=false;dT=Date.now();dS=gAT(t);dL=dS;dV=0;},{passive:false});
cv.addEventListener('touchmove',e=>{e.preventDefault();if(!drag)return;const t=e.touches[0];let d=gAT(t)-dL;if(d>Math.PI)d-=2*Math.PI;if(d<-Math.PI)d+=2*Math.PI;if(Math.abs(d)>0.005)dM=true;dV=d;rot+=d;tRot=rot;dL=gAT(t);},{passive:false});
cv.addEventListener('touchend',e=>{e.preventDefault();if(!drag)return;drag=false;
const raw=rot+dV*8;tRot=snapRot(raw);
if(!dM&&(Date.now()-dT)<300){const t=e.changedTouches[0];const r=cv.getBoundingClientRect();hS((t.clientX-r.left)/r.width*Z,(t.clientY-r.top)/r.height*Z);}},{passive:false});
// === INTERACTION ===
function gSA(x,y){
const rs=rr();
if(pIC(x,y))return{r:'center',i:-1};
for(const ring of['note','triad','seventh','mode'])for(let i=0;i<7;i++)if(pIS(x,y,rs[ring].i,rs[ring].o,i))return{r:ring,i};
return null;
}
function hS(x,y){
const sg=gSA(x,y);if(!sg)return;
if(sel&&sel.r===sg.r&&sel.i===sg.i){sel=null;}
else{sel=sg;
if(sg.r==='center')pS(MS.map(i=>(R+i)%12));
else if(sg.r==='note')pN((R+MS[sg.i])%12);
else if(sg.r==='triad')pC(gS(R,sg.i,'t'));
else if(sg.r==='seventh')pC(gS(R,sg.i,'s'));
else if(sg.r==='mode')pS(gS(R,sg.i,'m'));
}
uIP();
}
function uIP(){
const it=document.getElementById('it'),iN=document.getElementById('in'),iI=document.getElementById('ii'),iD=document.getElementById('id'),em=document.getElementById('em'),ip=document.getElementById('ip');
if(!sel){ip.classList.remove('active');['it','in','ii','id'].forEach(x=>document.getElementById(x).classList.remove('v'));em.style.display='block';return;}
em.style.display='none';ip.classList.add('active');
const s=sn(R),{r:ring,i:idx}=sel;
if(ring==='center'){it.textContent=`${s[0]} Major Scale`;iN.textContent=s.join(' · ');iI.textContent='1 · 2 · 3 · 4 · 5 · 6 · 7';iD.textContent='The parent scale — all chords and modes on this wheel are derived from these seven notes.';}
else if(ring==='note'){it.textContent=`${s[idx]} — Scale Degree ${idx+1}`;iN.textContent=s[idx];iI.textContent=`Degree ${idx+1} of ${s[0]} Major`;iD.textContent=`The ${['root','second','third','fourth','fifth','sixth','seventh'][idx]} degree of the ${s[0]} major scale.`;}
else if(ring==='triad'){const q=DQ[idx];it.textContent=`${s[idx]} ${q}`;iN.textContent=tn(R,idx).join(' · ');iI.textContent=TF[q];iD.textContent=`The ${['I','ii','iii','IV','V','vi','vii°'][idx]} chord — a ${q==='dim'?'diminished':q==='min'?'minor':'major'} triad.`;}
else if(ring==='seventh'){const q=D7[idx];it.textContent=`${s[idx]} ${q}`;iN.textContent=svn(R,idx).join(' · ');iI.textContent=SF[q];iD.textContent=`A ${q} chord — the triad plus a ${q.includes('maj')?'major':'minor'} seventh.`;}
else if(ring==='mode'){const m=MN[idx];it.textContent=`${s[idx]} ${m}`;iN.textContent=mn(R,idx).join(' · ');iI.textContent=MF[m];iD.textContent=MD[m];}
it.classList.add('v');iN.classList.add('v');iI.classList.add('v');iD.classList.add('v');
}
// === CONTROLS ===
document.getElementById('rs').addEventListener('change',function(){R=parseInt(this.value);sel=null;uIP();});
document.getElementById('bs').addEventListener('click',function(){sndOn=!sndOn;this.classList.toggle('active',sndOn);this.textContent=sndOn?'Sound: On':'Sound: Off';});
// === FRETBOARD ===
const TUNING=[4,9,2,7,11,4]; // E A D G B E (low to high) as semitones
const FRETS=15;
const INT_NAMES=['1','♭2','2','♭3','3','4','♭5','5','♭6','6','♭7','7'];
let fbView='notes'; // 'notes' or 'intervals'
// Get fretboard note data for current selection
function getFBData(){
if(!sel) return null;
const {r:ring,i:idx}=sel;
// Scale semitones (always the full parent scale)
const scaleSemis=new Set(MS.map(i=>(R+i)%12));
let chordSemis=new Set();
let rootSemi=(R+MS[idx<0?0:idx])%12;
let modeSemis=null;
if(ring==='center'){
rootSemi=R;
chordSemis=scaleSemis;
} else if(ring==='note'){
chordSemis.add(rootSemi);
} else if(ring==='triad'){
gS(R,idx,'t').forEach(s=>chordSemis.add(s));
} else if(ring==='seventh'){
gS(R,idx,'s').forEach(s=>chordSemis.add(s));
} else if(ring==='mode'){
// Mode uses all 7 notes but from the mode root
modeSemis=new Set(MS.map((_,i)=>(R+MS[(idx+i)%7])%12));
chordSemis=modeSemis;
rootSemi=(R+MS[idx])%12;
}
return {scaleSemis,chordSemis,rootSemi,modeSemis};
}
function getIntervalLabel(noteSemi,rootSemi){
const diff=((noteSemi-rootSemi)%12+12)%12;
return INT_NAMES[diff];
}
const fbc=document.getElementById('fbc');
const fc=fbc.getContext('2d');
function drawFretboard(){
const data=getFBData();
const emEl=document.getElementById('fb-empty');
if(!data){emEl.style.display='block';fbc.style.display='none';return;}
emEl.style.display='none';fbc.style.display='block';
const W=Math.max(800,fbc.parentElement.clientWidth);
const strSpacing=28;
const topPad=32,botPad=20,leftPad=36,rightPad=16;
const H=topPad+strSpacing*5+botPad;
const dpr=window.devicePixelRatio||1;
fbc.width=W*dpr;fbc.height=H*dpr;
fbc.style.width=W+'px';fbc.style.height=H+'px';
fc.setTransform(dpr,0,0,dpr,0,0);
const fretArea=W-leftPad-rightPad;
// Fret positions (approximate real guitar scaling)
const fretX=[];
for(let i=0;i<=FRETS;i++){
fretX.push(leftPad+fretArea*(1-Math.pow(0.94387,i))/(1-Math.pow(0.94387,FRETS)));
}
// Background
fc.fillStyle='#1a1e28';
fc.fillRect(0,0,W,H);
// Fret markers (dots at 3,5,7,9,12,15)
const markers=[3,5,7,9,15];
const dblMarker=[12];
fc.fillStyle='#252a35';
for(const f of markers){
if(f>FRETS) continue;
const mx=(fretX[f-1]+fretX[f])/2;
fc.beginPath();fc.arc(mx,topPad+strSpacing*2.5,6,0,Math.PI*2);fc.fill();
}
for(const f of dblMarker){
if(f>FRETS) continue;
const mx=(fretX[f-1]+fretX[f])/2;
fc.beginPath();fc.arc(mx,topPad+strSpacing*1.5,5,0,Math.PI*2);fc.fill();
fc.beginPath();fc.arc(mx,topPad+strSpacing*3.5,5,0,Math.PI*2);fc.fill();
}
// Strings
for(let s=0;s<6;s++){
const y=topPad+s*strSpacing;
const thickness=0.8+s*0.15;
fc.strokeStyle='#4a4e58';fc.lineWidth=thickness;
fc.beginPath();fc.moveTo(leftPad,y);fc.lineTo(W-rightPad,y);fc.stroke();
}
// Frets
for(let f=0;f<=FRETS;f++){
const x=fretX[f];
fc.strokeStyle=f===0?'#888':'#333840';
fc.lineWidth=f===0?3:1;
fc.beginPath();fc.moveTo(x,topPad-4);fc.lineTo(x,topPad+strSpacing*5+4);fc.stroke();
}
// Fret numbers
fc.fillStyle='#3a3e48';fc.font="400 9px 'JetBrains Mono',monospace";fc.textAlign='center';fc.textBaseline='top';
for(let f=1;f<=FRETS;f++){
const mx=(fretX[f-1]+fretX[f])/2;
fc.fillText(f,mx,topPad+strSpacing*5+8);
}
// String labels
const strNames=['E','B','G','D','A','E'];
fc.fillStyle='#6a6e78';fc.font="500 11px 'Outfit',sans-serif";fc.textAlign='right';fc.textBaseline='middle';
for(let s=0;s<6;s++){
fc.fillText(strNames[s],leftPad-10,topPad+s*strSpacing);
}
// Notes on fretboard
const {scaleSemis,chordSemis,rootSemi}=data;
const strSemis=[4,11,7,2,9,4]; // high E, B, G, D, A, low E
for(let s=0;s<6;s++){
const openNote=strSemis[s];
for(let f=0;f<=FRETS;f++){
const noteSemi=(openNote+f)%12;
const inScale=scaleSemis.has(noteSemi);
const inChord=chordSemis.has(noteSemi);
const isRoot=noteSemi===rootSemi;
if(!inScale&&!inChord) continue;
const x=f===0?fretX[0]-12:(fretX[f-1]+fretX[f])/2;
const y=topPad+s*strSpacing;
if(isRoot){
// Root: orange filled circle
const rad=inChord?11:9;
fc.beginPath();fc.arc(x,y,rad,0,Math.PI*2);
fc.fillStyle='#d4a054';fc.fill();
fc.strokeStyle='#b8862a';fc.lineWidth=1.5;fc.stroke();
fc.fillStyle='#1a0e00';
fc.font="600 9px 'Outfit',sans-serif";fc.textAlign='center';fc.textBaseline='middle';
fc.fillText(fbView==='notes'?nn(noteSemi,R):getIntervalLabel(noteSemi,rootSemi),x,y);
} else if(inChord){
// Chord tone: blue circle
fc.beginPath();fc.arc(x,y,10,0,Math.PI*2);
fc.fillStyle='#4a90b0';fc.fill();
fc.strokeStyle='#3a7090';fc.lineWidth=1;fc.stroke();
fc.fillStyle='#e0f0ff';
fc.font="500 8px 'Outfit',sans-serif";fc.textAlign='center';fc.textBaseline='middle';
fc.fillText(fbView==='notes'?nn(noteSemi,R):getIntervalLabel(noteSemi,rootSemi),x,y);
} else if(inScale){
// Scale tone: small grey dot
fc.beginPath();fc.arc(x,y,4.5,0,Math.PI*2);
fc.fillStyle='#555a68';fc.fill();
}
}
}
}
// View toggle
document.getElementById('fb-notes').addEventListener('click',function(){
fbView='notes';this.classList.add('active');document.getElementById('fb-intervals').classList.remove('active');drawFretboard();
});
document.getElementById('fb-intervals').addEventListener('click',function(){
fbView='intervals';this.classList.add('active');document.getElementById('fb-notes').classList.remove('active');drawFretboard();
});
// Hook fretboard into selection updates
const origUIP=uIP;
uIP=function(){origUIP();drawFretboard();};
// Also redraw on root change
const origRS=document.getElementById('rs').onchange;
document.getElementById('rs').addEventListener('change',()=>drawFretboard());
// Initial draw
drawFretboard();
// === LOOP ===
function animate(){
if(!drag){
// Smooth ease toward snapped target
const diff=tRot-rot;
rot+=diff*0.18;
// Stop jittering when close enough
if(Math.abs(diff)<0.0005) rot=tRot;
}
draw();requestAnimationFrame(animate);
}
animate();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment