スキル一覧に戻る
EvanMcCormick

angular-component-development

by EvanMcCormick

Interactive Music Theory tool

0🍴 0📅 2026年1月20日
GitHubで見るManusで実行

SKILL.md


name: angular-component-development description: Use when creating or modifying Angular components in MusicTheory - ensures standalone component pattern, proper RxJS subscription management, and integration with MusicTheoryService

Angular Component Development

Overview

All components in MusicTheory use Angular 21's standalone component pattern. This skill ensures consistency with existing architecture and proper integration.

Core principle: Standalone components, centralized state in MusicTheoryService, proper cleanup.

When to Use

  • Creating a new component
  • Modifying existing component structure
  • Adding new features to components
  • Fixing component-related bugs
  • Refactoring component architecture

Standalone Component Template

import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
import { MusicTheoryService } from '../../services/music-theory.service';
import { MusicTheoryState } from '../../models/music-theory.model';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [CommonModule],  // Add other imports as needed
  templateUrl: './my-component.component.html',
  styleUrls: ['./my-component.component.scss']
})
export class MyComponent implements OnInit, OnDestroy {
  // Inputs from parent
  @Input() someInput: string = '';

  // Outputs to parent
  @Output() someEvent = new EventEmitter<void>();

  // Destroy subject for cleanup
  private destroy$ = new Subject<void>();

  // State from service
  state: MusicTheoryState | null = null;

  constructor(private musicTheoryService: MusicTheoryService) {}

  ngOnInit(): void {
    // Subscribe to service state
    this.musicTheoryService.getState()
      .pipe(takeUntil(this.destroy$))
      .subscribe(state => {
        this.state = state;
        this.onStateChange(state);
      });
  }

  ngOnDestroy(): void {
    // CRITICAL: Always clean up subscriptions
    this.destroy$.next();
    this.destroy$.complete();
  }

  private onStateChange(state: MusicTheoryState): void {
    // React to state changes
  }
}

File Structure

components/
  my-component/
    my-component.component.ts      # Component class
    my-component.component.html    # Template
    my-component.component.scss    # Styles (component-scoped)
    my-component.component.spec.ts # Tests (optional but recommended)

State Management Pattern

Reading State

// Get current state value (one-time)
const currentState = this.musicTheoryService.getState().value;

// Subscribe to state changes (reactive)
this.musicTheoryService.getState()
  .pipe(takeUntil(this.destroy$))
  .subscribe(state => {
    // React to changes
  });

Updating State

// Use service methods - NEVER modify state directly
this.musicTheoryService.setSelectedKey(newKey);
this.musicTheoryService.setSelectedCategory(categoryId);
this.musicTheoryService.setInstrument(instrumentId);

State Interface

interface MusicTheoryState {
  selectedKey: string;        // Current root note (C, C#, D, etc.)
  selectedCategoryId: string; // Scale/chord category
  selectedItemId: string;     // Specific scale or chord
  instrumentId: string;       // guitar, bass, piano
  tuningName: string;         // Current tuning
  stringCount: number;        // For stringed instruments
  showNashvilleNumbers: boolean;
}

Template Patterns

Conditional Rendering

<!-- Use *ngIf for conditional blocks -->
<div *ngIf="state?.instrumentId === 'piano'" class="keyboard-container">
  <app-keyboard></app-keyboard>
</div>

<!-- Use @if for new control flow (Angular 17+) -->
@if (state?.instrumentId === 'piano') {
  <app-keyboard></app-keyboard>
}

List Rendering with trackBy

<!-- Always use trackBy for performance -->
<div *ngFor="let note of notes; trackBy: trackByIndex" class="note">
  {{ note.name }}
</div>

<!-- In component -->
trackByIndex(index: number): number {
  return index;
}

<!-- Or track by unique property -->
trackByNoteId(index: number, note: FretNote): string {
  return `${note.fret}-${note.noteValue}`;
}

Event Binding

<!-- Click events -->
<button (click)="onNoteClick(note)">{{ note.name }}</button>

<!-- Prevent default -->
<a (click)="handleClick($event)">Link</a>

<!-- In component -->
handleClick(event: Event): void {
  event.preventDefault();
  // Handle click
}

Styling Patterns

Component-Scoped SCSS

// my-component.component.scss
:host {
  display: block;  // Set host display
}

.container {
  // Scoped to this component only
}

// Use CSS variables for theming
.theme-element {
  background-color: var(--nav-bg-color, #2c3e50);
  color: var(--text-color, white);
}

Existing Color Scheme

// Dark navigation
$nav-bg-primary: #2c3e50;
$nav-bg-hover: #34495e;

// Note highlighting
$root-note-color: #e74c3c;      // Red for root
$in-mode-note-color: #3498db;  // Blue for scale tones
$out-of-mode-color: #95a5a6;   // Gray for chromatic

Integration with Audio

Component with Audio Playback

import * as Tone from 'tone';

export class AudioComponent implements OnDestroy {
  private synth: Tone.PolySynth | null = null;

  async initAudio(): Promise<void> {
    await Tone.start();
    this.synth = new Tone.PolySynth(Tone.Synth).toDestination();
  }

  playNote(note: string): void {
    this.synth?.triggerAttackRelease(note, '8n');
  }

  ngOnDestroy(): void {
    // CRITICAL: Dispose audio resources
    this.synth?.dispose();
    this.synth = null;
  }
}

Common Patterns

Loading State

export class DataComponent implements OnInit {
  isLoading = true;
  data: any[] = [];

  ngOnInit(): void {
    this.loadData();
  }

  private async loadData(): Promise<void> {
    this.isLoading = true;
    try {
      this.data = await this.service.getData();
    } finally {
      this.isLoading = false;
    }
  }
}

Form Handling

import { FormsModule } from '@angular/forms';

@Component({
  // ...
  imports: [CommonModule, FormsModule]
})
export class FormComponent {
  selectedValue = '';

  onSelectionChange(value: string): void {
    this.selectedValue = value;
    this.musicTheoryService.setSelectedKey(value);
  }
}
<select [(ngModel)]="selectedValue" (ngModelChange)="onSelectionChange($event)">
  <option *ngFor="let option of options" [value]="option.id">
    {{ option.name }}
  </option>
</select>

External Library Integration (ChangeDetectorRef)

When integrating external libraries that emit events outside Angular's zone (like alphaTab, IndexedDB callbacks), use ChangeDetectorRef to manually trigger change detection:

import { ChangeDetectorRef } from '@angular/core';

export class MyComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  constructor(
    private externalService: ExternalService,
    private cdr: ChangeDetectorRef
  ) {}

  ngOnInit(): void {
    // Subscribe to service state
    this.externalService.getState()
      .pipe(takeUntil(this.destroy$))
      .subscribe(state => {
        this.state = state;
        // Force change detection for events from outside Angular zone
        this.cdr.detectChanges();
      });
  }
}

When to use ChangeDetectorRef:

  • alphaTab events (scoreLoaded, playerStateChanged, etc.)
  • IndexedDB callbacks
  • Web Worker messages
  • WebSocket events
  • Any third-party library using native callbacks

Anti-Patterns

Anti-PatternProblemCorrect Approach
NgModule componentsInconsistent with codebaseUse standalone: true
Direct state mutationBypasses change detectionUse service methods
Missing takeUntilMemory leaksAlways use destroy$ pattern
Computations in templatePerformance issuesUse methods or pipes
Missing trackByPoor list performanceAlways provide trackBy
Audio without disposalMemory/resource leakDispose in ngOnDestroy
Missing ChangeDetectorRefUI not updating from external eventsUse cdr.detectChanges()

Checklist

When creating/modifying a component:

  • Uses standalone: true
  • Imports are in component decorator (not module)
  • Has OnDestroy implementation
  • Uses takeUntil(this.destroy$) for subscriptions
  • Cleans up subscriptions in ngOnDestroy
  • Disposes audio resources if applicable
  • Uses MusicTheoryService for state (not local state)
  • Uses trackBy for *ngFor loops
  • Template avoids complex computations
  • SCSS is component-scoped
  • Follows existing naming conventions
  • Uses ChangeDetectorRef for external library events

Lazy-Loaded Routes

For larger components (like GP viewer), use lazy loading to reduce initial bundle size:

// app-routing.module.ts
const routes: Routes = [
  { path: 'fretboard', component: FretboardComponent },  // Eager load core feature
  {
    path: 'gp-viewer',
    loadComponent: () => import('./components/gp-viewer/gp-viewer.component')
      .then(m => m.GpViewerComponent)  // Lazy load
  },
  {
    path: 'gp-library',
    loadComponent: () => import('./components/gp-library/gp-library.component')
      .then(m => m.GpLibraryComponent)
  }
];

Query Parameters with ActivatedRoute

For deep linking (e.g., opening a file from library):

import { ActivatedRoute } from '@angular/router';

export class MyComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  constructor(private route: ActivatedRoute) {}

  ngOnInit(): void {
    // Subscribe to query params
    this.route.queryParams
      .pipe(takeUntil(this.destroy$))
      .subscribe(params => {
        if (params['id']) {
          this.loadItem(params['id']);
        }
      });
  }
}

Navigate with query params:

this.router.navigate(['/gp-viewer'], {
  queryParams: { id: entry.id }
});

IndexedDB Service Integration

For client-side storage (like GP Library):

@Injectable({ providedIn: 'root' })
export class MyStorageService {
  private db: IDBDatabase | null = null;
  private entriesSubject = new BehaviorSubject<MyEntry[]>([]);

  constructor(private ngZone: NgZone) {
    this.initDatabase();
  }

  getEntries(): Observable<MyEntry[]> {
    return this.entriesSubject.asObservable();
  }

  private initDatabase(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('my-db', 1);

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains('items')) {
          const store = db.createObjectStore('items', { keyPath: 'id' });
          store.createIndex('name', 'name', { unique: false });
        }
      };

      request.onsuccess = () => {
        this.db = request.result;
        this.loadAllEntries();
        resolve();
      };
    });
  }

  // IMPORTANT: Wrap callbacks with NgZone.run() for change detection
  private loadAllEntries(): void {
    const request = this.db!.transaction('items', 'readonly')
      .objectStore('items').getAll();

    request.onsuccess = () => {
      this.ngZone.run(() => {
        this.entriesSubject.next(request.result || []);
      });
    };
  }
}

Generating Components

Use Angular CLI:

# Generate standalone component
ng generate component components/my-component --standalone

# This creates:
# - my-component.component.ts
# - my-component.component.html
# - my-component.component.scss
# - my-component.component.spec.ts

スコア

総合スコア

60/100

リポジトリの品質指標に基づく評価

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

レビュー

💬

レビュー機能は近日公開予定です