Instantly share code, notes, and snippets.
Created
February 8, 2026 09:39
-
Star
1
(1)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save raphi011/f455b24f9afc8a260c02e1cc0639fc5f to your computer and use it in GitHub Desktop.
Interactive Scale Navigation with Guitar Fretboard
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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