Waves

Visual and sonic adding of two simple sine waves.

## 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"}) ```

code.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"})

Markdown source · More bulbs by samples · Typebulb home