Files
guitar-practice-tool/src/components/views/PickingView.svelte
T
immortaly007 365aac58a0 Phase 3: chord/fretboard diagrams + theory drills + R4
Add ChordDiagram (SVG chord box) and horizontal Fretboard components, plus a
tonal-backed music layer: fretboard note mapping, scale/interval/diatonic helpers,
curated chord shapes, and computed triad inversions + CAGED shapes. Wire T1
(note prompter), T2 (CAGED), T3 (triads, optional click + auto-advance), T4
(diatonic), T5 (intervals), R4 (one-minute changes with best score), and L4
(reference tone). Upgrade F1-F3 to show real chord diagrams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:31:33 +02:00

166 lines
4.6 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import type { PickingDrill } from '../../drills'
import { effectiveMeter, patternFromAccents } from '../../drills'
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
import * as storage from '../../lib/storage'
import { router } from '../../router.svelte'
import DrillLayout from '../common/DrillLayout.svelte'
import Transport from '../metronome/Transport.svelte'
import PickingLane from '../patterns/PickingLane.svelte'
import ChordDiagram from '../diagrams/ChordDiagram.svelte'
import { getChordShape } from '../../lib/music/chords'
interface Props {
drill: PickingDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
function initialBpm(): number {
const q = Number(params.get('bpm'))
if (Number.isFinite(q) && q > 0) return q
return storage.getDrillBpm(drill.code, drill.defaultBpm)
}
function initialVariantId(): string {
const q = params.get('variant')
if (q && drill.variants.some((v) => v.id === q)) return q
const stored = storage.getDrillVariant(drill.code, drill.variants[0].id)
return drill.variants.some((v) => v.id === stored) ? stored : drill.variants[0].id
}
const controller = new MetronomeController(initialBpm())
let variantId = $state(initialVariantId())
let variant = $derived(
drill.variants.find((v) => v.id === variantId) ?? drill.variants[0],
)
let meter = $derived(effectiveMeter(drill, variant))
$effect(() => {
controller.setPattern(patternFromAccents(meter, variant.accentSteps))
})
function persistBpm(bpm: number) {
storage.setDrillBpm(drill.code, bpm)
}
function selectVariant(id: string) {
variantId = id
storage.setDrillVariant(drill.code, id)
router.setQuery({ variant: id })
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
if (e.code === 'Space') {
e.preventDefault()
controller.toggle()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
controller.nudgeBpm(2)
persistBpm(controller.bpm)
} else if (e.key === 'ArrowDown') {
e.preventDefault()
controller.nudgeBpm(-2)
persistBpm(controller.bpm)
}
}
onMount(() => window.addEventListener('keydown', onKey))
onDestroy(() => {
window.removeEventListener('keydown', onKey)
controller.destroy()
})
</script>
<DrillLayout
code={drill.code}
name={drill.name}
category={drill.category}
description={drill.description}
meterLabel={meter.meterLabel}
>
<Transport {controller} stepsPerBeat={meter.stepsPerBeat} onBpmCommit={persistBpm} />
<PickingLane
steps={variant.steps}
stepsPerBeat={meter.stepsPerBeat}
currentStep={controller.currentStep}
/>
{#if drill.chordLabels && drill.chordLabels.length}
<div class="chords">
<span class="eyebrow">Chords{drill.chordLabels.length > 1 ? ' (one per bar)' : ''}</span>
<div class="chip-row">
{#each drill.chordLabels as c}
{@const shape = getChordShape(c)}
{#if shape}
<ChordDiagram {shape} label={c} size={96} />
{:else}
<span class="chord num">{c}</span>
{/if}
{/each}
</div>
</div>
{/if}
{#if drill.variants.length > 1}
<div class="variants">
<span class="eyebrow">Variant</span>
<div class="chips">
{#each drill.variants as v}
<button class="chip" class:sel={v.id === variantId} onclick={() => selectVariant(v.id)}>
{v.label}
</button>
{/each}
</div>
</div>
{/if}
</DrillLayout>
<style>
.chords,
.variants {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin-bottom: 1.25rem;
}
.chip-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.chord {
padding: 0.4rem 0.7rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
color: var(--inlay);
font-size: var(--step-2);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
padding: 0.55rem 0.8rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-size: var(--step-0);
}
.chip:hover {
border-color: var(--fretwire);
color: var(--inlay);
}
.chip.sel {
color: var(--tubeglow);
border-color: var(--tubeglow-soft);
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
}
</style>