Visual and sonic adding of two simple sine waves.
---
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"})
```
````