| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| const FRET_COLORS = ['#22c55e', '#ef4444', '#eab308', '#3b82f6', '#f97316']; |
| const FRET_GLOW = ['#4ade80', '#f87171', '#facc15', '#60a5fa', '#fb923c']; |
| const LANE_COUNT = 5; |
|
|
| |
| const VISIBLE_SEC = 4.5; |
| const CANVAS_ASPECT = 0.72; |
| const CANVAS_MIN_H = 400; |
| const CANVAS_MAX_H = 700; |
| const STRIKE_Y_FRAC = 0.88; |
| const TOP_Y_FRAC = 0.04; |
| const BOTTOM_W_FRAC = 0.52; |
| const TOP_W_FRAC = 0.14; |
| const NOTE_LANE_RATIO = 0.42; |
| const NOTE_SQUISH = 0.40; |
|
|
| |
| let DATA = null, audio = null, canvas, ctx; |
| let W, H; |
| let currentDiff = 'expert'; |
| let playing = false; |
| let noteCache = [], beatCache = [], sectionCache = []; |
| let tempoMap = [], RES = 192, totalDuration = 0; |
|
|
| |
| let strikeY, topY, centerX, bottomW, topW, zFar, sFar, noteRX; |
|
|
| |
|
|
| function tickToSec(tick) { |
| let sec = 0, prevTick = 0, bpm = tempoMap[0].bpm; |
| for (let i = 1; i < tempoMap.length; i++) { |
| if (tempoMap[i].tick > tick) break; |
| sec += (tempoMap[i].tick - prevTick) / RES * 60 / bpm; |
| prevTick = tempoMap[i].tick; |
| bpm = tempoMap[i].bpm; |
| } |
| return sec + (tick - prevTick) / RES * 60 / bpm; |
| } |
|
|
| |
|
|
| function buildNoteCache(diff) { |
| return (DATA.notes[diff] || []).map(n => ({ |
| sec: tickToSec(n.tick), |
| frets: n.frets, |
| sustainSec: n.sustain > 0 ? tickToSec(n.tick + n.sustain) - tickToSec(n.tick) : 0, |
| hopo: n.hopo, |
| })); |
| } |
|
|
| function rebuildCaches() { |
| tempoMap = DATA.tempo_events.map(e => ({ tick: e.tick, bpm: e.bpm })); |
| RES = DATA.resolution; |
| noteCache = buildNoteCache(currentDiff); |
|
|
| |
| |
| const rawBeats = DATA.beats.map(b => ({ sec: tickToSec(b.tick), downbeat: b.downbeat })); |
| beatCache = []; |
| for (let i = 0; i < rawBeats.length; i++) { |
| beatCache.push({ sec: rawBeats[i].sec, level: rawBeats[i].downbeat ? 0 : 1 }); |
| if (i < rawBeats.length - 1) { |
| beatCache.push({ sec: (rawBeats[i].sec + rawBeats[i + 1].sec) / 2, level: 2 }); |
| } |
| } |
|
|
| sectionCache = DATA.sections.map(s => ({ sec: tickToSec(s.tick), label: s.label })); |
| } |
|
|
| |
|
|
| function getDuration() { |
| if (totalDuration && isFinite(totalDuration)) return totalDuration; |
| const all = Object.values(DATA.notes).flat().map(n => tickToSec(n.tick + (n.sustain || 0))); |
| return all.length ? Math.max(...all) + 5 : 120; |
| } |
|
|
| function fmt(s) { |
| const m = Math.floor(s / 60), sc = Math.floor(s % 60); |
| return m + ':' + (sc < 10 ? '0' : '') + sc; |
| } |
|
|
| |
|
|
| function resize() { |
| const container = document.getElementById('midmid-viz'); |
| W = container.clientWidth; |
| H = Math.round(Math.max(CANVAS_MIN_H, Math.min(CANVAS_MAX_H, W * CANVAS_ASPECT))); |
| canvas.width = W * devicePixelRatio; |
| canvas.height = H * devicePixelRatio; |
| canvas.style.height = H + 'px'; |
| ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); |
|
|
| centerX = W / 2; |
| strikeY = H * STRIKE_Y_FRAC; |
| topY = H * TOP_Y_FRAC; |
| bottomW = W * BOTTOM_W_FRAC; |
| topW = W * TOP_W_FRAC; |
| |
| zFar = BOTTOM_W_FRAC / TOP_W_FRAC; |
| sFar = 1 / zFar; |
| |
| noteRX = (bottomW / LANE_COUNT) * NOTE_LANE_RATIO; |
| } |
|
|
| |
| |
| |
|
|
| const clamp01 = v => Math.max(0, Math.min(1, v)); |
|
|
| |
| |
| |
| function project(secAhead) { |
| const t = clamp01(secAhead / VISIBLE_SEC); |
| const z = 1 + t * (zFar - 1); |
| const s = 1 / z; |
| const y = strikeY - (1 - s) / (1 - sFar) * (strikeY - topY); |
| const w = bottomW * s; |
| return { y, w, s }; |
| } |
|
|
| |
| function getPoint(lane, secAhead) { |
| const { y, w, s } = project(Math.max(0, secAhead)); |
| const lw = w / LANE_COUNT; |
| const x = centerX - w / 2 + (lane + 0.5) * lw; |
| return { x, y, scale: s }; |
| } |
|
|
| |
|
|
| function hexRgb(hex) { |
| return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; |
| } |
| function rgba(r, g, b, a) { return `rgba(${r},${g},${b},${a})`; } |
| function colorAlpha(hex, a) { const [r, g, b] = hexRgb(hex); return rgba(r, g, b, a); } |
|
|
| |
|
|
| function drawTrackSurface() { |
| const bL = centerX - bottomW / 2, bR = centerX + bottomW / 2; |
| const tL = centerX - topW / 2, tR = centerX + topW / 2; |
|
|
| const grad = ctx.createLinearGradient(0, topY, 0, strikeY); |
| grad.addColorStop(0, '#0d0d0d'); |
| grad.addColorStop(0.6, '#141414'); |
| grad.addColorStop(1, '#1a1a1a'); |
|
|
| ctx.beginPath(); |
| ctx.moveTo(bL, strikeY); |
| ctx.lineTo(tL, topY); |
| ctx.lineTo(tR, topY); |
| ctx.lineTo(bR, strikeY); |
| ctx.closePath(); |
| ctx.fillStyle = grad; |
| ctx.fill(); |
|
|
| |
| ctx.strokeStyle = '#2a2a2a'; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); ctx.moveTo(bL, strikeY); ctx.lineTo(tL, topY); ctx.stroke(); |
| ctx.beginPath(); ctx.moveTo(bR, strikeY); ctx.lineTo(tR, topY); ctx.stroke(); |
| } |
|
|
| function drawLaneLines() { |
| for (let i = 1; i < LANE_COUNT; i++) { |
| const frac = i / LANE_COUNT; |
| const bx = centerX - bottomW / 2 + frac * bottomW; |
| const tx = centerX - topW / 2 + frac * topW; |
| ctx.beginPath(); |
| ctx.moveTo(bx, strikeY); |
| ctx.lineTo(tx, topY); |
| ctx.strokeStyle = '#1f1f1f'; |
| ctx.lineWidth = 1; |
| ctx.stroke(); |
| } |
| } |
|
|
| function drawBeatLines(t) { |
| |
| |
| const styles = [ |
| { color: 'rgba(255,255,255,0.25)', width: 2 }, |
| { color: 'rgba(255,255,255,0.10)', width: 1 }, |
| { color: 'rgba(255,255,255,0.04)', width: 0.5 }, |
| ]; |
| for (const beat of beatCache) { |
| const ahead = beat.sec - t; |
| if (ahead < 0 || ahead > VISIBLE_SEC) continue; |
| const { y, w } = project(ahead); |
| const st = styles[beat.level]; |
| ctx.beginPath(); |
| ctx.moveTo(centerX - w / 2, y); |
| ctx.lineTo(centerX + w / 2, y); |
| ctx.strokeStyle = st.color; |
| ctx.lineWidth = st.width; |
| ctx.stroke(); |
| } |
| } |
|
|
| function drawSectionMarkers(t) { |
| for (const sec of sectionCache) { |
| const ahead = sec.sec - t; |
| if (ahead < 0 || ahead > VISIBLE_SEC) continue; |
| const { y, w, s } = project(ahead); |
| const right = centerX + w / 2; |
|
|
| if (s > 0.25) { |
| ctx.fillStyle = colorAlpha('#7c3aed', 0.4 + 0.5 * s); |
| ctx.font = `${Math.max(9, Math.round(11 * s))}px system-ui`; |
| ctx.textAlign = 'left'; |
| ctx.fillText(sec.label, right + 8, y + 4); |
| } |
| } |
| } |
|
|
| |
|
|
| function drawStrikeline() { |
| const left = centerX - bottomW / 2, right = centerX + bottomW / 2; |
|
|
| |
| const grad = ctx.createLinearGradient(0, strikeY - 14, 0, strikeY + 14); |
| grad.addColorStop(0, 'rgba(255,255,255,0)'); |
| grad.addColorStop(0.5, 'rgba(255,255,255,0.08)'); |
| grad.addColorStop(1, 'rgba(255,255,255,0)'); |
| ctx.fillStyle = grad; |
| ctx.fillRect(left, strikeY - 14, right - left, 28); |
|
|
| |
| ctx.beginPath(); |
| ctx.moveTo(left, strikeY); |
| ctx.lineTo(right, strikeY); |
| ctx.strokeStyle = 'rgba(255,255,255,0.55)'; |
| ctx.lineWidth = 2; |
| ctx.stroke(); |
| } |
|
|
| function drawFretButtons(fretRise) { |
| for (let i = 0; i < LANE_COUNT; i++) { |
| const pt = getPoint(i, 0); |
| const cx = pt.x, cy = strikeY; |
| const rx = noteRX * 1.1; |
| const ry = rx * NOTE_SQUISH; |
| const color = FRET_COLORS[i]; |
| const rise = fretRise[i]; |
| const active = rise > 0.05; |
|
|
| |
| if (active) { |
| const gr = rx * 1.6; |
| const pulse = ctx.createRadialGradient(cx, cy, rx * 0.5, cx, cy, gr); |
| pulse.addColorStop(0, colorAlpha(color, 0.25 * rise)); |
| pulse.addColorStop(1, colorAlpha(color, 0)); |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy, gr, gr * NOTE_SQUISH, 0, 0, Math.PI * 2); |
| ctx.fillStyle = pulse; |
| ctx.fill(); |
| } |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy, rx * 1.08, ry * 1.08, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#1a1a1a'; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); |
| ctx.strokeStyle = active ? color : colorAlpha(color, 0.7); |
| ctx.lineWidth = 3 + rise; |
| ctx.stroke(); |
|
|
| |
| const riseH = ry * 0.38 * rise; |
| const rY = cy - riseH; |
|
|
| |
| if (active) { |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy, rx * 0.9, ry * 0.9, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#222'; |
| ctx.fill(); |
| } |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, rY, rx * 0.9, ry * 0.9, 0, 0, Math.PI * 2); |
| const silver = ctx.createRadialGradient( |
| cx - rx * 0.2, rY - ry * 0.15, rx * 0.05, |
| cx, rY, rx * 0.9 |
| ); |
| silver.addColorStop(0, '#d8d8d8'); |
| silver.addColorStop(0.3, '#b0b0b0'); |
| silver.addColorStop(0.7, '#808080'); |
| silver.addColorStop(1, '#606060'); |
| ctx.fillStyle = silver; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, rY, rx * 0.75, ry * 0.75, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#111'; |
| ctx.fill(); |
|
|
| |
| const [cr, cg, cb] = hexRgb(color); |
| if (rise > 0.05) { |
| const glow = ctx.createRadialGradient(cx, rY, 0, cx, rY, rx * 0.62); |
| glow.addColorStop(0, rgba( |
| Math.round(10 + (Math.min(255, cr + 80) - 10) * rise), |
| Math.round(10 + (Math.min(255, cg + 80) - 10) * rise), |
| Math.round(10 + (Math.min(255, cb + 80) - 10) * rise), 1)); |
| glow.addColorStop(0.7, rgba( |
| Math.round(10 + cr * rise), Math.round(10 + cg * rise), Math.round(10 + cb * rise), 1)); |
| glow.addColorStop(1, rgba( |
| Math.round(10 + Math.max(0, cr - 20) * rise), |
| Math.round(10 + Math.max(0, cg - 20) * rise), |
| Math.round(10 + Math.max(0, cb - 20) * rise), 1)); |
| ctx.beginPath(); |
| ctx.ellipse(cx, rY, rx * 0.65, ry * 0.65, 0, 0, Math.PI * 2); |
| ctx.fillStyle = glow; |
| ctx.fill(); |
| } else { |
| ctx.beginPath(); |
| ctx.ellipse(cx, rY, rx * 0.65, ry * 0.65, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#0a0a0a'; |
| ctx.fill(); |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| function drawNotePuck(cx, cy, scale, color, isHopo) { |
| const rx = noteRX * scale; |
| const ry = rx * NOTE_SQUISH; |
| if (rx < 3) return; |
|
|
| const [cr, cg, cb] = hexRgb(color); |
| const pH = ry * 0.6; |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy + pH + ry * 0.12, rx * 1.04, ry * 0.45, 0, 0, Math.PI * 2); |
| ctx.fillStyle = 'rgba(0,0,0,0.4)'; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy + pH, rx * 1.01, ry, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#080808'; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy + pH * 0.45, rx, ry, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#c8c8c8'; |
| ctx.fill(); |
|
|
| |
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#0e0e0e'; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, cy - ry * 0.04, rx * 0.92, ry * 0.85, 0, 0, Math.PI * 2); |
| const topGrad = ctx.createLinearGradient(cx - rx, cy - ry, cx + rx * 0.3, cy + ry * 0.5); |
| topGrad.addColorStop(0, rgba(Math.min(255, cr + 40), Math.min(255, cg + 40), Math.min(255, cb + 40), 1)); |
| topGrad.addColorStop(0.5, rgba(cr, cg, cb, 1)); |
| topGrad.addColorStop(1, rgba(Math.max(0, cr - 15), Math.max(0, cg - 15), Math.max(0, cb - 15), 1)); |
| ctx.fillStyle = topGrad; |
| ctx.fill(); |
|
|
| |
| |
| const capY = cy - ry * 0.32; |
|
|
| if (!isHopo) { |
| |
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, capY, rx * 0.55, ry * 0.58, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#0e0e0e'; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, capY, rx * 0.46, ry * 0.42, 0, 0, Math.PI * 2); |
| ctx.fillStyle = 'rgba(175,175,175,0.95)'; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, capY - ry * 0.06, rx * 0.39, ry * 0.33, 0, 0, Math.PI * 2); |
| ctx.fillStyle = 'rgba(238,238,238,0.97)'; |
| ctx.fill(); |
|
|
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, capY - ry * 0.12, rx * 0.24, ry * 0.19, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#fff'; |
| ctx.fill(); |
| } else { |
| |
| ctx.beginPath(); |
| ctx.ellipse(cx, capY, rx * 0.55, ry * 0.58, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#0e0e0e'; |
| ctx.fill(); |
| ctx.beginPath(); |
| ctx.ellipse(cx, capY, rx * 0.32, ry * 0.28, 0, 0, Math.PI * 2); |
| ctx.fillStyle = '#080808'; |
| ctx.fill(); |
| } |
| } |
|
|
| |
|
|
| function drawSustainTail(fret, startSec, endSec, t, color, isPlaying) { |
| const clipStart = Math.max(startSec - t, 0); |
| const clipEnd = Math.min(endSec - t, VISIBLE_SEC); |
| if (clipStart >= clipEnd) return; |
|
|
| const steps = Math.max(8, Math.ceil((clipEnd - clipStart) * 8)); |
| const [cr, cg, cb] = hexRgb(color); |
|
|
| |
| const playing = isPlaying && startSec <= t; |
|
|
| |
| const pts = []; |
| for (let i = 0; i <= steps; i++) { |
| pts.push(getPoint(fret, clipStart + (clipEnd - clipStart) * (i / steps))); |
| } |
|
|
| |
| ctx.save(); |
| if (playing) { |
| ctx.shadowColor = rgba(cr, cg, cb, 0.8); |
| ctx.shadowBlur = noteRX * 0.5; |
| } |
|
|
| |
| ctx.beginPath(); |
| for (let i = 0; i <= steps; i++) { |
| const hw = Math.max(1, noteRX * pts[i].scale * (playing ? 0.16 : 0.12)); |
| if (i === 0) ctx.moveTo(pts[i].x - hw, pts[i].y); |
| else ctx.lineTo(pts[i].x - hw, pts[i].y); |
| } |
| for (let i = steps; i >= 0; i--) { |
| ctx.lineTo(pts[i].x + Math.max(1, noteRX * pts[i].scale * (playing ? 0.16 : 0.12)), pts[i].y); |
| } |
| ctx.closePath(); |
| ctx.fillStyle = playing ? rgba(Math.min(255,cr+60), Math.min(255,cg+60), Math.min(255,cb+60), 0.9) |
| : rgba(cr, cg, cb, 0.45); |
| ctx.fill(); |
| ctx.shadowBlur = 0; |
| ctx.restore(); |
|
|
| |
| if (playing) { |
| ctx.beginPath(); |
| for (let i = 0; i <= steps; i++) { |
| const hw = Math.max(0.3, noteRX * pts[i].scale * 0.05); |
| if (i === 0) ctx.moveTo(pts[i].x - hw, pts[i].y); |
| else ctx.lineTo(pts[i].x - hw, pts[i].y); |
| } |
| for (let i = steps; i >= 0; i--) { |
| ctx.lineTo(pts[i].x + Math.max(0.3, noteRX * pts[i].scale * 0.05), pts[i].y); |
| } |
| ctx.closePath(); |
| ctx.fillStyle = 'rgba(255,255,255,0.7)'; |
| ctx.fill(); |
| } |
| } |
|
|
| |
|
|
| function drawFadeOverlay() { |
| const h = (strikeY - topY) * 0.18; |
| const grad = ctx.createLinearGradient(0, topY - 5, 0, topY + h); |
| grad.addColorStop(0, '#0a0a0a'); |
| grad.addColorStop(1, 'rgba(10,10,10,0)'); |
| ctx.fillStyle = grad; |
| ctx.fillRect(0, 0, W, topY + h); |
| } |
|
|
| |
|
|
| function draw() { |
| const t = audio ? audio.currentTime || 0 : 0; |
| const dur = getDuration(); |
|
|
| |
| const seekFill = document.getElementById('viz-seekfill'); |
| const timeDiv = document.getElementById('viz-time'); |
| const secDiv = document.getElementById('viz-sections'); |
| if (seekFill) seekFill.style.width = (t / dur * 100) + '%'; |
| if (timeDiv) timeDiv.textContent = fmt(t) + ' / ' + fmt(dur); |
| let curSec = ''; |
| for (let i = sectionCache.length - 1; i >= 0; i--) { |
| if (sectionCache[i].sec <= t) { curSec = sectionCache[i].label; break; } |
| } |
| if (secDiv) secDiv.textContent = curSec; |
|
|
| |
| ctx.fillStyle = '#0a0a0a'; |
| ctx.fillRect(0, 0, W, H); |
|
|
| |
| drawTrackSurface(); |
| drawLaneLines(); |
| drawBeatLines(t); |
| drawSectionMarkers(t); |
|
|
| |
| const viewEnd = t + VISIBLE_SEC; |
| const visible = []; |
| for (const note of noteCache) { |
| if (note.sec > viewEnd) break; |
| if (note.sec + Math.max(note.sustainSec, 0) < t) continue; |
| visible.push(note); |
| } |
|
|
| |
| for (let i = visible.length - 1; i >= 0; i--) { |
| const note = visible[i]; |
| if (note.sustainSec <= 0) continue; |
| for (const fret of note.frets) { |
| if (fret > 4) continue; |
| const playing = note.sec <= t && note.sec + note.sustainSec > t; |
| drawSustainTail(fret, note.sec, note.sec + note.sustainSec, t, FRET_COLORS[fret], playing); |
| } |
| } |
|
|
| |
| |
| const fretRise = [0, 0, 0, 0, 0]; |
| const fretSustaining = [false, false, false, false, false]; |
| const RISE_BEFORE = 0.10; |
| const RISE_HOLD = 0.04; |
| const RISE_AFTER = 0.08; |
| for (const note of noteCache) { |
| const ahead = note.sec - t; |
| const noteEnd = note.sec + note.sustainSec; |
| const endAhead = noteEnd - t; |
| if (ahead > RISE_BEFORE + 0.5) break; |
| if (endAhead < -RISE_HOLD - RISE_AFTER - 0.5 && ahead < -RISE_HOLD - RISE_AFTER - 0.5) continue; |
| let rise = 0; |
| if (note.sustainSec > 0 && ahead <= 0 && endAhead > 0) { |
| |
| rise = 1; |
| for (const fret of note.frets) { |
| if (fret <= 4) fretSustaining[fret] = true; |
| } |
| } else if (ahead > 0 && ahead < RISE_BEFORE) { |
| |
| rise = 1 - ahead / RISE_BEFORE; |
| } else { |
| |
| const fallRef = note.sustainSec > 0 ? endAhead : ahead; |
| if (fallRef <= 0 && fallRef > -RISE_HOLD) { |
| |
| rise = 1; |
| } else if (fallRef <= -RISE_HOLD && fallRef > -RISE_HOLD - RISE_AFTER) { |
| |
| const f = 1 + (fallRef + RISE_HOLD) / RISE_AFTER; |
| rise = f * f; |
| } |
| } |
| for (const fret of note.frets) { |
| if (fret <= 4) fretRise[fret] = Math.max(fretRise[fret], rise); |
| } |
| } |
|
|
| |
| for (let i = visible.length - 1; i >= 0; i--) { |
| const note = visible[i]; |
| const ahead = note.sec - t; |
| if (ahead < 0) continue; |
|
|
| for (const fret of note.frets) { |
| if (fret > 4) continue; |
| const pt = getPoint(fret, ahead); |
|
|
| |
| if (ahead < 0.2) { |
| const intensity = 1 - ahead / 0.2; |
| const gr = noteRX * pt.scale * 2.5; |
| const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, gr); |
| glow.addColorStop(0, colorAlpha(FRET_GLOW[fret], 0.35 * intensity)); |
| glow.addColorStop(1, colorAlpha(FRET_GLOW[fret], 0)); |
| ctx.fillStyle = glow; |
| ctx.beginPath(); |
| ctx.arc(pt.x, pt.y, gr, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| drawNotePuck(pt.x, pt.y, pt.scale, FRET_COLORS[fret], note.hopo); |
| } |
| } |
|
|
| |
| drawFadeOverlay(); |
| drawStrikeline(); |
| drawFretButtons(fretRise); |
|
|
| |
| ctx.fillStyle = '#555'; |
| ctx.font = '11px system-ui'; |
| ctx.textAlign = 'right'; |
| ctx.fillText(`${noteCache.length} notes (${currentDiff})`, W - 16, 20); |
| ctx.textAlign = 'left'; |
|
|
| requestAnimationFrame(draw); |
| } |
|
|
| |
|
|
| function buildUI(container) { |
| container.style.background = '#0a0a0a'; |
| container.style.borderRadius = '12px'; |
| container.style.overflow = 'hidden'; |
|
|
| container.innerHTML = ` |
| <div style="display:flex; align-items:center; gap:12px; padding:10px 16px; background:#111; border-bottom:1px solid #222;"> |
| <button id="viz-play" style="background:none; border:none; color:#fff; font-size:22px; cursor:pointer; padding:4px 8px;" title="Play/Pause">▶</button> |
| <div id="viz-time" style="color:#aaa; font-size:13px; min-width:80px;">0:00 / 0:00</div> |
| <div style="flex:1; position:relative; height:6px; background:#222; border-radius:3px; cursor:pointer;" id="viz-seekbar"> |
| <div id="viz-seekfill" style="height:100%; background:#7c3aed; border-radius:3px; width:0%; pointer-events:none;"></div> |
| </div> |
| <select id="viz-diff" style="background:#1a1a1a; color:#fff; border:1px solid #333; border-radius:4px; padding:2px 6px; font-size:13px;"> |
| <option value="expert">Expert</option> |
| <option value="hard">Hard</option> |
| <option value="medium">Medium</option> |
| <option value="easy">Easy</option> |
| </select> |
| </div> |
| <canvas id="viz-canvas" style="width:100%; display:block;"></canvas> |
| <div id="viz-sections" style="padding:6px 16px 10px; background:#111; border-top:1px solid #222; color:#666; font-size:11px; min-height:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div> |
| `; |
|
|
| canvas = document.getElementById('viz-canvas'); |
| ctx = canvas.getContext('2d'); |
|
|
| document.getElementById('viz-play').addEventListener('click', () => { |
| if (!audio) return; |
| if (playing) { |
| audio.pause(); playing = false; |
| document.getElementById('viz-play').textContent = '\u25B6'; |
| } else { |
| audio.play(); playing = true; |
| document.getElementById('viz-play').textContent = '\u23F8'; |
| } |
| }); |
|
|
| document.getElementById('viz-seekbar').addEventListener('click', e => { |
| if (!audio) return; |
| const rect = e.currentTarget.getBoundingClientRect(); |
| audio.currentTime = ((e.clientX - rect.left) / rect.width) * getDuration(); |
| }); |
|
|
| document.getElementById('viz-diff').addEventListener('change', e => { |
| currentDiff = e.target.value; |
| noteCache = buildNoteCache(currentDiff); |
| }); |
|
|
| resize(); |
| window.addEventListener('resize', resize); |
| } |
|
|
| |
|
|
| async function init() { |
| const container = document.getElementById('midmid-viz'); |
| if (!container) { console.error('No #midmid-viz element'); return; } |
|
|
| |
| if (window.CHART_DATA) { |
| DATA = window.CHART_DATA; |
| } else { |
| const resp = await fetch('/demo-data.json'); |
| if (!resp.ok) { |
| container.innerHTML = '<div style="padding:40px; text-align:center; color:#f66;">No demo-data.json found. Run: bun run extract</div>'; |
| return; |
| } |
| DATA = await resp.json(); |
| } |
|
|
| buildUI(container); |
| rebuildCaches(); |
|
|
| if (DATA.audio_b64) { |
| audio = new Audio(); |
| audio.src = 'data:audio/' + DATA.audio_format + ';base64,' + DATA.audio_b64; |
| audio.preload = 'auto'; |
| audio.addEventListener('loadedmetadata', () => { totalDuration = audio.duration; }); |
| audio.addEventListener('ended', () => { |
| playing = false; |
| document.getElementById('viz-play').textContent = '\u25B6'; |
| }); |
| } |
|
|
| requestAnimationFrame(draw); |
| } |
|
|
| init(); |
|
|
| |
| if (typeof import.meta !== 'undefined' && import.meta.hot) { |
| import.meta.hot.accept(() => {}); |
| import.meta.hot.dispose(() => { window.removeEventListener('resize', resize); }); |
| } |
|
|