---
format: typebulb/v1
name: Waves
---

**code.tsx**

```tsx
import { App, Component, canvas, div, formField, inputRange } from "domeleon"

function getNoteName(semitones: number): string {
  const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
  const octave = 4 + Math.floor(semitones / 12); 
  return `${noteNames[semitones % 12]}${octave}`;
}

class Wave extends Component {
  amplitude: number;
  semitones: number; // 0-24 (C4 to C6, 2 octaves)
  color: string;
  waveNumber: number;

  constructor(amplitude: number, semitones: number, color: string, waveNumber: number) {
    super();
    this.amplitude = amplitude;
    this.semitones = semitones;
    this.color = color;
    this.waveNumber = waveNumber;
  }

  get className() { return `wave${this.waveNumber}`; }

  get audioFrequency(): number { 
    return 261.63 * Math.pow(2, this.semitones / 12); // C4 = 261.63 Hz
  }

  get visualFrequency(): number { 
    return this.audioFrequency / 261.63; 
  }

  get noteName(): string { 
    return getNoteName(this.semitones); 
  }

  // Calculate y-value at given x position and time offset
  valueAt(x: number, offset: number): number {
    return this.amplitude * Math.sin((x / 50) * this.visualFrequency + offset);
  }

  view() {
    return div({ className: this.className },
      div({ className: "wave-label" }, `Wave ${this.waveNumber}`),
        formField({
          prop: () => this.amplitude,
          target: this,
          inputFn: inputRange,
          inputProps: { attrs: { min: 0, max: 100, step: 1 } },
          label: "Amplitude:"
        }),
        formField({
          prop: () => this.semitones,
          target: this,           
          inputFn: inputRange,
          inputProps: { attrs: { min: 0, max: 24, step: 1 } },
          label: `Note: ${this.noteName} (${Math.round(this.audioFrequency)} Hz)`
        })
      )
  }
}

class Waves extends Component {
  wave1 = new Wave(40, 0, "#ff4444", 1);   // C4
  wave2 = new Wave(30, 7, "#4444ff", 2);  // G4 (perfect fifth)
  speed = 0.05;
  offset = 0;

  private canvasEl!: HTMLCanvasElement;
  private canvasCtx!: CanvasRenderingContext2D;
  private animId: number | null = null;

  private audioCtx!: AudioContext;
  private sources: Array<{ osc: OscillatorNode; gain: GainNode }> = [];
  private resizeObserver!: ResizeObserver;

  private drawWave(
    color: string,
    lineWidth: number,
    valueFunction: (x: number, offset: number) => number
  )
  {
    const { canvasCtx: ctx, canvasEl, offset } = this;
    const centerY = canvasEl.height / 2;

    ctx.beginPath();
    ctx.strokeStyle = color;
    ctx.lineWidth = lineWidth;

    for (let x = 0; x < canvasEl.width; x++) {
      const y = centerY + valueFunction(x, offset);
      x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  private drawGrid() {
    const { canvasCtx: ctx, canvasEl } = this;
    const gridColor = getComputedStyle(document.documentElement)
      .getPropertyValue('--grid-color').trim();

    ctx.strokeStyle = gridColor || '#888';
    ctx.lineWidth = 0.5;

    for (let x = 50; x < canvasEl.width; x += 50) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, canvasEl.height);
      ctx.stroke();
    }

    for (let y = 50; y < canvasEl.height; y += 50) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(canvasEl.width, y);
      ctx.stroke();
    }
  }

  private animate = () => {
    const { canvasCtx, canvasEl } = this;
    canvasCtx.clearRect(0, 0, canvasEl.width, canvasEl.height);
    this.drawGrid();

    this.drawWave(this.wave1.color, 2, (x, off) => this.wave1.valueAt(x, off));
    this.drawWave(this.wave2.color, 2, (x, off) => this.wave2.valueAt(x, off));
    this.drawWave("#00dd00", 3, (x, off) => 
      this.wave1.valueAt(x, off) + this.wave2.valueAt(x, off)
    );

    this.offset += this.speed;
    this.animId = requestAnimationFrame(this.animate);
  };

  private resizeCanvas() {
    if (!this.canvasEl) return;

    const container = this.canvasEl.parentElement;
    if (!container) return;

    const rect = container.getBoundingClientRect();
    const width = Math.floor(rect.width - 20); // Account for container padding
    const height = 400;

    this.canvasEl.width = width;
    this.canvasEl.height = height;
  }

  private initAudio() {
    this.audioCtx = new AudioContext();

    // Create oscillators for both waves
    [this.wave1, this.wave2].forEach(() => {
      const osc = this.audioCtx.createOscillator();
      const gain = this.audioCtx.createGain();
      osc.type = 'sine';
      osc.connect(gain);
      gain.connect(this.audioCtx.destination);
      osc.start();
      this.sources.push({ osc, gain });
    });

    this.updateAudio();
  }

  updateAudio() {
    if (!this.audioCtx || this.sources.length === 0) return;

    const waves = [this.wave1, this.wave2];
    const t = this.audioCtx.currentTime;

    this.sources.forEach(({ osc, gain }, i) => {
      const wave = waves[i];
      osc.frequency.setValueAtTime(wave.audioFrequency, t);
      gain.gain.setValueAtTime((wave.amplitude / 100) * 0.15, t);
    });
  }

  onUpdated() {
    this.updateAudio();
  }

  private startAnimation(el: HTMLCanvasElement) {
    this.canvasEl = el;
    this.canvasCtx = el.getContext('2d')!;
    this.offset = 0;

    // Set initial size and observe container for changes
    this.resizeCanvas();
    this.resizeObserver = new ResizeObserver(() => this.resizeCanvas());
    if (el.parentElement) {
      this.resizeObserver.observe(el.parentElement);
    }
    this.animate();
  }

  private stopAnimation() {
    if (this.animId) {
      cancelAnimationFrame(this.animId);
    }

    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
    }

    this.sources.forEach(({ osc }) => {
      osc.stop();
      osc.disconnect();
    });
    this.sources = [];

    if (this.audioCtx) {
      this.audioCtx.close();
    }
  }

  view() {
    return div({ className: "container" },
      canvas({
        className: "viz-canvas",
        onMounted: el => {
          const initAudioOnce = () => {
            if (!this.audioCtx) this.initAudio();
            document.removeEventListener('click', initAudioOnce);
          };
          document.addEventListener('click', initAudioOnce);
          this.startAnimation(el as HTMLCanvasElement);
          return () => this.stopAnimation();
        }
      }),
      div({ className: "controls" },
        div({ className: "waves-grid" },
          this.wave1.view(),
          this.wave2.view()
        ),
        div({ className: "wave3" },
          div({ className: "wave-label wave3" }, "Wave = Sum of Wave 1 + Wave 2"),
          formField({
            prop: () => this.speed,
            target: this,
            inputFn: inputRange,
            labelAttrs: { class: "wave3" },
            inputProps: { attrs: { min: 0.01, max: 0.2, step: 0.01 } },
            label: "Speed:"
          })
        )
      )
    )
  }
}

new App({root: new Waves(), id: "app"})
```
**styles.css**

```css
* {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

:root {
  --border-color: #ccc;
  --bg-color: #fafafa;
  --grid-color: #888;
  --text-color: #333;
  --control-bg: #f5f5f5;
}

html[data-theme="dark"] {
  --border-color: #555;
  --bg-color: #1e1e1e;
  --grid-color: #555;
  --text-color: #ddd;
  --control-bg: #2a2a2a;
}

html[data-theme="light"] {
  --border-color: #ccc;
  --bg-color: #fafafa;
  --grid-color: #888;
  --text-color: #333;
  --control-bg: #f5f5f5;
}

.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  padding: 20px;
  color: var(--text-color);
  background: var(--bg-color);
}

.viz-canvas {
  border: 1px solid var(--border-color);
  background: var(--bg-color);
  border-radius: 4px;
}

.controls {
  display: flex;
  flex-direction: column;
  gap: 15px;
  width: 100%;
  max-width: 800px;
  background: var(--control-bg);
  margin: 10px;
  padding: 10px;
  border-radius: 4px;
  border: 1px solid var(--border-color);
}

.waves-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}

.wave1, .wave2, .wave3 {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.wave-label {
  font-weight: 600;
  font-size: 15px;
}

.wave1 {
  color: #ff4444;
}

.wave2 {
  color: #6666ff;
}

.wave3 {
  color: #00dd00;
}

.wave1 > *:not(.wave-label),
.wave2 > *:not(.wave-label),
.wave3 > *:not(.wave-label) {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 14px;
}

label {
  font-weight: 500;
  font-size: 13px;
}

input[type="range"] {
  width: 100%;
  cursor: pointer;
  margin-top: 4px;
}
```
**index.html**

```html
<div id="app"></div>
```
**config.json**

```json
{
  "dependencies": {
    "domeleon": "^0.5.2"
  },
  "description": "Visual and sonic adding of two simple sine waves."
}
```
**notes.md**

````md
## Using Domeleon

Domeleon is a compact abstraction over any virtual DOM, including Preact (the default), React, and Vue. A Domeleon Component is a class representing application state rather than a wrapper around a DOM element. Components may define a pure view function of their state, and compose into hierarchies that can be updated, serialized, validated, or routed.

### Example

**HTML**
```html
<div id="app"></div>
```

**Code**
```ts
import { App, Component, div, button, formField, inputRange, canvas } from "domeleon"
import { inputNumber } from "domeleon/maskito"

class Counter extends Component {
  count: number
  role: string
  #canvasEl: HTMLCanvasElement
  #canvasCtx: CanvasRenderingContext2D

  constructor(initial: number, role: string) {
    super()
    this.count = initial
    this.role = role
  }

  view() {
    return div({ class: "counter" },
      formField ({ // databound input
        target: this,
        inputFn: inputRange, // inputSelect, inputText, inputTextArea, inputCheckbox etc.
        prop: () => this.count,
        label: this.role,
        fieldAttrs: { class: "field-class"},
        labelAttrs: { class: "label-class"},
        inputProps: { attrs: { min: 100, max: 250, step: 1 } }
      }),      
      button({ onClick: () => this.inc(+1) }, "+"), // always use closure to bind to preserve "this"
      button({ onClick: () => this.inc(-1) }, "-"),
      this.count,
      canvas({
        width: 400,
        height: 20,
        onMounted: el => {
          this.#canvasEl = el as HTMLCanvasElement
          this.#canvasCtx = this.#canvasEl.getContext("2d")
          return () => { // dispose
            this.#canvasEl = undefined
            this.#canvasCtx = undefined
          }
        }
      })
    )
  }
  
  inc(x: number) {
    this.count+=x
    this.update() // triggers render on app
  }

  // `onRendered` called after VDOM has created/updated DOM elements
  // useful for operations that require raw DOM access, such as canvas operations
  override onRendered () { 
     this.drawOnCanvas()
  }

  drawOnCanvas() {    
    const canvas = this.#canvasEl
    const cc = this.#canvasCtx
    cc.clearRect(0, 0, canvas.width, canvas.height)
    const barWidth = (this.count / 250) * canvas.width
    cc.fillStyle = "#4a90e2"
    cc.fillRect(0, 0, barWidth, canvas.height)
  }
}

class Game extends Component {
  wins = new Counter(10, "wins")
  losses = new Counter(5, "losses")

  view() {
    return div(
      this.wins.view(),
      this.losses.view()
    )
  }
}

new App({ root: new Game(), id: "app"})
```
````